W krainie ELFów, zabawy kodem maszynowym

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ł.

W tym artykule:

  1. Cel
  2. Segmentacja pamięci, praca z plikiem ELF/PE
  3. Dezasemblacja, inicjalizacja biblioteki standardowej
  4. Jak skompilować kod, żeby pasował?
  5. Dołączam kod do modyfikowanego programu
  6. Przypadek x86\_64 (Arch Linux)

Cel

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)

Segmentacja pamięci, praca z plikiem ELF/PE

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.

Dezasemblacja, inicjalizacja biblioteki standardowej

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

Jak skompilować kod, żeby pasował?

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

Dołączam kod do modyfikowanego programu

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)

Przypadek x86_64 (Arch Linux)

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);