- Selbst ein kleines C-
hello-Programm wird unter Linux zu einer ausführbaren ELF-Datei; mit readelf, nm und objdump lässt sich ihr innerer Aufbau direkt untersuchen
- Die zentralen Achsen zum Verständnis ausführbarer Dateien sind Symbole, Sections und Segmente; sie sind jeweils für Funktionsverknüpfungen, die Trennung von Code/Daten und die Speicheranordnung zur Laufzeit zuständig
- Mit
objdump und readelf kann man die Bytes und Eigenschaften von Sections wie .text, .rodata, .data, .bss und .interp einsehen
- Ein Programm startet nicht direkt bei
main, sondern tritt zuerst in _start ein und ruft nach mehreren Initialisierungsschritten main auf
- Eine ausführbare Datei ist kein „unlesbarer Klotz“, sondern eine Datei in einem festgelegten Format; mit den passenden Tools lassen sich Code, Strings und Linking-Informationen Schritt für Schritt nachverfolgen
Ausführbare Dateien sind ein lesbares Dateiformat
- Eine kompilierte ausführbare Datei wirkt zunächst wie eine unlesbare „magische Binärdatei“, ist tatsächlich aber ein verständliches Dateiformat
- Das Beispiel bezieht sich auf ELF-Binärdateien unter Linux; da Binärdateien plattformabhängig sind, ist auch die Erklärung an diese Plattform gebunden
- Als Beispiel dient das folgende C-Programm
#include <stdio.h>
int main() {
printf("Penguin!\n");
}
- Es wird mit
gcc -o hello hello.c kompiliert, um die ausführbare Datei hello zu erzeugen, deren Inneres anschließend untersucht wird
- Der Gesamtüberblick orientiert sich an drei Konzepten
- Symbole (symbols): werden verwendet, um die Position von Funktionen zu finden, die an anderer Stelle definiert sind, etwa
printf
- Sections (sections): Einheiten zur Trennung von Code und Daten, darunter
.text, .data, .rodata usw.
- Segmente (segments): fassen Sections zu Einheiten der Speicheranordnung zur Laufzeit zusammen
Auch beim Öffnen als Text sind Hinweise sichtbar
- Öffnet man eine ausführbare Datei direkt, etwa mit
cat hello, wird größtenteils Zeichensalat ausgegeben
- Dennoch lassen sich in der Ausgabe Strings wie
Penguin! und ELF finden
- ELF ist der Name des Dateiformats dieser Binärdatei
- Der Großteil der Ausgabe ist für Menschen schwer lesbar, weil eine ausführbare Datei aus Binärdaten besteht
Mit der Symboltabelle Funktionsnamen und Verknüpfungen prüfen
readelf --symbols hello gibt die Symboltabelle der ausführbaren Datei aus
- In der Beispielausgabe erscheinen wichtige Symbole
main: die Adresse der selbst geschriebenen Funktion main()
puts@@GLIBC_2.2.5: offenbar eine Referenz im Zusammenhang mit dem im Code aufgerufenen printf; vermutlich hat der Compiler sie per Optimierung in puts umgewandelt
_start: ein wichtiges Symbol im Zusammenhang mit dem Programmstart
- Ein Programm startet nicht direkt bei
main, sondern tritt tatsächlich zuerst in _start ein
_start erledigt mehrere wichtige Aufgaben, darunter auch den Aufruf von main
Symbole ermöglichen Verknüpfungen
- Wenn man im Programm eine Funktion namens
hello schreibt, wird der Code dieser Funktion in der kompilierten Binärdatei mit dem Symbol hello versehen
- Um eine Bibliotheksfunktion wie
printf aufzurufen, braucht man eine Möglichkeit, die Position des Codes dieser Funktion zu finden
- Der Vorgang, die Position einer Funktion zu finden, heißt Linking
- geschieht er direkt nach dem Kompilieren, spricht man von statischem Linking
- geschieht er beim Starten des Programms, spricht man von dynamischem Linking
libc enthält die Funktionen der C-Standardbibliothek
- Auch wenn
nm bei libc „no symbols“ ausgibt, kann man mit objdump -tT /lib/x86_64-linux-gnu/libc-2.15.so die Symbole sehen
- In der Symboltabelle von libc lassen sich Funktionen wie
sprintf, strlen, fork und exec finden
- Man kann sich die Funktionsweise des dynamischen Linkings als Ablauf vorstellen, bei dem
hello puts aufruft und die Position von puts in der Symboltabelle von libc gesucht wird
Sections trennen Code und Daten
objdump -s hello gibt die Bytes in den einzelnen Sections der ausführbaren Datei als Hexadezimalwerte und ASCII aus
- Wichtige Sections sind unter anderem
.text: der eigentliche Code des Programms, also Assembly; enthält _start und main
.rodata: enthält schreibgeschützte Daten, im Beispiel den String "Penguin!"
.interp: enthält den Dateinamen des dynamischen Linkers
- Sections und Segmente werden zu unterschiedlichen Zeitpunkten verwendet
- Sections werden beim Linken von
ld verwendet
- Segmente werden zur Laufzeit verwendet
- Mit
readelf --sections hello lassen sich die Metadaten der Sections genauer betrachten
- Die Flags im Beispiel zeigen den Charakter der jeweiligen Section
.text: ausführbar und schreibgeschützt
.rodata: schreibgeschützt
.data: les- und schreibbar
.bss: beschreibbarer Datenbereich
Disassembly macht Maschinencode als Assembly sichtbar
- Die Section
.text enthält Bytes, die die CPU als Code interpretiert und ausführt
- Die Anfangsbytes
31 ed von .text im Beispiel sind für Menschen nicht direkt verständlich, daher braucht man einen Disassembler
objdump -d ./hello disassembliert die Section .text und zeigt sie als Assembly-Instruktionen
- In der Beispielausgabe wird
31 ed als xor %ebp,%ebp angezeigt
- Auf diese Weise lässt sich feststellen, welchen Assembly-Instruktionen die Code-Bytes in der Binärdatei entsprechen
Segmente legen die Speicheranordnung zur Laufzeit fest
- Eine ausführbare Datei besteht auch aus Segmenten bzw. Program Headers
readelf --segments hello zeigt die Segmente des Programms sowie die Zuordnung von Sections zu Segmenten
- Segmente werden verwendet, um festzulegen, wie die einzelnen Teile eines Programms im Speicher angeordnet werden
- Im Beispiel gibt es zwei wichtige
LOAD-Segmente
- erstes
LOAD: mit R E markiert, also les- und ausführbar
- zweites
LOAD: mit RW markiert, also les- und schreibbar
.text muss gelesen und ausgeführt werden, darf aber nicht beschrieben werden; daher liegt es im ersten Segment
.data und .bss müssen beschreibbar sein, müssen aber nicht ausgeführt werden; daher liegen sie im zweiten Segment
Weitere Tools und Materialien
- ELF-Executable-Dateien sind keine besondere Magie, sondern ein gewöhnliches Dateiformat; Linux-Binärdateien lassen sich mit
readelf, nm und objdump untersuchen
- Verwandte Materialien
1 Kommentare
Kommentare auf Hacker News
Wie schon in einem anderen Thread gesagt: https://news.ycombinator.com/item?id=38847750#38862450, ich kann nur dringend empfehlen, einmal von Hand eine ELF-Datei zu schreiben.
Das ist eine gute Übung, um die grundlegenden Bausteine ausführbarer Dateien zu verstehen, und hilft auch, wenn man im Gegensatz zu diesem Artikel nicht von oben nach unten, sondern von unten nach oben herangehen möchte.
Auch in mehreren Threads dieses anderen HN-Beitrags gibt es viele gute Diskussionen.
Außerdem habe ich eine interaktive Visualisierung erstellt, die die Bytes der Datei zeigt, um mir selbst und anderen das Format zu erklären.
Wenn man auf ein Byte klickt, erscheint eine Erklärung, und die zugehörigen Bytes in der Datei werden hervorgehoben, was beim Verständnis hilft: https://scratchpad.avikdas.com/elf-explanation/elf-explanati...
Dynamisches Linking bringt einiges an Implementierungskomplexität mit sich, aber wenn man nur statische ELF-Dateien unterstützt, ist es ziemlich geradlinig.
Am Anfang ist es frustrierend, weil Debugging praktisch unmöglich ist, wenn es nicht funktioniert, aber wenn es schließlich läuft, ist es wirklich großartig.
ELF lässt sich auf interessante Weise patchen, und mit dem Auxiliary Vector ist auch Selbstbeobachtung zur Laufzeit möglich.
Da Linux die Adresse der Program Header Table bereitstellt, kommt man von dort aus überall hin, und man muss nur das LOAD-Segment so erweitern, dass es die gesamte Binärdatei abdeckt.
Zum Beispiel habe ich ein Tool gebaut, das Lisp-Module und Code direkt in die ausführbare Datei meines Lisp-Interpreters einfügt.
Das eingefügte Segment wird von ELF automatisch geladen, und der Interpreter findet es und führt es aus.
Diese kleine Funktion hat mir so gut gefallen, dass ich auch einen Artikel darüber geschrieben habe: https://www.matheusmoreira.com/articles/self-contained-lone-...
Ich wünschte, Mainstream-Sprachen würden diesen Ansatz ebenfalls übernehmen.
Man liest die Dokumentation, wählt anhand der Prozessordatenblätter die nötigen Bytes aus, legt sie der Reihe nach in mehreren Sektionen ab, füllt die ELF-Felder aus – am Ende läuft alles darauf hinaus, die Bytes einzugeben.
In Umgebungen aus der Zeit vor ELF, etwa auf einem 8-Bit-Apple II, konnte man mit einem Machine-Code-Monitor Programmbits direkt eingeben, und diese Bytes wurden ausgeführt.
Das Speichern auf Diskette ist nur etwas komplizierter, und auch darin steckt wieder eine Gelegenheit.
Mit einem Disk-Sektor-Editor kann man Dateien erstellen, und so geht es immer weiter.
Soweit ich es verstehe, ist das Symbol
mainC-spezifisch.Das Symbol
_startist der sprachunabhängige Einstiegspunkt der Binärdatei und ruft in diesem Fallmainauf.Hätte es die Konvention gegeben, den Einstiegspunkt
_startzu nennen und ihmargc/argvvonmainzu übergeben, wäre das Format deutlich weniger flexibel gewesen._startnichts Besonderes.Die Binärdatei enthält im Header die Adresse des Einstiegspunkts, und das Betriebssystem beginnt die Ausführung an dieser Adresse.
Dieses Symbol
_startzu nennen, ist lediglich eine Konvention von C und anderen Sprachen; der Linker verwendet es, um beim Schreiben des ELF-Headers den Einstiegspunkt zu setzen.Wenn man ein eigenes Linker-Skript schreibt, kann man dem Einstiegspunkt einen beliebigen Namen geben.
maingibt es richtigerweise nur in hosted C.In freestanding C kann man einen beliebigen Einstiegspunkt haben.
Auch
_startist nur der Default des Linkers; mit-Wl,--entry="${symbol}"kann man ein besseres Symbol angeben, und GCC unterstützt es auch, dies direkt ohne das unschöne-Wlzu setzen.Außerdem ist der Einstiegspunkt tatsächlich kein Symbol, sondern ein Pointer.
Der Linker nimmt lediglich die Adresse des angegebenen Symbols und setzt sie als ELF-Einstiegspunkt.
Auf dem Stack liegen nicht nur Argumentanzahl und Argumentvektor, sondern auch der Environment Vector und der Auxiliary Vector.
Der Prozess-Startcode kann so einfach sein, dass er diese Werte vom Stack nimmt, sie in die passenden Register legt und die gewünschte C-Funktion aufruft.
Der Einstiegspunkt selbst ist keine Funktion, daher gibt es keinen Ort, an den er zurückkehren könnte.
Der Code des Einstiegspunkts muss mit dem Systemaufruf
exitenden, damit der Prozess sauber beendet wird, wennmaineinen Statuscode zurückgibt.Zumindest unter Linux funktioniert es so.
In Sprachen wie Rust/C/C++ kann man über Linker-Flags auch Variablen injizieren, die initialisiert werden sollen.
Wenn das Programm dynamisch gelinkt ist, läuft meines Wissens vor
_startdie Linker-Runtime, löst die Links auf und übergibt dann die Kontrolle an_start.Letztlich sind das Hacks auf Hacks, die organisch hinzugefügt wurden, um Erweiterbarkeit zu bieten, und die sozial ausreichend akzeptiert sind und ausreichend gut funktionieren, dass wir sie weiterverwenden.
2012 wechselte ich meinen akademischen Weg von der Mathematik zur Informatik und begann zu bloggen; dieses Thema war buchstäblich das Erste, womit ich mich beschäftigt habe: https://heinrichhartmann.com/archive/Dissecting-Hello-World....
Ich habe es nie bereut, in diesen tiefen Kaninchenbau hinabgestiegen zu sein.
Wenn ich mich recht erinnere, hat Julia ebenfalls einen mathematischen Hintergrund.
Vielleicht zieht es Mathematiker zu solchen Experimenten, weil sie den Drang haben, von Grund auf zu argumentieren.
Schön, dass sie dieses Thema für viele Menschen zugänglich gemacht hat.
Julias Texte sind immer hervorragend.
Wenn man zeigen will, dass kompilierter Code keine Geheimnisse verbergen kann, kommt eine Demonstration von
stringsimmer gut an.Irgendein armer Kerl wurde mit einer Geldstrafe belegt, weil er ein Passwort auf dieselbe Weise gefunden hatte, wie wenn man
stringsauf ein Binary ausführt.Die Richter sahen darin ein „Umgehen“ der Sicherheitsmaßnahmen der Software: https://www.theregister.com/2024/01/19/germany_fine_security...
Keine Kritik und keine Haarspalterei, nur ein Gedanke, der mir kam.
Der Satz „Da Binaries gewissermaßen per Definition plattformspezifisch sind, ist all das ebenfalls plattformspezifisch“ erinnerte mich an den Moment, als Actually Portable Executable zeigte, dass dasselbe Binary auf mehreren Plattformen laufen kann.
Von diesem surrealen Moment habe ich mich geistig immer noch nicht ganz erholt.
Jahrzehntelang haben wir versucht, das Cross-Platform-Problem auf allen möglichen fraktalen Wegen zu lösen, mit Java, Cross-Platform-Libraries und so weiter, dabei lag die Lösung die ganze Zeit direkt vor unserer Nase.
Im Zeitalter schneller Computer halte ich die Verteilung von Quellcode und lokales Kompilieren für besser als die Verteilung von Binaries.
Leider ist ein großer Teil der Software, von der wir abhängen, zu groß, und Compiler sind vergleichsweise langsam, sodass Binary-Distribution zu einer Art notwendigem Übel wird.
Mir wäre lieber, es würde mehr Aufwand in einfachere Softwarebausteine fließen, die sich von Natur aus schnell kompilieren lassen, sowie in schnellere Compiler, statt in portable Binaries.
Es ist ein Skript, das auf jedem System ausgeführt werden kann, und dieses Skript kann ein Binary laden.
Wenn ich mich recht erinnere, musste die ursprüngliche Version vor dem Laden aus Base64 dekodiert werden.
Es ist also eher ein ausführbarer Binary-Loader.
Anfang der 1990er war ich von Executable-Formaten fasziniert, baute einige Wochen lang in Modula 2 einen Viewer für DOS- und Windows-Executables, nannte ihn VEXE und veröffentlichte ihn 1991 als Shareware.
Das Tool erlangte unter Crackern eine gewisse Nischenpopularität und wurde sogar in den +ORC-Tutorials erwähnt: https://gist.github.com/callowaysutton/48bdf0245e17e72d41a15...
Vermutlich, weil es verschiedene Verschlüsselungs- und Kompressionsverfahren erkennen konnte, die eingesetzt wurden, um Reverse Engineering von Programmen zu verhindern.
Wenn dich interessiert, wie klein eine ELF-Binary-Datei werden kann, dürfte dir dieser unterhaltsame Artikel gefallen: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
Schau dir auch mein Tool an, mit dem man ELF per SQL erkunden kann: https://github.com/fzakaria/sqlelf
Kann jemand jemandem mit starkem Python-Hintergrund praktische Einstiegsressourcen oder Bücher zur Low-Level-Programmierung empfehlen?
Ich habe vor Kurzem angefangen, Rust zu lernen, und gemerkt, dass ich viel aufzuholen habe.
Da ich nie einen Compiler-Kurs belegt habe, entgehen mir vielleicht viele Informationen.
Zum Beispiel wusste ich nicht einmal, dass es in Binaries so etwas wie Symbole gibt, und kannte auch den Unterschied zwischen ELF und Mach-O nicht.
Ein Binary im Terminal mit
catauszugeben, ist eine Abkürzung ins Elend.Ich mag
| hd, was im Grundehexdump -Cist und mit bloßem Auge allerdings ähnlich kryptisch wirkt.