从最短的 HelloWorld 到 ShellCode
环境为 Linux x86_64
如果用 C 语言打印 hello world 会是这样:
#include <stdio.h>
int main() {
printf("hello world\n");
return 0;
}
将他编译后用 strace
跟踪就会发现调用了非常多的函数
有 mmap
, openat
... 等等, 并且用 objdump
查看汇编时会看到大量的函数
hello world 的打印过程:
OS 加载 ELF到内存 -> 分配栈和堆 -> 映射动态链接库到进程地址空间 -> 初始化寄存器 -> 进入入口函数 _start
-> ``_libc_start_main初始化 libc -> 调用程序的
main-> ..... -> 执行系统调用
write向
stdout` 输出 hello world
这是一个非常长的过程, 实际上最后是由 syscall
指令来调用 write
进行打印的, 那实际上就可以省略前面的过程, 直接调用 write
系统调用
间接
通过 unistd 中的 syscall
函数可以进行一个系统调用
通过 man syscall
, 可以看到 syscall
的函数签名: long syscall(long number, ...)
第一个参数是调用号, 从第二个参数开始则是目标函数的参数
通过 grep __NR_write /usr/include/asm/unistd_64.h
查到 write
的系统调用号:
#define __NR_write 1
#define __NR_writev 20
write
的函数签名: ssize_t write(int fd, const void buf[.count], size_t count);
第一参数是 fd, 第二个是 buffer, 第三个就是向 fd 写入多少个 buffer 中的元素
那么我就可以写一个执行起来更短的 hello world:
#include <unistd.h>
int main() {
syscall(1, 1, "test", 4);
return 0;
}
但这样仍然需要大量初始化, 和其他函数调用最后才执行 syscall
指令
直接
直接系统调用也就是执行 syscall
指令
操作系统管理计算机上的资源, 而运行在操作系统上的程序并不具备这种能力. 所有程序实际上都只有纯计算的能力, 也就是只能控制 CPU 进行 1 + 1, 2 - 2 这种算术运算, 对应的汇编指令就是 mov
, sub
, add
等等. 纯计算的程序连退出的能力都没有, 因为程序的运行和退出是操作系统控制的
那么要如何显示一张图片到电脑屏幕上?
在现实世界中, 需要使用某一个资源大多情况下都需要其管理者的允许, 而 syscall
指令正是程序访问操作系统所管理的资源的手段, 也就是所谓的陷入内核
syscall
会将程序完全交给操作系统, 操作系统会决定所有比如有没有权限等
进行系统调用
这里指的是汇编层面的系统调用, 跟 C 语言一样, 汇编也是需要满足一些调用约定的, 是在 ABI 中规定的
通常 x86-64 的 Linux 会用 System V AMD64 ABI
通过 man syscall
在 Architecture calling conventions
小节可以看到系统调用约定:
在 x86-64 下, 系统调用号放入
rax
寄存器, 第一个参数存放在rdi
寄存器, 依次是:rdi
,rsi
,rdx
,r10
,r8
,r9
, 返回值是rax
和rdx
也就是说, 只需要将上面的: syscall(1, 1, "test", 4)
, 按照系统调用约定, 将系统调用号 1
存入 rax
, 第一个参数 (这里是说传递的第一个参数) 1
存入 rdi
, 以此类推, 最后使用 syscall
指令就可以完成对 write
的调用
Hello World 汇编
使用 AT&T 语法:
.section .data
msg: .ascii "hello world\n"
.section .text
.globl _start
_start:
movq $1,%rax
movq $1,%rdi
movq $,%rsi
movq $12,%rdx
syscall
文件保存为 hello.asm
as --64 hello.asm -o hello.o
ld hello.o -o hello
在执行 ./hello
后会看到先打印完成了, 但是后续就报了 Segmentation fault
这是因为这个程序并没有退出, 而没有退出的程序继续执行指令 (程序运行起来的时候会被分配更大的空间), 因为非法的指令或者是访问了非法的内存, 就会 Segmentation fault
所以还需要正确退出这个程序, 否则将会发生未定义行为
需要通过 void exit(int status)
函数来退出, 这个函数的系统调用号是 60
.section .data
msg:.ascii "hello world\n"
.section .text
.globl _start
_start:
movq $1,%rax
movq $1,%rdi
movq $msg,%rsi
movq $12,%rdx
syscall
movq $60,%rax
movq $0,%rdi
syscall
最后 $0
是 Linux 的正常退出的退出码
这差不多就是一个最短的 hello world 了
shellcode hello world
shellcode 就是一种能直接运行的代码, 无需任何依赖, 比如上面的 hello world 程序
在知道了 hello world 程序的原理之后, 那就可以构造一个 shellcode,比如下面构造一个调用 /bin/echo
来输出 hello world 的 shellcode
而想在程序中运行另一个程序可以使用 execve
系统调用, 调用号为: 59, 函数签名: int execve(const char *pathname, char *const _Nullable argv[], char *const _Nullable envp[])
构造字符串
上面的程序跟 shellcode 有一些不同的是, shellcode 通常直接嵌入到程序中运行. 所以不能写 data 段, 但可以在栈上构造将字符串
hello world
是一个 11 个 char
的字符串, 字符串以 \0
结尾, 所以可以在栈上用 12 个 CHAR_BIT
来存放
subq $12, %rsp
sub
是减法指令, 加 q
是操作 64 位寄存器. 这条指令就是让 rsp
寄存器向下移动 12 个字节
高地址
│
│ +---------------------+ 栈底 <- rbp
│ | 返回地址 |
│ +---------------------+
│ | 参数/保存寄存器 |
│ +---------------------+
│ | ........ |
│ +---------------------+ 移动前栈顶 <- rsp
│ | |
| +---------------------+ <- rsp - 12
|
低地址
所以实际上申请栈空间就是让 rsp
向低地址移动
移动了之后还没有实际的实际的数据, 接下来开始一个字符一个字符的构造
movb $'h', 0(%rsp)
movb $'e', 1(%rsp)
movb $'l', 2(%rsp)
movb $'l', 3(%rsp)
movb $'o', 4(%rsp)
movb $' ', 5(%rsp)
movb $'w', 6(%rsp)
movb $'o', 7(%rsp)
movb $'r', 8(%rsp)
movb $'l', 9(%rsp)
movb $'d', 10(%rsp)
movb $0, 11(%rsp)
()
是寻址, 1(%rsp)
就是取 rsp + 1
的地址, 这一段的意思就是就是将数据存到 rsp + x
的地址处, 也就是写到栈上相应位置
上面是以字符的顺序写入的, 实际上栈顶的字符是 h
, 倒着或许更加直观
实际上也可以用简单一点的方式: push
push
指令可以将一个 8/32 位的立即数或者一个寄存器的值压入栈中:
movq $0x0a6f6c6c6568, %rax # 将字符串 "hello\0" (小端序) 存放到 rax
push %rax # 将 rax 的数据压入栈中
movq %rsp, %rsi # 这时候栈顶指针就指向了这段字符串
各自都有特点, push
方便但不能精准控制字节, 每一次都是压入 1 / 4 / 8 个字节
构造字符指针数组
因为要向 /bin/echo
传递参数, 而 execve
的第二个参数需要传入一个 char*[]
, 所以需要构造一个数组存放字符指针
手册里也提到, argv[]
必须以 NULL
结尾, 也就是说要构造一个长度为 2 的数组, 就像这样:
char* argv[2] = {0x0001, NULL}
刚刚已经构建好了 hello\0
, 现在, 可以使用 lea
命令来获取其首地址, 也就是:
leaq (%rsp), %rax # 计算 rsp 指向的地址, 将其存入 rax
然后来构造数组
leaq (%rsp), %rax
movq %rax, (%rsp) # 给第一个元素赋值
接下来就是完整代码 (忽略没有对齐的情况):
.section .text
.global _start
_start:
subq $38, %rsp
movb $'/', (%rsp)
movb $'b', 1(%rsp)
movb $'i', 2(%rsp)
movb $'n', 3(%rsp)
movb $'/', 4(%rsp)
movb $'e', 5(%rsp)
movb $'c', 6(%rsp)
movb $'h', 7(%rsp)
movb $'o', 8(%rsp)
movb $0, 9(%rsp)
movb $'h', 10(%rsp)
movb $'e', 11(%rsp)
movb $'l', 12(%rsp)
movb $'l', 13(%rsp)
movb $'o', 14(%rsp)
movb $' ', 15(%rsp)
movb $'w', 16(%rsp)
movb $'o', 17(%rsp)
movb $'r', 18(%rsp)
movb $'l', 19(%rsp)
movb $'d', 20(%rsp)
movb $0, 21(%rsp)
leaq (%rsp), %rdi
movq %rdi, 22(%rsp)
leaq 10(%rsp), %rdi
movq %rdi, 30(%rsp) # argv[0] = &"hello..."
movq $0, 38(%rsp) # argv[1] = NULL
leaq (%rsp), %rdi # filename = &"...echo..."
leaq 22(%rsp), %rsi # argv = 22(%rsp)
xorq %rdx, %rdx # envp = NULL
movq $59, %rax # execve
syscall
movq $60, %rax # exit
movq $0, %rdi
syscall
编译运行后就可以看到输出了 hello world
提取 shellcode
这里的操作其实就是把机器码拿出来, 可以人工一条一条对着手册来写, 也可以直接看编译后的机器码直接拿出来
未完...
Comment