✨ Witamy na nowej odsłonie naszej strony internetowej! ✨
Linux ASLR Internals

Linux ASLR Internals

18 lipca 2025·
Adrian Śliwa

Wstęp

W dzisiejszych czasach bezpieczeństwo naszych programów nie zależy tylko od programisty. Współczesne kompilatory i systemy operacyjne, np. Linux i Windows, implementują różnego rodzaju mitygacje mające utrudnić atakującemu wyexploitowanie występującej podatności - jedną z tych mitygacji jest randomizacja układu przestrzeni adresowej (ang. Address Space Layout Randomization), powszechnie nazywaną ASLR. W niniejszym artykule zajmiemy się nie tylko podstawami, ale także dzięki dobrodziejstwu open-source przyjrzymy się od kuchni, w jaki sposób każdy losowy adres jest wyznaczany. Zobaczymy także, że żadna implementacja nie jest idealna, jako że programiści implementujący takie rozwiązania muszą balansować między bezpieczeństwem, szybkością a prostotą rozwiązania. Zachęcam do przeczytania artykułu “Stack based buffer overflow - Wstęp teoretyczny i (nie do końca) praktyczna demonstracja” autorstwa Dawida Ciemały, ponieważ ten artykuł można traktować jako pewnego rodzaju rozszerzenie przedstawionej tam wiedzy.

Krótka demonstracja

Na Wikipedii możemy przeczytać, że: “Address Space Layout Randomization to technika bezpieczeństwa komputerowego stosowana w celu zapobiegania wykorzystaniu podatności związanych z korupcją pamięci. Aby uniemożliwić atakującemu przekierowanie wykonania kodu, na przykład do określonej funkcji w pamięci, ASLR losowo rozmieszcza pozycje w przestrzeni adresowej kluczowych obszarów danych procesu, w tym podstawę pliku wykonywalnego oraz położenia stosu, sterty i bibliotek”. Jest to definicja jak najbardziej poprawna, natomiast uważam, że najłatwiej tłumaczy się poprzez pokazywanie. Weźmy sobie jako przykład prosty program w języku C printujący na ekran adres zmiennej lokalnej c:

#include <stdio.h>
#include <stdlib.h>

int main() {
    char c;
    printf("%p\n", &c);
    return EXIT_SUCCESS;
}

Po skompilowaniu tego kodu i jego wielokrotnym uruchomieniu jesteśmy w stanie zaobserwować, że adres zmiennej za każdym razem się zmienia. Jest to możliwe właśnie dzięki mechanizmowi ASLR!

image

Dodatkowo możemy powtórzyć eksperyment dla zmiennych należących do różnych segmentów pamięci: np. lokalnych, globalnych i adresów funkcji.

#include <stdio.h>
#include <stdlib.h>

int globalna;

void funkcja(void) {}

int main() {
    int lokalna;
    printf("lokalna:\t%p\n", &lokalna);
    printf("globalna:\t%p\n", &globalna);
    printf("funkcja:\t%p\n", &funkcja);
    printf("printf: \t%p\n", &printf);
    return EXIT_SUCCESS;
}

Oto rezultat wykonania powyższego programu:

image

Uważni czytelnicy z pewnością są już w stanie zobaczyć pewne niedoskonałości, ale nie wyprzedzajmy faktów :).

Krótka historia

Pierwszą implementację ASLR w systemie Linux zawierała seria łatek PaX z 2001 roku. Dodawała ona również inne zabezpieczenia, ale wychodzi to poza zakres tego artykułu. W wersji 2.6.12 (2005) do oficjalnego jądra Linuxa trafiła podstawowa implementacja ASLR. Ta pierwsza implementacja mainline obejmowała randomizację pozycji stosu i regionów mmap (w tym bibliotek współdzielonych). Heap pozostawał jeszcze bez randomizacji. Jednak implementacja ta była znacznie słabsza niż oryginalne patche PaX - używała mniejszej liczby bitów entropii, co oznaczało węższą przestrzeń losowości i większą podatność na ataki brute-force. ASLR zaczął być stopniowo wzmacniany poprzez zwiększanie entropii i dodawanie nowych funkcjonalności, w tym randomizację heapa oraz system kontroli ASLR przez /proc/sys/kernel/randomize_va_space z trzema poziomami: 0 - brak ASLR, 1 - randomizacja częściowa (stos + mmap), 2 - pełna randomizacja (dodatkowo heap).

