跳转至

面向 Reverse 与 Pwn 的汇编

Reverse 关注“程序实际做了什么”,Pwn 基础关注“错误的内存操作如何破坏程序安全边界”。二者都依赖寄存器、内存、控制流和 ABI,但安全学习必须限定在授权题目、本地实验和防御性验证中。 本篇讲分析思路、缺陷成因、保护机制和修复方向,不提供真实目标利用步骤或可直接复用的武器化 payload。

从问题出发,而不是从指令表出发

拿到一个 ELF 后,先提出可验证的问题: - 程序从哪里接收输入? - 输入经过哪些长度、范围或格式检查? - 哪些函数决定成功、失败或权限状态? - 哪些数据影响间接调用、返回地址或对象生命周期? - 程序启用了哪些编译与链接保护? 随后用静态分析建立路径,用调试器在关键点验证数据。

Reverse 的基础工作流

  1. 用 file、readelf 确认架构、ELF 类型和入口。
  2. 浏览导入函数、字符串、符号和交叉引用。
  3. 从 main、输入函数、比较函数或输出函数开始。
  4. 切分基本块,标记条件和循环。
  5. 根据 ABI 恢复参数与返回值。
  6. 为寄存器、栈槽和结构体偏移赋予临时语义名。
  7. 用不同输入动态验证关键路径。 重命名是推理工具。名称应随证据更新,例如从 v3 改为 input_len,而不是一开始就写成未经证实的 password_len。

识别程序入口与 main

ELF 入口通常是 C 运行时启动代码,而不是 main。动态链接程序会初始化运行环境,再通过 libc 启动函数间接调用 main。 有符号时工具会直接标出 main;无符号时可从启动代码传给 libc 启动例程的函数指针、交叉引用或运行时断点定位。 不要把入口函数的参数规则误当成普通用户函数逻辑。

从导入和字符串建立地图

常见导入提供行为线索: - read/fgets/getline:输入 - strlen/memcmp/strcmp:长度或内容比较 - malloc/free:动态对象生命周期 - open/read/write:文件操作 - printf/puts:输出与格式串 字符串交叉引用可以定位错误路径和成功路径。但未引用字符串、内联实现、运行时解码和间接调用都会让线索不完整。

恢复 if 条件

遇到:

cmp eax, 5
jne .fail
只能先得到“eax 不等于 5 时走向 .fail”。接下来要追溯: - eax 的值从哪里来? - 宽度和符号是什么? - .fail 是否真的失败,还是共同清理代码? - 另一分支最终是否一定成功? 控制流标签和反编译器命名不应替代数据流证明。

恢复循环

循环通常包含: - 初始化:索引或指针起点 - 条件:长度、终止符或哨兵比较 - 更新:索引加一、指针增加步长 - 回边:跳回较低地址或循环头 数组处理可能使用索引:

movzx eax, byte ptr [rdi+rcx]
inc rcx
也可能使用指针递增:
movzx eax, byte ptr [rdi]
inc rdi
二者可对应相同源码语义。

识别长度和边界检查

安全分析中最重要的是确认检查与访问是否匹配:

cmp rdx, 64
ja .reject
; 后续复制 rdx 字节到 64 字节目标
应继续确认: - 64 是否包括字符串终止符? - 比较是 > 还是 >=? - rdx 是否可能因截断、符号转换或整数溢出改变? - 目标实际容量是否确实是 64? - 复制后是否又追加额外数据? - 检查与使用之间数据是否被修改? 孤立看到一次 cmp 不足以证明边界安全。

有符号与无符号边界错误

若长度先以有符号整数检查负数,后又转成无符号 size_t,负值可能变成很大的正数。汇编层面可观察: - 使用 jl/jg 还是 jb/ja - 参数进入 64 位寄存器时是 movsxd 还是零扩展 - 比较宽度与后续内存操作宽度是否一致 防御性修复应统一长度类型、在转换前验证范围,并使用目标对象的真实容量。

