Każdy z nas korzysta z komputera, uruchamia programy czy je tworzy. W dzisiejszym świecie procesory wykonują miliardy operacji na sekundę, jednak jak to porównać do pojedynczego kliknięcia w celu uruchomienia programu? Aby odpowiedzieć na to pytanie należy zagłębić się w działanie systemu operacyjnego i zrozumieć jak działa od środka. Każde uruchomienie programu powoduje załadowanie go do pamięci, a jego instrukcje (w postaci opcode’ów) są dostarczane binarnie do procesora, który je wykonuje bez żadnego „zastanowienia”. A co jeżeli podstawimy procesorowi nasze niecnie spreparowane instrukcje?

Procesory działają automatycznie. Nie słyszałem aby protestowały na poziomie parsowania instrukcji. Jest masa technik i klas błędów, które pozwalają na wykonanie kodu w kontekście obcej aplikacji m.in : buffer overflow (przepełnienie danych na stosie czy stercie). Powstają jednak coraz nowsze zabezpieczenia, które w znaczącym stopniu to utrudniają:

  1. DEP (Data Execution Prevention, od Windowsa XP SP2, Mac OS X 10.4.4, a w Linuxie od wersji 2.6.8 jądra). Można ominąć jego działanie poprzez ROP (Return Oriented Programming) wykonując VirtualProtect czy VirtualAlloc na stronie pamięci, gdzie leży nasz shellcode.
  2. ASLR (Address Space Layout Randomization, od Windows Visty, Mac OS X 10.5, wersja 2.6.12 jądra linuxa). Sposób ominięcia tego zabezpieczenia zależy ściśle od naszej sytuacji. Jeżeli w kontekście naszego procesu znajdują się biblioteki, które są z jakiś przyczyn ładowane pod stały adres, możemy to łatwo wykorzystać do stworzenia łańcucha ROP. Innym pomysłem są techniki heap spraying oraz nop sled, czyli zapełnienie pamięci ogromną ilością opcodów 0x90 (nop) przeplatanymi naszym shellcodem. Technikę tą możemy zastosować gdy mamy informacje o jakimś wskaźniku czy pamięci procesu.
  3. Stack Canaries oraz zmiana kolejności buforów na stosie (każda funkcja, która prawdopodobnie jest podatna na stack buffer overflow otrzymuje w swojej ramce stosu pseudolosową wartość na jej najwyższym adresie, a następnie przed wyjściem z funkcji jest sprawdzana, przez co niemożliwe jest nadpisanie adresu powrotu z funkcji, czyli nie mamy możliwości sterowaniem flow programu) Można ominąć nadpisaniem adresu obsługi wyjątków na stosie i spowodowania rzucenia wyjątku przez program.
  4. SEHOP (Structured Exception Handler Overwrite Protection) czy „SafeSEH” zabezpiecza przed nadpisaniem adresów obsługi wyjątków, co uniemożliwia aby wyjątek mógł być obsłużony przez złośliwą funkcję. SEH to sposób obsługi wyjątków wyłącznie dla Windowsa. To zabezpieczenie chroni tylko 32 bitowe aplikacje, ze względu na to, że 64 bitowe aplikacje wszystkie informacje dotyczące wyjątków mają dodawane do nagłówka pliku PE przez kompilator.

Jednak czy te zabezpieczenia można obejść w tak prosty sposób jak napisałem wyżej? Jeżeli nasza binarka jest mała; jest w niej mało kodu, a cały layout pamięci jest losowy oraz w tym samym czasie DEP jest włączony to nasze szanse spadają niemalże do zera. Ewentualnie jeżeli atak przeprowadzany jest lokalnie można spróbować odgadnąć adresy załadowanych bibliotek metodą brute-force, dla 64 bitowej wersji Windowsa wraz z HiASLR jest to bardzo utrudnione poprzez bardzo dużą możliwą ilość adresów bazowych. Entropia adresów zależy m.in od tego czy jest to obraz .exe (8 bądź 17 bitów entropii) czy .dll (14 bądź 19 bitów entropii), oraz od ich adresów bazowych (powyżej 4 GB entropia jest zwiększa). Czy sama wersja systemu operacyjnego pozwala nam być spokojnym co do bezpieczeństwa aplikacji ? Aby lepiej zrozumieć ten temat spróbujmy wykorzystać stack buffer overflow w starej aplikacji DVD Playera (z 2009 roku) na w pełni zaktualizowanym Windowsie 10.0.15063 (x86-64) link -> audconv.exe