Ciasto - Position Independent Executable (PIE)

Sam fakt obecności ASLR w systemie nie wystarcza. Żeby plik wykonywalny mógł być załadowany w dowolne miejsce w przestrzeni adresowej, musi być skompilowany w specjalny sposób uwzględniający tę możliwość. Kompilator musi unikać adresowania absolutnego i wszędzie używać adresowania relatywnego. Ładowane biblioteki zawsze były kompilowane z myślą o tym, że nie będą wiedzieć w którym miejscu w pamięci się znajdą, natomiast historycznie to nie zawsze była prawda dla plików wykonywanych programów. Dla przykładu weźmy taki program i skompilujmy go na różne sposoby:

#include <stdlib.h>
int global = 0x41;

int main() {
    global = 0x61;
    return EXIT_SUCCESS;
}

Dla systemu 64-bitowego x86-64 nie ma różnicy w jaki sposób skompilujemy binarkę - w obu przypadkach (przynajmniej na mojej wersji gcc 15.1.1) jest używana instrukcja relatywnego adresowania w zależności od zawartości instruction pointera (rejestru rip): movl $0x61, 0x2efc(%rip).

image

W przypadku systemu 32-bitowego x86 sprawa wygląda już ciekawiej. Architektura x86 nie ma relatywnego adresowania zależnego od zawartości rejestru %eip (32-bitowy odpowiednik %rip) i kompilator musi trochę pokombinować, w rezultacie czego wypluwa inny kod dla binarki, która jest position-independent. W przypadku no-pie znamy adres zmiennej, do której chcemy się dostać, więc nie ma żadnego problemu. W przypadku pie kompilator stworzył specjalną funkcję __x86.get_pc_thunk.ax, która ładuje do rejestru %eax zawartość %eip (robi to poprzez czytanie adresu powrotu ze stosu).

image

image

Zależnie od tego jaką dystrybucję używacie, możecie mieć pie włączone domyślnie albo i nie. Niestety, nie ma żadnego standardu. Według moich testów na Fedorze i Opensuse, flagi -pie i -fpie nie są dodawane za nas i musimy je dodać sami. Na Archu (którego używam), Gentoo, Ubuntu i Debianie flagi -pie i -fpie są dodawane za nas i nie musimy nic pisać… poza pewnym wyjątkiem! Do ilustracji użyjemy narzędzia checksec, który jest częścią pythonowej biblioteki pwntools. Pozwala ono w łatwy sposób sprawdzić, które zabezpieczenia zostały włączone bądź wyłączone podczas kompilacji programu. Na poniższym zdjęciu możemy zobaczyć, że kompilacja z flagą -static pozostawia program bez position-independent kodu. Osobiście, polecałbym unikać tej flagi i zamiast tego używać -static-pie. Nic na tym nie tracimy, poza paroma cyklami procesora podczas losowania adresu, a potencjalnie zyskujemy dużo na bezpieczeństwie!

W artykule “Stack based buffer overflow - Wstęp teoretyczny i (nie do końca) praktyczna demonstracja” Dawid specjalnie kompilował program z no-pie właśnie po to, żeby uniknąć dodatkowego zabezpieczenia wprowadzanego przez ASLR. W rzeczywistej eksploitacji zazwyczaj wymagane są dwa błędy - jeden, żeby złamać ASLR przez wycieki adresów, drugi żeby przejąć kontrolę nad przepływem programu.

image

