Dane oryginału

Copyright © 2019 by Dennis Andriesse. Title of English-language original: Practical Binary Analysis. Build Your Own Linux Tools for Binary Instrumentation, Analysis, and Disassembly,

ISBN 978-1-59327-912-7, published by No Starch Press. Polish-language edition copyright © 2020 by Polish Scientific Publishers PWN Wydawnictwo Naukowe PWN Spółka Akcyjna. All rights reserved.

Przekład

Grzegorz Frelik

Projekt okładki polskiego wydania

INT-MEDIA Dawid Mazur na podstawie oryginału

Wydawcy

Edyta Kawala i Joanna Banasiach-Lach

Redaktor prowadzący

Jolanta Kowalczuk

Redaktor

Anna Marecka

Koordynator produkcji

Anna Bączkowska

Skład wersji elektronicznej na zlecenie Wydawnictwo Naukowe PWN

Michał Latusek

Konsultacja merytoryczna

Kamil Frankowicz

Zastrzeżonych nazw firm i produktów użyto w książce wyłącznie w celu identyfikacji.

Copyright © for the Polish edition by Wydawnictwo Naukowe PWN SA

Warszawa 2020

ISBN 978-83-01-21224-7

eBook został przygotowany na podstawie wydania papierowego z 2020r. (wyd. I)

Warszawa 2020

Wydawnictwo Naukowe PWN SA

02-460 Warszawa, ul. Gottlieba Daimlera 2

tel. 22 69 54 321, faks 22 69 54 288

infolinia 801 33 33 88

e-mail: pwn@pwn.com.pl, reklama@pwn.pl
www.pwn.pl

BEZPŁATNY FRAGMENT

SŁOWO WSTĘPNE

PRZEDMOWA

PODZIĘKOWANIA

WSTĘP

Czym jest analiza binarna i dlaczego jej potrzebujesz?

Dlaczego analiza binarna stanowi wyzwanie?

Kto powinien przeczytać tę książkę?

Co jest w tej książce?

Jak korzystać z tej książki?

Model programowy procesora

Składnia asemblera

Format binarny i platforma systemowa

Przykładowy kod i maszyna wirtualna

CZĘŚĆ I. FORMATY BINARNE

1. ANATOMIA PLIKU BINARNEGO

1.1. Proces kompilacji

1.1.1. Faza preprocesowania

1.1.2. Faza kompilacji

1.1.3. Faza asemblacji

1.1.4. Faza konsolidacji (linkowania)

1.2. Symbole i okrojone pliki binarne

1.2.1. Podgląd symboli w pliku

1.2.2. Kolejny plik binarny przechodzi na ciemną stronę: „okrajanie” pliku

1.3. Deasemblacja pliku binarnego

1.3.1. Zaglądanie do wnętrza pliku obiektowego

1.3.2. Badamy kompletny wykonywalny plik binarny

1.4. Ładowanie i wykonywanie pliku binarnego

1.5. Podsumowanie

2. FORMAT ELF

2.1. Nagłówek pliku

2.1.1. Tablica e_ident

2.1.2. Pola e_type, e_machine i e_version

2.1.3. Pole e_entry

2.1.4. Pola e_phoff i e_shoff

2.1.5. Pole e_flags

2.1.6. Pole e_ehsize

2.1.7. Pole e_*entsize i e_*num

2.1.8. Pole e_shstrndx

2.2. Nagłówki sekcji

2.2.1. Pole sh_name

2.2.2. Pole sh_type

2.2.3. Pole sh_flags

2.2.4. Pola sh_addr, sh_offset oraz sh_size

2.2.5. Pole sh_link

2.2.6. Pole sh_info

2.2.7. Pole sh_addralign

2.2.8. Pole sh_entsize

2.3. Sekcje

2.3.1. Sekcje .init i .fini

2.3.2. Sekcja .text

2.3.3. Sekcje .bss, .data i .rodata

2.3.4. Wiązanie leniwe symboli i sekcje .plt, .got oraz .got.plt

2.3.5. Sekcje .rel.* i .rela.*

2.3.6. Sekcja .dynamic

2.3.7. Sekcje .init_array i .fini_array

2.3.8. Sekcje .shstrtab, .symtab, .strttab, .dynsym i .dynstr

2.4. Nagłówki programów

2.4.1. Pole p_type

2.4.2. Pole p_flags

2.4.3. Pola p_offset, p_vaddr, p_paddr, p_filesz i p_memsz

2.4.4. Pole p_align

2.5. Podsumowanie

3. FORMAT PE: KRÓTKIE WPROWADZENIE

3.1. Nagłówek MS-DOS i stub MS-DOS

3.2. Sygnatura, nagłówek i nagłówek opcjonalny pliku PE

3.2.1. Sygnatura pliku PE

3.2.2. Nagłówek pliku PE

3.2.3. Nagłówek opcjonalny pliku PE

3.3. Tablica nagłówków sekcji

3.4. Sekcje

3.4.1. Sekcje .edata i .idata

3.4.2. Wyrównywanie w sekcjach kodu PE

3.5. Podsumowanie

4. TWORZENIE PROGRAMU ŁADUJĄCEGO PLIKI BINARNE PRZY UŻYCIU LIBBFD

4.1. Czym jest libbfd?

4.2. Prosty interfejs do ładowania plików binarnych

4.2.1. Klasa Binary

4.2.2. Klasa Section

4.2.3. Klasa Symbol

4.3. Implementacja loadera plików binarnych

4.3.1. Inicjalizacja libbfd i otwieranie pliku binarnego

4.3.2. Parsowanie podstawowych właściwości pliku binarnego

4.3.3. Ładowanie symboli

4.3.4. Ładowanie sekcji

4.4. Testowanie loadera binarnego

4.5. Podsumowanie

CZĘŚĆ II. PODSTAWY ANALIZY BINARNEJ

5. PODSTAWOWA ANALIZA BINARNA W LINUKSIE

5.1. Rozwiązywanie kryzysów tożsamości przy użyciu file

5.2. Użycie ldd do badania zależności

5.4. Parsowanie ekstrahowanego ELF za pomocą readelf

5.5. Parsowanie symboli za pomocą nm

5.6. Szukanie wskazówek za pomocą strings

5.7. Śledzenie wywołań systemowych i bibliotecznych za pomocą strace i ltrace

5.8. Badanie zachowań na poziomie instrukcji przy użyciu objdump

5.9. Zrzut dynamicznego bufora łańcucha za pomocą gdb

5.10. Podsumowanie

6. DEASEMBLACJA I PODSTAWY ANALIZY BINARNEJ

6.1. Deasemblacja statyczna

6.1.1. Deasemblacja liniowa

6.1.2. Deasemblacja rekurencyjna

6.2. Deasemblacja dynamiczna

6.2.1. Przykład: śledzenie wykonywania pliku binarnego za pomocą gdb

6.2.2. Strategie pokrycia kodu

6.3. Strukturyzacja deasemblowanego kodu i danych

6.3.1. Strukturyzowanie kodu

6.3.2. Strukturyzacja danych

6.3.3. Dekompilacja

6.3.4. Reprezentacje pośrednie

6.4. Podstawowe metody analizy

6.4.1. Własności analizy binarnej

6.4.2. Analiza przepływu sterowania

6.4.3. Analiza przepływu danych

6.5. Wpływ ustawień kompilatora na deasemblację

6.6. Podsumowanie

7. PROSTE METODY WSTRZYKIWANIA KODU DLA ELF

7.1. Najprostsze modyfikacje binarne przy użyciu edycji hex

7.1.1. Obserwowanie błędu off-by-one w akcji

7.1.2. Naprawienie błędu off-by-one

7.2. Modyfikacja zachowania biblioteki współdzielonej przy użyciu LD_PRELOAD

7.2.1. Zagrożenie przepełnieniem sterty

7.2.2. Wykrywanie przepełnienia sterty

7.3. Wstrzykiwanie sekcji kodu

7.3.1. Wstrzykiwanie sekcji ELF: przegląd wysokiego poziomu

7.3.2. Użycie elfinject do wstrzykiwania sekcji ELF

7.4. Wywołanie wstrzykniętego kodu

7.4.1. Modyfikacja adresu startowego

7.4.2. Przejęcie konstruktorów i destruktorów

7.4.3. Przejęcie wpisów w GOT

7.4.4. Przejęcie wpisów PLT

7.4.5. Przekierowanie wywołań bezpośrednich i pośrednich

7.5. Podsumowanie

CZĘŚĆ III. ZAAWANSOWANA ANALIZA BINARNA

8. DOSTOSOWYWANIE DEASEMBLACJI DO POTRZEB

8.1. Dlaczego pisać własny program do deasemblacji

8.1.1. Sprawa dla dostosowania deasemblacji: zaciemniony kod

8.1.2. Inne powody, by napisać dostosowany deasembler

8.2. Wprowadzenie do Capstone’a

8.2.1. Instalacja Capstone’a

8.2.2. Deasemblacja liniowa z Capstone'a

8.2.3. Eksploracja API C Capstone’a

8.2.4. Deasemblacja rekurencyjna z Capstone

8.3. Implementacja skanera gadżetów ROP

8.3.1. Wprowadzenie do programowania zorientowanego na powrót (ROP)

8.3.2. Wykrywanie gadżetów ROP

8.4. Podsumowanie

9. INSTRUMENTACJA BINARNA

9.1. Czym jest instrumentacja binarna?

9.1.1. API instrumentacji binarnej

9.1.2. Statyczna kontra dynamiczna instrumentacja binarna

9.2. Statyczna instrumentacja binarna

