Ein (nicht ganz so) kleiner Beitrag der tief in die Welt des Linux Dynamic Loaders mit seinen Helferchen GOT und PLT hinabsteigt, damit man mal sehen kann wie das eigentlich mit den Shared Librarys bei Linux funktioniert…
Wenn man heutzutage programmiert schreibt man viele Funktionen gar nicht selbst, denn viele Funktionen werden von Librarys bereitgestellt.
Librarys sind dabei Teile von Programmcode (die nicht alleine gestartet werden können) die jedes Programm verwenden kann.
Das ist nicht nur einfacher (und fehlerfreier) als alle Funktionen selbst zu schreiben, es ist auch sparsamer – denn wenn mehrere Programme die gleichen Funktionen aus der gleichen Library verwenden so muss diese Library nur einmal in den RAM geladen werden.
Bei Linux enden solche Programmteile auf ‚.so‘ was soviel wie Shared Library (Update: Shared Object!) heißt.
Jetzt bauen wir uns erstmal unsere eigene Shared Library und dann nehmen wir sie auseinander. Los gehts…
Zuerst erzeugen wir eine Datei ‚libfoo.c‘. Dies ist unsere Shared Library.
Sie ist in der Programmierspache C geschrieben, bekommt einen String (einen Satz) den sie auf dem Bildschirm ausgibt, und gibt einen Integer (Zahl) als Wert zurück, hier ist der Programmcode:
#include <stdio.h> int foo(char* myval) { printf("I should tell: %s\n", myval); return 42; }
Und dazu haben wir ein Programm welches diese Library aufruft, testprog.c:
extern int foo(char*); #include <stdio.h> int main(void) { int ret; printf("Calling my Lib!\n"); ret = foo("Do what I tell you!"); printf("My Lib told me: %d\n", ret); return 0; }
Hier fällt auf dass wir oben in unser Programm ‚extern int foo(char*);‘ geschrieben haben, das bedeutet dass wir eine Funktion ‚foo‘ in unserem Programm verwenden, die einen Integer zurückgibt und einen String (char*) bekommt. Wir definieren hier nicht wo das ist, denn beim bauen unseres Programmes wird der Linker das für uns auflösen (Symbol Resolving).
Jetzt nehmen wir erstmal unseren Gnu C-Compiler und sagen ihm dass wir aus der Datei ‚libfoo.c‘ eine Datei ‚libfoo.so‘ haben möchten. Diese Datei soll Prozessorcode beinhalten (also kompiliert werden) und sie soll eine Shared Library sein. Außerdem soll der Prozessorcode Positionsunabhängig sein. Während unser Programm nämlich Linux sagen kann ‚lade mich an Adresse 0x100000‘ und somit weiß an welcher Speicheradresse seine Programmteile stehen kann unsere Library das nicht. Unter anderem aus Sicherheitsgründen wird sie irgendwo im Speicher plaziert (ASLR = Address Space Layout Randomization). Da muss man beim Erzeugen des Assembler/Prozessorcodes gut aufpassen dass man keine absoluten Adressen verwendet, sondern nur relative.
gcc -shared -fPIC -o libfoo.so libfoo.c
Nachdem es nun hoffentlich keine Fehler gab (meine Beispiele sind selbstverständlich geprüft und fehlerfrei *hust* *hust*) compilieren wir nun noch unser Programm. Wir sagen dem Gnu Compiler diesmal dass wir alle Warnungen ausgegeben haben möchten wenn wir die Datei ‚testprog.c‘ zum Programm ‚testprog‘ kompilieren. Außerdem geben wir ihm an dass wir die Library ‚libfoo‘ verwenden möchten, und dass er diese im aktuellen Verzeichnis ‚.‘ suchen soll. Wenn wir das weglassen so wird sich der Linker beschweren dass wir eine Funktion ‚foo‘ nutzen wollen, er sie aber nirgendwo finden konnte!
gcc -Wall -o testprog testprog.c -L. -lfoo
Jetzt starten wir mal unser Programm mit dem Befehl ‚./testprog‘, und was passiert?
./testprog: error while loading shared libraries: libfoo.so: cannot open shared object file: No such file or directory
Aha! Immer wenn wir unser Programm starten sorgt ein Teil von Linux (der Loader) dafür dass alle Shared Librarys die unser Programm braucht auch verfügbar sind. Aber warum findet er unsere Library nicht?
Ganz einfach: Bei Linux werden Shared Librarys immer in bestimmten Ordnern erwartet (‚/usr/local/lib‘). Wenn wir dem Loader nun sagen dass der Ordner ‚.‘ mit verwendet werden soll geht es. Das machen wir ganz einfach über die Umgebungsvariable LD_LIBRARY_PATH, die wir erweitern:
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
Und schon läuft unser Programm:
Calling my Lib! I should tell: Do what I tell you! My Lib told me: 42
Nun sind wir aber sehr neugierige Menschen und wollen wissen wie das eigentlich genau funktioniert:
Und zwar hat der Linker beim erzeugen des Programmes zwei Programmteile (Sections) erzeugt die hier verwendet werden:
1. Die PLT (Procedure Linkage Table)
2. Die GOT (Global Offset Table)
Diese können wir uns ansehen. Zuerst sehen wir uns aber unseren Programmcode als Assemblercode an ‚objdump –disassemble ./testprog‘.
Interessant wird es ab der Funktion ‚0000000000400716 <main>‘,
die einzige Zeile die für uns wichtig ist, ist diese hier:
40072d: e8 ce fe ff ff callq 400600 <foo@plt>
Hier wird also unsere Funktion ‚foo‘ aufgerufen (das wird im Assemblercode mit einem ‚call‘ gemacht, dieser geht immer bis zum nächsten ‚ret‘). Hier callt er also Adresse 400600. Was steht dort?
0000000000400600 <foo@plt>: 400600: ff 25 2a 0a 20 00 jmpq *0x200a2a(%rip) # 601030 <_GLOBAL_OFFSET_TABLE_+0x30> 400606: 68 03 00 00 00 pushq $0x3 40060b: e9 b0 ff ff ff jmpq 4005c0 <_init+0x28>
Anhand des ‚foo@plt‘ erkennen wir dass die Adresse in der PLT liegt.
Der Programmcode nimmt zuerst die Adresse die an der Adresse 601030 steht und springt diese an (‚jmpq‘). Danach setzt er einen Übergabeparameter (‚pushq‘) und springt zu einer anderen Adresse (‚jmpq‘). Diese zweite Adresse zu der er springt (‚4005c0‘) ist die Adresse des Linux Dynamic Loaders. Das müsst ihr mir jetzt einfach mal glauben.
Der Parameter den er davor übergibt ist die Nummer der Funktion die wir laden möchten (Nummer 3 ist eben unser foo()).
Mittels ‚readelf –relocs ./testprog‘ können wir alle Funktionen die wir beim Start des Programmes laden müssen ansehen:
Relocation section '.rela.plt' at offset 0x538 contains 4 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000601018 000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0 000000601020 000300000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0 000000601028 000400000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0 000000601030 000600000007 R_X86_64_JUMP_SLO 0000000000000000 foo + 0
Bleibt also die Frage: Wo springt der obere jumpq hin? Gucken wir uns die Zieladresse ‚601030‘ die in der GOT liegt an, ‚readelf -x .got ./testprog‘:
Hex dump of section '.got': 0x00600ff8 00000000 00000000 ........
Hmmm, ziemlich leer diese GOT. Ist auch logisch, weil diese erst beim laufenden Programm gefüllt wird. Lassen wir das Programm laufen und schauen uns dann an was hier steht, ‚gdb ./testprog‘:
set disassembly-flavor intel break main run disassemble
Wir sehen nun wieder unseren Call als Assemblercode:
0x000000000040072d <+23>: call 0x400600 <foo@plt>
Schauen wir uns den Assembler-Code an Adresse 0x400600 also an,
‚x /3i 0x400600‘:
0x400600 <foo@plt>: jmp QWORD PTR [rip+0x200a2a] # 0x601030 0x400606 <foo@plt+6>: push 0x3 0x40060b <foo@plt+11>: jmp 0x4005c0
Aha, wieder der Sprung zu 0x601030. Die dortige Adresse wird als Zieladresse für den Sprung genommen (Pointer, sieht man im Assemblercode daran dass die Adresse in eckigen Klammern steht). Was steht also da für eine Adresse? ‚x /1xg 0x601030‘:
0x601030: 0x0000000000400606
Aha, ein Sprung zu 400606. Also eigentlich einfach in die nächste Zeile. Häh? Lassen wir das Programm mal bis zum Ende laufen und dann gucken wir nochmal was für eine Zieladresse bei 601030 steht,
‚break *0x400749‘, ‚continue‘, ‚x /1xg 0x601030‘:
0x601030: 0x00007ffff7bd56a0
Aha! Da steht jetzt eine andere Adresse. Und wo geht die hin?
‚info proc mappings‘:
Start Addr End Addr Size Offset objfile 0x400000 0x401000 0x1000 0x0 /root/test/testprog 0x600000 0x601000 0x1000 0x0 /root/test/testprog 0x601000 0x602000 0x1000 0x1000 /root/test/testprog 0x602000 0x623000 0x21000 0x0 [heap] 0x7ffff780c000 0x7ffff79cc000 0x1c0000 0x0 /lib/x86_64-linux-gnu/libc-2.23.so 0x7ffff79cc000 0x7ffff7bcb000 0x1ff000 0x1c0000 /lib/x86_64-linux-gnu/libc-2.23.so 0x7ffff7bcb000 0x7ffff7bcf000 0x4000 0x1bf000 /lib/x86_64-linux-gnu/libc-2.23.so 0x7ffff7bcf000 0x7ffff7bd1000 0x2000 0x1c3000 /lib/x86_64-linux-gnu/libc-2.23.so 0x7ffff7bd1000 0x7ffff7bd5000 0x4000 0x0 0x7ffff7bd5000 0x7ffff7bd6000 0x1000 0x0 /root/test/libfoo.so 0x7ffff7bd6000 0x7ffff7dd5000 0x1ff000 0x1000 /root/test/libfoo.so 0x7ffff7dd5000 0x7ffff7dd6000 0x1000 0x0 /root/test/libfoo.so 0x7ffff7dd6000 0x7ffff7dd7000 0x1000 0x1000 /root/test/libfoo.so 0x7ffff7dd7000 0x7ffff7dfd000 0x26000 0x0 /lib/x86_64-linux-gnu/ld-2.23.so 0x7ffff7fe7000 0x7ffff7fea000 0x3000 0x0 0x7ffff7ff6000 0x7ffff7ff8000 0x2000 0x0 0x7ffff7ff8000 0x7ffff7ffa000 0x2000 0x0 [vvar] 0x7ffff7ffa000 0x7ffff7ffc000 0x2000 0x0 [vdso] 0x7ffff7ffc000 0x7ffff7ffd000 0x1000 0x25000 /lib/x86_64-linux-gnu/ld-2.23.so 0x7ffff7ffd000 0x7ffff7ffe000 0x1000 0x26000 /lib/x86_64-linux-gnu/ld-2.23.so 0x7ffff7ffe000 0x7ffff7fff000 0x1000 0x0 0x7ffffffde000 0x7ffffffff000 0x21000 0x0 [stack] 0xffffffffff600000 0xffffffffff601000 0x1000 0x0 [vsyscall]
Von Adresse ‚0x7ffff7bd5000‘ bis ‚0x7ffff7bd6000‘ ist die Library ‚/root/test/libfoo.so‘. Ein Disassemblen zeigt das auch,
‚x /10i 0x00007ffff7bd56a0‘:
0x7ffff7bd56a0 <foo>: push rbp 0x7ffff7bd56a1 <foo+1>: mov rbp,rsp 0x7ffff7bd56a4 <foo+4>: sub rsp,0x10 0x7ffff7bd56a8 <foo+8>: mov QWORD PTR [rbp-0x8],rdi 0x7ffff7bd56ac <foo+12>: mov rax,QWORD PTR [rbp-0x8] 0x7ffff7bd56b0 <foo+16>: mov rsi,rax 0x7ffff7bd56b3 <foo+19>: lea rdi,[rip+0x1b] # 0x7ffff7bd56d5 0x7ffff7bd56ba <foo+26>: mov eax,0x0 0x7ffff7bd56bf <foo+31>: call 0x7ffff7bd5580 <printf@plt> 0x7ffff7bd56c4 <foo+36>: mov eax,0x2a
Okay, vielleicht müsst ihr mir jetzt einfach glauben dass das der Programmcode unserer Library ist 😛
Und die Erklärung?
Ganz einfach! Das erste Mal springen wir einfach eine Zeile weiter, damit wird der Linux Loader aufgefordert uns die Adresse der Shared Library in der GOT zu hinterlegen. Das zweite Mal ist die Adresse da und wir kommen garnicht mehr zum Programmcode des Linux Loaders weil wir direkt unsere Lib aufrufen. Das ganze nennt sich ‚Lazy Binding‘ – es macht ja keinen Sinn wenn der Linux Loader alle Library Funktionen suchen und bereitstellen würde, obwohl man sie garnicht nutzt.
Und jetzt gibts noch ein kleines spannendes Tool welches sich in die PLT reinhängen kann und alle Library Aufrufe mit aufzeichnet: ‚ltrace‘ – quasi der kleine Bruder von ’strace‘.
strace zeichnet alle System Calls auf, ltrace alle Library Calls.
Wie sieht das also bei uns aus?
‚ltrace ./testprog > /dev/null‘:
(das ‚> /dev/null‘ ist da, damit die Ausgabe unseres Programmes nicht die Ausgabe von ltrace durcheinander bringt!)
__libc_start_main(0x400716, 1, 0x7fff3293d8c8, 0x400750 <unfinished ...> puts("Calling my Lib!") = 16 foo(0x4007e4, 0x4007d4, 0x7f0a5710d780, 4096) = 42 printf("My Lib told me: %d\n", 42) = 19 +++ exited (status 0) +++
Hmmm, die Ausgabe
‚foo(0x4007e4, 0x4007d4, 0x7f0a5710d780, 4096) = 42‘
ist irgendwie noch etwas unspezifisch. Der Grund liegt auf der Hand: Woher soll ltrace wissen was für Übergabeparameter ‚foo()‘ denn kriegt?
Beim Returnwert Integer (42!) hat es geraten, aber bei den Übergabeparametern zeigt es nur deren Adressen an.
Zum Glück liegt die Lösung nahe: Wenn wir in der Konfigurationsdatei ‚/etc/ltrace.conf‘ unsere Funktion eintragen weiß ltrace Bescheid.
Der Syntax ist fast etwas wie in C, nur dass ein String nicht ‚char*‘ sondern ’string‘ heißt. Also tragen wir ein:
int foo(string)
Und schon:
__libc_start_main(0x400716, 1, 0x7fff68ec92d8, 0x400750 <unfinished ...> puts("Calling my Lib!") = 16 foo("Do what I tell you!") = 42 printf("My Lib told me: %d\n", 42) = 19 +++ exited (status 0) +++
Übrigens kann ltrace (und strace) auch anzeigen wir lange jeder Aufruf gedauert hat. Damit weiß man was im eigenen Programm viel Zeit kostet.
‚ltrace -r ./testprog > /dev/null‘:
0.000000 __libc_start_main(0x400716, 1, 0x7ffc4ce25a58, 0x400750 <unfinished ...> 0.000441 puts("Calling my Lib!") = 16 0.001814 foo("Do what I tell you!") = 42 0.000368 printf("My Lib told me: %d\n", 42) = 19 0.001271 +++ exited (status 0) +++
Und was noch interessanter ist, ltrace (und strace) kann auch kummuliert die Aufrufe zusammenfassen und die verwendete Zeit zusammenrechnen. Dazu nehmen wir mal einen Aufruf wo etwas mehr Arbeit passiert,
‚ltrace -c ls .‘:
% time seconds usecs/call calls function ------ ----------- ----------- --------- -------------------- 20.50 0.007947 162 49 malloc 18.89 0.007320 7320 1 setlocale 9.59 0.003717 154 24 __ctype_get_mb_cur_max 8.05 0.003119 155 20 __errno_location 5.42 0.002101 150 14 free 4.90 0.001901 211 9 getenv 3.64 0.001410 201 7 __overflow 3.61 0.001400 140 10 memcpy 2.70 0.001046 149 7 readdir 2.40 0.000930 465 2 fclose 2.33 0.000904 226 4 fwrite_unlocked 2.17 0.000842 140 6 strlen 1.86 0.000720 144 5 strcoll 1.76 0.000684 684 1 bindtextdomain 1.44 0.000558 139 4 __freading 1.33 0.000515 515 1 opendir 1.25 0.000486 486 1 textdomain 0.93 0.000360 360 1 __xstat 0.91 0.000354 354 1 closedir 0.90 0.000350 175 2 _setjmp 0.73 0.000283 141 2 __fpending 0.68 0.000263 131 2 fileno 0.67 0.000261 130 2 fflush 0.64 0.000249 249 1 isatty 0.58 0.000226 226 1 ioctl 0.56 0.000217 217 1 __cxa_atexit 0.54 0.000211 211 1 getopt_long 0.34 0.000132 132 1 strrchr 0.33 0.000127 127 1 freecon 0.32 0.000125 125 1 realloc ------ ----------- ----------- --------- -------------------- 100.00 0.038758 182 total
Und jetzt mal sehen was davon denn die Systemcalls an Zeit brauchen,
’strace -c ls .‘:
% time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 0.00 0.000000 0 7 read 0.00 0.000000 0 1 write 0.00 0.000000 0 49 40 open 0.00 0.000000 0 11 close 0.00 0.000000 0 1 stat 0.00 0.000000 0 10 fstat 0.00 0.000000 0 19 mmap 0.00 0.000000 0 12 mprotect 0.00 0.000000 0 1 munmap 0.00 0.000000 0 3 brk 0.00 0.000000 0 2 rt_sigaction 0.00 0.000000 0 1 rt_sigprocmask 0.00 0.000000 0 2 ioctl 0.00 0.000000 0 7 7 access 0.00 0.000000 0 1 execve 0.00 0.000000 0 2 getdents 0.00 0.000000 0 1 getrlimit 0.00 0.000000 0 2 2 statfs 0.00 0.000000 0 1 arch_prctl 0.00 0.000000 0 1 set_tid_address 0.00 0.000000 0 1 set_robust_list ------ ----------- ----------- --------- --------- ---------------- 100.00 0.000000 135 49 total
Puhhh, das war ein harter Brocken Arbeit. Dafür haben wir dem Computer ganz ordentlich auf die Finger geschaut. Ich weiß der Beitrag ist lang, schwer und kompliziert und hoffe ich konnte alles einigermaßen erklären.
Fragen aller Art bitte in die Kommentare.
Irgendwann mach ich auch nochmal nen Beitrag über Debugging mit strace/ltrace. Mal sehen…
Schreibe einen Kommentar