Jeśli jesteście nowi w temacie i pomyśleliście sobie “co, jeśli dwa procesy będą miały ten sam adres? Nie wystąpi wtedy jakaś kolizja?”, to już śpieszę się z wyjaśnieniem. Otóż, nie! Nazwa “wirtualna przestrzeń adresowa” wzięła się stąd, że jest wirtualna… co znaczy, że nie jest fizyczna… Co? Współczesne systemy operacyjne tłumaczą adresy na fizyczne za pomocą MMU - różne procesy mogą używać tego samego adresu, ale będzie on wskazywał na inną lokalizację w pamięci fizycznej. Z punktu widzenia procesu, nie jest on nawet świadomy istnienia innych procesów i wydaje mu się, że cała przestrzeń adresowa istnieje tylko dla niego.

Linux Internals

Postanowiłem nie robić wstępnych założeń i nie patrzeć tylko na kod. Żeby sprawdzić eksperymentalnie, jak liczone są wszystkie adresy, zbudowałem kernel Linuxa z symbolami do debugowania. Podpinając debugger gdb do kernela uruchomionego w wirtualnej maszynie QEMU zobaczymy od środka jaki kod się wykonuje i jakie zmienne przyjmują jakie wartości. Zachęcam do wykonania eksperymentu również u siebie w domu!

Przygotowanie środowiska

Kroki, które wykonam będą robione na podstawie tego poradnika: https://vccolombo.github.io/cybersecurity/linux-kernel-qemu-setup/. Jeśli nie jesteście zainteresowani robieniem tego własnoręcznie, czytanie tego podrozdziału można spokojnie pominąć.

Na początek instalujemy wszystkie zależności:

# w przypadku ubuntu
sudo apt-get update
sudo apt-get install git fakeroot build-essential ncurses-dev xz-utils libssl-dev bc flex libelf-dev bison qemu-system-x86 debootstrap

# w przypadku archlinux
sudo pacman -S git fakeroot base-devel ncurses xz openssl bc flex libelf bison qemu-system-x86 debootstrap

Należy pobrać najnowszego tarballa z kernelem Linuxa z https://www.kernel.org/, w moim przypadku jest to 6.15-rc7, czyli najnowszy w momencie pisania, i go wypakować.

image

Następnie należy wygenerować pliki potrzebne do kompilacji poprzez napisanie $ make defconfig, po czym otworzyć plik .config w ulubionym edytorze tekstowym, w moim przypadku jest to Emacs, i dopisać na koniec:

# Coverage collection.
CONFIG_KCOV=y

# Debug info for symbolization.
CONFIG_DEBUG_INFO=y

# Memory bug detector
CONFIG_KASAN=y
CONFIG_KASAN_INLINE=y

# Required for Debian Stretch
CONFIG_CONFIGFS_FS=y
CONFIG_SECURITYFS=y

CONFIG_DEBUG_INFO_DWARF5=y

a potem napisać $ make olddefconfig i rozpocząć proces kompilacji za pomocą polecenia $ make -j$(nproc). Może to potrwać od 10 minut do paru godzin, zależnie od komputera.

image

Jeśli nigdy nie kompilowaliście kernela, to spokojnie! Jak widać, nie jest to wcale takie straszne, jak może się wydawać! Po tym, jak wykona się kompilacja, powinny się stworzyć dwa pliki: ./vmlinux, który jest plikiem z kernelem z symbolami do debugowania i arch/x86_64/boot/bzImage, który jest skompresowanym vmlinux ładowanym przez bootloader.

image

Następnie musimy przygotować obraz systemu plików, z którego nasz skompilowany kernel będzie korzystał. Wykonujemy polecenia, które stworzą nam obraz dysku na podstawie Debiana:

$ mkdir image && cd image
$ wget https://raw.githubusercontent.com/google/syzkaller/master/tools/create-image.sh -O create-image.sh
$ chmod +x create-image.sh
$ ./create-image.sh

image

