W tym odcinku, chcę Wam pokazać jak przygotować i podmienić kawałek kodu maszynowego w istniejącym programie. Jest to rozgrzewka do działania zdeasembluj-zmień-zasembluj z powrotem. Chciałem się upewnić, że mam wszystkie narzędzia, żeby to zrobić. Przykłady dotyczą środowiska Linux/x86, jednak sposób działania jest taki sam. Przy okazji dowiemy się trochę o działaniu kompilatora, linkera i ładowania wykonywalnych binarek przez system operacyjny.
Artykuł zakłada elementarną znajomość języka C i assemblera. Nieobeznanym polecam przejrzeć ten artykuł.
Wybieram sobie byle jaki program (niech będzie /usr/bin/awk) i chcę w nim umieścić swój kawałek kodu. Będę chciał wypisać jakąś wartość z jego sekcji danych. Aby zobaczyć co tam jest:
[lwik@lenh bincom]$ objcopy -j .data -O binary /usr/bin/awk awk.data
[lwik@lenh bincom]$ hexdump -C awk.data
00000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |...............
.|
*
00000020 00 00 00 00 00 00 00 00 50 30 05 08 f0 2f 05 08 |........P0.../.
.|
00000030 60 30 05 08 60 30 05 08 60 30 05 08 60 30 05 08 |`0..`0..`0..`0.
.|
00000040 60 30 05 08 80 43 05 08 00 00 00 00 20 20 20 20 |`0...C......
|
00000050 00 28 45 4e 44 20 4f 46 20 46 49 4c 45 29 00 00 |.(END OF FILE).
.|
00000060 01 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 |...............
.|
# ...
Nie ma sensu wypisywać wszystkiego, pod adresem 0x51 jest coś co można ładnie wypisać. Gwoli ścisłości, jest to adres względem początku sekcji danych, za chwilę dojdziemy do tego jak ustalić gdzie ta sekcja w pamięci się pojawi. Tak samo będę musiał dojść pod jakim adresem pojawią się funkcje printf i exit, których użyję aby wyświetlić napis.
Chcę uruchomić więc taki kod (kilka adresów do wstawienia jak je poznamy)…
typedef void (*PrintCall) (char *fmt, ...);
typedef void (*ExitCall) (int code);
const PrintCall print = (PrintCall) /* gdzie jest printf? */;
const ExitCall close = (ExitCall) /* gdzie jest exit? */;
int _start(int argc, char **argv) {
print("%s\n", /* gdzie jest napis? */);
close(0);
}
Jest to trochę nietypowy kod w C, bo będę go doklejał do kodu, który już używa biblioteki standardowej. Dlatego też skompiluję ten kod bez żadnych bibliotek, tylko wpiszę z palca adresy, które odpowiadają odpowiednim funkcjom w modyfikowanym programie. Dlatego też nie importuję żadnych nagłówków, a zamiast main mam _start (domyślna nazwa punktu startowego w Linuksie).
Ostatecznie mam dojść do czegoś takiego:
[lwik@lenh bincom]$ ./awk
(END OF FILE)
Współczesne systemy operacyjne (w dużej mierze i procesory) mają sporo funkcji ochrony pamięci przed niewłaściwym dostępem. Jest to jedna z głównych przyczyn, dla których pamięć jest dzielona na segmenty, cechujące się określonymi prawami, którym odpowiadają sekcje plików wykonywalnych. To dotyczy zarówno plików PE (Windows, UEFI) jak i ELF (Linux), choć różnią się nazwami sekcji i mogą się różnić sposobem ich wykorzystania. Najistotniejszymi sekcjami w plikach ELF są:
Każda sekcja ma przypisany adres pamięci, pod którym zostanie załadowana, rozmiar i ustawienia dostępu. Jest to możliwe dzięki temu, że używamy pamięci wirtualnej — każdy proces w systemie ma odrębną przestrzeń adresową, dzięki czemu procesy nie mają dostępu do pamięci innych procesów, o ile system operacyjny na to nie pozwoli. Więcej o wirtualizacji pamięci napisałem w tekście o assemblerach.
Te nazwy i ich użycie są kwestią konwencji. Mogłyby się nazywać inaczej. Teoretycznie też, moglibyśmy mieć kilka sekcji kodu, kilka sekcji danych, z drugiej strony, jest wysoce prawdopodobne, że oprogramowanie antywirusowe uznałoby taki program za zainfekowany. Warto też wspomnieć, że ustawienia dostępu do fragmentów pamięci może być zmieniany przy pomocy funkcji systemowej mprotect().
Bardzo łatwo możemy przejrzeć listę sekcji w pliku wykonywalnym. Dla każdej pozycji mamy docelowy adres w pamięci wirtualnej, pierwszy bajt pliku wykonywalnego danej sekcji, i jej rozmiar. Użyję narzędzia objdump z pakietu Binutils (wspiera też pliki PE).
[lwik@lenh bincom]$ objdump -f awk
awk: file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x08052eb3
[lwik@lenh bincom]$ objdump -h awk
awk: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .interp 00000013 08048134 08048134 00000134 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
1 .note.ABI-tag 00000020 08048148 08048148 00000148 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .hash 00001508 08048168 08048168 00000168 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .dynsym 00003370 08049670 08049670 00001670 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .dynstr 00002256 0804c9e0 0804c9e0 000049e0 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
5 .gnu.version 0000066e 0804ec36 0804ec36 00006c36 2**1
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .gnu.version_r 000000b0 0804f2a4 0804f2a4 000072a4 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
7 .rel.dyn 00000068 0804f354 0804f354 00007354 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
8 .rel.plt 00000820 0804f3bc 0804f3bc 000073bc 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
9 .init 0000002d 0804fbdc 0804fbdc 00007bdc 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
10 .plt 00001050 0804fc10 0804fc10 00007c10 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
11 .text 000717a0 08050c60 08050c60 00008c60 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
12 .fini 00000019 080c2400 080c2400 0007a400 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
13 .rodata 00000ba0 080c2420 080c2420 0007a420 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
14 .eh_frame_hdr 000018fc 080d24f0 080d24f0 0008a4f0 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
15 .eh_frame 0000a5dc 080d3dec 080d3dec 0008bdec 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
16 .ctors 00000008 080df3c8 080df3c8 000963c8 2**2
CONTENTS, ALLOC, LOAD, DATA
17 .dtors 00000008 080df3d0 080df3d0 000963d0 2**2
CONTENTS, ALLOC, LOAD, DATA
18 .jcr 00000004 080df3d8 080df3d8 000963d8 2**2
CONTENTS, ALLOC, LOAD, DATA
19 .dynamic 00000100 080df3dc 080df3dc 000963dc 2**2
CONTENTS, ALLOC, LOAD, DATA
20 .got 00000004 080df4dc 080df4dc 000964dc 2**2
CONTENTS, ALLOC, LOAD, DATA
21 .got.plt 0000041c 080df4e0 080df4e0 000964e0 2**2
CONTENTS, ALLOC, LOAD, DATA
22 .data 00000b94 080df900 080df900 00096900 2**5
CONTENTS, ALLOC, LOAD, DATA
23 .bss 000049ec 080e04a0 080e04a0 00097494 2**5
ALLOC
24 .comment 00000011 00000000 00000000 00097494 2**0
CONTENTS, READONLY
Zatem możemy odczytać już adres pod którym pojawi się sekcja .data: 0x080df900, a zatem możemy uzupełnić adres napisu: 0x080df951.
Możemy użyć następującego polecenia, żeby zdezasemblować program:
[lwik@lenh bincom]$ objdump -D awk -j .text | less
Otrzymamy w ten sposób cały kod zapisany w postaci instrukcji assemblera oraz kodu maszynowego, a gdzie to możliwe, z symbolami (nazwami funkcji, zmiennych, które zostały dołączonych do programu).
Proponuję następujący eksperyment:
[lwik@lenh bincom]$ echo "int main() {return 0;}" > x.c
[lwik@lenh bincom]$ gcc x.c -o x
[lwik@lenh bincom]$ objdump -d -j .text x | wc -l
209
Nawet pusty program zawiera w sobie całkiem sporo kodu. Dzieje się tak dlatego, że zanim zostanie uruchomiona funkcja main, wykonywana jest inicjalizacja biblioteki standardowej. Kluczowe są te linijki:
80482e5: 51 push %ecx
80482e6: 56 push %esi
80482e7: 68 0a 84 04 08 push $0x804840a
80482ec: e8 bf ff ff ff call 80482b0 <__libc_start_main@pl
t>
Pierwszy parametr funkcji libcstartmain to adres funkcji wejściowej. Przeważnie i tak będzie opatrzony odpowiednią etykietą:
0804840a <main>:
804840a: 55 push %ebp
804840b: 89 e5 mov %esp,%ebp
804840d: b8 00 00 00 00 mov $0x0,%eax
8048412: 5d pop %ebp
8048413: c3 ret
Czyli naszego kodu w tym wszystkim zaledwie jakieś 2%. Wspominam o tym dlatego, że będziemy musieli znaleźć w zmienianym programie adres funkcji main, a nie adres wejściowy programu, który jest wpisany w nagłówkach.
Warto zaznaczyć, że nie musi to wcale wyglądać w ten sposób. Istnieją alternatywne implementacje biblioteki standardowej, ktoś również mógłby zaobfuskować ten kod, itd.
Jak widzimy, możemy użyć również dezasemblacji, żeby znaleźć adresy interesujących nas funkcji:
[lwik@lenh bincom]$ objdump -d -j .text awk | grep '<printf@plt' | head -n1
8051c70: e8 db e0 ff ff call 804fd50 <printf@plt>
[lwik@lenh bincom]$ objdump -d -j .text awk | grep '<exit@plt' | head -n1
80514d9: e8 b2 ef ff ff call 8050490 <exit@plt>
[lwik@lenh bincom]$ objdump -d -j .text awk | grep -B2 '<__libc_start_main@p
lt>'
8052ec9: 56 push %esi
8052eca: 68 00 15 05 08 push $0x8051500
8052ecf: e8 dc d6 ff ff call 80505b0 <__libc_start_main@plt>
[lwik@lenh bincom]$ objdump -d -j .text awk | grep -A10 -P '^08051500'
08051500 <main@@Base>:
8051500: 55 push %ebp
8051501: 89 e5 mov %esp,%ebp
8051503: 83 ec 08 sub $0x8,%esp
8051506: b8 50 fd 04 08 mov $0x804fd50,%eax
805150b: 83 ec 08 sub $0x8,%esp
805150e: 68 51 f9 0d 08 push $0x80df951
8051513: 68 bc 2f 0c 08 push $0x80c2fbc
8051518: ff d0 call *%eax
805151a: 83 c4 10 add $0x10,%esp
805151d: b8 90 04 05 08 mov $0x8050490,%eax
Mamy już zasadniczo wszystkie informacje, które będą nam potrzebne, żeby umieścić nasz kod w tym programie. Sprawa jest jednak trochę podchwytliwa. Na co dzień raczej nie martwimy się tym co robi linker (ustalaniem adresów funkcji, zmiennych globalnych, itp). Przeważnie nie musi nas też interesować to, że skoki do funkcji najczęściej są kodowane poprzez adres względny, przez co przeniesienie takiej instrukcji pod inny adres, zepsuje program. W tym jednak przypadku musimy o to zadbać samodzielnie.
Skompilujmy zatem nasz program, żeby zobaczyć co będziemy musieli dopasować.
[lwik@lenh bincom]$ gcc -nostdlib x.c -c -o x.o
[lwik@lenh bincom]$ objdump -h x.o
x.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000002f 00000000 00000000 00000034 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 00000000 00000000 00000063 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 00000063 2**0
ALLOC
3 .rodata 0000000c 00000000 00000000 00000064 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 00000012 00000000 00000000 00000070 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 00000000 00000000 00000082 2**0
CONTENTS, READONLY
6 .eh_frame 00000038 00000000 00000000 00000084 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
[lwik@lenh bincom]$ objdump -d -j .text x.o
x.o: file format elf32-i386
Disassembly of section .text:
00000000 <_start>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 ec 08 sub $0x8,%esp
6: b8 50 fd 04 08 mov $0x804fd50,%eax
b: 83 ec 08 sub $0x8,%esp
e: 68 51 f9 0d 08 push $0x80df951
13: 68 08 00 00 00 push $0x8
18: ff d0 call *%eax
1a: 83 c4 10 add $0x10,%esp
1d: b8 90 04 05 08 mov $0x8050490,%eax
22: 83 ec 0c sub $0xc,%esp
25: 6a 00 push $0x0
27: ff d0 call *%eax
29: 83 c4 10 add $0x10,%esp
2c: 90 nop
2d: c9 leave
2e: c3 ret
Przyjemnie leciutkie prawda? Choć ten kod jest bardzo rozwlekły i nieefektywny… W każdym razie nie ma tylu śmieci jak w naszym eksperymencie z biblioteką standardową. Jest zasadniczo tylko to, co napisaliśmy w kodzie.
Kluczowe tu są instrukcje 0x06-0x18, które kodują wywołanie printf. Jak widzimy adres tekstu jest wpisany dosłownie, wartość taka jaką ustaliliśmy (adres 0x0e). Natomiast zamiast formatu "%s\n" mamy po prostu $0x08. Można się domyśleć, że jest to wartość do uzupełnienia w czasie linkowania. Możemy to potwierdzić zaglądając do tablicy relokacji:
k@lenh bincom]$ objdump -r x.o
x.o: file format elf32-i386
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
00000014 R_386_32 .rodata
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
00000020 R_386_PC32 .text
Widzimy, że rzeczywiście pod adresem 0x14 sekcji .text mamy adres względem sekcji .rodata, jak można było się łatwo domyśleć. Tym sposobem ustaliliśmy, że wraz z kodem musimy przenieść również sekcję .rodata (sekcja eh_frame jest opcjonalna). O relokację adresów funkcji nie musimy się w tym wypadku martwić, bo te skoki zostały zakodowane przez adres bezwzględny (za pewne dlatego, że w kodzie użyłem wskaźnika.
Żeby linker mógł dobrać odpowiedni adres, musimy napisać skrypt zawierający adresy sekcji jakie sobie życzymy. W tym przypadku chcemy, żeby sekcja .text była umieszczona pod adresem funkcji main modyfikowanego programu (0x08051500), natomiast sekcję .rodata dokleimy do odpowiedniej sekcji modyfikowanego pliku, zatem musimy ustawić jej adres 0x080c2420 + 0x0ba0 = 0x080c2fc0. Zatem, plik x.ld tworzę następujący:
SECTIONS {
. = 0x8051500;
.text : { *(.text) }
. = 0x80c2fc0;
.rodata : { *(.rodata) }
}
Nie będę wchodził w szczegóły (tym bardziej, że, z braku potrzeby, w nie nie wnikałem), myślę, że to mówi samo za siebie. Wówczas kompilowanie i linkowanie mogę wykonać następująco:
gcc -nostdlib x.c -c -o x.o
ld -T x.ld x.o -o x
W tym momencie mam już cały kod jaki potrzebuje, pozostaje go umieścić we właściwych miejscach. Do wyciągania i podmieniania sekcji plików ELF użyję narzędzia objcopy z Binutils. Pozwolę sobie przytoczyć od razu cały plik Makefile, który to wszystko robi:
inject: x.raw awk.data
cp /usr/bin/awk awk
dd conv=notrunc if=x.raw of=awk obs=1 seek=38144
objcopy --update-section .rodata=awk.data awk
x.o: x.c
gcc -nostdlib x.c -c -o x.o
x: x.o x.ld
ld -T x.ld x.o -o x
awk.data:
objcopy -O binary -j .rodata awk awk.data
objcopy -O binary -j .rodata x x.data
dd if=x.data of=awk.data obs=1 seek=2976
x.raw: x
objcopy -O binary -j .text x x.raw
clean:
-rm x x.o x.raw awk.data x.data
.PHONY: clean inject
Przepisy na x i x.o chyba nie wymagają komentarza. Przepis na x.raw pokazuje jak wyciągnąć sekcję .text z x. W awk.data wyciągam sekcje .rodata z x i awk i łączę je ze sobą, przy użyciu narzędzia dd. 2976 to rozmiar sekcji .rodata (0xba0), niestety dd nie przyjmuje wartości szesnastkowych. Innymi słowy, doklejam x.data na koniec awk.data.
Mając awk.data i x.raw, mogę je umieścić w binarce, przepisem na inject. sekcję .text po prostu nadpisuję, używając dd (offset 38144 otrzymałem dodając offset funkcji main w sekcji .text awk do offsetu tej sekcji w pliku: 0x08051500 - 0x08050c60 + 0x00008c60). Opcja conv=notrunc sprawia, że plik x.raw będzie wstawiony w pliku. Normalne zachowanie dd byłoby takie, że pozostała część pliku docelowego byłaby ucięta.
Na sam koniec, używam objcopy --update-section aby umieścić plik awk.data w sekcji .rodata awk. I tyle:
[lwik@lenh bincom]$ make
gcc -nostdlib x.c -c -o x.o
ld -T x.ld x.o -o x
objcopy -O binary -j .text x x.raw
objcopy -O binary -j .rodata awk awk.data
objcopy -O binary -j .rodata x x.data
dd if=x.data of=awk.data obs=1 seek=2976
0+1 przeczytanych recordów
12+0 zapisanych recordów
12 bytes copied, 0,000455016 s, 26,4 kB/s
cp /usr/bin/awk awk
dd conv=notrunc if=x.raw of=awk obs=1 seek=38144
0+1 przeczytanych recordów
47+0 zapisanych recordów
47 bytes copied, 0,000548813 s, 85,6 kB/s
objcopy --update-section .rodata=awk.data awk
[lwik@lenh bincom]$ ./awk
(END OF FILE)
Następnie, spróbowałem uruchomić to samo na nowym (wreszcie współczesnym) komputerze z Arch Linuksem. Co oczywiste, trzeba było znaleźć nowe adresy. Tu okazało się, że mam do dyspozycji znacznie mniej symboli. Nawet symbolu main nie było. Na szcżęście było __libc_start_main, więc z łatwością ustaliłem jej adres. Również okazało się, że nie było wśród symboli printf, tylko __printf_chk, niskopoziomowej wersji z jednym dodatkowym parametrem.
Jednak okazało się, że różnic jest trochę więcej. Przede wszystkim, okazało się, że nie mogę polegać na tym, że program jest ładowany pod adresem wskazanym w binarce, ze względu na mechanizm ASLR. W związku z tym musiałem zrezygnować z umieszczania adresów w kodzie, bo wówczas adres jest przekazywany w formie bezwzględnej. Żeby kompilator dobrze to zakodował, musiałem zrobić to tak:
extern void print(char *fmt, ...);
extern void close(int code);
extern char data;
int _start(int argc, char **argv) {
print(10, "%s\n", data);
close(0);
}
natomiast w x.ld dodać wartości dla symboli:
SECTIONS {
. = 0xb0b0;
.text : { *(.text) }
. = 0x97730;
.rodata : { *(.rodata) }
. = 0x00000000000a9778;
.got : { *(.got) }
}
data = 0x0aa018;
print = 0xa9948;
close = 0xa9940;
Jednak i to nie wystarczyło. Jak zaobserwowałem, w tej binarce, skoki są wykonywane przy pomocą wariantu CALL (0xff) czyli skoku odległego, przez adres względny, jak przypuszczam ma to związek z mechanizmem RELRO.
11c1a: b8 5e 20 00 00 mov $0x205e,%eax
11c1f: 48 8d 79 02 lea 0x2(%rcx),%rdi
11c23: 66 89 01 mov %ax,(%rcx)
11c26: 4c 89 ee mov %r13,%rsi
11c29: ff 15 e9 81 09 00 callq *0x981e9(%rip) # a9e18 <s
trcpy@GLIBC_2.2.5>
Żeby skłonić kompilator do użycia skoku odległego, wystarczyło zmienić typ definicji na wskaźnikowe:
extern void (*print)(char *fmt, ...);
extern void (*close)(int code);