Ankündigung

Einklappen
Keine Ankündigung bisher.

mb_substr mit invaliden UTF-8 strings

Einklappen

Neue Werbung 2019

Einklappen
X
  • Filter
  • Zeit
  • Anzeigen
Alles löschen
neue Beiträge

  • mb_substr mit invaliden UTF-8 strings

    Ich benutze mb_substr() um strings für Diagnosezwecke in einzelne Zeichen zu zerlegen.
    Diese Strings können bei meiner Anwendung vom Grundsatz beliebige Byte-Sequenzen sein.
    Das Verhalten von mb_substr() für solche Byte-Sequenzen ist nach meiner Kenntnis nicht dokumentiert.
    Wenn doch, bitte hier die Links dazu posten.

    Vorab: Ist hier zwar unter Fortgeschritten, kann jedoch nicht schaden um Missverständnisse zu vermeiden.
    Was verstehe ich unter invaliden UTF-8 Strings?

    Valide UTF-8 Strings ist das womit wir gewöhnlich arbeiten:
    PHP-Code:
    $str "aäö€"
    Um diesen String auf UTF8 zu validieren gibt es eine einfache Möglichkeit:
    PHP-Code:
    $isValidUTF8 = (bool)preg_match('//u',$str); 
    Invalide UTF-8 strings entstehen wenn valide Strings falsch verarbeitet werden.
    Als einfaches Beispiel wenn ich so versuche das letzte Zeichen zu entfernen:

    PHP-Code:
    $str substr("aäö€",0,-1);  //Das ist falsch!
    $isValidUTF8 = (bool)preg_match('//u',$str);
    var_dump($isValidUTF8$str);
    //bool(false) string(7) "aäö��" 
    Solche invaliden UTF-8-Strings bereiten dann oft auch Profis massive Probleme an Stellen wo sie nicht vermutet werden.

    Mir geht es speziell um das folgende Verhalten von mb_substr() womit auch Bytesequenzen geliefert werden die kein UTF-8 Zeichen sind.
    Beispiel:

    PHP-Code:
    $invalidUTF8Str substr("aäö€",0,-1); 
    $subStr mb_substr($invalidUTF8Str,3,1);
    var_dump($subStr);  //string(2) "��" 
    Bin dankbar für alle Erfahrungen und Hinweise zu diesen Verhalten von mb_substr.
    PHP-Klassen auf github


  • #2
    Also ich habe ja eigentlich keine Ahnung davon, aber wenn ich mir die erste Tabelle hier ansehe:
    https://en.wikipedia.org/wiki/UTF-8
    1 7 U+0000 U+007F 0xxxxxxx
    2 11 U+0080 U+07FF 110xxxxx 10xxxxxx
    3 16 U+0800 U+FFFF 1110xxxx 10xxxxxx 10xxxxxx
    4 21 U+10000 U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
    Würde ich halt behaupten dass er aufgrund der ersten Bits erkennt, wo ein neuer Character beginnt und aus wievielen Bytes der Character besteht und nachfolgende Bytes beginnen immer mit 10xxxxx.
    € besteht aus 3 Bytes (?), mb_substr() kann aber nur eine Sequenz aus 2 Bytes ausmachen, wo 3 erwartet werden und liefert eben zumindest diese beiden Bytes zurück. Also alles vom Initiator-Byte bis zum abrupten Ende.

    Gut, habe dafür natürlich keine Quelle, aber das scheint mir die einzig logische Vorgehensweise, wenn ich das selbst programmieren müsste.

    Edit: Vielleicht sollte ich auch die Vermutungen lassen und jemand kann übersetzen was hier wirklich passiert: https://github.com/php/php-src/blob/...filter.c#L1123

    Kommentar


    • #3
      Komplizierter wird es wenn invalide UTF-8 Fragmente innerhalb einer Zeichenkette liegen. mb_substr versucht dann vermutlich ausgehend vom 1.Byte genau so viele Bytes zu greifen wie es entsprechend der UTF-8 Codierungsvorschrift erwartet. Auch nur eine Vermutung, denn durch den Quelltext steige ich nicht durch. Doch der folgende kleine Test bestärkt diese These. Dort wird vom 3 Byte Zeichen "€" nur das erste Byte genommen wird und dann der string "abc" angehangen. Da nach dem 1. Byte noch 2 weitere Byte erwartet werden wird "ab" noch den ersten Zeichen zugeordnet und "c" als 2. Zeichen erkannt.
      PHP-Code:
      $str substr("€",0,1)."abc";
      for(
      $i=0; ($subStr mb_substr($str,$i,1,"UTF-8")) !== "";$i++){
        
      var_dump($subStr);
      }
      //string(3) "�ab" string(1) "c" 
      Zum Hintergrund:
      Bin dabei eine Funktion/Methode zu schreiben, die UTF-8 Zeichen (Strings) in die unter PHP 7 zulässigen String-Notation mit Unicode-Kodierung konvertiert, also "€" in " \u{20ac}".
      Mit dieser Notation können UTF-8 Zeichen mit gleichen oder ähnlichen Erscheinungsbild eindeutig identifiziert werden ohne irgendwelche Ausgaben als Hex-Code analysieren zu müssen.
      PHP-Klassen auf github

      Kommentar


      • #4
        Kein Code der Welt kann fehlende Informationen erraten.. kaputt ist kaputt.

        Best practice um einen UTF-8 String in seine Zeichen zu zerlegen ist IMHO das:
        PHP-Code:
        preg_split('//u'$str0PREG_SPLIT_NO_EMPTY); 
        Über 90% aller Gewaltverbrechen passieren innerhalb von 24 Stunden nach dem Konsum von Brot.

        Kommentar


        • #5
          Zitat von lstegelitz Beitrag anzeigen
          Kein Code der Welt kann fehlende Informationen erraten.. kaputt ist kaputt.
          Richtig. Ist aber nicht das Thema hier.

          Zitat von lstegelitz Beitrag anzeigen
          Best practice um einen UTF-8 String in seine Zeichen zu zerlegen ist IMHO das:

          preg_split('//u', $str, 0, PREG_SPLIT_NO_EMPTY);
          Mag sein. Funktioniert aber "nur" mit validen UTF-8 Strings.

          Was möchte ich erreichen?
          1. valide UTF-8 Strings mittels PHP 7 Unicode-Notation so darstellen, das diese Zeichen einfach und eindeutig identifiziert und reproduziert werden können.

          Beispiel: Um Herauszufinden um welches Emoji es sich handelt bedarf schon etwas Übung. Die Routine indentifiziert das eindeutig als U+1f601.
          Der konvertierte String besteht nur noch aus ASCII-Zeichen, kann überall dargestellt werden und in double quotes " gepackt zu 100% als PHP String reproduziert werden.

          Edit: Beim Versuch hier einUTF8-mb4 Emoji anstelle von "\xf0\x9f\x98\x81" reinzustellen hat der tolle Forumeditor hier mir den 2.Teil des Beitrages gelöscht!

          PHP-Code:
          $string "Hallo \xf0\x9f\x98\x81";
          echo 
          debug::strToUnicode($string);
          //Hallo\u{20}\u{1f601} 
          2. invalide UTF-8 Strings sollen möglichst in valide und kaputte Teile gesplittet werden sowie gut lesbar und reproduzierbar dargestellt werden.
          Ich denke das ist mir weitgehend gelungen.

          PHP-Code:
          $invalid substr("€",0,1);
          $string "äö".$invalid."äö";
          echo 
          debug::strToUnicode($string);
          //\u{e4}\u{f6}\xe2\u{e4}\u{f6} 
          Die UTF-8 Zeichen ä und ö werden als \u{e4} und \u{f6} ausgegeben, das Byte-Fragment (vom €) als \xe2 und die folgenden Zeichen wieder als Unicode.
          Diese Zeichenkette reproduziert $string zu 100%.

          Probe
          PHP-Code:
          $strFromOutput "\u{e4}\u{f6}\xe2\u{e4}\u{f6}";
          var_dump($strFromOutput === $string);
          //bool(true) 
          PHP-Klassen auf github

          Kommentar


          • #6
            Mehr so als Randnotiz:

            Ich habe spaßeshalber Code, der direkt mit dem UTF-8-Format arbeitet (siehe etwa Wikipedia/sboeschs Beitrag), mal exemplarisch als simple State Machine gebaut.

            So als Ansatz, wie ein entsprechender Algorithmus funktionieren kann. (Es ist nicht groß getestet. Können auch offensichtliche Fehler drin sein.) Da alle auftretenden Pfade sehr explizit im Code modelliert sind, könnte man zum Beispiel recht leicht Fehlermeldungen hinzufügen wie: „UTF-8-Folge-Byte an Position x erwartet.“

            Die Idee ist, dass eine Eingabe (String) byteweise abgearbeitet wird, wobei der Code bei jedem Byte in einen anderen (oder den aktuellen) Zustand übergeht.

            Als Seiteneffekt werden Eingabebytes gegebenenfalls zwischengespeichert (gebuffert) und bei bestimmten Zustandsübergängen in die Ausgabe hinzugefügt (als „Fehlerbytes“, als Unicode-Code-Point oder als ASCII-Zeichen – fast genau wie im Code von jspit).

            Zustände:

            S (START): Ausgangszustand und Zustand, wenn nicht in Multibyte-Sequenz.
            E3 (EXPECT3): In Multibyte-Sequenz. Erwartet drei UTF-8-Folge-Bytes.
            E2, E1: Analog zu E3 mit zwei bzw. einem erwarteten UTF-8-Folge-Byte.

            Übergänge:

            S→S: Wenn Byte ein ASCII-Zeichen ist.
            S→E3, S→E2, S→E1: Wenn Byte ein UTF-8-Start-Byte ist (je nach Länge der Multibyte-Sequenz).
            E3→S, E2→S: Ausschließlich bei Fehlern in der Eingabe (z. B. Start-Byte, obwohl Folge-Byte erwartet). Wenn es um reine Validierung ginge, müssten die beiden Übergänge in einen zusätzlichen Error-Zustand laufen.
            E1→S: Bei Fehlern (siehe oben) oder wenn Multibyte-Sequenz erfolgreich abgeschlossen.
            E3→E2, E2→E1: Bei erwarteten Folge-Bytes wird jeweils in den Zustand gewechselt, der ein Folge-Byte weniger erwartet.

            PHP-Code:
            <?php

            class StateMachine
            {
                private const 
            STATE_START   1// not in character
                
            private const STATE_EXPECT1 2// expect one more continuation byte
                
            private const STATE_EXPECT2 3// expect two more continuation bytes
                
            private const STATE_EXPECT3 4// expect three more continuation bytes

                
            private const BYTE_TYPE_ASCII        1;   // 0xxxxxxx
                
            private const BYTE_TYPE_START2       2;   // 110xxxxx -- starting byte of 2 byte character
                
            private const BYTE_TYPE_START3       3;   // 1110xxxx -- starting byte of 3 byte character
                
            private const BYTE_TYPE_START4       4;   // 11110xxx -- starting byte of 4 byte character
                
            private const BYTE_TYPE_CONTINUATION 128// 10xxxxxx -- continuation byte in character
                
            private const BYTE_TYPE_UNKNOWN      255// other

                
            private function formatError(array $bytes): string
                
            {
                    
            $ret '';

                    foreach (
            $bytes as $byte) {
                        
            $ret .= '\x' sprintf('%2X'ord($byte));
                    }

                    return 
            $ret;
                }

                private function 
            format(array $bytes): string
                
            {
                    
            // Show ASCII bytes "as is"
                    
            if (count($bytes) === 1) {
                        return 
            $bytes[0];
                    }

                    
            $unicodeCodePoint $this->getUnicodeCodePoint(implode(''$bytes));

                    return 
            sprintf('\u{%s}'$unicodeCodePoint);
                }

                private function 
            getByteType(string $byte): int
                
            {
                    
            $numeric ord($byte);

                    
            $byteType self::BYTE_TYPE_UNKNOWN;

                    if (
            $numeric >> === 0x00) {
                        
            // 0xxxxxxx
                        
            $byteType self::BYTE_TYPE_ASCII;
                    } elseif (
            $numeric >> === 0x02) {
                        
            // 10xxxxxx
                        
            $byteType self::BYTE_TYPE_CONTINUATION;
                    } elseif (
            $numeric >> === 0x06) {
                        
            // 110xxxxx
                        
            $byteType self::BYTE_TYPE_START2;
                    } elseif (
            $numeric >> === 0x0E) {
                        
            // 1110xxxx
                        
            $byteType self::BYTE_TYPE_START3;
                    } elseif (
            $numeric >> === 0x1E) {
                        
            // 11110xxx
                        
            $byteType self::BYTE_TYPE_START4;
                    }

                    return 
            $byteType;
                }

                private function 
            getUnicodeCodePoint(string $utf8char): string
                
            {
                    
            // Copied from Debug class. Didn't look into it
                    
            $utf32char mb_convert_encoding($utf8char'UTF-32BE''UTF-8');
                    
            $int32arr  unpack('N'$utf32char);

                    return 
            dechex($int32arr[1]);
                }

                public function 
            run(string $input): string
                
            {
                    
            $buffer = [];
                    
            $state  self::STATE_START;
                    
            $ret    '';

                    foreach (
            str_split($input) as $byte) {
                        
            $byteType $this->getByteType($byte);

                        if (
            $state === self::STATE_START) {
                            switch (
            $byteType) {
                                case 
            self::BYTE_TYPE_ASCII:
                                    
            $buffer[] = $byte;
                                    
            $ret      .= $this->format($buffer);
                                    
            $buffer   = [];
                                    break;
                                case 
            self::BYTE_TYPE_CONTINUATION:
                                case 
            self::BYTE_TYPE_UNKNOWN:
                                    
            $buffer[] = $byte;
                                    
            $ret      .= $this->formatError($buffer);
                                    
            $buffer   = [];
                                    break;
                                case 
            self::BYTE_TYPE_START2:
                                    
            $buffer[] = $byte;
                                    
            $state    self::STATE_EXPECT1;
                                    break;
                                case 
            self::BYTE_TYPE_START3:
                                    
            $buffer[] = $byte;
                                    
            $state    self::STATE_EXPECT2;
                                    break;
                                case 
            self::BYTE_TYPE_START4:
                                    
            $buffer[] = $byte;
                                    
            $state    self::STATE_EXPECT3;
                                    break;
                            }
                        } elseif (
            $state === self::STATE_EXPECT1) {
                            switch (
            $byteType) {
                                case 
            self::BYTE_TYPE_ASCII:
                                case 
            self::BYTE_TYPE_START2:
                                case 
            self::BYTE_TYPE_START3:
                                case 
            self::BYTE_TYPE_START4:
                                case 
            self::BYTE_TYPE_UNKNOWN:
                                    
            $buffer[] = $byte;
                                    
            $ret      .= $this->formatError($buffer);
                                    
            $buffer   = [];
                                    
            $state    self::STATE_START;
                                    break;
                                case 
            self::BYTE_TYPE_CONTINUATION:
                                    
            $buffer[] = $byte;
                                    
            $ret      .= $this->format($buffer);
                                    
            $buffer   = [];
                                    
            $state    self::STATE_START;
                                    break;
                            }
                        } elseif (
            $state === self::STATE_EXPECT2) {
                            switch (
            $byteType) {
                                case 
            self::BYTE_TYPE_ASCII:
                                case 
            self::BYTE_TYPE_START2:
                                case 
            self::BYTE_TYPE_START3:
                                case 
            self::BYTE_TYPE_START4:
                                case 
            self::BYTE_TYPE_UNKNOWN:
                                    
            $buffer[] = $byte;
                                    
            $ret      .= $this->formatError($buffer);
                                    
            $buffer   = [];
                                    
            $state    self::STATE_START;
                                    break;
                                case 
            self::BYTE_TYPE_CONTINUATION:
                                    
            $buffer[] = $byte;
                                    
            $state    self::STATE_EXPECT1;
                                    break;
                            }
                        } elseif (
            $state === self::STATE_EXPECT3) {
                            switch (
            $byteType) {
                                case 
            self::BYTE_TYPE_ASCII:
                                case 
            self::BYTE_TYPE_START2:
                                case 
            self::BYTE_TYPE_START3:
                                case 
            self::BYTE_TYPE_START4:
                                case 
            self::BYTE_TYPE_UNKNOWN:
                                    
            $buffer[] = $byte;
                                    
            $ret      .= $this->formatError($buffer);
                                    
            $buffer   = [];
                                    
            $state    self::STATE_START;
                                    break;
                                case 
            self::BYTE_TYPE_CONTINUATION:
                                    
            $buffer[] = $byte;
                                    
            $state    self::STATE_EXPECT2;
                                    break;
                            }
                        }
                    }

                    
            // Processing should terminate in STATE_START. If not, there is a dangling buffer with an incomplete UTF-8
                    // character
                    
            if ($state !== self::STATE_START) {
                        
            $ret .= $this->formatError($buffer);
                    }

                    return 
            $ret;
                }
            }

            $euroSign = ["\xE2""\x82""\xAC"];

            $input 'a'                              // => "a"
                
            $euroSign[0] . $euroSign[1] . 'z'    // => "\xE2\x82\x7A" [1]
                
            'xy'                                 // => "xy"
                
            implode(''$euroSign)               // => "\u{20ac}"
                
            $euroSign[0];                        // => "\xE2"

            // [1]: "z" is interpreted as invalid continuation byte.

            $stateMachine = new StateMachine();

            $return $stateMachine->run($input);

            var_dump(
                
            $return,
                
            "a\xE2\x82\x7Axy\u{20ac}\xE2" === $input
            );

            Kommentar


            • #7
              Eine interssante Variante. Habe mal ein paar Test's gemacht. Vorab, die Klasse tut was sie soll.
              Für mich wichtige Merkmale für die erzeugten codierten Strings sind ja

              1. Das diese nur aus reinen im Browser darstellbaren ASCII-Zeichen bestehen
              2. Als String kopiert und in doppelte Hochkommas eingeschlossen von PHP so geparst werden,
              das der ursprüngliche UTF8 oder Binär-String reproduziert wird.

              Der folgende Test prüft Punkt 2 das anhand einiger Beispiele.

              PHP-Code:
              $stateMachine = new StateMachine();

              $euroSign = ["\xE2""\x82""\xAC"];
              $input 'a'                              // => "a"
                  
              $euroSign[0] . $euroSign[1] . 'z'    // => "\xE2\x82\x7A" [1]
                  
              'xy'                                 // => "xy"
                  
              implode(''$euroSign)               // => "\u{20ac}"
                  
              $euroSign[0];                        // => "\xE2"

              $inputs = ['A','Ab0ä߀',"Ab0ä\x80߀","ä\x80\u{11680}€","€\u{1f604}A\u{10348}¢",'عربى',"\x03\r\n",$input];

              foreach(
              $inputs as $testStr){
                
              $ret $stateMachine->run($testStr);
                
              //$ret = debug::strToUnicode($testStr);
                
              $parseRet = eval('return "'.$ret.'";');
                echo 
              $ret.' is '.($parseRet === $testStr"Ok" "not Ok")."<br>";

              Für das erneute Parsen zum Überprüfen des codierten Strings wird eval verwendet.

              Im Orginalcode haben anstelle dieser \u{11680} Notationen die echten UTF-8 Zeichen gestanden. Doch leider versagt die Forensoftware an dieser Stelle und löscht diese Zeichen inklusive den folgenden Text komplett. Ohne die Umcodierung wäre der Sourcecode hier nicht (reproduzierbar) darstellbar. Da haben wir gleich ein praktisches Beispiel.

              Ausgabe:
              Code:
              A is Ok
              Ab0\u{e4}\u{df}\u{20ac} is Ok
              Ab0\u{e4}\x80\u{df}\u{20ac} is Ok
              \u{e4}\x80\u{11680}\u{20ac} is Ok
              \u{20ac}\u{1f604}A\u{10348}\u{a2} is Ok
              \u{639}\u{631}\u{628}\u{649} is Ok
               is Ok
              a\xE2\x82\x7Axy\u{20ac}\xE2 is Ok

              Den Punkt 1 betreffend gibt es durch die unterschiedlichen Verfahren bedingt minimale Unterschiede.
              So erkennt die Klasse StateMachine sogar das hinter der beginnenden Unicode-Sequenz \xE2\x82 das 3.Byte falsch ist
              und liefert das Zeichen "z" deshalb als "\x7A" aus.
              strToUnicode() unterscheidet an der Stelle nicht und liefert "z" aus.
              Steuerzeichen die im Browser Probleme machen liefert strToUnicode() als Hexcodierung aus,
              so das im Test anstelle von
              Code:
               is Ok
              das
              Code:
              \u{3}\u{d}\u{a} is Ok
              geliefert wird. Der Klasse StateMachine dieses Verhalten beizubringen (sofern gewünscht) ist jedoch kein Problem.

              Mit den hier gezeigten Verfahren können ja beliebige UTF-8 oder Binärstrings als ASCII codiert werden und wieder als
              Unicode/Binärsequenz reproduziert werden. Da sind neben der Diagnose weitere Anwendungen denkbar.



              PHP-Klassen auf github

              Kommentar


              • #8
                Habe jetzt eine Lösung welche reguläre Ausdrücke nutzt und mb_substr() nicht mehr benötigt. Eine Funktion welche ein nicht dokumentiertes Verhalten ausnutzt war mir doch zu unsicher. Dieses Verhalten kann ja in der nächsten PHP-Version womöglich anders sein.
                Die Funktion steht in dieser Debug-Klasse (ab. Version: 2.46 ) zur Verfügung. Zusätzlich wurde eine Methode writeUni zugefügt:
                PHP-Code:
                $string "takriLetterA:☐";
                debug::writeUni($string); 
                Ausgabe:
                [10.12.2019 18:51:07,572][+586 μs](690k/775k) Debug::writeUni "phpcheck.class.debug.php" Line 122
                0 string(17) UTF-8mb4 takriLetterA:\u{11680}

                Die Methode hat sich schon bewährt bei der Identifizierung von Zeichen die im Editor/Browser nicht oder schlecht zu erkennen sind.
                (Hinweis: Das Zeichen im obigen Code ist nur optisch gleich).
                PHP-Klassen auf github

                Kommentar

                Lädt...
                X