W tym momencie mamy już wszystko gotowe do uruchomienia systemu. Tworzymy plik ./run.sh, do którego wpisujemy:

qemu-system-x86_64 \
        -m 1G \
        -smp 2 \
        -gdb tcp::1234 \
        -kernel $1/arch/x86/boot/bzImage \
        -append "console=ttyS0 root=/dev/sda earlyprintk=serial net.ifnames=0 nokaslr" \
        -drive file=$2/bullseye.img,format=raw \
        -net user,host=10.0.2.10,hostfwd=tcp:127.0.0.1:10021-:22 \
        -net nic,model=e1000 \
        -enable-kvm \
        -nographic \
        -pidfile vm.pid \
        2>&1 | tee vm.log

i uruchamiamy go za pomocą poleceń:

$ chmod +x run.sh
$ ./run.sh . image/

image

Po wykonaniu powinniśmy zobaczyć ekran z logowaniem do systemu, logujemy się na użytkownika root, który nie ma hasła.

image

W tym momencie możemy w drugim terminalu podpiąć się za pomocą gdb do portu 1234 udostępnionego przez qemu. Uruchamiamy gdb poprzez $ gdb ./vmlinux i wpisujemy w gdb polecenie target remote 0:1234.

image

W tym momencie mamy podpięte gdb do kernela Linuxa, którego skompilowaliśmy, yay! Możemy wykonywać wszystkie komendy, które znamy z debugowania programów uruchamianych w userspace, jak breakpointy, czy stepowanie i nextowanie. Dodatkowo używam pluginu do gdb pwndbg, który teoretycznie jest stworzony z myślą o exploit developmencie i inżynierii wstecznej, natomiast używam go również do zwykłego debugowania, jako że ułatwia używanie gdb i jestem do niego przyzwyczajony. Są też inne pluginy jak gef albo gdb-dashboard, który jest bardziej stworzony z myślą o zwykłych programistach. Polecam sobie któryś wybrać i zainstalować zgodnie z instrukcjami w README danego pluginu.

image

Jesteśmy też w stanie się połączyć do użytkownika w środku qemu za pomocą ssh, jak i również przesyłać pliki przez scp (co się może przydać, np. żeby przesłać binarkę, którą kompilujemy lokalnie, a chcemy wykonać w środku QEMU).

$ scp -i image/bullseye.id_rsa -P 10021 -o "StrictHostKeyChecking no" ./plik root@localhost:/
$ ssh -i image/bullseye.id_rsa -p 10021 -o "StrictHostKeyChecking no" root@localhost

image

Deep-dive

Funkcja static int load_elf_binary(struct linux_binprm *bprm) w pliku fs/binfmt_elf.c jest odpowiedzialna za wczytywanie plików wykonywalnych do pamięci i to właśnie od jej fragmentów zaczniemy, jako że losowanie zaczyna się właśnie tutaj.

Do zrozumienia jak to działa w gdb, stawiamy breakpoint w wyżej wymienionej funkcji i będziemy uruchamiać program $ cat /proc/self/maps, który wyświetli, gdzie są zmapowane wszystkie segmenty pamięci.

Binarka