Podczas otwierania playlisty w formacie .pls, jej zawartość jest wrzucana na stos (sic!) oraz nie jest sprawdzany rozmiar pliku. Przyjrzyjmy się layoucie pamięci:

Można zauważyć brak ASLR oraz SafeSEH w bibliotece audconv.dll, wykorzystywaną do stworzenia rop chain (aby ominąć DEP), który z kolei wywoła VirtualAlloc na stosie aby zrobić ten fragment pamięci wykonywalnym. Zacznijmy od zlokalizowania ciekawych offsetów na stosie. Stwórzmy wzór o długości 10 000 bajtów i zapiszmy go do pliku .pls, oraz sprawdźmy zachowanie programu.

Okazuje się, że nadpisaliśmy adres obsługi wyjątków (SEH) na stosie, a program właśnie rzucił wyjątek.

Sprawdźmy w której części naszego wzoru kontrolujemy ten adres oraz go nadpiszmy, lecz najpierw musimy przesunąć nasz wskaźnik stosu wyżej aby wskazywał na nasze dane wejściowe. Znajdźmy odpowiedni gadget typu:

add esp, wartosc > 0x500; ret

w pliku audconv.exe czy audconv.dll (które mają ASLR wyłączone). Zaprzęgnijmy do tego komputer i napiszmy prosty skrypt python’a 2.7, który wyszuka nam wszystkie gadgety (dla 32 bitowych plików wykonywalnych Windowsa). Do deasemblacji użyłem biblioteki Distorm.

import sys
from distorm3 import Decode, Decode32Bits
import struct
if len(sys.argv) < 2:
  print "Usage ./disasm.py file.exe|file.dll"
  sys.exit()
buf = open(sys.argv[1],'rb').read()
peheader = struct.unpack("I",buf[0x3C:0x40])[0]
f1 = peheader+0x10c
t1 = peheader+0x110
f2 = peheader+0x1c
t2 = peheader+0x20
f3 = peheader+0x104
t3 = peheader+0x108
codestart = struct.unpack("I",buf[f1:t1])[0]
sizeofcode = struct.unpack("I",buf[f2:t2])[0]
rva = struct.unpack("I",buf[f3:t3])[0]
print "[-] Code starts at %.08x in file" % (codestart)
print "[-] Size of code %.08x" % (sizeofcode)
print "[-] RVA of .text section %.08x" % (rva)
print "[*] Generating code chunks...\n"
dec = Decode (rva,buf[codestart:codestart+sizeofcode],Decode32Bits)
i = 0
for i in range(len(dec)):
  if (dec[i][2] == "RET"):
      for j in dec[i-5:i+1]:
          print "0x%08x (%02x) %-20s %s" % (j[0], j[1], j[3], j[2])
      print "\n"

Na adresie 0x00002d01 (relatywnym do Image Base) w bibliotece audconv.dll znajduje się gadget

add esp, 0x10e4
ret

Teraz możemy budować dalej rop chain. Naszym zadaniem jest teraz wykonanie VirtualAlloc (które aktualnie jest importowane w kontekście tej aplikacji) na stronie pamięci stosu. Peter Van Eeckhoutte napisał skrypt mona.py do Immunity Debugger wykonujący tą prace za nas. Wpiszmy więc w konsolę debuggera „!mona rop” a dostaniemy łańcuch, którego potrzebujemy.

Ten łańcuch wykorzystuje, że w IAT (Import Address Table) jest adres VirtualAlloc oraz przygotowuje parametry na stosie (PAGE_EXECUTE_READWRITE - 0x40, MEM_COMMIT - 0x1000, adres stosu, gdzie znajduje się nasz shellcode oraz ilość pamięci do zmiany (mimo argumentu, który mówi o 1 bajcie, system przydzieli nowe prawa całej stronie pamięci (4096 bajtów), gdyż wszystkie alokacje są wyrównywane do wielkości strony pamięci)).

