לדלג לתוכן

0.3 חזרה ממוקדת על הבסיס פתרון

פתרון - חזרה ממוקדת על הבסיס

הפתרון עובר תרגיל-תרגיל עם הפקודות המדויקות והפלט הצפוי. הכתובות אצלכם יהיו שונות (בגלל ASLR), אבל המבנה יהיה זהה. שווה להשוות שורה-שורה מול מה שיצא לכם.

תזכורת לקימפול:

gcc -g -o basics_pie   basics.c
gcc -g -no-pie -fno-pie -fno-stack-protector -o basics_nopie basics.c

הוספתי -fno-stack-protector לגרסת ה-nopie כדי שתרגיל 5 יעבוד בלי שהcanary יעצור אותנו.


פתרון תרגיל 1 - מפת הזיכרון של תהליך חי

מריצים ולוקחים את ה-pid:

./basics_pie
# pid is 40213, press ENTER
# in another terminal:
cat /proc/40213/maps

פלט מקוצר וטיפוסי:

55d3c8a2a000-55d3c8a2b000 r--p 00000000 08:01 ...  /home/amit/basics_pie   <- ELF header
55d3c8a2b000-55d3c8a2c000 r-xp 00001000 08:01 ...  /home/amit/basics_pie   <- .text  (r-x)
55d3c8a2c000-55d3c8a2d000 r--p 00002000 08:01 ...  /home/amit/basics_pie   <- .rodata
55d3c8a2d000-55d3c8a2e000 r--p 00002000 08:01 ...  /home/amit/basics_pie   <- RELRO
55d3c8a2e000-55d3c8a2f000 rw-p 00003000 08:01 ...  /home/amit/basics_pie   <- .data/.bss (rw)
55d3ca1f7000-55d3ca218000 rw-p 00000000 00:00 0    [heap]                  <- heap (rw)
7f2b1c400000-...          r-xp ...            /usr/lib/libc.so.6           <- libc (r-x)
...
7ffd9e3c0000-7ffd9e3e1000 rw-p 00000000 00:00 0    [stack]                 <- stack (rw)

תשובות:

  • הקוד (.text) הוא r-x: קריאה והרצה, בלי כתיבה.
  • המחסנית, הערמה ואזור ה-.data הם rw-: קריאה וכתיבה, בלי הרצה.
  • אין אזור שהוא גם w וגם x בו-זמנית. זה בדיוק ה-NX: אי אפשר לכתוב shellcode למחסנית ולהריץ אותו, כי המחסנית לא ניתנת להרצה.
  • הרצה שנייה תיתן כתובות בסיס שונות לגמרי (55d3... יהפוך למשהו אחר), כי ASLR פעיל. בדיקה:
cat /proc/sys/kernel/randomize_va_space
# 2   -> full randomization

למה זה עבד / איך להכליל: /proc/pid/maps הוא החבר הכי טוב שלנו כשאנחנו רוצים להבין תהליך חי. בכל אתגר, אחת הפעולות הראשונות תהיה להסתכל אילו אזורים כתיבים, אילו ניתנים להרצה, ואיפה libc יושבת. ההרשאות של האזורים הן שמכתיבות אילו טכניקות בכלל אפשריות.


פתרון תרגיל 2 - PIE לעומת לא-PIE

checksec --file=./basics_pie
#   PIE:       PIE enabled
checksec --file=./basics_nopie
#   PIE:       No PIE (0x400000)

בדיקת ה-Type בכותרת ה-ELF:

readelf -h ./basics_pie   | grep Type
#   Type:  DYN (Position-Independent Executable file)
readelf -h ./basics_nopie | grep Type
#   Type:  EXEC (Executable file)

מפת הזיכרון של הגרסה הלא-PIE:

./basics_nopie &
grep basics_nopie /proc/$!/maps
# 00400000-00401000 r--p ...  basics_nopie
# 00401000-00402000 r-xp ...  basics_nopie   <- the code is always at 0x40....
# ...

הקוד יושב תמיד סביב 0x400000, וזה לא משתנה בין הרצות (הבינארי עצמו לא מושפע מ-ASLR; רק המחסנית, הערמה ו-libc כן).