Na linii 1130 mamy kod wykonujący się dla plików ELF z ET_DYN (inaczej mówiąc, są to binarki skompilowane z -pie -fpie). W warunku ifa dodatkowo sprawdzamy, czy nasza binarka ma jakiś “interpreter”. W przypadku ELFów jest to loader/ld-linux.so, natomiast w przypadku wykonywalnych plików tekstowych, interpreter jest definiowany przez shebang.

			if (interpreter) {
				/* On ET_DYN with PT_INTERP, we do the ASLR. */
				load_bias = ELF_ET_DYN_BASE;
				if (current->flags & PF_RANDOMIZE)
					load_bias += arch_mmap_rnd();
				/* Adjust alignment as requested. */
				if (alignment)
					load_bias &= ~(alignment - 1);
				elf_flags |= MAP_FIXED_NOREPLACE;

Makro ELF_ET_DYN_BASE jest równe 0x555555554aaa i definiuje bazowy adres (precyzyjnie mówiąc 0x555555554000 po wyrównaniu do stron pamięci) ładowania dla PIE, do którego nasz program byłby ładowany gdyby nie ASLR. Ta pozornie losowa wartość została najprawdopodobniej wybrana jako łatwa do rozpoznania podczas debugowania - wzór “555” jest charakterystyczny i natychmiast wskazuje na PIE binarkę. Ponieważ mamy włączone ASLR, wykonuje się dodatkowo linijka load_bias += arch_mmap_rnd();, która dodaje losowość do adresu, do którego będzie ładowany nasz program. Teraz przyjrzymy się tej funkcji.

unsigned long arch_mmap_rnd(void)
{
	return arch_rnd(mmap_is_ia32() ? mmap32_rnd_bits : mmap64_rnd_bits);
}

static unsigned long arch_rnd(unsigned int rndbits)
{
	if (!(current->flags & PF_RANDOMIZE))
		return 0;
	return (get_random_long() & ((1UL << rndbits) - 1)) << PAGE_SHIFT;
}

Funkcja arch_mmap_rnd wywołuje arch_rnd z argumentem mmap64_rnd_bits lub mmap32_rnd_bits, zależnie od tego, czy nasz program jest 32-bitowy. Stała mmap64_rnd_bits jest równa 28. Funkcja arch_rnd losuje nam 28-bitową liczbę, po czym wykonuje przesunięcie bitowe (PAGE_SHIFT), żeby wyrównać liczby do stron pamięci, które mają rozmiar 4KiB. Przykładowe liczby, które ta funkcja zwróci, to: 0xe14e215000, 0x110feee000 i 0x6eb8eda000.

Wylosowana liczba jest dodawana do zmiennej load_bias, która jest równa 0x555555554aaa. Po dodaniu 0x6eb8eda000 do tej zmiennej, przyjmuje ona wartość 0x55c40e42eaaa. Maska ~(alignment - 1) jest równa 0xfffffffffffff000, zatem po operacji maskowania load_bias &= ~(alignment - 1); zmienna load_bias jest równa 0x55c40e42e000. Po wpisaniu continue do gdb, komenda cat pokazuje nam, że rzeczywiście w tym miejscu w pamięci nasz program się pojawił.

root@syzkaller:~# cat /proc/self/maps
55c40e42e000-55c40e430000 r--p 00000000 08:00 12243                      /usr/bin/cat
55c40e430000-55c40e435000 r-xp 00002000 08:00 12243                      /usr/bin/cat
55c40e435000-55c40e438000 r--p 00007000 08:00 12243                      /usr/bin/cat
55c40e438000-55c40e439000 r--p 00009000 08:00 12243                      /usr/bin/cat
55c40e439000-55c40e43a000 rw-p 0000a000 08:00 12243                      /usr/bin/cat
55c448bc7000-55c448be8000 rw-p 00000000 00:00 0                          [heap]
...

Heap

Dużo niżej w tej samej funkcji, na samym końcu w linii 1330 mamy losowanie miejsca, w którym znajdzie się początek sterty.

	mm->start_brk = mm->brk = ELF_PAGEALIGN(elf_brk);

	if ((current->flags & PF_RANDOMIZE) && snapshot_randomize_va_space > 1) {
		/*
		 * If we didn't move the brk to ELF_ET_DYN_BASE (above),
		 * leave a gap between .bss and brk.
		 */
		if (!brk_moved)
			mm->brk = mm->start_brk = mm->brk + PAGE_SIZE;

		mm->brk = mm->start_brk = arch_randomize_brk(mm);
		brk_moved = true;
	}

Bez randomize_va_space, wspomnianego na początku artykułu, ustawionego na więcej niż 1, mm->brk będzie po prostu położone zaraz po tym, gdzie kończy się nasza binarka. W przeciwnym przypadku, ta wartość jest losowana w funkcji arch_randomize_brk, której się przyjrzymy. Skąd pochodzi nazwa brk? Otóż, miejsce, w którym wyznaczany jest heap, jest definiowane przez wywołanie systemowe brk. Słowo “break” oznacza tutaj “granicę” lub “punkt przerwania” - brk to wskaźnik w pamięci, który określa koniec sterty. Gdy program potrzebuje więcej pamięci na stercie, używa wywołania systemowego brk i “przesuwa break” wyżej w pamięci, zwiększając tym dostępną przestrzeń.

unsigned long arch_randomize_brk(struct mm_struct *mm)
{
	if (mmap_is_ia32())
		return randomize_page(mm->brk, SZ_32M);

	return randomize_page(mm->brk, SZ_1G);
}

unsigned long randomize_page(unsigned long start, unsigned long range)
{
	if (!PAGE_ALIGNED(start)) {
		range -= PAGE_ALIGN(start) - start;
		start = PAGE_ALIGN(start);
	}

	if (start > ULONG_MAX - range)
		range = ULONG_MAX - start;

	range >>= PAGE_SHIFT;

	if (range == 0)
		return start;

	return start + (get_random_long() % range << PAGE_SHIFT);
}

W funkcji arch_randomize_brk wywołujemy randomize_page z argumentem zależnym od tego, czy program jest 32-bitowy. Funkcja randomize_page losuje liczbę w zakresie od start do start+range (nie włącznie). Zatem będziemy losować liczbę w zakresie od mm->brk (które jest równe adresowi strony pamięci po której kończy się nasz załadowany program) do mm->brk+1GiB.

Zatem może się wydawać, że mamy dodatkowy 1 Gigabajt (2^30) losowości. Jednak należy pamiętać, że ten adres jest wyrównany do strony pamięci (4KiB czyli 2^12), więc odejmowane jest 12 bitów. Zakładając, że znamy adres binarki, ale nie znamy adresu sterty, mamy 18 (30-12) bitów entropii. Nie jest to jakoś szczególnie dużo i jest to pewna słabość w ASLR na Linuxie. Fakt ten został wykorzystany w tegorocznej edycji konkursu hakerskiego Break The Syntax w jednym z moich zadań poniponi-virus, które było jednym z trudniejszych zadań. Omówienie zadania można zobaczyć tutaj.

Stos

Adres stosu wyznaczamy w linii 1020 w funkcji load_elf_binary. Zaraz po tym, gdy wyznaczony jest adres, do którego załadowane będą nasze biblioteki współdzielone (które będą omówione później).

	/* Do this so that we can load the interpreter, if need be.  We will
	   change some of these later */
	retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
				 executable_stack);
	if (retval < 0)
		goto out_free_dentry;

