栈与程序参数¶
Computing 101. - The Stack | pwn.college
Intel® 64 and IA-32 Architectures Software Developer's Manual
在指针访存的汇编描述中, 被访问的地址要么写死在代码里, 要么来自程序自己在 .data 中定义的变量. 但进程启动时还有一块无需任何准备就能使用的内存: 栈. 本文以 Linux x86-64 和 Intel 语法为例, 说明汇编如何通过 rsp 读取栈上的程序参数, 以及 push / pop 如何在读写栈的同时移动 rsp.
栈的访问¶
栈是一段连续的内存, x86_64约定使用寄存器 rsp (Stack Pointer) 保存栈顶的地址作为基址.
本质上访问栈与访问普通内存没有区别, 均使用同样的方括号语法, 只是基址固定为 rsp:
| 写法 | 含义 |
|---|---|
mov rdi, [rsp] | 读栈顶的 8 字节 |
mov rdi, [rsp+8] | 读栈顶之后的下一个 8 字节 |
mov rdi, [rsp+N] | 读地址 rsp+N 处的 8 字节 |
偏移量以字节为单位, 而栈上的数值和地址通常都是 8 字节, 所以偏移常为 8 的倍数.
另外需要注意方向: 正偏移指向更高地址, 负偏移指向更低地址:
──低地址────────────────────────+──────────────────────────高地址──▸
... rsp-0x10 rsp-0x08 rsp rsp+0x08 rsp+0x10 ...
进程启动时, 内核已经在初始 rsp 及其更高的地址处放好了启动数据. 对于 x86-64 栈为何向低地址增长, 以及函数调用时 call / ret 如何使用栈, 请回顾函数调用与栈.
汇编视角下的程序参数传递¶
C 程序的 main(int argc, char *argv[]) 能拿到参数, 是因为 C 运行时在 main 之前替它整理好了. 而在不使用 C 运行时的最简汇编中, 程序从 _start 直接开始执行, 此时没有任何调用约定, 参数就原封不动地躺在栈上.
Review
ELF 的装入与启动中提到过, execve 之后内核会准备初始栈, 放入 argc, argv, envp 等信息.
执行程序并传入参数时, 简化后的入口处栈布局模型如下:
Address │ Contents
+───────────────────────────────+
│ rsp+32 │ 0 │ argv 数组的结尾 (NULL)
│ rsp+24 │ &"world" │ argv[2]
│ rsp+16 │ &"hello" │ argv[1]
│ rsp+8 │ &"./program" │ argv[0], 程序名
│ rsp │ 3 │ argc, 含程序名本身
+───────────────────────────────+ ◂── rsp 初始指向这里
由此可见:
-
[rsp]是argc, 它是一个整数, 不是指针. 它统计了程序名, 所以hello world对应3. -
[rsp+8]起是argv数组, 每个槽位存的是一个指针, 指向字符串本体, 而不是字符串内容. 数组以NULL结尾, 其后是envp数组. -
字符串本体 (以
\0结尾) 存放在栈的更高地址处, 与上面的指针数组分开.
因此读取 argv[1] 的字符内容需要解引用两次, 与在内存中存放地址中的二级指针模型完全一致:
mov rdi, [rsp+16] # 第 1 次: 从栈上取出 argv[1] 这个指针
mov rdi, [rdi] # 第 2 次: 沿指针读取字符串本体 (一次读 8 字节)
结合此前的二级指针访存模型, 以 ./program hello world 为例, 这里的栈内存访存模型可以简化成这样:
Register │ Contents
+─────────────────────+
│ rsp │ 0x1337000 │─┐
+─────────────────────+ │
│
┌─────────────────────┘
│
│ +───────────────────────+
└▸│ 0x1337000 │ 3 │ argc, 整数
+───────────────────────+ +─────────────────────────+
│ 0x1337008 │ 0x1338000 │────────────────▸│ 0x1338000 │ "./program" │ argv[0]
+───────────────────────+ +─────────────────────────+
│ 0x1337016 │ 0x1338010 │────────────────▸│ 0x1338010 │ "hello" │ argv[1]
+───────────────────────+ +─────────────────────────+
│ 0x1337024 │ 0x1338016 │────────────────▸│ 0x1338016 │ "world" │ argv[2]
+───────────────────────+ +─────────────────────────+
│ 0x1337032 │ 0 │ NULL, argv 数组的结尾
+───────────────────────+
栈 (参数指针数组) 字符串本体 (以 \0 结尾)
图中有两层存储:
-
左侧是栈, 从
rsp开始依次存放argc和argv[]的指针槽位, 每个槽位 8 字节, 所以地址相差 8. 这一层存的是整数和地址. -
右侧是字符串本体, 各字符串长度不同 (
"./program"含\0共 10 字节,"hello"含\0共 6 字节), 所以地址间隔并不固定. 这一层存的才是字符数据.
读取 argv[1] 的两次解引用在图上就是两步跳转: mov rdi, [rsp+16] 读出左侧第三行槽位的内容 1338010, 即沿箭头得到右侧的地址; mov rdi, [rdi] 再到 1338010 处读出 "hello". 这与二级指针中 pp -> p -> msg 的结构相同, 区别在于 rsp+8 是 argv 数组的起点, 数组里每个元素都是一个指针.
C 中 argv 是 char **的说明对应: rsp+8 就是 argv 的值, 所以 argv[i] 即 [rsp+8+i*8]. 第二次解引用若只想读一个字符, 则要像访问宽度中那样写成 movzx rdi, byte ptr [rdi].
POP/PUSH 指令¶
push 和 pop 是专为栈设计的指令, 它们把"读写栈内存"与"移动 rsp"合并成一条:
| 指令 | 等价的两步操作 |
|---|---|
pop rdi | mov rdi, [rsp]; add rsp, 8 |
push rax | sub rsp, 8; mov [rsp], rax |
以 pop rdi 为例, 执行前后的变化如下:
执行前 执行后
Address │ Contents Address │ Contents
+──────────────────+ +──────────────────+
│ rsp+8 │ ??? │ │ rsp(新) │ ??? │ ◂── rsp
│ rsp │ 3 │ ◂── rsp │ (旧) │ 3 │ 数据仍在内存里
+──────────────────+ +──────────────────+
rdi = 0 rdi = 3
3 被读入了 rdi, rsp 向高地址移动 8 字节. 栈上原来的数据并没有被擦除, 只是栈顶已经越过了它, 在栈的语义里它已被"弹出".
push 则相反: 先让 rsp 减 8 腾出空间, 再把值写入新的栈顶. 它们的方向对应栈向低地址增长的约定: 压栈使 rsp 变小, 弹栈使 rsp 变大.
使用时要注意:
-
push/pop必须成对. 否则rsp会错位, 之后[rsp+N]读到的就不是原来预期的数据. -
pop隐含读栈顶, 所以会改变后续[rsp+N]的含义. 例如pop rax之后, 原来的[rsp+16]变成了[rsp+8]. -
push 10这类立即数压栈会写入 8 字节, 由于小端序, 值的最低字节位于最低地址, 因此[rsp]处的第 1 个字节恰好是0x0a.
综合示例¶
下面的程序用 pop 消费栈上的启动数据, 逐个输出所有命令行参数, 每个一行. 它依然不依赖 C 运行时:
.intel_syntax noprefix
.global _start
.section .text
_start:
pop rbx # rbx = argc, rsp 前进到 argv[0]
pop rax # 丢弃 argv[0] (程序名), rsp 前进到 argv[1]
dec rbx # rbx = argc - 1, 剩余待输出的参数个数
.Lnext_arg:
test rbx, rbx # 输出完了吗? 没传参时直接结束
jz .Lexit
pop rsi # rsi = argv[i] (指针), rsp 前进到下一个 argv
xor rdx, rdx # rdx = 0, 长度计数器, 同时也是 write 的 count
.Lstrlen:
cmp byte ptr [rsi+rdx], 0 # 第 2 次解引用: 按字节读字符串本体
je .Lwrite # 遇到 '\0', rdx 恰为字符串长度
inc rdx
jmp .Lstrlen
.Lwrite:
mov rax, 1 # SYS_write
mov rdi, 1 # fd = stdout
syscall # write(1, argv[i], len)
push 10 # 借栈临时存放 '\n': rsp -= 8, [rsp] = 0x0a
mov rax, 1 # SYS_write (rax 已被返回值覆盖, 需重设)
mov rdi, 1
mov rsi, rsp # buf = 栈顶地址, 要的是地址, 所以用 mov 而非解引用
mov rdx, 1
syscall # write(1, rsp, 1)
pop rax # 弹出并丢弃, rsp 恢复原值
dec rbx
jmp .Lnext_arg # 此时 rsp 正好指向下一个 argv[i+1]
.Lexit:
mov rdi, 0
mov rax, 60 # SYS_exit
syscall
汇编, 链接并运行:
> as -o stack.o stack.s && ld -o stack stack.o
> ./stack.out hello "world and hacker" pwn!
hello
world and hacker
pwn!
栈布局模型¶
整个程序中 rsp 的变化可以分成三段来看:
入口: pop rbx pop rax pop rsi (循环内)
rsp ─▸ argc rsp ─▸ argv[0] rsp ─▸ argv[1] rsp ─▸ argv[2] ...
每次 pop 都让 rsp 前进一个槽位, 所以循环里不需要自己维护下标, rsp 本身就是游标. 与之对照, 输出换行时的 push 10 / pop rax 是一次临时借用: rsp 先下降 8 字节腾出空位, 用完立刻回收, 循环才能继续沿着 argv 前进.
其他值得注意的点¶
-
计数器选用
rbx而不是rcx, 因为syscall会破坏rcx和r11, 并用rax返回结果, 而rbx不受影响. -
_start不是函数, 栈顶是argc而不是返回地址, 所以结尾必须用exit系统调用而不能用ret. 可与最简汇编对照. -
没有现成的位置放单独的
'\n', 这里借栈实现. 也可以像综合示例那样在.data中定义. -
可以用
gdb观察rsp变化: 在_start处断点后x/4gx $rsp查看栈, 逐条stepi并p $rsp, 即可看到每次pop后rsp增加 8,push后减少 8.
常见误区¶
-
把
[rsp+16]当成字符串内容: 它是argv[1]这个指针, 要得到字符还需再解引用一次. -
忘记
argc含程序名: 有N个参数时argc为N+1, 第一个参数在argv[1]. -
pop后仍用旧偏移:pop会移动rsp, 之后的[rsp+N]基准已变. -
push/pop不成对:rsp错位会让后面读到的数据全部偏移, 甚至在函数返回时ret到错误地址.