Jedyne co nam pozostało to wykonać nasz shellcode. Możemy pobrać go z exploit-db.com lub zagłębić się w środowisko Windowsa i napisać własny. W kodzie nie było zabezpieczenia w postaci stack canaries, jednak i tak byśmy je ominęli za pomocą nadpisania SEH. Ja zdecydowałem się, aby wykonać MessageBox z napisem DONE.

Cały kod źródłowy dostępny [tutaj]

Mając najnowszy system operacyjny nadal jest możliwość, że staniemy się ofiarą błędu programu i nawet zaawansowane techniki jego obrony nic nam nie pomogą. Jako developer należy zwrócić uwagę na domyślne ustawienia bezpieczeństwa kompilatora, którego używamy.

Sprawdźmy, które zabezpieczenia są obecne w prostym podatnym programie „test.exe” skompilowanym przez gcc 7.1.0 („gcc test.c -o test.exe”)

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
int main (int argc,char ** argv)
{
    if (argc != 2)
    {
        puts("Usage: foo <password>");
        return -1;
    }
    size_t s = strlen(argv[1]);
    char buf [0x10];
    memcpy (buf,argv[1],s); 
    return 0;
}

Co można zauważyć: ASLR jest włączony dla bibliotek systemowych (adresy losowane są podczas startu systemu), jednak sama aplikacja jest ładowana pod adres stały. DEP z uwagi na swoją specyfikę oraz bez względu na kompilator, również jest obecny w kontekście procesu. Jednak stack canaries już nie są domyślnie umieszczane w kodzie (można to uzyskać poprzez przełącznik -fstack-protector); przed wyjściem z funkcji main() nie jest sprawdzana żadna wartość ze stosu. Aby zabezpieczyć naszą aplikacje możemy dodać następujące opcje do linkera:

  • dynamicbase (ustawia w polu DllCharacteristics w nagłówku pliku PE flagę, że ten plik jest aslr-aware)
  • nxcompat (nazwa pochodzi od No Execute Compatible, domyślnie Windows włącza DEP tylko dla takich plików wykonywalnych, które mają tę flagę)
  • high-entropy-va (jeżeli nasz target to x86-64 możemy ustawić wyższą entropie adresów losowanych przez kernel (HiASLR))
  • export-all-symbols (dzięki tej opcji program wyeksportuje wszystkie symbole, między innymi te potrzebne dla relokacji, umożliwiając zmianę adresu bazowego)
gcc -Wl,high-entropy-va,dynamicbase,nxcompat,export-all-symbols test.c -o test.exe

Jak ma się sprawa z kompilatorem Visual Studio (ver. 14.10.25017)?

cl test.c

Jak widać wszystkie opcje, które musieliśmy ręcznie wpisać dla gcc są tutaj obecne domyślnie, stack canaries również. (Na obrazku w prologu funkcji main() ciastko bezpieczeństwa jest ładowane pod najwyższy adres na stosie)

W kompilatorze Microsoftu można również aktywować opcje „Control Flow Guard” (przełącznik /guard:cf), która podczas kompilacji analizuje wszystkie pośrednie wykonania funkcji i dodaje kod sprawdzający poprawność ich wywołań. Można to lepiej zrozumieć za pomocą poniższego pseudokodu.

void foo (CALLBACK_ROUTINE * f)
{
    if (!isValidTarget(f))
    {
         TerminateProcess();
    }
}
f();

Niezależnie od tego jak nowoczesny mamy system operacyjny, stajemy się ofiarą ataków przez nasze złe nawyki lub korzystanie ze starego oprogramowania. Jeżeli chcemy podnieść bezpieczeństwo naszego Windowsa, możemy zainstalować EMET (Enhanced Mitigation Experience Toolkit). Dzięki temu narzędziu opisywany przeze mnie sposób exploitacji DVD Playera nie powiódłby się. Ten program pozwala na łatwe operowanie zabezpieczeniami w naszym systemie. Możemy zabezpieczyć również starsze aplikacje, które nie są skompilowane aby współpracować z nowymi zabezpieczeniami.

Dominik Tamiołło