Funkcja setup_arg_pages przyjmuje jako jeden ze swoich argumentów górę stosu. Należy pamiętać, że w przypadku architektury x64 stos rośnie w dół (tzn. w kierunku adresów niższych), więc tak naprawdę wyznaczamy początek stosu. Makro STACK_TOP jest równe 0x7ffffffff000.

#define __STACK_RND_MASK(is32bit) ((is32bit) ? 0x7ff : 0x3fffff)
#define STACK_RND_MASK __STACK_RND_MASK(mmap_is_ia32())

unsigned long randomize_stack_top(unsigned long stack_top)
{
	unsigned long random_variable = 0;

	if (current->flags & PF_RANDOMIZE) {
		random_variable = get_random_long();
		random_variable &= STACK_RND_MASK;
		random_variable <<= PAGE_SHIFT;
	}
#ifdef CONFIG_STACK_GROWSUP
	return PAGE_ALIGN(stack_top) + random_variable;
#else
	return PAGE_ALIGN(stack_top) - random_variable;
#endif
}

Makro STACK_RND_MASK jest równe 0x3fffff, także generujemy liczbę składającą się z 22 losowych bitów wyrównanych do strony pamięci. Po tym odejmujemy tę liczbę z adresu, który byłby górą stosu gdyby nie ASLR.