למה צריך leak ב-PIE ולא בלא-PIE: בבינארי לא-PIE הכתובת של main, של גאדג'טים ושל הטבלאות ידועה מראש וקבועה - אפשר לכתוב אותה ישירות ב-exploit. בבינארי PIE כל הכתובות הפנימיות מוזזות בכמות אקראית בכל הרצה, אז אנחנו לא יודעים אותן מראש. הפתרון: לדלוף כתובת אחת שאנחנו כן מזהים, לחשב ממנה את בסיס הטעינה (base = כתובת שדלפנו פחות ה-offset הקבוע שלה בקובץ), ואז לחשב כל כתובת אחרת יחסית לבסיס.

למה זה עבד / איך להכליל: checksec ו-readelf -h הם שתי הבדיקות הראשונות על כל בינארי חדש. Type: DYN = PIE = צריך leak. Type: EXEC = כתובות קבועות = החיים קלים יותר.


פתרון תרגיל 3 - איתור GOT ו-PLT

טבלת ה-relocations:

readelf -r ./basics_nopie

הקטע הרלוונטי (בערך):

Relocation section '.rela.plt' at offset 0x5c0 contains 4 entries:
  Offset          Info           Type           Sym. Name
  0000000404018  ...  R_X86_64_JUMP_SLOT   puts@GLIBC_2.2.5
  0000000404020  ...  R_X86_64_JUMP_SLOT   printf@GLIBC_2.2.5
  0000000404028  ...  R_X86_64_JUMP_SLOT   strcpy@GLIBC_2.2.5
  0000000404030  ...  R_X86_64_JUMP_SLOT   getchar@GLIBC_2.2.5

אז כניסת ה-GOT של puts היא בכתובת 0x404018, וזו של strcpy היא 0x404028.

עכשיו ה-PLT:

objdump -d -j .plt ./basics_nopie

הקטע של puts:

0000000000401030 <puts@plt>:
  401030: ff 25 e2 2f 00 00   jmp   *0x2fe2(%rip)   # 404018 <puts@GLIBC_2.2.5>
  401036: 68 00 00 00 00      push  $0x0
  40103b: e9 e0 ff ff ff      jmp   401020 <_init+0x20>

שימו לב לתגובית שהדיסאסמבלר הוסיף: # 404018. זו בדיוק כתובת ה-GOT של puts מטבלת ה-relocations. הן חייבות להתאים, כי ה-PLT קופץ (jmp) דרך כניסת ה-GOT: הקוד קורא ל-puts@plt, שקופץ לכתובת שכתובה ב-0x404018. בהתחלה שם כתובת שמחזירה ל-resolver, ואחרי הפתרון הראשון שם כבר הכתובת האמיתית של puts ב-libc.

ובתוך main:

objdump -d ./basics_nopie | grep -A2 'call.*puts'
#   40118e:  e8 9d fe ff ff   call  401030 <puts@plt>

הפונקציה main קוראת ל-puts@plt, לא ל-puts עצמו, כי בזמן הקומפילציה הכתובת האמיתית של puts בתוך libc עדיין לא ידועה (libc נטענת דינמית לכתובת אקראית). ה-PLT הוא רמת הכוונה (indirection) שמאפשרת לדחות את קביעת הכתובת האמיתית לזמן ריצה.

למה זה עבד / איך להכליל: readelf -r נותן לכם את מפת כתובות ה-GOT לפי שם פונקציה, ו-objdump -d -j .plt מראה איך ה-PLT קופץ דרכן. הצמד הזה הוא בדיוק מה שנצטרך כשנרצה לדלוף כתובת libc (קוראים את ה-GOT של פונקציה שכבר נפתרה) או לדרוס GOT (כותבים ל-Offset שמצאנו).


פתרון תרגיל 4 - לצעוד על call ו-ret ב-gdb

gdb ./basics_nopie
pwndbg> break greet
pwndbg> run AAAA

עכשיו אנחנו בתוך greet. מסתכלים על ראש המחסנית:

