3 Punkte von GN⁺ 2024-07-01 | 1 Kommentare | Auf WhatsApp teilen
  • C ist einfach und ausdrucksstark, aber schon allein mit der switch-Anweisung und den Regeln für Labels lässt sich Code schreiben, der selbst erfahrene Entwickler aus dem Konzept bringt
  • Obfuskation im IOCCC-Stil versteckt Code mit Makros und Formatierung, doch allein mit der C-Grammatik sind lesbarer, aber fremdartiger Code und ungewöhnliche Konstrukte möglich
  • switch (...) braucht keine geschweiften Klammern, und weil zu einem passenden case-Label gesprungen wird, kann das Verhalten von normalen Initialisierungsabläufen in Blöcken abweichen
  • case-Labels müssen nicht nur auf der obersten Ebene eines switch-Blocks stehen, sodass sogar ein klammerloses switch in Kombination mit if (0) case... und einer else if-Kette kompiliert
  • Die GNU-C-Erweiterung für Label-Adressen mit && ermöglicht sogar selbst gebaute switch-Konstrukte oder labelbasierte Schleifen, aber einige Beispiele sind nur für GCC und möglicherweise nicht sicher gegenüber undefiniertem Verhalten

Fremdartiger Code durch die C-Syntax

  • C hat viele Schwächen, wird aber dank seiner einfachen Syntax und Ausdrucksstärke als Sprache genutzt, mit der sich große Software wie Betriebssysteme schreiben lässt
  • Die knappe Syntax hat auch die Codestruktur vieler späterer Mainstream-Sprachen von Java bis Go beeinflusst
  • Ein typisches Beispiel für Code-Obfuskation ist der IOCCC
    • Beiträge, die beim IOCCC ausgezeichnet werden, bestehen oft aus Präprozessor-Makros, ungewöhnlicher Formatierung, nichtssagenden Variablennamen und schwer verständlichen arithmetischen Ausdrücken
    • Solcher Code ist beeindruckend, kann zum Lernen aber weniger Spaß machen, weil man ihn erst in normalen Code zurückübersetzen muss

Die tiefen Ecken von C, sichtbar in switch, case und goto

  • switch (...) kann wie if (...) oder for (...) ohne geschweifte Klammern geschrieben werden
    • switch (i) case 1: puts("i = 1"); lässt sich kompilieren
    • Ohne Klammern gehört aber nur genau eine Anweisung zu switch, daher liegt ein folgendes case 2: nicht mehr innerhalb des switch und führt zu einem Fehler
  • switch ist im Kern eher eine goto-artige Struktur, die zu einem passenden case-Label springt
    • Selbst wenn im switch-Block int a = 123; und puts(...) stehen, wird beim Sprung zu default: der davorstehende Initialisierungscode nicht ausgeführt
    • In diesem Fall wird a nicht mit 123 initialisiert; technisch gesehen ist das undefiniertes Verhalten
    • Das Beispiel ist auf Godbolt zu sehen
  • case-Labels müssen nicht nur auf der obersten Ebene des zugehörigen switch-Blocks stehen
    • Auch eine Form wie if (0) case 0: puts("i = 0"); funktioniert
    • Weil switch direkt zu diesem case springt, wird das vorangestellte if (0) übersprungen
    • Andere Ausgaben nach diesem puts(...) bleiben aber weiter durch die Bedingung if (0) blockiert, sodass sich Fallthrough auch ohne break vermeiden lässt
    • Das Beispiel ist auf Godbolt zu sehen
  • Eine if ... else-Kette verhält sich syntaktisch wie eine einzige Anweisung auf oberster Ebene und lässt sich daher mit switch zu einem bizarren switch ohne Klammern kombinieren
    • Das Beispiel enthält sogar case 1 ... 10 als Bereichs-case sowie default
    • Das Beispiel ist auf Godbolt zu sehen
  • Der GNU-spezifische Operator && liefert die Adresse eines Labels und erlaubt ein goto zu dieser Adresse
    • Damit lässt sich ein selbst gebautes switch wie goto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i]; implementieren
    • Das Beispiel ist auf Godbolt zu sehen
  • Mit derselben GNU-Erweiterung lässt sich sogar ohne for (...) eine labelbasierte Schleife innerhalb einer Variablendeklaration umsetzen
    • Das Beispiel besteht aus einer Schleife, die von i = 0 bis i = 5 ausgibt und mit Labels sowie goto *&&_ aufgebaut ist
    • Das Beispiel ist auf Godbolt zu sehen
    • Dieses letzte Snippet ist nur für GCC und möglicherweise nicht sicher gegenüber undefiniertem Verhalten
  • In C lassen sich auch ohne absichtliche Obfuskation Formen erzeugen, die völlig fremd und verwirrend wirken