9.2.1. Podejście int 3

9.2.2. Podejście z użyciem trampolin

9.3. Dynamiczna instrumentacja binarna

9.3.1. Architektura systemu DBI

9.3.2. Wprowadzenie do Pin

9.4. Profilowanie za pomocą Pin

9.4.1. Struktury danych profilera i kod instalacyjny

9.4.2. Parsowanie symboli funkcji

9.4.3. Instrumentacja bloków podstawowych

9.4.4. Instrumentacja instrukcji przepływu sterowania

9.4.5. Liczenie instrukcji, transferów sterowania i wywołań systemowych

9.4.6. Testowanie profilera

9.5. Automatyczne rozpakowywanie plików binarnych z Pin

9.5.1. Wprowadzenie do programów pakujących

9.5.2. Struktury danych i kod instalacyjny programu rozpakowującego

9.5.3. Instrumentowanie zapisów do pamięci

9.5.4. Instrumentowanie instrukcji przepływu sterowania

9.5.5. Śledzenie zapisów do pamięci

9.5.6. Wykrywanie pierwotnego punktu wejścia i zrzucanie rozpakowanego pliku binarnego

9.5.7. Testowanie programu rozpakowującego

9.6. Podsumowanie

10. ZASADY DYNAMICZNEJ ANALIZY TAINT

10.1. Czym jest DTA?

10.2. DTA w trzech krokach: źródła taint, taint sinks i propagacja taint

10.2.1. Definiowanie źródeł taint

10.2.2. Definiowanie taint sinks

10.2.3. Śledzenie propagacji taint

10.3. Użycie DTA do wykrycia błędu Heartbleed

10.3.1. Krótki przegląd podatności na Heartbleed

10.3.2. Wykrywanie Heartbleed przez taint

10.4. Czynniki w projektowaniu DTA: ziarnistość taint, kolory taint i zasady propagacji taint

10.4.1. Ziarnistość taint

10.4.2. Kolory taint

10.4.3. Zasady propagacji taint

10.4.4. Nadmierne i niedostateczne pokrycie taint

10.4.5. Zależności sterowania

10.4.6. Shadow memory

10.5. Podsumowanie

11. PRAKTYCZNA DYNAMICZNA ANALIZA TAINT Z LIBDFT

11.1. Wstęp do libdft

11.1.1. Organizacja wewnętrzna libdft

11.1.2. Zasady taint

11.2. Wykorzystanie DTA do wykrywania zdalnego przechwycenia sterowania

11.2.1. Sprawdzanie informacji o taint

11.2.2. Źródła taint: taintowanie otrzymywanych bajtów

11.2.3. Taint sinks: sprawdzanie argumentów execve

11.2.4. Wykrywanie próby przejęcia przepływu sterowania

11.3. Obejście DTA za pomocą przepływów niejawnych

11.4. Detektor eksfiltracji danych oparty na DTA

11.4.1. Źródła taint: śledzenie taint dla otwartych plików

11.4.2. Taint sinks: monitorowanie przesyłów sieciowych w poszukiwaniu eksfiltracji danych

11.4.3. Wykrywanie próby eksfiltracji danych

11.5. Podsumowanie

12. PODSTAWY WYKONYWANIA SYMBOLICZNEGO

12.1. Przegląd wykonywania symbolicznego

12.1.1. Wykonywanie symboliczne kontra konkretne

12.1.2. Rodzaje i ograniczenia wykonywania symbolicznego

12.1.3. Zwiększanie skalowalności wykonywania symbolicznego

12.2. Rozwiązywanie ograniczeń za pomocą Z3

12.2.1. Dowodzenie osiągalności instrukcji

12.2.2. Dowodzenie nieosiągalności instrukcji

12.2.3. Dowodzenie poprawności formuły

12.2.4. Upraszczanie wyrażeń

12.2.5. Modelowanie ograniczeń dla kodu maszynowego za pomocą wektorów bitowych

12.2.6. Rozwiązywanie nieprzejrzystego kodu warunkowego za pomocą wektorów bitowych

12.3. Podsumowanie

13. PRAKTYCZNE WYKONYWANIE SYMBOLICZNE Z TRITONEM

13.1. Wprowadzenie do Tritona

13.2. Utrzymywanie stanu symbolicznego przy użyciu drzew składniowych (AST)

13.3. Slicing wsteczny z Tritonem

13.3.1. Pliki nagłówkowe i konfigurowanie Tritona

13.3.2. Plik konfiguracji symbolicznej

13.3.3. Emulowanie instrukcji

13.3.4. Konfigurowanie architektury Tritona

13.3.5. Obliczanie „wycinka wstecznego”

13.4. Wykorzystanie Tritona do zwiększenia pokrycia kodu

13.4.1. Tworzenie zmiennych symbolicznych

13.4.2. Znajdowanie modelu dla nowej ścieżki

13.4.3. Testowanie narzędzia pokrycia kodu

13.5. Automatyczna eksploitacja podatności

13.5.1. Podatny program

13.5.2. Znajdowanie adresu podatnego miejsca wywołania

13.5.3. Budowanie generatora exploita

13.5.4. Uzyskiwanie dostępu do powłoki administratora

13.6. Podsumowanie

CZĘŚĆ IV. DODATKI

A. BŁYSKAWICZNY KURS ASEMBLERA X86

A.1. Zarys programu w języku asemblera

A.1.1. Instrukcje języka asemblera, dyrektywy, etykiety i komentarze

A.1.2. Rozdzielenie kodu od danych

A.1.3. Składnia AT and T i Intela

A.2. Struktura instrukcji x86

A.2.1. Reprezentacja instrukcji x86 na poziomie języka asemblera

A.2.2. Instrukcje x86 na poziomie kodu maszynowego

A.2.3. Operandy rejestru

A.2.4. Operandy pamięci

A.2.5. Operandy bezpośrednie

A3. Częste instrukcje x86

A.3.1. Porównywanie operandów i ustawianie flag stanu

A.3.2. Implementacja wywołań systemowych

A.3.3. Implementacja skoków warunkowych

A.3.4. Ładowanie adresów pamięci

A.4. Częste konstrukcje kodu w języku asemblera

A.4.1. Stos

A.4.2. Wywołania funkcji i ramki funkcji

A.4.3. Instrukcje warunkowe

A.4.4. Pętle

B. IMPLEMENTACJA NADPISYWANIA PT_NOTE PRZY UŻYCIU LIBELF

B.1. Wymagane pliki nagłówkowe

B.2. Struktury danych używane w elfinject

B.3. Inicjalizacja libelf

B.4. Uzyskiwanie nagłówka pliku wykonywalnego

B.5. Znajdowanie segmentu PT_NOTE

B.6. Wstrzykiwanie bajtów kodu

B.7. Dopasowanie adresu ładowania dla wstrzykiwanej sekcji

B.8. Nadpisanie nagłówka sekcji .note.ABI-tag

B.9. Ustawienie nazwy dla wstrzykniętej sekcji

B.10. Nadpisanie nagłówka programu PT_NOTE

B.11. Modyfikacja adresu startowego

C. SPIS NARZĘDZI ANALIZY BINARNEJ

C.1. Deasemblery

C.2. Debuggery

C.3. Platformy deasemblerów

C.4. Platformy analizy binarnej

D. DALSZA LEKTURA

D.1. Standardy i źródła

D.2. Artykuły i raporty techniczne

D.3. Książki

O autorze

Przypisy

O Autorze

Dennis Andriesse ma doktorat z zakresu bezpieczeństwa systemów i sieci oraz w swoich badaniach wykorzystuje na co dzień analizę binarną. Jest jednym z głównych współtwórców PathArmor, systemu integralności przepływu kodu, który broni przed atakami opartymi na przejęciu kontroli wykonania, takimi jak Return Oriented Programming (ROP). Andriesse był jednym z twórców ataku zaangażowanych w wyłączenie botneta GameOver Zeus P2P.

O recenzentach technicznych

Thorsten Holz jest profesorem na Wydziale Inżynierii Elektrycznej i Informatyki Technicznej na Uniwersytecie Ruhr w Bochum w Niemczech. Jego zainteresowania badawcze obejmują techniczne aspekty bezpiecznych systemów informatycznych. Obecnie skupia się na inżynierii wstecznej, zautomatyzowanym wykrywaniu słabych punktów i badaniu najnowszych metod ataku.

Tim Vidas jest badaczem bezpieczeństwa. Przez wiele lat Tim szefował zespołowi infrastruktury CGC DARPA, wdrażał innowacje w Dell Secureworks i nadzorował grupę badawczą CERTu Carnegie Mellon University w zakresie informatyki śledczej. Ma doktorat z Carnegie Mellon, wiele plakietek konferencyjnych (niektóre czarne) i liczbę Erdősa-Bacona 4–3. Zazwyczaj Tim po prostu cieszy się z bycia ojcem i mężem.

Słowo wstępne

Obecnie możesz znaleźć wiele książek o asemblerach, a jeszcze więcej o formatach plików binarnych ELF i PE. Stosy artykułów o śledzeniu przepływu informacji i wykonywaniu analiz symbolicznych są ogromne. A jednak nie ma nawet jednej książki, by czytelnik mógł przejść od, powiedzmy, zrozumienia podstaw asemblacji do przeprowadzania zaawansowanej analizy plików binarnych. Nie istnieje książka, która pokazuje czytelnikowi, jak instrumentować programy binarne, stosować dynamiczną analizę taint celem śledzenia interesujących nas danych w czasie wykonywania programu lub używać wykonywania symbolicznego dla zautomatyzowanej generacji eksploitów. Innymi słowy, brak książki, która nauczy cię metod, narzędzi i dostarczy wiedzę potrzebną do analizy plików binarnych. Aż do teraz.