int setup_arg_pages(struct linux_binprm *bprm,
		    unsigned long stack_top,
		    int executable_stack)
#else
	stack_top = arch_align_stack(stack_top);
	stack_top = PAGE_ALIGN(stack_top);

	if (unlikely(stack_top < mmap_min_addr) ||
	    unlikely(vma->vm_end - vma->vm_start >= stack_top - mmap_min_addr))
		return -ENOMEM;

	stack_shift = vma->vm_end - stack_top;

	bprm->p -= stack_shift;
	mm->arg_start = bprm->p;
#endif

W funkcji setup_arg_pages dodatkowo wywołujemy funkcję arch_align_stack, która dodaje losowe nadprogramowe mniej znaczące bity do naszego stosu. W wyniku czego stos nie będzie się zaczynał na początku/końcu jakiejś strony pamięci, tylko w środku. Reszta strony będzie najzwyczajniej wypełniona zerami. Ta dwupoziomowa randomizacja stosu (gruba randomizacja na poziomie stron + drobna randomizacja wewnątrz strony) zapewnia zarówno znaczną entropię jak i poprawne wyrównanie dla instrukcji wymagających określonego alignmentu.

unsigned long arch_align_stack(unsigned long sp)
{
	if (!(current->personality & ADDR_NO_RANDOMIZE) && randomize_va_space)
		sp -= get_random_u32_below(8192);
	return sp & ~0xf;
}

W funkcji arch_align_stack możemy zobaczyć, że losujemy liczbę mniejszą niż 8192 (0x2000). Czyli mamy 13 losowych bitów. Po tym, przed zwróceniem tej liczby maskujemy ją z ~0xf, czyli usuwamy 4 bity losowości, otrzymując ostatecznie 9 bitów losowości. Jednak jeden bit pokrywa się z innym bitem poprzedniej wylosowanej liczby, więc w praktyce mamy około 30 bitów losowości (22+9-1).

Biblioteki współdzielone i mmap

W funkcji load_elf_binary w linii 1016 mamy funkcję setup_new_exec, która wywołuje arch_pick_mmap_layout, która z kolei wywołuje arch_pick_mmap_base. W tej funkcji wyznaczamy adres bazowy dla dynamicznie ładowanych bibliotek współdzielonych (takich jak np. libc.so.6) oraz adresów zwracanych przez syscall mmap.

void arch_pick_mmap_layout(struct mm_struct *mm, struct rlimit *rlim_stack)
{
	if (mmap_is_legacy())
		clear_bit(MMF_TOPDOWN, &mm->flags);
	else
		set_bit(MMF_TOPDOWN, &mm->flags);

	arch_pick_mmap_base(&mm->mmap_base, &mm->mmap_legacy_base,
			arch_rnd(mmap64_rnd_bits), task_size_64bit(0),
			rlim_stack);
}

/*
 * This function, called very early during the creation of a new
 * process VM image, sets up which VM layout function to use:
 */
static void arch_pick_mmap_base(unsigned long *base, unsigned long *legacy_base,
		unsigned long random_factor, unsigned long task_size,
		struct rlimit *rlim_stack)
{
	*legacy_base = mmap_legacy_base(random_factor, task_size);
	if (mmap_is_legacy())
		*base = *legacy_base;
	else
		*base = mmap_base(random_factor, task_size, rlim_stack);
}

Zmienna *base jest naszym adresem bazowym, a przypisywana jest do niego wartość zwracana przez funkcję mmap_base.

static unsigned long mmap_base(unsigned long rnd, unsigned long task_size,
			       struct rlimit *rlim_stack)
{
	unsigned long gap = rlim_stack->rlim_cur;
	unsigned long pad = stack_maxrandom_size(task_size) + stack_guard_gap;