1 Kommentare

 
GN⁺ 2024-07-01
Meinungen auf Hacker News
  • Im obigen Beispiel wird zwar der Wert von a ausgegeben, aber gesagt, dass er nicht mit 123 initialisiert wird; in C kann das tatsächlich trotzdem passieren.
    Eine nicht initialisierte Variable zu verwenden bedeutet nicht, „den Wert zu lesen, der zufällig noch in diesem Speicher lag“, sondern ist undefiniertes Verhalten; der Compiler darf also machen, was er will.
    Er könnte zum Beispiel diesen Speicher grundsätzlich mit 123 initialisieren, oder den gesamten Abschnitt als undefiniertes Verhalten ansehen und alle Anweisungen entfernen, sodass gar nichts ausgegeben wird. Darüber hinaus kann er sogar das nachfolgende return oder vorherige Anweisungen wegoptimieren, sodass undefiniertes Verhalten so wirken kann, als würde es „in der Zeit zurück“ Einfluss nehmen.

    • In C reist undefiniertes Verhalten nicht wirklich in der Zeit zurück.
      Es kann zwar frühere Anweisungen beeinflussen, aber Code-Reordering und komplexe Transformationen passieren auch ohne undefiniertes Verhalten.
    • In C ist es nur beim Zugriff auf ein nicht initialisiertes Objekt mit automatischer Speicherdauer undefiniertes Verhalten.
      Statische Objekte werden immer initialisiert, daher entsteht diese Situation dort nicht.
      Übrig bleiben dynamische Objekte, etwa nicht initialisierte Member einer mit malloc allozierten Struktur. Das Lesen von nicht initialisiertem dynamischem Speicher ist in C kein undefiniertes Verhalten; man erhält den Wert, den die nicht initialisierten Bits bedeuten. Wenn der entsprechende Typ keine Trap-Repräsentation hat, kann es nicht fehlschlagen.
  • Auch solche Konstrukte sind möglich:
    switch(k) { if (0) case 0: x = 1; if (0) case 1: x = 2; if (0) default: x = 3; }
    Damit kann man eine Art switch bauen, bei dem man am Ende jedes Abschnitts kein break schreiben muss; auch ein Makro wie #define brkcase if (0) case wäre möglich. Dem Compiler dürfte der Kontrollfluss nicht gefallen, aber vermutlich optimiert er das meiste davon gut weg.

    • #define brkcase break;case scheint ähnlich zu funktionieren, aber dann geht der Zweck des Makros ein Stück weit verloren.
    • Es ist sehr unklar, wie diese Methode funktioniert; statt eines Switch-Missbrauchs im Stil von Duff's device wäre es wohl besser, denselben Kontrollfluss mit goto zu schreiben.
    • Diese Methode funktioniert nur, wenn der Body eines case-Labels aus einer Zeile besteht oder in geschweifte Klammern eingeschlossen ist.
      Früher habe ich diese Struktur einmal verwendet, um auszudrücken: „Nur die erste Zeile des nächsten case-Labels überspringen und den Rest per Fallthrough weiter ausführen.“
      Wenn man case-Labels nicht als Trenner zwischen Anweisungen, sondern einfach als Labels betrachtet, ergibt alles Sinn.
  • Mit dieser Technik lassen sich in C Koroutinen implementieren: https://stackoverflow.com/questions/24202890/switch-based-co...

  • Ich weiß nicht, warum mir nicht bewusst war, dass case 1 ... 10: gültiges C ist.
    Ich habe C jahrelang verwendet und frage mich, aus welchem Standard das stammt.

    • Wenn das nicht kürzlich standardisiert wurde, ist es kein gültiges C, sondern eine GNU-Erweiterung.
    • Sieht nach einer GNU-C-Erweiterung aus: https://gcc.gnu.org/onlinedocs/gcc/Case-Ranges.html
      Zur Geschichte der Erweiterung habe ich nichts gefunden, und soweit ich weiß, gibt es sie in Standard-C nicht. Bei clang bin ich mir nicht sicher.
  • Vor einiger Zeit habe ich aus Spaß merkwürdigen C-Code geschrieben, der von 10 bis 1 herunterzählt.
    Die C-, Python- und Shell-Versionen lassen sich alle mit && und rekursiven Aufrufen als Einzeiler bauen.

    • Kleinigkeit: sys.stdout.write(f"{n}\n") kann durch print(n) ersetzt werden.
      Der aktuelle Code wirkt, abgesehen vom f-string, wie Python-2-Stil, als print noch ein Statement war. In Python 3 ist print eine normale Funktion und gibt None zurück, was als falsch ausgewertet wird; daher müsste auch das erste and durch or ersetzt werden.
  • Eine weitere Überraschung ist, dass 4[arr] dasselbe ist wie arr[4].

    • Weil ein Array in einen Pointer umgewandelt wird, ist es im Grunde *(array_label+offset), und hier wird daraus *(offset+array_label).
      Es geht also darum, dass *(arr+4) und *(4+arr) gleich sind.
    • Nach demselben Prinzip sind auch arr[i][j] und j[i[arr]] exakt gleich.
      Man muss nur wissen, dass a[x][y] dasselbe ist wie (a[x])[y], und dass a[x] dasselbe ist wie x[a].
      arr[i][j](arr[i])[j](i[arr])[j]j[i[arr]]
  • Das letzte obfuskierte Codefragment des Artikels zeigt noch eine weitere GCC-Erweiterung: https://stackoverflow.com/questions/34559705/ternary-conditi...

  • Ich habe solche spielerischen Techniken dieses Blogautors zuerst auf Twitter gesehen.
    Mit einer switch-Anweisung kann man auch eine Schleife bauen: https://twitter.com/lcamtuf/status/1807129116980007037

  • Ich denke, solche switch-Spielereien sind ein wichtiger Teil von Duff's device.

    • Duff's device beruht darauf, dass man in der K&R-C-Syntax switch-Blöcke und Schleifen mischen kann, dass case ohne explizites break per Fallthrough weiterläuft, und dass eine Schleife in C wieder ins Innere eines switch springen kann.
      Duff wollte speicherabgebildete Ein-/Ausgabe (MMIO) optimieren, und in heutigem C würde man das wohl nicht mehr so machen. MMIO ist heute nicht mehr ähnlich schnell wie CPU-Instruktionen, und schon bei etwas mehr Daten kann man DMA verwenden.
      Moderne Sprachen würden MMIO vermutlich auch nicht als einfache Pointer-Indirektion behandeln; C hat dafür immer wieder Umgehungen in sein Typsystem eingebaut, um dieses Modell beizubehalten.
      Persönlich sehe ich den Nachfolger von Tom Duffs „Device“ im iterate loops-Mechanismus von WUFFS. Dabei legt man fest, wie die N Schritte einer Schleife teilweise entrollt werden, mit dem Versprechen, dass das Ergebnis dem N-maligen Ausführen des Hauptschleifenbodys entspricht, aber schneller sein kann. Vektorisierung kann die Absicht leichter erkennen, und lästige Randfälle wie M % N != 0 werden korrekt vom Werkzeug statt von einem Menschen behandelt.