Tym, co sprawia, że analiza plików binarnych stanowi wyzwanie, jest to, że wymaga ona zrozumienia różnych kwestii. Tak, musisz wiedzieć coś nie tylko o asemblerach, ale także o formatach plików binarnych, konsolidacji i ładowaniu, analizie statycznej i dynamicznej, systemach pamięci i konwencjach kompilatorów – a to jedynie podstawy. Twoja konkretna analiza zadań instrumentacji może wymagać jeszcze bardziej specjalistycznej wiedzy. Oczywiście, te wszystkie aspekty potrzebują swych własnych narzędzi. Dla wielu ten obszar wygląda na tak onieśmielający, że poddają się, zanim nawet zaczną. Jest tyle do nauczenia się. Gdzie rozpocząć?

Odpowiedź brzmi: tutaj. Ta książka w dobrze ułożony i przystępny sposób zbiera wszystko, co potrzebujesz wiedzieć, by zacząć. Jest to także zabawa! Nawet jeżeli nie masz pojęcia o tym, jak wyglądają programy w kodzie binarnym, jak są ładowane i co się dzieje w trakcie ich wykonywania, ta książka uważnie wprowadza te wszystkie tematy wraz z towarzyszącymi narzędziami, tak, że szybko uczysz się, nie tylko jak one działają w teorii, ale także jak postępować z nimi w prawdziwych sytuacjach. Moim zdaniem jest to jedyna droga do osiągnięcia głębokiego i trwałego zrozumienia.

Nawet jeżeli masz już znaczne doświadczenie w analizowaniu kodu binarnego i jesteś być może magikiem Capstone’a, Radare, IDA Pro czy OllyDbg, zawarto tutaj wiele ciekawych problemów. Zaawansowane metody z dalszych rozdziałów pokażą ci, jak zbudować pewne z najbardziej skomplikowanych narzędzi analizy i instrumentacji, jakie możesz sobie wyobrazić.

Analiza binarna i instrumentacja binarna to fascynujące, lecz wymagające tematy, zwykle do opanowania tylko przez małą grupę ekspertów-hakerów. Wraz z rosnącymi obawami o bezpieczeństwo stają się one coraz ważniejsze. Musimy potrafić analizować złośliwe oprogramowanie, by zrozumieć, co może ono zrobić i jak można je powstrzymać. Jednak w miarę, jak coraz więcej takiego oprogramowania zaciemnia swój kod i stosuje techniki przeciwdziałania analizie, by storpedować nasze wysiłki, potrzebujemy coraz wymyślniejszych metod.

Coraz więcej analizujemy także zwykłego oprogramowania, choćby po to, by istniejące pliki binarne uodpornić na ataki. Na przykład możemy instrumentować istniejące pliki binarne w C++, by mieć pewność, że wszystkie wywołania funkcji (wirtualnych) mogą być tylko do uprawnionych metod. Aby to zrobić, po pierwsze, musimy przeanalizować plik binarny, aby zidentyfikować metody i wywołania funkcji. Po drugie, musimy dodać instrumentację, upewniając się przy tym, że zachowana jest oryginalna semantyka programu. Łatwiej powiedzieć, niż zrobić.

Wielu z nas zaczyna się uczyć tych technik, ponieważ natykamy się na problem, który okazuje się zarazem fascynujący i zbyt skomplikowany jak na nasze umiejętności. Problemem może być cokolwiek – może chcesz przerobić swoją konsolę do gier na komputer do ogólnych zastosowań, złamać jakieś oprogramowanie albo dowiedzieć się, jak naprawdę działa malware, które znalazłeś w swoim komputerze.

Z zażenowaniem wyznaję, że moją motywacją była chęć złamania zabezpieczeń przed kopiowaniem gier wideo, na których kupno nie mogłem sobie pozwolić. A zatem nauczyłem się asemblera i przedzierałem się przez kod binarny, szukając punktów zaczepienia. Działo się to w czasach 6510, 8-bitowego procesora z akumulatorem i dwoma rejestrami ogólnego przeznaczenia. Chociaż wykorzystanie pełnych 64 KB pamięci w tym systemie wymagało wielu dziwacznych, niejasnych rytuałów, sam system był prosty. Jednak na początku wszystko było bez sensu. Wraz z upływem czasu i pomocy ze strony bardziej doświadczonych kolegów rzeczy stopniowo się rozjaśniały. Podróż ta była z pewnością interesująca, ale jednocześnie bolesna, frustrująca i długa. Co ja bym dał za książkę, która poprowadziłaby mnie przez ten proces! Dzisiejsze 64-bitowe procesory x86 są o wiele bardziej złożone niż tamten, tak jak i kompilatory generujące kod binarny. Zrozumienie tego kodu jest teraz trudniejsze niż kiedykolwiek wcześniej. Kiedy masz eksperta, który pokaże ci drogę i rozjaśni rzeczy, które mógłbyś w przeciwnym razie pominąć, twoja podróż będzie krótsza, ciekawsza i, co najważniejsze, weselsza.

Dennis Andriesse jest specjalistą od analizy binarnej ni mniej, ni więcej, z doktoratem z analizy binarnej na potwierdzenie. Jednak nie jest on tylko naukowcem piszącym artykuły dla innych specjalistów. Większość jego pracy jest zakorzeniona w praktyce. Na przykład był on jednym z kilku ludzi na świecie, którzy dokonali inżynierii wstecznej osławionego botneta GameOver Zeus, który spowodował straty szacowane na ponad 100 milionów dolarów. Jeszcze lepiej, był on jednym z garści ekspertów zaangażowanych w ostateczne wyłączenie GameOver Zeus w operacji prowadzonej przez FBI. Pracując ze złośliwym oprogramowaniem, zorientował się w mocnych stronach i słabościach istniejących narzędzi analizy binarnej i wpadł na pomysły ich ulepszenia. Nowe techniki deasemblacji opracowane przez Dennisa znalazły teraz zastosowanie w produktach komercyjnych takich jak Binary Ninja.

Jednak nawet bycie ekspertem nie wystarczy. Aby książka była dobra, autor musi także wiedzieć, jak pisać. Dennisa Andriesse cechuje rzadkie połączenie talentów: to ekspert od analizy binarnej, który potrafi wyjaśnić nawet najbardziej skomplikowane pojęcia w prosty sposób, jednocześnie nie upraszczając. Jego styl jest przyjemny, a przykłady bardzo jasne i objaśniające.

Osobiście, od dłuższego czasu czekałem na taką książkę. Przez lata uczyłem analizy złośliwego oprogramowania na Vrije Universitetit w Amsterdamie, nie używając podręcznika, bo takiego po prostu nie było. Zamiast tego wykorzystywałem dość przypadkowe połączenie źródeł online, tutoriali i eklektycznego zestawu slajdów. Kiedy studenci pytali, dlaczego nie możemy użyć książki (a było tak co roku), mówiłem im, że nie ma dobrego podręcznika poświęconego analizie binarnej, ale jeżeli znajdę czas, może kiedyś taki napiszę. Rzecz jasna, nigdy tego nie zrobiłem.

Oto książka o analizie binarnej, jaką chciałem napisać, ale nigdy nie zdołałem, w dodatku lepsza niż moje ewentualne dzieło.

Miłej podróży.

Herbert Bos

Przedmowa

Analiza binarna to jedno z najbardziej fascynujących i ambitnych zagadnień hakowania oraz informatyki.

Jest także jednym z najtrudniejszych do nauczenia się, co w niemałym stopniu wypływa z braku dostępnych na ten temat informacji.

O ile książek dotyczących inżynierii wstecznej i analizy złośliwego oprogramowanie jest pod dostatkiem, o tyle tego samego nie da się powiedzieć o tematyce zaawansowanej analizy binarnej, takiej jak instrumentacja binarna, dynamiczna analiza taint czy wykonywanie symboliczne. Początkujący analityk binarny jest zmuszony wygrzebywać wiadomości z ciemnych zakątków sieci, przestarzałych, a czasem po prostu mylących postów na grupach tematycznych i niejasnych publikacji. Wiele artykułów oraz literatura naukowa dotycząca analizy binarnej zakładają już na wejściu znaczną wiedzę u czytelnika, co sprawia, że uczenie się z tych zasobów przywodzi na myśl problem „jajko czy kura”. Co gorsza, wiele narzędzi analitycznych i bibliotek ma niekompletną dokumentację albo wręcz nie ma żadnej.

Mam nadzieję, że dzięki tej książce dziedzina analizy binarnej stanie się łatwiej dostępna przez dostarczenie spójnego źródła wprowadzającego cię we wszystkie istotne zagadnienia w prosty i praktyczny sposób. Po lekturze będziesz dobrze przygotowany, by zrozumieć szybko zmieniający się świat analizy binarnej i wyruszyć tam na własną rękę.

Podziękowania

Po pierwsze, chcę podziękować mojej żonie Noortje i memu synowi Sietse za wspieranie mnie podczas pisania tej książki. Był to niewiarygodnie pracowity okres, ale zawsze byliście gotowi pomóc.

Chcę także złożyć podziękowania tym wszystkich w No Starch Press, którzy pomogli, by ta książka ujrzała światło dzienne, zwłaszcza Billowi Pollockowi i Tylerowi Ortmanowi za danie szansy na stworzenie tej książki oraz innym osobom, a byli to: Annie Choi, Riley Hoffman i Kim Wimpsett, za ich wielki wkład w redakcję i wydanie jej drukiem. Dziękuję także moim recenzentom naukowym, Thorstenowi Holzowi i Timowi Vidasowi, za ich szczegółowe uwagi, które pomogły w ulepszeniu tej książki.