	/* Values close to RLIM_INFINITY can overflow. */
	if (gap + pad > gap)
		gap += pad;

	/*
	 * Top of mmap area (just below the process stack).
	 * Leave an at least ~128 MB hole with possible stack randomization.
	 */
	gap = clamp(gap, SIZE_128M, (task_size / 6) * 5);

	return PAGE_ALIGN(task_size - gap - rnd);
}

Argument rnd jest równy wynikowi arch_rnd(mmap64_rnd_bits), które było omówione przy tym, jak losowana jest binarka i działa dokładnie tak samo. Dla przypomnienia, ta funkcja zwraca wartość dającą nam 28 bitów entropii.

unsigned long task_size_64bit(int full_addr_space)
{
	return full_addr_space ? TASK_SIZE_MAX : DEFAULT_MAP_WINDOW;
}

Argument task_size jest równy wynikowi task_size_64bit(0), czyli DEFAULT_MAP_WINDOW (0x7ffffffff000), jako że funkcja jest wywoływana z argumentem 0.

Adres bazowy jest wyznaczany tylko raz podczas ładowania programu i wszystkie biblioteki/wywołania systemowe mmap są od niego zależne! W funkcji unmapped_area_topdown w pliku ./mm/vma.c wyznaczamy kolejne adresy zwracane przez mmap oraz ładowanych bibliotek. Nie będziemy się już tej funkcji przyglądać, jako że jest dosyć skomplikowana i rozpatruje wiele przypadków brzegowych.

Jak widzimy na poniższych zdjęciach:

image

image

dla wywołania polecenia cat /proc/self/maps, funkcja mmap_base zwróci nam 0x7f1c82d07000. Jest to adres równy końcowi ostatniego segmentu pamięci pojawiającego się przed stosem. W praktyce okazuje się, że za każdym razem funkcja unmapped_area_topdown zwraca nam najwyższy możliwy adres, który jest niższy od poprzedniego, przez co wszystkie segmenty pamięci “stykają się i nie ma dziur”. W wyniku tego, jeśli atakujący zna jeden adres (np. wyciek adresu jakiegoś wywołania anonimowej pamięci zwróconej przez syscall mmap), to zna wszystkie inne adresy (np. adres biblioteki standardowej libc), ponieważ relatywny offset pozostaje stały. Jest to duża słabość w tym, w jaki sposób Linux implementuje ASLR.

Porównanie z innymi systemami operacyjnymi

Ciekawostką jest, że ASLR w Linuxie, w przeciwieństwie do Windowsa i macOS, jako jedyne ma wszędzie równą dystrybucję prawdopodobieństwa. Badanie przeprowadzone w pracy “The Illusion of Randomness” porównywało losowość (ilość bitów entropii absolutnej i zależnej, oraz dystrybucję) w różnych systemach operacyjnych.

Tak owa dystrybucja wygląda na Linuxie:

image

a tak na Windowsie i macOS:

image

image

Ciekawskich zachęcam do przeczytania oryginalnego artykułu, jako że jest on bardzo intrygujący.

Podsumowanie

ASLR w jądrze Linuxa, mimo że implementowane w sposób stosunkowo prosty i zrozumiały, ma kilka znaczących słabości bezpieczeństwa. Najważniejsze z nich to niska entropia zależna sterty (18 bitów) oraz deterministyczny układ bibliotek współdzielonych, gdzie znajomość jednego adresu pozwala na wywnioskowanie wszystkich pozostałych.

Implementacja bazuje na kilku kluczowych funkcjach: arch_mmap_rnd() dla randomizacji binarki i bibliotek (28 bitów entropii), arch_randomize_brk() dla sterty (18 bitów entropii) oraz randomize_stack_top() i arch_align_stack() dla stosu (łącznie około 30 bitów entropii). Każdy z tych mechanizmów działa niezależnie podczas ładowania programu.

Źródła