环境为 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-> ..... -> 执行系统调用writestdout` 输出 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 syscallArchitecture calling conventions 小节可以看到系统调用约定:

在 x86-64 下, 系统调用号放入 rax 寄存器, 第一个参数存放在 rdi 寄存器, 依次是: rdi, rsi, rdx, r10, r8, r9, 返回值是 raxrdx

也就是说, 只需要将上面的: 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

这里的操作其实就是把机器码拿出来, 可以人工一条一条对着手册来写, 也可以直接看编译后的机器码直接拿出来

未完...