Dziękuję Benowi Grasowi za jego pomoc w uruchomieniu libdft na nowym Ubuntu, Jonathanowi Salwanowi za uwagi do rozdziałów o wykonywaniu symbolicznym, a ponadto Lorenzo Cavallaro, Erikowi van der Kouwe i innym twórcom pierwotnych slajdów, na których oparty został dodatek o asemblerze.

W końcu podziękowania niech przyjmą Herbert Bos, Asia Slowinska i wszyscy moi koledzy, którzy zapewnili wspaniałe otoczenie badawcze, gdzie mogłem w ogóle rozwinąć pomysł na tę książkę.

Wstęp

Znaczna większość programów komputerowych pisana jest w językach wysokiego poziomu, takich jak C lub C++, które nie mogą być bezpośrednio uruchamiane na komputerze. Zanim użyjesz takich programów, po pierwsze, musisz je skompilować do wykonywalnych plików binarnych zawierających kod maszynowy, który może być obsłużony przez procesor. Skąd jednak wiesz, że taki skompilowany program jest semantycznie tożsamy z jego źródłem wysokiego poziomu? Niepokojąca odpowiedź brzmi: tego nie wiesz!

Istnieje duża semantyczna luka między językami wysokiego poziomu i binarnym kodem maszynowym, którą umie przekroczyć tylko niewielu. Nawet większość programistów ma tylko ograniczoną wiedzę o tym, jak naprawdę działa ich program na najniższym poziomie, i po prostu ufają oni, że skompilowany program zachowuje się zgodnie z ich zamiarami. W rezultacie wiele błędów kompilatora, subtelnych błędów implementacji, luk bezpieczeństwa w kodzie binarnym i złośliwych pasożytów może przemknąć się niezauważenie.

Co gorsza, istnieją niezliczone programy binarne i biblioteki – w przemyśle, bankach, w systemach wbudowanych – dla których kod źródłowy albo dawno zaginął, albo jest czyjąś własnością. Sprawia to, że nie można łatać tych programów czy bibliotek lub ocenić ich bezpieczeństwa na poziomie kodu źródłowego przy użyciu metod konwencjonalnych. Stanowi to poważny problem nawet dla wielkich producentów oprogramowania, na co wskazuje niedawne wypuszczenie przez Microsoft ręcznie wykonanej łatki binarnej dla przepełnienia bufora w programie Equation Editor, który stanowi część pakietu Microsoft Office (podatność CVE-2017-11882)[1].

Dzięki tej książce nauczysz się, jak analizować, a nawet modyfikować programy na poziomie kodu binarnego. Obojętne, czy jesteś hakerem, badaczem bezpieczeństwa, analitykiem złośliwego oprogramowania, programistą, czy po prostu zainteresowanym, techniki te dadzą ci większą kontrolę i wgląd w programy binarne, które tworzysz i których codziennie używasz.

Czym jest analiza binarna i dlaczego jej potrzebujesz?

Analiza binarna to nauka i sztuka analizowania własności kodowanych binarnie programów komputerowych, kodu maszynowego i danych, które one zawierają. Krótko mówiąc, celem każdej analizy binarnej jest zrozumienie (a niekiedy modyfikacja) prawdziwych własności programów binarnych – innymi słowy, tego, co rzeczywiście robią, w przeciwieństwie do tego, co myślimy, że powinny robić.

Wielu ludzi kojarzy analizę binarną z inżynierią wsteczną oraz deasemblacją i przynajmniej częściowo mają oni rację. Deasemblacja stanowi ważny pierwszy krok w wielu przypadkach analizy binarnej, a inżynieria wsteczna jest powszechnym zastosowaniem analizy binarnej, a często jedynym sposobem udokumentowania zachowania oprogramowania własnościowego czy złośliwego. Jednak zakres analizy binarnej jest znacznie szerszy.

Mówiąc ogólnie, można podzielić techniki analizy binarnej na dwie grupy oraz ich kombinację:

Analiza statyczna Techniki analizy statycznej wnioskują na temat kodu binarnego bez uruchamiania go. Podejście to ma wiele zalet: możesz potencjalnie przeanalizować cały program za jednym razem i nie potrzebujesz CPU, by uruchomić plik binarny. Na przykład możesz statycznie analizować kod binarny ARM na maszynie x86. Wadą jest to, że analiza statyczna nie dostarcza żadnych informacji o stanie uruchomieniowym kodu binarnego, co może wysoce utrudnić taką analizę.

Analiza dynamiczna Z kolei analiza dynamiczna wymaga uruchomienia pliku binarnego i analizuje go w trakcie wykonywania. Podejście takie często jest prostsze od analizy statycznej, ponieważ dysponujesz pełną wiedzą o całym stanie uruchomieniowym, w tym wartościach zmiennych i wyniku instrukcji warunkowych. Jednak widzisz tylko wykonany kod, toteż analiza może pominąć interesujące części programu.

Zarówno analizy statyczne, jak i dynamiczne mają swoje zalety i wady, a w tej książce poznasz techniki stosowane w każdej z nich. Oprócz biernej analizy binarnej nauczysz się także technik instrumentacji binarnej, których możesz użyć, by zmienić programy binarne bez potrzeby kodu źródłowego. Instrumentacja binarna polega na takich samych technikach analizy jak deasemblacja i jednocześnie może zostać użyta jako pomoc w analizie binarnej. Ze względu na tę zależność symbiotyczną między technikami analizy binarnej i instrumentacji książka omawia obie.

Wspomniałem już, że możesz użyć analizy binarnej do dokumentowania albo testu penetracyjnego programów, dla których nie masz kodu źródłowego. Nawet jeżeli dysponujesz takim źródłem, analiza binarna może się przydać do wychwycenia subtelnych błędów, które wyraźniej ujawniają się na poziomie kodu binarnego niż źródłowego. Wiele technik analizy binarnej jest użytecznych też przy zaawansowanym debugowaniu. Książka ta omawia techniki analizy binarnej, których możesz użyć we wszystkich tych scenariuszach i poza nimi.

Dlaczego analiza binarna stanowi wyzwanie?

Analiza binarna dostarcza wyzwań i jest o wiele trudniejsza niż odpowiednia analiza na poziomie kodu źródłowego. W rzeczywistości wiele zadań analizy binarnej jest fundamentalnie nierozstrzygalnych, co oznacza, że nie da się zbudować dla tych problemów maszyny analitycznej, która zawsze dostarczałaby poprawnego wyniku! Aby dać ci pojęcie o oczekiwanej skali trudności, oto lista niektórych rzeczy, które sprawiają, że analiza binarna jest trudna. Niestety, lista ta jest daleka od kompletnej.

Brak informacji o symbolach Kiedy piszemy kod źródłowy w języku wysokiego poziomu takim jak C albo C++, nadajemy znaczące nazwy takim elementom, jak zmienne, funkcje i klasy. Określamy te nazwy mianem informacji symbolicznej lub krótko – symboli. Dobre konwencje nazywania sprawiają, że znacznie łatwiej zrozumieć kod źródłowy, lecz nie mają one rzeczywistego znaczenia na poziomie kodu binarnego. W rezultacie pliki binarne często są okrojone z symboli, co czyni ich kod znacznie trudniejszym do zrozumienia.

Brak informacji o typach Inną własnością programowania wysokiego poziomu jest to, że mamy tam do czynienia ze zmiennymi o dobrze zdefiniowanym typie, takimi jak int, float czy string, jak również z bardziej złożonymi strukturami danych jak typy struct. Z kolei na poziomie binarnym typy nie są nigdy jawnie deklarowane, co sprawia, że przeznaczenie i struktura danych jest trudna do wywnioskowania.

Brak abstrakcji wysokiego poziomu Współczesne programy są ujęte w kategoriach klas i funkcji, lecz kompilatory usuwają te konstrukcje wysokiego poziomu. Oznacza to, że pliki binarne wyglądają jak duże masy kodu i danych, a nie dobrze ustrukturyzowane programy, a przywrócenie im struktury wysokiego poziomu jest złożone i podatne na błędy.

Kod pomieszany z danymi Pliki binarne mogą zawierać (i często tak jest) fragmenty danych przemieszane z wykonywalnym kodem[2]. Sprawia to, że można przypadkowo potraktować dane jako kod i na odwrót, co prowadzi do niepoprawnych wyników.

Kod i dane zależne od położenia Ponieważ pliki binarne nie są przeznaczone do modyfikowania, nawet dodanie pojedynczej instrukcji w kodzie maszynowym może spowodować problemy, gdyż przesuwa resztę kodu, unieważniając dotychczasowe adresy pamięci i odniesienia z innych miejsc kodu. W rezultacie jakakolwiek manipulacja kodem czy danymi jest niezwykle trudna i grozi uszkodzeniem pliku binarnego.

Wskutek powyższych wyzwań w praktyce często musimy zadowolić się wynikami niedokładnej analizy. Ważną częścią analizy binarnej jest proponowanie twórczych sposobów tworzenia nadających się do użytku narzędzi mimo błędów w analizie!

Kto powinien przeczytać tę książkę?

Docelowy krąg odbiorców tej książki obejmuje inżynierów bezpieczeństwa, badaczy bezpieczeństwa, hakerów i przeprowadzających testy penetracyjne, inżynierów wstecznych, analityków złośliwego oprogramowania i studentów informatyki. Bardzo starałem się, by książka była zrozumiała dla każdego zainteresowanego analizą binarną.

