I/O与系统调用¶
Computing 101. - Output and Input | pwn.college
write(2) - Linux manual page / read(2) / open(2) / syscall(2)
在最简汇编中, 程序只用 exit 就能把一个数交给 shell. 但真正有用的程序必须与外界交换数据: 向终端输出, 从标准输入读取, 打开文件.
在 Linux 上, 这些都通过系统调用完成. 本文以 Linux x86-64 和 Intel 语法为例, 依次介绍 write, read, open, close 几个系统调用, 并说明程序如何在不依赖绝对地址的前提下引用自己的字符串.
系统调用约定¶
系统调用是用户程序请求内核服务的入口. Linux x86-64 的约定如下:
| 寄存器 | 作用 |
|---|---|
rax | 入口: 系统调用号; 返回: 结果 (失败时为负的错误码) |
rdi | 第 1 个参数 |
rsi | 第 2 个参数 |
rdx | 第 3 个参数 |
rcx, r11 | 被 syscall 指令破坏, 不能指望它们保持原值 |
本文用到的系统调用如下:
| 调用号 | 名称 | C 形式 | 作用 |
|---|---|---|---|
0 | read | read(fd, buf, count) | 从 fd 读取至多 count 字节到 buf |
1 | write | write(fd, buf, count) | 把 buf 开头的 count 字节写入 fd |
2 | open | open(path, flags, mode) | 打开文件, 返回新的文件描述符 |
3 | close | close(fd) | 关闭文件描述符 |
60 | exit | exit(status) | 结束进程 |
Warning
rdi 与 rdx 名称相近, 极易写混. 可以记作: rdi 是 initial (第一个) 参数, rdx 是 extra (第三个) 参数.
文件描述符¶
文件描述符 (file descriptor, fd) 是内核给已打开文件分配的一个小整数, 程序之后用它指代这个文件. 进程启动时就已经打开了三个:
| fd | 名称 | 默认指向 |
|---|---|---|
0 | stdin | 终端输入 |
1 | stdout | 终端输出 |
2 | stderr | 终端输出 (用于错误信息) |
之后 open 新打开的文件通常从 3 开始依次分配. 所以 write(1, ...) 就是写到标准输出, read(0, ...) 就是从标准输入读取, 而 read(3, ...) 则是从第一个 open 的文件读取. 用同一对 read / write 既能处理终端和管道, 也能处理文件, 差别只在 fd.
write: 输出一个字符¶
write(1, buf, 1) 的三个参数依次放入 rdi, rsi, rdx, 同时 rax = 1. 其中 buf 必须是内存地址, 而不是字符本身.
在栈与程序参数中已经知道, 程序以 ./prog H 方式运行时, [rsp+16] 保存的是 argv[1] 这个指针, 它恰好就是 write 需要的地址:
.intel_syntax noprefix
.global _start
.section .text
_start:
mov rax, 1 # SYS_write
mov rdi, 1 # stdout
mov rsi, [rsp + 16] # argv[1] 字符串的地址
mov rdx, 1 # 只写出一个字符
syscall
mov rax, 60 # SYS_exit
xor rdi, rdi # 退出码 0
syscall
mov rsi, [rsp+16] 只解引用一次: 取出指针本身放入 rsi, 由内核沿着这个地址去读取字符. 如果再多解引用一次 (mov rsi, [rsi]), rsi 里放的就是字符的值而不是地址, write 将因地址无效而失败.
read: 读入到缓冲区¶
read(0, buf, n) 与 write 的参数顺序相同, 但数据流向相反: 内核把读到的字节写入 buf. 若输入为 HELLO, read(0, buf, 5) 之后内存中是这些字节 (ASCII 的十六进制):
Address │ Contents
+──────────────────+
│ buf │ 48 'H' │
│ buf+1 │ 45 'E' │
│ buf+2 │ 4c 'L' │
│ buf+3 │ 4c 'L' │
│ buf+4 │ 4f 'O' │
+──────────────────+
缓冲区的本质¶
缓冲区的本质就是一段有效且可写的内存.
最方便的就是栈: rsp 本身就是一个可写地址, 因此 mov rsi, rsp 即可把栈顶当作缓冲区, 向更高地址延伸. 若要用更大的空间, 先 sub rsp, N 向低地址扩展出 N 字节, 再把 rsp 作为缓冲区起点.
精确读取字节数¶
read 的第三个参数只是上限, 返回值才是实际读取的字节数. 要求读 128 字节而输入只有 50 字节, 则 rax = 50.
返回值 rax | 含义 |
|---|---|
> 0 | 实际读到的字节数 (可能小于 count) |
0 | 到达文件末尾 (EOF) |
< 0 | 出错, 值为 -errno |
所以"读入后原样输出"的正确写法是把 read 的返回值交给 write 的长度参数:
.intel_syntax noprefix
.global _start
.section .text
_start:
xor rax, rax # SYS_read (0), 异或自身清零
xor rdi, rdi # stdin (0)
mov rsi, rsp # 将输入读到栈顶
mov rdx, 128 # 最多读取 128 字节
syscall # rax = 实际读取的字节数
mov rdx, rax # 先保存读取长度, 再覆盖 rax
mov rax, 1 # SYS_write
mov rdi, 1 # stdout
mov rsi, rsp # 从同一缓冲区写出
syscall
mov rax, 60 # SYS_exit
xor rdi, rdi # 退出码 0
syscall
Warning
mov rdx, rax 必须放在 mov rax, 1 之前. 否则 rax 里的读取长度已被系统调用号覆盖, write 的长度会变成 1.
如果图省事固定写出 128 字节, 则输入不足 128 字节时, 多出来的部分是缓冲区里没有初始化的旧内容, 常表现为乱码. 因此 write 的长度应当使用 read 的返回值, 而不是 read 的上限.
open: 打开文件¶
open(path, flags, mode) 的第一个参数 rdi 是以 \0 结尾的文件名字符串的地址, 第二个参数 rsi 是访问方式, 0 表示只读 (O_RDONLY); 第三个参数 rdx 只在新建文件时用到. 成功时 rax 返回新的 fd, 失败时返回负的错误码, 例如 -2 (-ENOENT, 文件不存在) 或 -14 (-EFAULT, 地址无效).
流程只比读标准输入多一步: 先 open 得到 fd, 再把这个 fd 作为 read 的第一个参数:
open(path, 0)
│
▼
rax = fd
│ mov rdi, rax
▼
read(fd, buf, n)
│
▼
rax = len
│ mov rdx, rax
▼
write(1, buf, len)
Tip
每一步都要把上一步留在 rax 的结果在被覆盖前挪走: open 的 fd 先移入 rdi, 再把 rax 改成 read 的调用号; read 的长度先移入 rdx, 再把 rax 改成 write 的调用号.
写文件: flags 与 mode¶
要向文件写入内容, 需要把 flags 设成可写, 这些标志按位或 (|) 组合后放入 rsi:
| 标志 | 值 | 含义 |
|---|---|---|
O_RDONLY | 0x0 | 只读 |
O_WRONLY | 0x1 | 只写 |
O_RDWR | 0x2 | 读写 |
O_CREAT | 0x40 | 文件不存在时创建 |
O_TRUNC | 0x200 | 文件已存在时先清空 |
"创建并清空, 以只写方式打开"即 O_WRONLY | O_CREAT | O_TRUNC = 0x1 | 0x40 | 0x200 = 0x241. 一旦带有 O_CREAT, 第三个参数 rdx 就是新文件的权限位 mode, 如八进制 0644 对应 rw-r--r-- (文件属主可读写, 其他人只读); 文件已存在时 mode 被忽略. 汇编器 as 中以 0 开头的整数视为八进制.
Warning
O_CREAT 之后忘记设置 rdx, 新文件的权限就取决于 rdx 里恰好遗留的值, 可能得到权限混乱的文件.
用完文件后可以用 close(fd) (调用号 3) 释放文件描述符. 进程退出时内核也会自动关闭, 但显式关闭是更好的习惯.
文件名从哪里来¶
文件名是字符串, 而 open 要的是它的地址. 常见有三种来源.
-
来自命令行参数.
直接用
mov rdi, [rsp+16]取argv[1]即可, 路径由调用者在运行时传入, 不需要改动程序. -
逐字节写到栈上.
例如:
mov BYTE PTR [rsp], '/' mov BYTE PTR [rsp+1], 'f' ... mov BYTE PTR [rsp+5], 0 mov rdi, rsp # 别忘了把地址放进 rdi这种写法能工作, 但冗长且容易出错; 而且
[rsp]起始处原本是argc,[rsp+8]是argv[0], 直接写入会把它们覆盖.Failure
最常见的错误是写完字符串后忘记
mov rdi, rsp, 此时rdi仍是进程启动时的初值 (通常是0), 相当于open(NULL, 0), 返回-EFAULT. 此后read与write因 fd 或长度非法接连失败, 但程序依然exit退出, 终端上没有任何输出. -
作为数据放在代码之后.
这是更推荐的做法, 下一节展开.
字符串与 RIP 相对寻址¶
把字符串放在代码后面¶
汇编文件中不仅可以放指令, 也可以放不打算被执行的字节. 只要放在最后的 exit 系统调用之后, CPU 就不会走到那里, 这些字节依然存在于程序的内存中. 汇编器提供 .asciz 指令来生成字符串字节:
mov rax, 60
syscall # exit, 不会返回
path:
.asciz "/flag" # 从不执行, 但字节仍在内存里
-
.asciz会在字符串末尾自动追加\0. 内核正是靠这个\0判断文件名的结尾, 因此不要再手写\0. 而.ascii则不会追加, 需要自己补. -
path:是一个标签, 代表这些字节的起始地址.
Warning
若把字符串放在 .data 节而不是紧跟在代码之后, 虽然程序同样可以运行, 但这不是 pwn.college 此处要求的写法, 其检查器会提示"文件名应紧接在代码之后, 以 .asciz 存放".
为什么不能直接用标签的地址¶
很自然的想法是直接把标签值放入寄存器:
mov rdi, path # 在现代程序中不可行
早期程序总是被装入固定地址, 汇编器在汇编时就知道 path 的绝对地址. 但现代程序为了安全 (这是后面章节的内容) 被设计成可以装入内存任意位置, 汇编时并不知道最终地址, 所以不能把绝对地址编码进指令.
RIP 相对寻址¶
x86_64的解决办法是以指令指针 rip 为基准编码地址. rip 始终保存下一条指令的地址, 而代码与字符串之间的距离在汇编时是已知的, 与程序被装到哪里无关. 于是:
目标地址 = (下一条指令的地址) + (汇编时算好的偏移)
不过要注意两种写法的区别, 这在取地址指令中已经介绍过:
mov rdi, [rip+path] # 读内存: rdi 得到 path 处的 8 字节内容 ('/', 'f', 'l', ...), 不是地址
lea rdi, [rip+path] # 只计算地址: rdi = &path
open 需要的是地址, 所以必须使用 lea. 方括号里写的 rip+path 看上去像"rip 加上标签", 实际编码进指令的是标签与下一条指令之间的距离.
综合示例¶
本例综合了 read, open, write, close, 完成以下任务: 从终端读取用户输入, 写入文件 out.txt, 并回显到终端. 完整源码为 io.s:
.intel_syntax noprefix
.global _start
.section .text
_start:
sub rsp, 256 # 在栈上预留 256 字节作为输入缓冲区
# 1. read(0, buf, 256): 从终端 (stdin) 读取用户输入
xor rax, rax # SYS_read (0), xor 异或运算清零 rax 寄存器
xor rdi, rdi # stdin (0)
mov rsi, rsp # 缓冲区 = 栈顶
mov rdx, 256 # 最多读取 256 字节
syscall # rax = 实际读取的字节数
mov r12, rax # 把长度保存到 r12: syscall 只破坏 rax, rcx, r11,
# r12 不会被改写, 后面两次 write 都要用到它
# 2. open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644): 创建/清空并以只写方式打开文件
mov rax, 2 # SYS_open
lea rdi, [rip + path] # 文件名的地址 (RIP 相对寻址, 必须用 lea 取地址)
mov rsi, 0x241 # flags = O_WRONLY (0x1) | O_CREAT (0x40) | O_TRUNC (0x200)
mov rdx, 0644 # mode = 0644 (八进制), 仅在文件被新建时生效: rw-r--r--
syscall # rax = 新的 fd (通常是 3)
mov r13, rax # 保存文件的 fd, 写入和关闭时都要用
# 3. write(fd, buf, len): 把用户输入写入文件
mov rdx, r12 # 长度 = read 实际读取的字节数, 而不是上限 256
mov rax, 1 # SYS_write
mov rdi, r13 # 目标是刚打开的文件, 不是 stdout
mov rsi, rsp # 数据仍然在栈上的缓冲区里
syscall
# 4. write(1, buf, len): 把同样的数据输出到终端 (stdout)
mov rdx, r12 # 长度不变; rdx 可能已被内核改写, 所以重新设置
mov rax, 1 # SYS_write
mov rdi, 1 # stdout (1)
mov rsi, rsp # 同一块缓冲区
syscall
# 5. close(fd): 关闭文件, 释放文件描述符
mov rax, 3 # SYS_close
mov rdi, r13 # 要关闭的 fd
syscall
# 6. exit(0)
mov rax, 60 # SYS_exit
xor rdi, rdi # 退出码 0
syscall
path:
.asciz "out.txt" # 紧跟在代码后面, 永远不会被执行; .asciz 自动补上结尾的 \0
运行效果:
> make asm src=io.s
> echo "hello vglab" | ./io.out
hello vglab
> cat out.txt
hello vglab
> ls -l out.txt
-rw-r--r-- 1 user user 12 ... out.txt
这里是管道输入; 在终端直接运行 ./io.out, 输入一行文本并回车, 效果相同.
由于只调用一次 read, 终端默认的行缓冲使它通常一次只能拿到一行, 所以输入超过一行的内容时只会处理第一行.
数据流¶
终端 stdin ──read──▸ 栈缓冲区 ──write(fd)──▸ out.txt
│
└──write(1)──▸ 终端 stdout
同一个缓冲区被写了两次: 一次写到文件 (fd 为 open 的返回值), 一次写到标准输出 (fd 为 1). 这正说明了 write 对目标是"文件"还是"终端"并不关心, 区别仅在 fd.
r12 与 r13 的作用¶
此前的例子都是随手把返回值挪进下一个调用的参数寄存器.
本例需要把 read 的长度跨越多次系统调用反复使用, 而 rax, rdx 等寄存器随时会被覆盖, 所以需要找一个 syscall 不会改写的寄存器暂存.
约定中 syscall 只破坏 rax (返回值), rcx 和 r11, 因此 r12, r13 这类寄存器足以安全保存长度和 fd. 之所以不用 rcx, 与栈与程序参数中不选用 rcx 作计数器的原因相同.
Tip
write 的 rdx 在调用返回后一般不应再依赖, 本例每次 write 前都用 mov rdx, r12 重新设置, 比"假定它保持不变"更稳妥.
反汇编分析¶
汇编链接后用 objdump -d -M intel 查看 (地址可能与你的环境略有不同, 结构一致), 重点看 lea 与末尾字符串:
0000000000401000 <_start>:
...
4010xx: 48 8d 3d xx xx xx xx lea rdi,[rip+0x..] # 4010yy <path>
...
4010..: 0f 05 syscall # exit
00000000004010yy <path>:
4010yy: ... # 字符串字节, 被 objdump 误当作指令解码
-
lea的偏移是"下一条指令"与path之间的距离, 与程序被装到哪里无关, 这就是 RIP 相对寻址. -
path紧接在最后一条syscall之后. 它被objdump当作指令解码得到的(bad)之类的输出是无意义的: 这些字节本来就是字符串out.txt\0, 且因为前面的exit不返回, 程序也永远不会执行到它们. -
mov rsi, 0x241与mov rdx, 0644的立即数在机器码中就是对应的数值, 可以通过反汇编核对 flags 的位组合是否正确.
其他值得注意的点¶
-
不使用 C 运行时时
_start不是函数, 因此末尾要用exit系统调用而不能用ret, 否则程序会继续往下执行, 甚至进入字符串区域. -
本例没有检查返回值:
read返回0(EOF) 时会创建一个空文件;open失败 (如目录不可写) 时rax为负, 后续write会因非法 fd 而失败, 终端上仍然会回显, 不容易察觉. 真实程序应检查每个返回值. -
可以用
strace ./io.out观察每一次系统调用的参数与返回值, 例如open("out.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644) = 3. -
再次运行会因为
O_TRUNC而覆盖旧内容; 若改为追加写入, 需要换成O_APPEND(0x400).
构建与运行¶
练习中的构建流程为:
as -o io.o io.s
ld -o io.out io.o
./io.out
echo $?
也可以把它们写进 Makefile:
.PHONY: asm clean
asm:
@if [ -z "$(src)" ]; then \
echo "Usage: make asm src=<file.s>" >&2; \
exit 2; \
fi
$(AS) "$(src)" -o "$(basename $(src)).o"
$(LD) "$(basename $(src)).o" -o "$(basename $(src)).out"
clean:
rm -f *.o *.out
之后 make asm src=io.s 即可. 其中 $(AS) 与 $(LD) 是 Make 的内置变量, 默认分别为 as 和 ld.
常见误区¶
-
漏设寄存器: 注释掉
mov rdi, ...后只准备了缓冲区, 却没有把地址放进rdi, 导致open返回-EFAULT, 之后各调用连锁失败而无输出. -
把长度写成
read的上限: 应使用read的返回值 (rax), 否则会输出缓冲区里的乱码. -
覆盖
rax之后才取返回值: 必须先mov rdx, rax或mov rdi, rax, 再改写rax为下一个调用号. -
混淆
rdi与rdx: 第 1 个参数用rdi, 第 3 个参数用rdx. -
用
mov代替lea取标签地址:mov rdi, [rip+path]读出的是内容, 不是地址. -
O_CREAT忘记设置mode: 新建文件的权限由rdx的遗留值决定, 应显式设置如0644. -
跨调用复用
rax,rdx: 每次系统调用都会改写rax; 需要保留的长度或 fd 应存入r12,r13等不被syscall破坏的寄存器. -
字符串缺少结尾
\0: 使用.ascii时需要自己补\0, 而.asciz已自动补上, 不要重复书写. -
把
[rsp+16]当成字符串内容: 它是argv[1]的指针, 仅需解引用一次就能作为write/open的地址参数.