栈缓冲区与控制数据

局部数组可能与保存寄存器、帧信息和返回地址位于同一栈帧。如果写入超过数组边界,可能先破坏其他局部数据,再影响控制数据。 概念图:

高地址
返回地址
保存的寄存器或旧 rbp
其他局部数据
固定大小缓冲区
低地址
真实布局由编译器、优化、对齐、Canary 和变量重排决定。不能仅根据源码声明顺序计算距离。

安全地观察栈破坏

在自己编写的最小实验程序中,可以使用 GDB: 1. 在写入函数前后设置断点。 2. 记录目标缓冲区地址和容量。 3. 用观察点定位首次越界写。 4. 检查受影响内存属于哪个对象。 5. 使用 AddressSanitizer 复现并获得诊断。 推荐防御性构建:

gcc -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer demo.c -o demo
Sanitizer 适合定位错误,不应被视为生产环境中所有内存问题的完整防护。

栈 Canary

启用栈保护时,函数可能在入口读取一个线程相关随机值并放到栈帧,在返回前比较:

入口:保存 canary
...
出口:比较当前值与原值
不一致:调用 __stack_chk_fail
Canary 主要检测某些覆盖到函数尾部控制数据的连续栈写。它不能防止所有越界、任意地址写、堆错误或逻辑漏洞。 安全构建常使用 -fstack-protector-strong 或发行版默认强化选项。

NX / DEP

NX(不可执行位)让可写数据页通常不可执行,例如栈和堆多为 rw- 而不是 rwx。它降低“把输入数据直接当指令执行”的风险。 NX 不阻止: - 破坏数据但不改变控制流 - 跳转到已有可执行代码 - 逻辑和权限错误 防御上应遵循 W^X:页面不同时可写和可执行,并减少运行时生成代码的权限窗口。

ASLR 与 PIE

ASLR 随机化栈、堆、共享库等映射地址。主程序若编译为 PIE,其代码基址也可随机化。 - ASLR 增加攻击者预测地址的难度。 - PIE 让主可执行文件参与地址随机化。 - 信息泄露可能削弱随机化效果。 - 低熵、地址复用或部分覆盖等情况会影响实际强度。 调试时地址变化是正常现象,应使用“模块基址 + 偏移”记录位置。

RELRO 与 GOT

动态链接需要 GOT 保存解析后的函数或数据地址。RELRO 用于减少关键重定位区域在运行期保持可写的时间或范围。 - Partial RELRO 提供部分保护。 - Full RELRO 通常配合立即绑定,使相关 GOT 区域在初始化后只读。 RELRO 保护特定链接结构,不是通用内存安全机制。

FORTIFY 与安全 API

编译器在已知对象大小时,可通过 _FORTIFY_SOURCE 把部分库调用替换为带边界检查的版本。反汇编中可能看到 __memcpy_chk、__printf_chk 等符号。 FORTIFY 依赖编译优化和可推断对象大小,不能替代正确的长度计算、输入验证与内存安全设计。

ROP 的防御性认识

Return-Oriented Programming(ROP)是一类代码复用思想:不向数据区注入新指令,而是把已有可执行代码中的短指令序列按控制流效果组合。 理解它的防御意义在于: - NX 并不能单独阻止所有控制流劫持。 - 返回地址和间接调用目标都是高价值控制数据。 - ASLR/PIE、Canary、控制流完整性和硬件辅助机制应分层使用。 - 程序仍需首先消除越界写、释放后使用等根因。 本章不构造 gadget 链或可执行 payload。授权实验中也应把目标限定为验证保护是否生效,而非迁移到真实系统。 ret 从栈上读取下一条执行地址,因此防御分析应确认缺陷能否触及控制数据、Canary 是否覆盖该路径、地址随机化是否有效,以及 CET shadow stack 等机制是否启用。只有证明根因和可达性后才能评价风险;“程序崩溃”不等于“可稳定控制执行”。