Niemniej jednak, ponieważ książka dotyczy tematów zaawansowanych, pewna znajomość programowania i systemów komputerowych jest wymagana. Aby skorzystać z książki jak najwięcej, powinieneś mieć poniższe:

• Praktyczna znajomość programowania w C lub C++.

• Podstawową, roboczą wiedzę na temat systemów operacyjnych (czym jest proces, pamięć wirtualna i tak dalej).

• Wiedzę, jak używać powłoki Linuksa (najlepiej bash).

• Znajomość asemblera x86/x86-64. Jeżeli nie masz jeszcze pojęcia o asemblerze, przeczytaj najpierw Dodatek A!

Jeżeli nigdy dotąd nie programowałeś albo nie lubisz zanurzać się w niskopoziomowych szczegółach systemów komputerowych, ta książka nie jest prawdopodobnie dla ciebie.

Co jest w tej książce?

Podstawowym celem tej książki jest uczynienie z ciebie wszechstronnego analityka plików binarnych, który jest zaznajomiony ze wszystkimi ważnymi tematami w tej dziedzinie, zarówno z podstawowymi, jak i zaawansowanymi, takimi jak instrumentacja binarna, analiza taint i wykonywanie symboliczne. Książka ta nie pretenduje do bycia wyczerpującym kompendium, ponieważ zmiany w dziedzinie i narzędziach analizy binarnej dokonują się tak szybko, że taka książka najprawdopodobniej byłaby nieaktualna w ciągu roku. Natomiast celem jest dostarczenie ci takiej wiedzy na wszystkie istotne tematy, że będziesz dobrze przygotowany do bardziej samodzielnej nauki.

Podobnie, książka ta nie wchodzi we wszystkie zawiłości inżynierii wstecznej kodu x86 i x86-64 (chociaż dodatek A omawia podstawy) czy analizy złośliwego oprogramowania na tych platformach. Istnieje już wiele książek poświęconych tym tematom i nie ma sensu powielać tutaj ich zawartości. Sięgnij do Dodatku D po listę książek poświęconych inżynierii wstecznej i analizie złośliwego oprogramowania.

Książka ta składa się z czterech części.

Część I: Formaty binarne wprowadza cię w formaty plików binarnych, które są kluczowe do zrozumienia pozostałej części książki. Jeżeli znasz już formaty binarne ELF i PE oraz bibliotekę libbfd, możesz spokojnie opuścić jeden lub więcej rozdziałów tej części.

Rozdział 1: Anatomia pliku binarnego dostarcza ogólnego wprowadzenia do budowy programów w kodzie binarnym.

Rozdział 2: Format ELF wprowadza cię do formatu binarnego ELF używanego przez Linuksa.

Rozdział 3: Format PE: krótkie wprowadzenie zawiera omówienie PE, formatu binarnego używanego przez Windows.

Rozdział 4: Tworzenie programu ładującego pliki binarne przy użyciu libbfd pokazuje ci, jak parsować pliki binarne przy użyciu libbfd i buduje loader plików binarnych używany dalej w książce.

Część II: Podstawy analizy binarnej zawiera podstawowe techniki analizy binarnej.

Rozdział 5: Podstawowa analiza binarna w Linuksie wprowadza w narzędzia niezbędne do przeprowadzenia analizy binarnej w środowisku Linuksa.

Rozdział 6: Deasemblacja i podstawy analizy binarnej omawia podstawowe techniki i wzorce analizy.

Rozdział 7: Proste metody wstrzykiwania kodu dla ELF daje ci przedsmak tego, jak modyfikować pliki binarne ELF za pomocą takich metod, jak wstrzykiwanie kodu i edycja heksadecymalna.

Część III: Zaawansowana analiza binarna omawia zaawansowane techniki analizy.

Rozdział 8: Dostosowywanie deasemblacji do potrzeb pokazuje ci, jak zbudować własne, skrojone na miarę narzędzia do deasemblacji przy użyciu Capstone’a.

Rozdział 9: Instrumentacja binarna dotyczy modyfikowania plików binarnych z Pin, dojrzałą platformą do instrumentacji binarnej.

Rozdział 10: Zasady dynamicznej analizy taint wprowadza cię w zagadnienia dynamicznej analizy taint, najnowocześniejszej metody analizy binarnej, która pozwala śledzić przepływy danych w programach.

Rozdział 11: Praktyczna dynamiczna analiza taint z libdft nauczy cię, jak zbudować własne narzędzia do dynamicznej analizy skażenia z użyciem libdft.

Rozdział 12: Podstawy wykonywania symbolicznego są poświęcone wykonywaniu symbolicznemu, kolejnej zaawansowanej technice, dzięki której w sposób zautomatyzowany można wnioskować o złożonych własnościach programu.

Rozdział 13: Praktyczne wykonywanie symboliczne z Tritonem pokazuje, jak zbudować narzędzia do praktycznego wykonywania symbolicznego.

Część IV: Dodatki zawiera zasoby, które mogą ci się przydać.

Dodatek A: Błyskawiczny kurs asemblera x86 zawiera krótkie wprowadzenie do asemblera x86 dla czytelników, którzy jeszcze nie mieli z nim styczności.

Dodatek B: Implementacja nadpisywania PT_NOTE przy użyciu libelf dostarcza szczegółów implementacji narzędzia elfinject wykorzystywanego w rozdziale 7 i służy jako wprowadzenie do biblioteki libelf.

Dodatek C: Spis narzędzi analizy binarnej zawiera listę narzędzi do analizy binarnej, których możesz zechcieć używać.

Dodatek D: Dalsza lektura zawiera spis źródeł, artykułów i książek związanych z przedmiotami omawianymi w tej książce.

Jak korzystać z tej książki?

Aby pomóc ci jak najwięcej skorzystać z lektury, omówmy krótko konwencje dotyczące przykładowego kodu, składni asemblera i platform oprogramowania.

Model programowy procesora

Chociaż wiele technik opisanych w książce możesz uogólnić na inne maszyny, w praktycznych przykładach skupiam się na modelu programowym procesora (Instruction Set Architecture, ISA) x86 Intela i jego 64-bitowej wersji x86-64 (w skrócie x64). Zarówno do x86, jak i x64 będę się odnosił po prostu jako „x86 ISA”. Zazwyczaj przykłady będą dotyczyć kodu dla x64, chyba że zaznaczono inaczej.

ISA dla procesora x86 jest interesująca, ponieważ jest on niezwykle rozpowszechniony w komputerach stacjonarnych i laptopach, jak również w badaniach z zakresu analizy binarnej (częściowo ze względu na jego popularność w maszynach użytkowników końcowych). W rezultacie wiele platform analizy binarnej jest nastawiona na x86.

Ponadto złożoność modelu programowego procesora x86 pozwala ci zapoznać się z pewnymi wyzwaniami analizy binarnej, które nie występują w prostszych architekturach. Architektura x86 ma długą historię zgodności wstecznej (sięgającą 1978 roku), co prowadzi do bardzo gęstego zestawu instrukcji, w tym znaczeniu, że znaczna większość możliwych wartości bajtów reprezentuje ważny kod operacji. Zaostrza to problem „kod kontra dane”, ponieważ mniej oczywiste staje się dla deasemblerów, że omyłkowo potraktowały dane jako kod. Ponadto instrukcje mają zmienną długość i możliwy jest dostęp do pamięci bez wyrównania dla wszystkich przyjętych długości słów. Stąd x86 pozwala na wyjątkowe konstrukty binarne takie jak (częściowo) nakrywające się i niewyrównane instrukcje. Innymi słowy, kiedy umiesz już postępować z tak złożonym modelem programowym procesora jak x86, inne ISA (takie jak dla ARM) będą już łatwe do zrozumienia.

Składnia asemblera

Jak wyjaśniono w Dodatku A, istnieją dwa popularne formaty składni używane do przedstawiania instrukcji kodu maszynowego w x86: składnia Intela i AT&T. Tutaj będę używał składni Intela, ponieważ jest oszczędniejsza w słowach. W składni Intela przesunięcie stałej do rejestru edi wygląda następująco:

Zauważ, że operand docelowy (edi) pojawia się najpierw. Jeżeli nie jesteś pewien różnic między składnią AT&T i Intela, zajrzyj do omówienia głównych cech charakterystycznych każdego stylu w Dodatku A.

Format binarny i platforma systemowa

Wszystkie przykłady kodu, które towarzyszą tej książce, powstały na wersji Linuksa Ubuntu w języku C/C++, z wyjątkiem niewielu przykładów napisanych w Pythonie. Stało się tak, gdyż wiele popularnych bibliotek dla analizy binarnej przeznaczonych jest głównie na Linuksa i mają wygodne API dla C/C++ lub Python. Jednak wszystkie techniki oraz większość bibliotek i narzędzi używanych w tej książce stosuje się też do Windows, więc jeżeli wybrałeś platformę Windows, nie powinieneś mieć większych kłopotów z przeniesieniem na nią wszystkiego, czego się nauczyłeś. W terminach formatów binarnych książka ta skupia się głównie na plikach binarnych ELF, domyślnych dla platform linuksowych, chociaż wiele z narzędzi obsługuje także pliki binarne PE dla Windows.

Przykładowy kod i maszyna wirtualna