pwndbg> x/gx $rsp
0x7fffffffe288: 0x0000000000401234      <- return address, inside main

מצליבים עם הדיסאסמבלי של main:

pwndbg> disassemble main
   ...
   0x000000000040122f <+...>: call   0x4011a9 <greet>
   0x0000000000401234 <+...>: lea    rax, [rip+...]      <- the instruction after the call
   ...

הכתובת 0x401234 שראינו בראש המחסנית היא בדיוק ההוראה שמיד אחרי ה-call greet. זו return address שה-call דחף.

רושמים את הערכים (דוגמה): $rsp = 0x7fffffffe288, $rip = 0x4011a9+... (איפשהו בתוך greet).

עכשיו שמים עצירה על ה-ret של greet:

pwndbg> disassemble greet
   ...
   0x00000000004011d8 <+47>: leave
   0x00000000004011d9 <+48>: ret
pwndbg> break *0x4011d9
pwndbg> continue

הגענו ל-ret. לפני שנצעד, נסתכל מה עומד להישלף:

pwndbg> print $rsp
$1 = (void *) 0x7fffffffe288
pwndbg> x/gx $rsp
0x7fffffffe288: 0x0000000000401234      <- this is what ret is about to load into rip

צועדים הוראה אחת:

pwndbg> stepi
pwndbg> print $rip
$2 = (void (*)()) 0x401234 <main+...>    <- rip got exactly the value that was at the top of the stack
pwndbg> print $rsp
$3 = (void *) 0x7fffffffe290              <- rsp grew by 8 (0xe288 -> 0xe290)

בשורה אחת: ret שלף 8 בתים מראש המחסנית לתוך rip (כלומר pop rip), והגדיל את rsp ב-8. שום בדיקה, שום ולידציה. הוא פשוט קפץ למה שהיה שם.

למה זה עבד / איך להכליל: זו התובנה שכל הקורס נשען עליה. ה-ret בוטח בעיוורון בערך שבראש המחסנית. אם נשלוט בערך הזה (וbuffer overflow נותנת לנו בדיוק את זה), נשלוט ב-rip. וכשנשרשר כמה ערכים כאלה בזה אחר זה - כל אחד גאדג'ט שמסתיים ב-ret - קיבלנו ROP.


פתרון תרגיל 5 (אתגר) - מה קורה כשדורסים את הreturn address

gdb ./basics_nopie
pwndbg> run $(python3 -c "print('A'*200)")

התוכנית קורסת. במצב הקריסה:

Program received signal SIGSEGV, Segmentation fault.
pwndbg> print $rip
$1 = (void (*)()) 0x4141414141414141

הערך 0x4141414141414141 הוא שמונה בתים של A (הקוד ההקסה של A הוא 0x41). מאיפה הוא הגיע? ה-strcpy בתוך greet העתיק את 200 ה-A-ים לתוך local[32], גלש הרבה מעבר לbuffer, ודרס את ה-rbp השמור ואת הreturn address. כשה-greet ביצע ret, הוא שלף את ה-A-ים לתוך rip - וקפץ לכתובת 0x4141414141414141, שאינה ממופה, ומכאן ה-SIGSEGV.

כמה בתים היה צריך? local הוא בגודל 32, ומעליו יושבים 8 בתים של rbp שמור ואז 8 בתים של הreturn address. כלומר, בערך 40 בתים ממלאים עד הreturn address, והבתים ב-offset 40..47 הם אלה שנוחתים ב-rip. את ה-offset המדויק נמצא בפרק 2 בצורה מסודרת עם cyclic, במקום לנחש.

למה זה עבד / איך להכליל: ראינו שליטה מלאה ב-rip דרך buffer overflow - בדיוק כפי שה-ret "בוטח" בראש המחסנית. זו התמונה השלמה של תרגיל 4 בפעולה: מי ששולט בראש המחסנית ברגע ה-ret שולט בזרימת התוכנית. בפרק הבא נפסיק לגרום לקריסות ונתחיל להפנות את השליטה הזו לכתובות שאנחנו בוחרים.