指针访存的汇编描述¶
Computing 101. - Computer Memory | pwn.college
Intel® 64 and IA-32 Architectures Software Developer's Manual
在最简汇编中, 我们设置的数据只在立即数和寄存器之间流动. 但寄存器数量有限, 我们显然无法把所有数据都放在寄存器里, 程序的大部分数据其实存放在内存里.
本文以 Linux x86-64 和 Intel 语法为例, 说明汇编层面如何访问内存, 并据此观察 C 语言中的指针和二级指针在汇编的视角是如何运作的.
内存与地址¶
寄存器"小而快", 内存"大而慢", 二者是存储层次中相邻的两级.
从程序的视角看, 内存是一个按字节编号的大数组, 每个字节有一个数值地址. 现代程序看到的是虚拟地址空间, 其中有大片地址并没有映射, 访问它们会触发段错误.
CPU 本身不区分一个数是"数据"还是"地址", 区别只在于指令如何使用它. 这是理解指针的关键.
立即数, 寄存器与内存¶
在 Intel 语法中, [...]表示"以括号内的值为地址访问内存":
| 写法 | 含义 | C 语言类比 |
|---|---|---|
mov rdi, 31337 | 把数值 31337 放入 rdi | rdi = 31337; |
mov rdi, rax | 把 rax 的值复制到 rdi | rdi = rax; |
mov rdi, [31337] | 读取地址 31337 处的 8 字节, 放入 rdi | rdi = *(long *)31337; |
mov rdi, [rax] | 读取以 rax 的值为地址的 8 字节, 放入 rdi | rdi = *(long *)rax; |
若 rax 为 133700, 则 mov rdi, rax 得到 133700, 而 mov rdi, [rax] 得到地址 133700 处存放的数据. 此时 rax 就是一个指针, 通过它读写所指数据的过程称为解引用.
Register │ Contents Address │ Contents
+─────────────────+ +─────────────────+
│ rax │ 133700 │ ───────▸ │ 133700 │ 42 │
+─────────────────+ +─────────────────+
mov rdi, [rax] ; rdi = 42
Tip
寄存器是通用的. rax 在系统调用中充当调用号, 在这里也可以充当指针, 用途取决于指令如何使用它. 解引用甚至可以覆盖寄存器自身: mov rax, [rax] 之后, rax 由地址变成了数据.
x86-64汇编基础中的 AT&T 写法与之对应: mov (%rax), %rdi 的源操作数与 mov rdi, [rax] 等价, 只是操作数顺序相反.
有效地址与偏移¶
方括号内不只可以是单个寄存器, 而是一个有效地址表达式:
即 [base + index*scale + disp], 比例因子只能是 1, 2, 4, 8. 表达式在 CPU 内部由地址生成单元一次算出, 不需要额外的加法指令.
设 rdi 指向一段连续字节:
Address │ Contents
+────────────────────+
┌▸│ 133700 │ 50 │
│ │ 133701 │ 42 │
│ │ 133702 │ 99 │
│ │ 133703 │ 14 │
│ +────────────────────+
└─ rdi = 133700
movzx rax, byte ptr [rdi+1] # 偏移 1 字节, 取到 42
movzx rax, byte ptr [rdi+rcx] # 变址: 等价于 C 的 p[i]
mov eax, [rdi+rcx*4] # 比例因子 4: 等价于 int 数组的 a[i]
偏移量以字节为单位. 因此 C 中的 a[i] 在汇编里要由编译器根据元素大小换算成 i * sizeof(*a), 这正是指针与数组中"指针加减按所指类型大小缩放"的来源. 汇编器视角下并不知道什么是数组, 只按地址表达式计算.
访问宽度¶
mov 一次搬运多少字节, 由寄存器宽度或显式的宽度标注决定, 而不由地址决定:
| 写法 | 宽度 |
|---|---|
mov rax, [rdi] | 8 字节 (rax 为 64 位) |
mov eax, [rdi] | 4 字节 |
mov al, [rdi] | 1 字节 |
mov qword ptr [rdi], 42 | 8 字节; 目的操作数是内存且源是立即数时, 无法由寄存器推断宽度, 必须用 byte / word / dword / qword ptr 标注 |
movzx rax, byte ptr [rdi] | 读 1 字节并零扩展到 64 位 |
读宽度大于对象本身时, 会把相邻数据一起读进来. 例如字符串 "Hello, world!\n" 起始处用 mov rdi, [rip+msg] 读 8 字节, 得到的是 "Hello, w" 的 8 个字符, 而不是一个字符串指针.
又因为 x86-64 是小端序, 这 8 字节中最低有效字节 'H' (0x48) 在最低地址, 因而放入 rdi 后, 低 8 位恰好是 0x48. 若把它当作退出码传给 exit, 实际生效的只有低 8 位, 即 72.
Review
大小端的定义也可参考数据的大小端和对齐存储.
取地址指令¶
mov reg, [expr] 会读内存, 而 lea reg, [expr] 只计算 expr 的值并写入寄存器, 不访问内存:
lea rsi, [rip+msg] # rsi = &msg, 对应 C 的 &msg
mov rsi, [rip+msg] # rsi = *(long *)&msg, 读出了 msg 处的 8 字节内容
两者的方括号写法相同, 因此最容易混淆. 记忆方法: lea 对应 &, 方括号形式的 mov 对应 *. 详见x86-64汇编基础对 lea 的说明.
另外, [rip+label] 是RIP 相对寻址: 地址 = 下一条指令地址 + 到 label 的偏移. 该偏移在汇编和链接时确定, 不依赖绝对地址, 因此是 x86-64 访问全局符号的常用写法, 也便于生成位置无关代码.
在内存中存放地址¶
地址本质上也是数值, 所以它可以像普通数据一样存进内存, 再被读出来作为新的指针使用. 设地址 123400 处存放着 133700, 地址 133700 处存放着 42:
mov rdi, 123400 # rdi = 123400
mov rdi, [rdi] # 第 1 次解引用: rdi = 133700, 内存里存的是地址
mov rax, [rdi] # 第 2 次解引用: rax = 42
上面的代码的访存模型可以简化成这样:
Address │ Contents
+────────────────────+
┌▸│ 123400 │ 133700 │─┐ 2) mov rdi, [rdi] rdi = 133700 (读出的是地址)
│ +────────────────────+ │
│ │ 133700 │ 42 │◂┘ 3) mov rax, [rdi] rax = 42 (读出的是数据)
│ +────────────────────+
│
│ Register │ Contents
│ +────────────────────+
└─│ rdi │ 123400 │ 1) mov rdi, 123400 rdi 指向 123400
+────────────────────+
每执行一次 mov reg, [reg], 指针就沿箭头前进一级: 第一次读出的内容恰好是下一级的地址, 第二次才读到真正的数据.
这与 C 的二级指针完全对应:
int v = 42;
int *p = &v; /* 内存里存放 v 的地址 */
int **pp = &p; /* 内存里存放 p 的地址 */
int x = **pp; /* 两次解引用 */
三个变量在内存中的关系如下, 每个箭头对应一次解引用:
pp p v
+──────────+ * +──────────+ * +──────────+
│ &p │──▸│ &v │──▸│ 42 │
+──────────+ +──────────+ +──────────+
*pp == p **pp == v
与汇编对照, 寄存器 rdi 的初值 123400 相当于 pp 的值 (即 &p), 123400 处存放 133700 的单元相当于 p, 133700 处的 42 相当于 v.
pwn.college 中 Computer Memory 的最后一关即基于这一模型: rax 保存 SECRET_LOCATION_2, 该处保存 SECRET_LOCATION_1, 再次解引用才能得到 SECRET_VALUE, 需要依次执行self-deferencing mov rax, [rax] 与single-level dereferencing mov rdi, [rax].
二级指针模型的安全风险
数据与地址在程序里被反复存取, 一旦二者被混用 (例如把数据当作地址解引用, 或让攻击者控制了指针的值), 就会引发安全问题. 缓冲区溢出覆盖返回地址就是典型例子.
综合示例¶
下面的程序在不依赖 C 运行时的前提下, 把前面的概念串在一起.
其在 .data 中构造 pp -> p -> msg 的指针链, 通过双重解引用得到字符串地址, 用 write 系统调用输出, 并用第三次解引用读出首字符作为退出码:
.intel_syntax noprefix
.global _start
# 内存布局, 等价于 C:
# char msg[] = "Hello, world!\n";
# char *p = msg; // 一级指针
# char **pp = &p; // 二级指针
#
# pp --> p --> msg --> "Hello, world!\n"
# (8B) (8B) (14B)
.section .data
msg:
.ascii "Hello, world!\n"
.set msg_len, . - msg # 汇编期常量: 当前位置减 msg 起点, 即 14, 不占内存
p:
.quad msg # 8 字节, 内容是 msg 的地址 (链接时重定位)
pp:
.quad p # 8 字节, 内容是 p 的地址
.section .text
_start:
lea rax, [rip+pp] # rax = &pp, 取变量自身的地址, 不读内存
mov rax, [rax] # 第 1 次解引用: rax = pp = &p
mov rsi, [rax] # 第 2 次解引用: rsi = p = &msg, 即 write 的 buf
mov rax, 1 # SYS_write
mov rdi, 1 # fd = stdout
# rsi = buf, 已由双重解引用得到
mov rdx, msg_len # count = 14
syscall # 会破坏 rcx 和 r11, 并用 rax 返回结果; rsi 保持不变
movzx rdi, byte ptr [rsi] # 第 3 次解引用(按字节): rdi = 'H' = 72
mov rax, 60 # SYS_exit
syscall
汇编, 链接并运行:
> as -o mem.o mem.s && ld -o mem mem.o && ./mem
Hello, world!
> echo $?
72
数据与内存模型¶
.data 中三个符号按定义顺序紧挨着排列, 没有填充 (地址以 .data 起点 msg 的偏移表示, 实际地址由链接器决定):
Address │ Contents Symbol
+──────────────────────────────────────+
│ msg+0 │ "Hello, world!\n" (14B) │ msg ◂──────────┐
+──────────────────────────────────────+ │ *p = msg
│ msg+14 │ &msg (8B) │ p ────────────┘
+──────────────────────────────────────+ ^ *pp = p
│ msg+22 │ &p (8B) │ pp
+──────────────────────────────────────+
Register │ Contents
+────────────────────────+
│ rax │ &pp │ lea rax, [rip+pp] 不读内存
│ rax │ &p │ mov rax, [rax] 第 1 次解引用
│ rsi │ &msg │ mov rsi, [rax] 第 2 次解引用, write 的 buf
│ rdi │ 'H' = 72 │ movzx rdi, byte ptr [rsi] 第 3 次解引用, 仅读 1 字节
+────────────────────────+
指针链为 pp ──▸ p ──▸ msg ──▸ "Hello, world!\n": 前两级里存放的是地址 (各占 8 字节), 只有最后一级才是字符数据, 所以第三次解引用必须按字节读取.
反汇编分析¶
> objdump -d -Mintel mem.out
mem.out: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000401000 <_start>:
401000: 48 8d 05 3f 20 00 00 lea rax,[rip+0x203f] # 403046 <pp>
401007: 48 8b 00 mov rax,QWORD PTR [rax]
40100a: 48 8b 30 mov rsi,QWORD PTR [rax]
40100d: 48 c7 c0 01 00 00 00 mov rax,0x1
401014: 48 c7 c7 01 00 00 00 mov rdi,0x1
40101b: 48 c7 c2 0e 00 00 00 mov rdx,0xe
401022: 0f 05 syscall
401024: 48 0f b6 3e movzx rdi,BYTE PTR [rsi]
401028: 48 c7 c0 3c 00 00 00 mov rax,0x3c
40102f: 0f 05 syscall
反汇编的读法与最简汇编的反汇编相同, 这里重点看访存指令如何被编码, 以及上图中的各层关系如何落到具体地址上:
| 地址 | 机器码 | 指令 | 说明 |
|---|---|---|---|
0x401000 | 48 8d 05 3f 20 00 00 | lea rax,[rip+0x203f] | 48 是 REX.W 前缀 (64 位操作), 8d 是 lea 操作码, 05 是 ModRM 字节 (目的 rax, 内存操作数为 rip+disp32), 后 4 字节是小端 32 位位移 0x0000203f |
0x401007 | 48 8b 00 | mov rax,QWORD PTR [rax] | 8b 是"内存读入寄存器"形式的 mov, ModRM 为 00: 目的 rax, 内存操作数为 [rax]. 没有位移字节 |
0x40100a | 48 8b 30 | mov rsi,QWORD PTR [rax] | 与上一条只差 ModRM: 30 把目的寄存器改为 rsi |
0x40100d | 48 c7 c0 01 00 00 00 | mov rax,0x1 | c7 /0 是"立即数写入寄存器/内存"形式, c0 选中 rax, 后接 32 位立即数 |
0x401014 | 48 c7 c7 01 00 00 00 | mov rdi,0x1 | 同上, c7 选中 rdi |
0x40101b | 48 c7 c2 0e 00 00 00 | mov rdx,0xe | 立即数 0xe 即汇编时由 .set msg_len 算出的 14 |
0x401022 | 0f 05 | syscall | 发起 write(1, rsi, 14) |
0x401024 | 48 0f b6 3e | movzx rdi,BYTE PTR [rsi] | 0f b6 是 movzx r, r/m8, ModRM 3e: 目的 rdi, 内存操作数 [rsi] |
0x401028 | 48 c7 c0 3c 00 00 00 | mov rax,0x3c | 0x3c 即 60, 对应 exit |
0x40102f | 0f 05 | syscall | 发起 exit(rdi) |
访存指令的共同点¶
三条访存指令的机器码形式完全统一: REX.W + 操作码 + ModRM. 其中 ModRM 的低 3 位选择基址寄存器, 中间 3 位选择另一个寄存器; 只有当基址是 rip 时才多出一个 4 字节位移. 因此:
-
mov rax,[rax]与mov rsi,[rax]在机器码层面只差一个字节, 区别仅是读出的值写入哪个寄存器. -
第 1, 2 次解引用的两条指令都是
QWORD PTR, 即读 8 字节, 因为这两级存放的是地址. 第 3 次是BYTE PTR, 只读 1 字节, 对应访问宽度一节的结论. -
lea与mov rax,[rax]的操作码不同 (8d与8b), 这也印证了lea只计算有效地址而不访问内存.
RIP 相对寻址¶
lea 的位移是 0x203f, 而 CPU 执行该指令时 rip 已指向下一条指令, 即 0x401007:
有效地址 = 0x401007 + 0x203f = 0x403046
与 objdump 注释中的 # 403046 <pp> 一致. 这个位移是链接时算出的, 代码里并没有写入 pp 的绝对地址, 这就是取地址指令中所说的位置无关寻址.
据此还能反推出 .data 的布局: pp 位于 msg+22, 所以 msg 在 0x403030, p 在 0x40303e, 即:
| 符号 | 地址 | 内容 |
|---|---|---|
msg | 0x403030 | "Hello, world!\n" |
p | 0x40303e | 0x403030 (即 &msg) |
pp | 0x403046 | 0x40303e (即 &p) |
这些地址由链接器决定, 不同工具链下可能不同, 但三者之间的相对偏移 14 与 8 是固定的. 可以用 objdump -s -j .data mem.out 查看 .data 的原始字节来验证: p 与 pp 处应分别是 &msg 与 &p 的小端表示:
> objdump -s -j .data mem.out
mem.out: 文件格式 elf64-x86-64
Contents of section .data:
403030 48656c6c 6f2c2077 6f726c64 210a3030 Hello, world!.00
403040 40000000 00003e30 40000000 0000 @.....>0@.....
其他值得注意的点¶
-
.set msg_len, . - msg中的.是汇编器的位置计数器, 两个同一节内标号之差在汇编期即可算出. 它是一个常量, 因此mov rdx, msg_len编码成立即数. 该行必须紧跟在msg后面. -
.quad msg在文件里存放的是msg的地址, 该值要等链接器确定了地址后才能填入, 属于重定位. 静态链接的ld下可直接使用; 若要生成位置无关可执行文件 (PIE), 则需要运行时重定位. -
.data与.text是常见节区之二. 本例的.data是可写的, 所以也可以用mov qword ptr [rip+p], 0修改指针. -
系统调用约定与最简汇编相同, 只是多了
write(调用号1), 其参数依次放在rdi,rsi,rdx. 相关背景见系统调用. -
可以用
gdb观察指针链: 在_start处断点后, 依次x/gx &pp,x/gx &p,x/s &msg, 即可逐级验证每一层存放的值.
常见误区¶
-
把
mov reg, [label]当作取地址: 它读出的是label处的内容. 取地址要用lea reg, [rip+label]. -
忽略访问宽度: 用 64 位寄存器读取单个字符, 会连同后面 7 个字节一起读入.
-
对宽度不明的内存操作数不写
ptr标注: 如mov [rdi], 1无法确定写 1, 2, 4 还是 8 字节, 汇编器会报错或产生歧义. -
把数据当作地址: CPU 不会检查, 只会照做并在非法地址处触发段错误.