格式字符串的汇编视角

格式化输出函数把格式串解释为参数描述。安全分析要区分:

printf("%s", user_input); // 固定格式串
printf(user_input);       // 输入被当作格式串
在 System V 反汇编中,检查 rdi 指向固定 .rodata,还是指向可控缓冲区,并继续核对后续参数寄存器。 修复原则是使用固定格式串、开启编译器格式检查,并避免把不可信数据作为格式模板。

间接调用与函数指针

call rax
call qword ptr [rbx+16]
可能对应回调、虚函数表或分发表。逆向时: 1. 追踪目标地址来源。 2. 列出所有合法赋值点。 3. 按调用点 ABI 推断函数原型。 4. 检查索引和对象生命周期是否受控。 防御性设计应验证索引、保护只读函数表,并采用 CFI 等机制降低非法目标风险。

堆问题的基础识别

汇编层面常见线索: - malloc/calloc/realloc/free 调用 - 对返回指针的空值检查 - 固定偏移字段访问 - 释放后仍使用旧指针 - 长度乘法后分配,存在溢出可能 - 两条路径释放同一对象 堆分配器内部细节随 libc 版本变化。初学阶段应先确认对象生命周期和边界,不要把所有堆崩溃都归因于分配器元数据。

整数溢出到内存错误

内存大小常由算术生成:

size = count * element_size;
ptr = malloc(size);
若乘法按固定宽度回绕,分配可能小于后续写入。汇编中检查: - imul 的宽度 - 是否检查 CF/OF 或预先比较上限 - count 的符号和扩展方式 - 分配大小与循环边界是否使用同一语义 修复应在乘法前检查 count > SIZE_MAX / element_size,或使用提供溢出检测的安全辅助函数。

从汇编验证修复

修复后应确认边界检查覆盖所有入口、比较宽度与有符号性正确、检查位于使用之前,并验证最终二进制仍包含预期检查和默认保护。单元测试、模糊测试与 Sanitizer 应覆盖原始触发场景。

Pwn 题目的防御性分析顺序

在授权题目中,可按以下顺序做风险评估: 1. 识别输入面和信任边界。 2. 确认缺陷类型与精确写入/读取范围。 3. 建立崩溃根因,不把症状当原因。 4. 检查 NX、PIE、ASLR、Canary、RELRO、FORTIFY、CET。 5. 判断缺陷影响的是普通数据、敏感数据还是控制数据。 6. 用最小、无害的实验验证判断。 7. 给出源码修复和构建加固建议。 这种顺序比先寻找“可利用技巧”更适合建立可迁移的安全能力。

防御性修复原则

  • 使用携带长度的接口,并让容量和实际长度采用 size_t 等一致类型。
  • 在所有算术和类型转换前后验证范围。
  • 不依赖终止符来弥补缺失的容量信息。
  • 明确对象所有权和生命周期,释放后清理或废弃引用。
  • 默认启用编译器警告、Canary、PIE、RELRO、FORTIFY 和平台控制流保护。
  • 在 CI 中使用 AddressSanitizer、UndefinedBehaviorSanitizer 和模糊测试。
  • 对解析器和高风险组件考虑内存安全语言或隔离边界。 缓解措施降低缺陷后果,正确的设计和边界检查才消除根因。

最终分析检查表

完成一个基础 Reverse/Pwn 分析时,应能清楚写出: 1. 文件架构、格式、ABI 和保护状态。 2. 输入如何到达关键函数和内存对象。 3. 每个关键比较的宽度、有符号性与分支含义。 4. 缺陷发生的第一条错误访问及其根因。 5. 受影响数据范围,以及是否涉及控制数据。 6. 现有缓解措施能阻止什么、不能阻止什么。 7. 无害验证方法、源码修复和回归测试方案。 8. 哪些结论已动态验证,哪些仍是假设。 回到章节首页复习整体路线,或结合工具、反汇编与调试建立自己的实验记录模板。