Każdy rozdział tej książki zawiera przykłady kodu i istnieje prekonfigurowana maszyna wirtualna (VM), która towarzyszy książce i zawiera wszystkie próbki kodu. Ta maszyna wirtualna działa na popularnej dystrybucji Linuksa Ubuntu 16.04 i ma zainstalowane wszystkie omawiane narzędzia analizy binarnej otwartego oprogramowania. Możesz używać VM, by eksperymentować z przykładowym kodem i rozwiązywać ćwiczenia umieszczone pod koniec każdego rozdziału. VM jest dostępna na witrynie książki, pod adresem https://practicalbinaryanalysis.com albo https://nostarch.com/binaryanalysis/.

Na stronie internetowej książki znajdziesz także archiwum zawierające kod źródłowy dla przykładów i ćwiczeń. Możesz je pobrać, jeżeli nie chcesz pobierać całej VM, ale miej na uwadze, że niektóre z wymaganych platform analizy binarnej wymagają skomplikowanych ustawień, które musisz skonfigurować samemu, jeżeli wybierzesz opcję pominięcia VM.

Aby używać VM, potrzebujesz oprogramowania do wirtualizacji. VM jest przeznaczona do używania z VirtualBox, które możesz pobrać za darmo z https://www.virtualbox.org/. VirtualBox jest dostępny na wszystkie popularne systemy operacyjne, w tym Windows, Linux i macOS.

Po zainstalowaniu VirtualBox po prostu uruchom je, przejdź do opcji File → Import Appliance i wybierz maszynę wirtualną, którą ściągnąłeś ze strony internetowej książki. Po jej dodaniu włącz ją, klikając zieloną strzałkę z napisem Start w głównym oknie VirtualBox. Kiedy VM ukończy rozruch, możesz zalogować się przy użyciu „binary” jako nazwy użytkownika i hasła. Następnie otwórz terminal, używając skrótu klawiaturowego CTRL-ALT-T, i będziesz gotowy, by podążać za książką.

W katalogu ~/code znajdziesz po jednym podkatalogu na rozdział, który zawiera wszystkie próbki kodu i inne pliki istotne dla danego rozdziału. Na przykład cały kod dla rozdziału 1 znajdziesz w katalogu ~/code/chapter1. Jest także katalog o nazwie ~/code/inc, który zawiera wspólny kod używany przez programy w wielu rozdziałach. Dla plików źródłowych w C++ używam rozszerzenia .cc, a .c dla plików w C, .h dla plików nagłówkowych i .py dla skryptów Pythona.

Aby utworzyć wszystkie przykładowe programy dla danego rozdziału, otwórz po prostu terminal, przejdź do katalogu dla tego rozdziału i wykonaj polecenie make, by zbudować całą zawartość katalogu. To działa we wszystkich przypadkach, z wyjątkiem podanych przeze mnie wprost innych poleceń, by zbudować jakiś przykład.

Większość z ważnych próbek kodu jest omawiana szczegółowo w odpowiadających im rozdziałach. Jeżeli listing kodu omawianego w książce jest dostępny jako plik źródłowy w VM, nazwa tego pliku pojawia się przed listingiem jak poniżej.

Taka nazwa listingu wskazuje, że kod z listingu znajdziesz w pliku filename.c. Jeśli nie zaznaczono inaczej, plik ten znajdziesz pod podaną nazwą w katalogu dla rozdziału, w którym pojawia się ten przykład. Napotkasz także listingi z podpisami, które nie są nazwami plików, co oznacza, że są to tylko przykłady wykorzystane w książce bez odpowiadającej im kopii w VM. Krótkie listingi kodu niewystępujące na VM mogą nie mieć podpisów, tak jak podany wcześniej przykład składni asemblera.

Listingi, które pokazują polecenia powłoki i ich wynik, wykorzystują symbol $ do zaznaczenia wiersza polecenia i używają fontu pogrubionego do oznaczenia wierszy zawierających dane wprowadzane przez użytkownika. Wiersze te są poleceniami, które możesz wypróbować na maszynie wirtualnej, a kolejne linie, niepoprzedzone przez znak zachęty albo druk półgruby, przedstawiają wynik polecenia. Na przykład poniżej podano przegląd katalogu ~/code w VM:

Zauważ, że czasem będę edytować wynik polecenia, by poprawić czytelność, toteż wynik, który zobaczysz w VM, może się nieco różnić.

Ćwiczenia

Na końcu każdego rozdziału znajdziesz kilka ćwiczeń i zadań celem utrwalenia umiejętności nabytych w danym rozdziale. Niektóre ćwiczenia powinny być proste do rozwiązania przy wykorzystaniu tego, czego nauczyłeś się w tym rozdziale, inne mogą wymagać więcej wysiłku i indywidualnych poszukiwań.

CZĘŚĆ I
FORMATY BINARNE

1
ANATOMIA PLIKU BINARNEGO

Analiza binarna w całości dotyczy analizowania plików binarnych. Czym właściwie jest taki plik? Ten rozdział wprowadzi cię w anatomię ogólną formatów binarnych i cykl życia pliku binarnego. Po przeczytaniu będziesz gotów zmierzyć się z kolejnymi dwoma rozdziałami na temat dwóch najszerzej używanych formatów binarnych ELF i PE, odpowiednio dla systemów Linux i Windows.

Dzisiejsze komputery wykonują obliczenia, używając dwójkowego systemu liczbowego, który każdą liczbę przedstawia w postaci ciągu jedynek i zer. Kod maszynowy wykonywany przez te systemy nosi nazwę kodu binarnego. Każdy program składa się ze zbioru kodu binarnego (instrukcji maszynowych) i danych (zmiennych, stałych itp.). Aby śledzić te wszystkie różne programy w danym systemie, potrzebujesz sposobu, by przechowywać cały kod i dane należące do każdego programu w pojedynczym samowystarczalnym pliku. Ponieważ te pliki zawierają wykonywalne programy w kodzie binarnym, nazywają się binarnymi plikami wykonywalnymi. Analizowanie takich plików jest celem tej książki.

Zanim zajmiemy się szczegółami formatów binarnych takich jak ELF i PE, zacznijmy od przeglądu wysokiego poziomu tego, jak z kodu źródłowego otrzymuje się wykonywalne pliki binarne. Potem przeprowadzam deasemblację przykładowego pliku binarnego, by dać ci solidne pojęcie o kodzie i danych zawartych w plikach binarnych. Tego, czego się tu nauczysz, użyjesz do badania plików binarnych ELF i PE w rozdziałach 2 i 3, ponadto zbudujesz własny loader plików binarnych, by parsować pliki binarne i otwierać je do analizy w rozdziale 4.

1.1. Proces kompilacji

Pliki binarne powstają wskutek kompilacji, która jest procesem przekształcania czytelnego dla człowieka kodu źródłowego napisanego np. w C lub C++ w kod maszynowy, który może zostać wykonany przez procesor[3]. Rysunek 1.1 przedstawia kroki zaangażowane w typowy proces kompilacji dla kodu w C (etapy dla języka C++ są podobne). Kompilowanie kodu języka C obejmuje cztery fazy, z których jedna (dosyć niezręcznie) również nosi nazwę kompilacji, tak samo jak cały proces. Te fazy to: preprocesowanie, kompilacja, asemblacja i linkowanie. W praktyce dzisiejsze kompilatory często łączą kilka lub wszystkie z tych faz, lecz dla celów poglądowych omówię je oddzielnie.

Rysunek 1.1: Proces kompilacji kodu w C

1.1.1. Faza preprocesowania

Proces kompilacji zaczyna się od pewnej liczby plików źródłowych, które chcesz skompilować (pokazanych jako file-1.c do file-n.c na rysunku 1.1). Można mieć tylko jeden plik źródłowy, ale duże programy zwykle składają się z wielu plików. Nie tylko sprawia to, że projektem łatwiej jest zarządzać, ale przyspiesza też kompilację, ponieważ gdy zmieni się jeden plik, musisz rekompilować tylko ten plik, a nie cały kod.

