Skip to content

栈与程序参数

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 到错误地址.