Pliki źródłowe w C zawierają makroinstrukcje (poprzedzane przez #define) i dyrektywy #include. Dyrektyw #include używasz, by włączyć pliki nagłówkowe (o rozszerzeniu .h), z których korzysta plik źrodłowy. Faza preprocesowania włącza wszystkie dyrektywy #define i #include do pliku źródłowego tak, że pozostaje czysty kod w C gotowy do skompilowania.

Zobaczmy na przykładzie, jak to konkretnie wygląda. Przykład ten wykorzystuje kompilator gcc, który jest domyślny w wielu dystrybucjach Linuksa (w tym Ubuntu, systemie operacyjnym zainstalowanym na maszynie wirtualnej). Wyniki dla innych kompilatorów, takich jak clang albo Visual Studio, będą podobne. Jak wspomniano we wstępie, będę kompilować wszystkie próbki kodu w tej książce (w tym bieżący przykład) do kodu dla x86-64, jeżeli nie zaznaczono inaczej.

Przypuśćmy, że chcesz skompilować plik źródłowy w C, pokazany na listingu 1.1, który pokazuje wszędobylski komunikat „Hello, world!” na ekranie.

Listing 1.1: compilation_example.c

Za chwilę zobaczysz, co stanie się z tym plikiem w dalszym ciągu procesu kompilacji, ale na razie rozważ wynik etapu preprocesowania. Domyślnie gcc przeprowadzi automatycznie wszystkie etapy kompilacji, stąd musisz wyraźnie powiedzieć mu, żeby zatrzymał się po preprocesowaniu i pokazał ci rezultat pośredni. W gcc można to zrobić, używając polecenia gcc -E -P, gdzie -E informuje gcc, by się zatrzymał po preprocesowaniu, natomiast -P sprawia, że kompilator ominie informacje o debugowaniu, dzięki czemu wynik wygląda trochę bardziej przejrzyście. Listing 1.2 pokazuje wynik fazy preprocesowania, edytowany celem skrócenia. Uruchom VM i śledź wykonanie, by obejrzeć pełny wynik preprocesora.

Listing 1.2: Wynik działania preprocesora C dla programu „Hello, world!”

Nagłówek stdio.h został włączony w całości, ze wszystkimi definicjami typów, zmiennymi globalnymi i prototypami funkcji „skopiowanymi” do pliku źródłowego. Ponieważ tak dzieje się dla każdej dyrektywy #include, plik wyjściowy preprocesora może być całkiem rozwlekły. Preprocesor także całkowicie włącza wszystkie wystąpienia makr, które zdefiniowałeś za pomocą #define. W powyższym przykładzie oznacza to, że oba argumenty dla printf (FORMAT_STRING i_010 i MESSAGE i_011) są ewaluowane i zastąpione stałymi łańcuchowymi, które reprezentują.

1.1.2. Faza kompilacji

Po ukończeniu fazy preprocesowania kod źródłowy jest gotowy do kompilacji. Faza ta bierze preprocesowany kod i tłumaczy go na język asemblera. (Większość kompilatorów przeprowadza w tej fazie znaczną optymalizację, zwykle konfigurowalną jako poziom optymalizacji do ustawienia w wierszu poleceń w gcc jako opcje -O0 do -O3. Jak dowiesz się w rozdziale 6, stopień optymalizacji podczas kompilacji może mieć znaczny wpływ na deasemblację).

Dlaczego etap kompilacji daje produkt w asemblerze, a nie kod maszynowy? Ta decyzja projektowa nie wydaje się mieć sensu w kontekście pojedynczego języka (w tym przypadku C), ale ma sens, kiedy pomyślisz o wszystkich innych dostępnych językach. Przykładami popularnych języków kompilowanych są C, C++, Objective-C, Common Lisp, Delphi, Go i Haskell, by wymienić tylko kilka. Napisanie kompilatora, który generowałby bezpośrednio kod maszynowy dla każdego z tych języków, byłoby bardzo wymagającym i czasochłonnym zadaniem. Lepiej jest zamiast tego generować kod w asemblerze (już i tak wystarczająco trudne zadanie) i mieć jeden dedykowany asembler, który może zająć się końcowym przekładem kodu asemblera na maszynowy dla każdego języka.

A zatem wynikiem fazy kompilacji jest kod w asemblerze, całkiem czytelny dla człowieka z nietkniętą informacją o symbolach. Jak wspomniano, gcc normalnie wywołuje wszystkie fazy kompilacji automatycznie, stąd, by zobaczyć wygenerowany kod w asemblerze, musisz powiadomić gcc, aby się zatrzymał i zapisać na dysku pliki asemblera. Możesz to zrobić, używając flagi -S (.s to konwencjonalne rozszerzenie dla plików asemblera). Przekazujesz także opcję -masm=intel do gcc po to, by wygenerował kod w składni Intela, a nie domyślnej AT&T. Listing 1.3 pokazuje wynik fazy kompilacji dla przykładowego programu[4].

Listing 1.3: Plik asemblera wygenerowany w fazie kompilacji programu „Hello, world!”

Nie wchodzę na razie w szczegóły dotyczące kodu w asemblerze. Tym, co interesujące w listingu 1.3, jest obserwacja, że kod asemblera jest łatwy do odczytania, ponieważ symbole i funkcje zostały zachowane. Na przykład stałe i zmienne mają nazwy symboli, a nie tylko adresy (nawet jeżeli jest to tylko automatycznie wygenerowana nazwa, taka jak LCO i_012 dla bezimiennego łańcucha „Hello, world!”), i mamy jawną etykietę dla funkcji main i_012 (w tym przypadku jedynej funkcji). Każde odniesienie do kodu i danych jest także w postaci symbolicznej, jak ta referencja do łańcucha „Hello, world!” i_013. Ten luksus zniknie, gdy będziesz miał do czynienia z „okrojonymi” plikami binarnymi (stripped binaries) w dalszej części książki!

1.1.3. Faza asemblacji

W fazie asemblacji wreszcie doczekasz się wygenerowania prawdziwego kodu maszynowego! Danymi wejściowymi dla fazy asemblacji jest zestaw plików w asemblerze wygenerowanych w fazie kompilacji, a dane wyjściowe to zestaw plików obiektowych, czasami określanych jako moduły wynikowe. Pliki obiektowe zawierają instrukcje maszynowe, które w zasadzie są wykonywalne przez procesor. Ale, jak wyjaśnię za chwilę, musisz wykonać trochę pracy, zanim otrzymasz gotowy do uruchomienia wykonywalny plik binarny. Zwykle każdy plik źródłowy odpowiada jednemu plikowi asemblera, a każdy plik asemblera jednemu plikowi obiektowemu. Aby wygenerować plik obiektowy, przekazujesz flagę -c do gcc, jak pokazano na listingu 1.4.

Listing 1.4: Generowanie pliku obiektowego w gcc

Możesz użyć programu narzędziowego file (przydatny program, do którego powrócę w rozdziale 5), by potwierdzić, że otrzymany plik, compilation_example.o jest naprawdę plikiem obiektowym. Jak możesz zobaczyć na listingu 1.4, tak właśnie jest: plik ten pokazuje się jako ELF 64-bit LSB relocatable file.

Co to dokładnie znaczy? Pierwsza część pliku wyjściowego narzędzia file wskazuje, że plik jest zgodny ze specyfikacją ELF dla binarnych plików wykonywalnych (które będę szczegółowo omawiał w rozdziale 2). Ściślej mówiąc, jest to 64-bitowy plik ELF (ponieważ w tym przykładzie kompilujesz na x86-64) i jest on LSB, co oznacza, że liczby są umieszczane w pamięci z najmniej znaczącym bitem jako pierwszym. Najważniejsze, że plik ten jest relokowalny (relocatable).

Pliki relokowalne nie muszą być umieszczone w pamięci pod jakimś konkretnym adresem, natomiast mogą być przenoszone dowolnie bez naruszenia jakichkolwiek założeń w kodzie. Kiedy zobaczysz termin relokowalny w pliku wyjściowym narzędzia file, wiesz, że masz do czynienia z plikiem obiektowym, a nie z wykonywalnym[5].

Pliki obiektowe są kompilowane niezależnie od siebie, stąd asembler nie dysponuje wiedzą o adresach pamięci innych plików obiektowych, kiedy asembluje dany plik obiektowy. Dlatego właśnie takie pliki muszą być relokowalne; w ten sposób możesz konsolidować je w dowolnym porządku, by utworzyć kompletny binarny plik wykonywalny. Gdyby pliki obiektowe nie były relokowalne, nie byłoby to możliwe.

Później w tym rozdziale zobaczysz zawartość pliku obiektowego, gdy będziesz gotowy deasemblować po raz pierwszy plik.

1.1.4. Faza konsolidacji (linkowania)

Etap konsolidacji stanowi ostatnią fazę procesu kompilacji. Jak sugeruje nazwa, faza ta łączy ze sobą wszystkie pliki obiektowe w jeden binarny plik wykonywalny. W nowszych systemach faza linkowania czasami obejmuje dodatkową turę optymalizacji, zwaną optymalizacją w czasie linkowania (link-time optimization, LTO)[6].

Nic dziwnego, że program, który przeprowadza fazę konsolidacji, nazywa się konsolidatorem (linker). Na ogół jest odrębny od kompilatora, który zwykle implementuje wszystkie wcześniejsze fazy.

Jak już wspomniałem, pliki obiektowe są relokowalne, ponieważ są kompilowane niezależnie od siebie, co zapobiega, by kompilator zakładał, że jakiś obiekt znajdzie się pod określonym adresem bazowym. Ponadto pliki obiektowe mogą odnosić się do funkcji czy zmiennych w innych plikach obiektowych lub w bibliotekach zewnętrznych w stosunku do programu. Przed fazą konsolidacji adresy, pod którymi znajdą się dane i kod odniesienia, nie są jeszcze znane, więc pliki obiektowe zawierają tylko symbole relokacji, które określają sposób, w jaki odniesienia do funkcji i zmiennych powinny zostać ostatecznie rozwiązane. W kontekście konsolidacji, odniesienia, które korzystają z symbolu relokacji, noszą nazwę odniesień symbolicznych. Kiedy jakiś plik obiektowy odnosi jedną ze swoich funkcji lub zmiennych do adresu bezwzględnego, to odniesienie także jest symboliczne.

Zadaniem konsolidatora jest zebrać wszystkie pliki obiektowe należące do jakiegoś programu i połączyć je w jeden spójny plik wykonywalny, zwykle do załadowania pod określony adres w pamięci. Teraz, kiedy znany jest już układ wszystkich modułów w pliku wykonywalnym, konsolidator może rozwiązać większość odniesień symbolicznych. Odniesienia do bibliotek mogą (ale nie muszą) zostać całkowicie rozwiązane, w zależności od rodzaju biblioteki.

Biblioteki statyczne (które w Linuksie mają zazwyczaj rozszerzenie .a, jak widać na rysunku 1.1) są włączane do binarnego pliku wykonywalnego, co pozwala na rozwiązanie wszystkich odniesień do nich. Istnieją także biblioteki dynamiczne (współdzielone), które są wspólne w pamięci dla wszystkich programów działających w systemie. Innymi słowy, zamiast kopiować taką bibliotekę do każdego pliku binarnego, który z niej korzysta, biblioteki dynamiczne są ładowane do pamięci tylko raz i każdy plik binarny chcący użyć tej biblioteki potrzebuje użyć tej współdzielonej kopii. Podczas fazy konsolidacji adresy, pod którymi znajdą się biblioteki dynamiczne, nie są jeszcze znane, stąd odniesienia do nich nie mogą zostać rozwiązane. Zamiast tego konsolidator pozostawia odniesienia symboliczne do tych bibliotek nawet w końcowym pliku wykonywalnym i te odniesienia nie zostaną rozwiązane do czasu rzeczywistego załadowania pliku binarnego do pamięci w celu jego wykonania.

Większość kompilatorów, w tym gcc, automatycznie wywołuje program konsolidujący pod koniec procesu kompilacji. Stąd, by wyprodukować kompletny binarny plik wykonywalny, możesz po prostu wywołać gcc bez żadnych specjalnych poleceń, co pokazuje listing 1.5.

Listing 1.5: Generowanie binarnego pliku wykonywalnego w gcc

Domyślnie, plik wykonywalny nazywa się a.out, ale możesz nadpisać tę nazwę przez przełącznik -o do gcc, po której następuje nazwa pliku wyjściowego. Narzędzie file informuje teraz, że masz do czynienia z plikiem typu ELF 64-bit LSB executable i_015, a nie z plikiem relokowalnym, jaki widziałeś pod koniec fazy asemblacji. Kolejną ważną informacją jest, że plik ten jest linkowany dynamicznie i_015, co oznacza, że używa on pewnych bibliotek, które nie są włączone do pliku wykonywalnego, ale współdzielone przez wszystkie programy uruchomione w tym systemie. W końcu interpreter /lib64/ld-linux-x86-64.so.2 i_015 w pliku wyjściowym narzędzia file mówi, który konsolidator (linker) dynamiczny zostanie użyty, by rozwiązać końcowe zależności od bibliotek dynamicznych, kiedy plik wykonywalny jest ładowany do pamięci w celu wykonania. Kiedy uruchomisz ten plik binarny (używając polecenia ./a.out), możesz zobaczyć, że dostarcza on oczekiwany wynik (drukując „Hello, world!”), co potwierdza, że stworzyłeś działający plik binarny.

Ale co oznacza fragment o pliku binarnym, że nie został on „stripped” i_016 („okrojony”)? Omówię to teraz!

1.2. Symbole i okrojone pliki binarne

Kod źródłowy wysokiego poziomu, taki jak kod w C, skupia się wokół funkcji i zmiennych noszących znaczące, czytelne dla człowieka nazwy. Podczas kompilacji programu kompilatory emitują symbole śledzące losy takich nazw symbolicznych i zapisujące, która część kodu binarnego i danych odpowiada każdemu symbolowi. Na przykład symbole funkcji dostarczają odwzorowania z symbolicznych, wysokiego poziomu nazw funkcji na pierwszy adres i rozmiar każdej funkcji. Ta informacja zwykle jest wykorzystywana przez konsolidator podczas łączenia plików obiektowych (na przykład, by rozwiązać odniesienia funkcji i zmiennych między modułami), a także pomaga przy debugowaniu.

1.2.1. Podgląd symboli w pliku

Aby dać ci pojęcie o tym, jak wygląda informacja symboliczna, na listingu 1.6 pokazano część symboli z przykładowego pliku binarnego.

Listing 1.6: Symbole w pliku binarnym a.out pokazane przez readelf

Na listingu 1.6 użyłem readelf do wyświetlenia symboli i_018. Powrócimy do wykorzystania narzędzia readelf i interpretacji jego całego wyniku w rozdziale 5. Na razie zauważ tylko, że wśród wielu nieznajomych dla ciebie symboli występuje symbol dla funkcji main i_018. Jak widzisz, określa ona adres (0x400526), pod którym znajdzie się ta funkcja po załadowaniu pliku binarnego do pamięci. Plik wyjściowy pokazuje także rozmiar kodu dla main (32 bajty) i zaznacza, że masz do czynienia z symbolem funkcji (typ FUNC).

Informacja symboliczna może być osadzana jako część pliku binarnego (co właśnie zobaczyłeś) albo w postaci oddzielnego pliku symboli (symbol file) i może różnie wyglądać. Konsolidator potrzebuje tylko podstawowych symboli, ale o wiele bardziej wyczerpująca informacja może być emitowana na potrzeby debugowania. Symbole debugowania dostarczają nie tylko pełnego mapowania między liniami kodu źródłowego i instrukcjami poziomu binarnego, ale opisują nawet parametry funkcji, informację o ramce stosu itd. Dla plików binarnych w formacie ELF symbole do debugowania są zwykle generowane w formacie DWARF[7], a pliki binarne PE zazwyczaj używają własnego formatu Microsoft Portable Debugging (PDB)[8]. Informacja DWARF jest przeważnie wbudowana w plik binarny, a PDB jest odrębnym plikiem symboli.

Jak możesz sobie wyobrazić, informacja o symbolach jest niezwykle użyteczna dla analizy binarnej. Aby podać tylko jeden przykład, gdy masz do dyspozycji zbiór dobrze zdefiniowanych symboli funkcji, deasemblacja jest o wiele prostsza, ponieważ możesz użyć każdego symbolu funkcji jako punktu startowego dla deasemblacji. Zmniejsza to znacznie prawdopodobieństwo, że przypadkowo deasemblujesz przykładowo dane jako kod (co mogłoby prowadzić do fałszywych instrukcji w wyniku deasemblacji). Wiedza, które części pliku binarnego należą do jakiej funkcji i jaka funkcja jest wywoływana, sprawia także, że analityk może łatwiej dokonać kompartmentalizacji kodu i zrozumieć, co ten kod robi. Nawet podstawowe symbole konsolidatora (w porównaniu do znacznie obszerniejszej informacji podczas debugowania) są już znaczącą pomocą przy wielu zastosowaniach analizy binarnej.

Możesz parsować symbole, używając readelf, jak wspomniałem powyżej, albo programowo z biblioteką taką, jak libbfd, co wyjaśnię w rozdziale 4. Istnieją także biblioteki, np. libdwarf, specjalnie przeznaczone do parsowania symboli debugowania DWARF, których nie omawiam w tej książce.

Niestety, obszerna informacja z debugowania przeważnie nie jest włączona do gotowych do uruchomienia plików binarnych, a nawet podstawowa informacja symboliczna jest często usuwana, by zmniejszyć wielkość plików i zapobiec inżynierii wstecznej, zwłaszcza w przypadku złośliwego czy własnościowego oprogramowania. Oznacza to, że jako analityk często będziesz miał do czynienia z o wiele trudniejszym przypadkiem okrojonych plików binarnych, bez informacji symbolicznej w jakiejkolwiek postaci. W tej książce będę zakładał tak mało informacji symbolicznej, jak tylko możliwe, i skupię się na „gołych” plikach binarnych, chyba że zaznaczono inaczej.

1.2.2. Kolejny plik binarny przechodzi na ciemną stronę: „okrajanie” pliku

Może pamiętasz, że przykładowy plik binarny nie został jeszcze okrojony (stripped; jak pokazano na wyniku narzędzia file na listingu 1.5). Najwidoczniej domyślne ustawienia w gcc nie okrajają automatycznie nowo skompilowanych plików binarnych. W razie gdyby ciekawiło cię, jak to się dzieje, że pliki binarne zostają okrojone, sprawia to po prostu pojedyncze polecenie, trafnie nazwane strip, jak pokazano na listingu 1.7.

Listing 1.7: Okrajanie pliku wykonywalnego

W ten sposób przykładowy plik binarny został teraz „okrojony” i_019, co potwierdza plik wyjściowy narzędzia file i_019. Tylko kilka symboli pozostało w tablicy .dynsym i_019. Używane są one do rozwiązania zależności dynamicznych (takich jak odniesienia do bibliotek dynamicznych), kiedy plik binarny zostanie załadowany do pamięci, ale nie przydają się przy deasemblacji. Wszystkie inne symbole, w tym również ten dla funkcji main, widoczny na listingu 1.6, zniknęły.

Przypisy

[1] https://0patch.blogspot.nl/2017/11/did-microsoft-just-manually-patch-their.html.

[2] Niektóre kompilatory robią to częściej niż inne. Visual Studio jest szczególnie znane, jeżeli chodzi o mieszanie kodu z danymi.

[3] Istnieją także języki, takie jak Python i JavaScript, w których programy są interpretowane w trakcie wykonywania, a nie kompilowane w całości. Czasami część interpretowanego kodu podlega kompilacji JIT ( just in time), kiedy program się wykonuje. Wytwarza to kod binarny w pamięci komputera, który możesz analizować za pomocą technik opisanych w tej książce. Ponieważ analiza języków interpretowanych wymaga wyspecjalizowanych kroków właściwych dla danego języka, nie będę wnikał w szczegóły tego procesu.

[4] Zauważ, że gcc zoptymalizował wywołanie funkcji printf, zastępując ją puts.

[5] Istnieją także relokowalne (niezależne od położenia) pliki wykonywalne, ale te pokazują się w file jako obiekty współdzielone, a nie pliki relokowalne. Możesz odróżnić je od normalnych bibliotek współdzielonych, ponieważ mają one adres startowy.

[6] Dalsze lektury dotyczące LTO znajdują się w Dodatku D.

[7] W razie gdyby cię to zaciekawiło, za akronimem DWARF nie kryje się nic szczególnego. Nazwę tę wybrano, gdyż ładnie łączy się z „ELF” (przynajmniej, kiedy masz na myśli mityczne stworzenia).

[8] Dla zainteresowanych, w Dodatku B są pewne odnośniki do literatury na temat DWARF i PDB.