HDCTF 2023 的 double_code,算是我整理 shellcode 逆向知识时绕不开的一道题。题目名里的 double 基本就把考点说透了——程序里会有一段代码先做引导,真正输出 flag 的 shellcode 藏在后面,需要你去把它从内存里“捞”出来再分析。很多第一次接触这类题的朋友会栽在一个地方:花大量时间试图纯静态把整段字节流解出来,结果越解越乱。其实这类题有更高效的路子,就是用调试器让它自己跑完解密过程,再直接 dump 内存。
这篇文章我打算从拿到二进制文件开始,把 shellcode 分析常用的思路、SMC(自修改代码)识别方法、动态调试取码的完整流程都过一遍。目标是让刚接触 CTF 逆向的新手,看完之后能自己复现一遍这类“数据段藏代码、运行时再解码”的题目。文末我还会把分析过程中踩过的坑整理成清单,方便你以后直接对照查。
1. 拿到题目先别急:建立分析基线
1.1 文件类型与保护机制排查
不管题目多花哨,我拿到二进制文件后的第一轮排查基本固定:先看文件信息,再看安全属性,最后看节区特征。对 double_code 这类题,这三个检查已经能透露出不少关键信息。
file double_code checksec --file=double_code readelf -S double_code正常情况下你会看到一个 64 位 ELF 文件。如果它开了 NX(栈不可执行),但又在某处调用了 mprotect 或者存在具备可执行权限的段,那基本可以确定程序运行时会动态调整内存权限来执行代码。这正是 shellcode 类题目最常见的处理方式:把雷埋在数据段,运行到某个时机再“通电”。
readelf 输出的节区信息里要重点看有没有.note.GNU-stack、.got、.plt,以及是否存在大段的只读数据。这道题里数据段往往藏着一长串看起来像随机字节的数组,长度通常在几十到几百字节之间。别急着在 IDA 里按 C 键把那块数据强行转成代码,因为这里的数据大概率不是直接可执行的明文代码,需要先在运行时被解码。
1.2 main 函数只是入口,真正的逻辑藏在数据里
把 main 函数反汇编出来你会发现它很短,可能只有几个函数调用。对于 double_code 这种题,main 里通常会发生这样几件事:把某个数据段地址传给解密函数或拷贝函数,然后调用 mprotect 把某段内存设为可执行,最后通过函数指针、间接跳转或者强制类型转换跳到那段内存。
这里用 IDA 反编译时,你会看到类似这样的伪代码模式:
void *buf = malloc(0x100); memcpy(buf, encoded_shellcode, 0x100); mprotect(buf, 0x100, PROT_READ | PROT_WRITE | PROT_EXEC); ((void(*)())buf)();这是典型的数据段代码执行流程。特点是目标代码没有出现在 IDA 反汇编的指令流里,而是以数据形式躺在.rodata或.data。初次接触这种结构的人容易慌,因为静态看 main 根本无法直接看到真正的业务逻辑,字符串窗口里也找不到 flag。其实这正是考点所在:你需要识别出这段数据会被执行,然后分析执行前的变换过程。
1.3 建立“先整体后局部”的分析路径
分析这类题,我建议的路径是:先梳理控制流——程序从哪里跳走、跳去哪块内存;再梳理数据流——执行的目标数据经历了哪些内存读写和计算;最后再落回到具体指令级分析。这个顺序能避免你过早陷入某一段字节流里出不来。
具体到 double_code,你先在 IDA 里看看有没有直接对数据段地址进行调用的指令,比如call rax、call [rbp+var_8]这类间接调用。找到之后,顺着数据来源往上游看,往往就是那张编码后的 shellcode 表,以及负责解码的小循环。到这一步,整体分析框架就出来了:一段 loader 负责把密文解码成可执行代码,然后跳过去。接下来才进入 shellcode 特征识别与动态取码环节。
2. shellcode 不是天书:先建立代码识别能力
2.1 shellcode 的典型特征
在分析 double_code 之前,我建议你先搞清楚 shellcode 到底是什么。简单说,shellcode 是一段可以脱离常规程序框架、位置无关、直接嵌入内存并执行的机器码。它通常不依赖 libc 的函数,而是通过系统调用直接和内核打交道。传统的 shellcode 目标是启动一个 shell(所以叫 shellcode),但现在的安全研究里,这个叫法已经扩展到任何一段独立的机器码载荷。
在二进制文件里识别 shellcode,最直观的特征有这几个:
- 它没有一个标准的函数头,不像普通 C 函数那样有
push rbp; mov rbp, rsp的序言,也不以ret正常返回。 - 它的执行流通常结束在
syscall、int 0x80或者一个跳转指令上。 - 它内部的所有地址引用尽量使用相对寻址,比如
lea rdi, [rip+xxx],这样可以保证放到任何内存位置都能跑。 - 机器码短小精悍,但往往包含大量异或、按位运算,特别是加载器形式的 shellcode。
举个典型的 Linux x64 下 execve("/bin/sh", NULL, NULL) 的 shellcode,机器码长这样:
31 f6 xor esi, esi 48 bb 2f 62 69 6e 2f 2f movabs rbx, 0x68732f2f6e69622f 73 68 53 push rbx 54 push rsp 5f pop rdi 6a 3b push 0x3b 58 pop rax 99 cdq 0f 05 syscall看到xor开头的31 f6、结尾的0f 05,基本就能判断这是 shellcode。这个特征在 double_code 里也一样,只是这道题的 shellcode 不是明文的,而是被编码过之后藏在数据段里。
2.2 识别“写入后执行”模式(SMC)
SMC,全称 Self-Modifying Code,翻译过来就是自修改代码。程序在执行过程中修改自己的指令字节,改完再跳到对应地址执行。CTF 逆向里遇到 SMC 的频次相当高,因为它天然对静态分析不友好——你在 IDA 里看到的字节是修改前的,根本不是 CPU 真正执行的指令。
SMC 在汇编层面的表现,通常是这样的循环结构:
lea rdi, [rip + encoded_data] mov ecx, 0x40 loop_start: xor byte ptr [rdi], 0x66 inc rdi dec ecx jnz loop_start jmp encoded_data这个循环会对一段连续内存逐字节做异或运算,完成后才跳进这段内存去执行。double_code 里的第一段代码,就很可能承担着类似的职责:先执行一个短小的解码循环,把后面一段密文还原成真正的 shellcode,再跳转执行。
识别 SMC 的静态方法是找循环体里有没有mov byte ptr [reg], imm或xor byte ptr [reg], reg2这类写内存指令,同时这个内存地址恰好是后面跳转目标。更快的识别方法是动态调试:直接在可能的跳转目标下断点,看程序运行时是否真的会飞过去。
2.3 动手实验:构造一个迷你双层 shellcode 样例
为了把原理讲明白,我写了一个简化版本的“双层代码”,结构和 double_code 非常接近。第一层是 XOR 解码器,第二层是一段输出字符串的 shellcode。你不要直接跑这个样例本身,而是要把它当分析方法练手——理解了这个小例子,再回头分析题目就顺了。
; 迷你双层shellcode,GNU汇编语法 ; 这段代码先解码自身后面的数据,再跳过去执行 global _start section .text _start: jmp short get_address ; 利用call-pop拿到地址 decode_start: pop rsi ; rsi = encoded_payload 地址 xor rcx, rcx mov cl, 0x20 ; 解码长度,按实际payload调整 decode_loop: xor byte [rsi+rcx-1], 0x66 ; 逐字节异或0x66 loop decode_loop jmp rsi ; 跳到解码后的payload get_address: call decode_start encoded_payload: ; 这里是加密后的"Hello"输出shellcode,先不展开这个结构的精妙之处在于:静态看encoded_payload字段时,它就是一堆无意义的字节。只有当程序运行到decode_loop之后,它才还原成可执行的指令。这正是 double_code 这类题目核心考点的缩影。你在分析实际题目时,遇到的结构可能更长、密钥可能更复杂,但主线永远是:找到解码逻辑,等它执行完,取解码后的内存。
3. 核心环节:double_code 的动态分析与取码
3.1 为什么动态分析在这里比静态快得多
有读者会问:“既然能做静态分析,为什么不直接把编码后的字节抄出来,自己写个脚本解出第二段代码,然后再静态分析?”这个思路本身没错,而且在一些场景下确实可行。但问题是,实际题目里的加密逻辑可能不止一层,可能混合了异或、加法、移位、字节交换,甚至每一轮的解码还依赖前一轮的结果。你纯静态解,相当于在没有 CPU 状态信息的情况下,手动模拟整个解码过程,很容易在某一步算错或者漏掉一个关键条件。
动态分析就不一样了。程序自己会在运行过程中完成解码,你的工作从“理解每一步运算”变成“选择合适的时机截获结果”。这个思路转换非常关键。我习惯把解码循环看作一个黑盒:你不需要知道它内部怎么变换,只需要知道进入前是一堆密文、退出后就是明文代码。这和打游戏开地图是一个道理——队友帮你探开了迷雾,你直接在亮处捡装备就行。
在 double_code 里,底层思路就是:找到解密循环结束、即将跳转到第二段 shellcode 的那个断点,让程序执行到那里,然后把内存导出来。剩下的分析,可以离线对一个“干净”的 shellcode 做,难度会直线下降。
3.2 gdb 调试实录:断点、单步、dump 一气呵成
下面我以 gdb 为工具,走一遍完整的动态取码流程。我用的环境是 Linux x64,调试对象是 double_code 这类结构的程序。命令中的地址在你实际调试时可能略有不同,但流程是一模一样的。
gdb ./double_code进入 gdb 后,先关闭随机化,保证调试过程中地址稳定:
set disable-randomization on然后随便下个断点让程序启动起来,我习惯先断在 main:
b main r启动后反汇编 main,找到那个跳转到未知地址的调用。注意看有没有call rax、jmp [reg]或者对栈上指针的调用:
disassemble main确认目标地址后,在跳转指令上下一个断点。比如跳转指令地址是0x401234:
b *0x401234 c等程序停在这个断点时,先用si单步执行跳转,进入第一层 shellcode:
si x/20i $rip这个时候你会看到解码循环的汇编代码。别急着逐条走过整个循环,那样太慢。你可以观察循环变量寄存器,我习惯的做法是在循环最后一条跳转指令处再下一个临时断点。比如循环是loop decode_loop,loop指令既会判断计数寄存器 RCX 是否为 0,又负责跳转,那就在它刚执行完、跳出循环后的下一条指令处断下:
b *0x401300继续执行,程序会跑完所有解码迭代,停在跳转到 payload 之前的那个位置。这时查看寄存器,尤其是 RSI、RDI 这种存放目标地址的寄存器,然后确认解码结果:
x/30i $rsi如果这 30 条指令已经不像是随机字节,而是有意义的汇编,说明解码成功。这时候就可以把第二段 shellcode 从内存里导出来:
dump binary memory /tmp/second_shell.bin $rsi $rsi+0x200我建议 dump 的范围稍微大一点,比如实际长度 0x100 就 dump 0x200,避免漏掉尾部数据。导出后直接退出 gdb,在系统里用 objdump 对二进制文件做反汇编:
objdump -D -b binary -m i386:x86-64 /tmp/second_shell.bin到这一步,你已经拿到了真正执行的代码。这比对着加密字节做数学题要省力得多。
3.3 解码后 shellcode 的 syscall 链路分析
拿到第二段 shellcode 的反汇编之后,下一步就是理解它干了什么。对于输出 flag 的题目,最典型的 syscall 组合是write或writev。在 Linux x64 下,syscall 号存放在 RAX 寄存器,调用指令是syscall。常见的几个 syscall 号我列一下:
| syscall 号 | 名称 | 参数含义 |
|---|---|---|
| 0 | read | rdi=fd, rsi=buf, rdx=count |
| 1 | write | rdi=fd, rsi=buf, rdx=count |
| 2 | open | rdi=path, rsi=flags, rdx=mode |
| 10 | mprotect | rdi=addr, rsi=len, rdx=prot |
| 59 | execve | rdi=filename, rsi=argv, rdx=envp |
分析第二段 shellcode 时,先找syscall指令,再往上看它之前对寄存器做了什么赋值。如果看到mov eax, 1; mov edi, 1; lea rsi, [rip+str],尤其是那个lea rsi, [rip+str],说明它在准备一个相对地址的字符串缓冲,后面大概率就是把这个缓冲区的数据写到 stdout。这种结构十有八九就是 flag 输出逻辑。你可以在反汇编窗口中跟着 RSI 指向的地址,用x/s $rsi直接查看字符串内容。如果 flag 不在眼前,那么通常会有一个逐字节比较的校验循环,把用户输入与内存中的某个加密后的 flag 比较,这时候就要继续跟踪比较指令两侧的数据来源。
我在分析中遇到最多的 shellcode 套路就是:先执行一次 write syscall,输出提示字符;再执行一次 read syscall,接收输入;最后是一个逐字节比对或 decrypt-and-compare 的过程。double_code 里的第二段 shellcode,如果设计成直接输出 flag,那最简单——dump 出来后你甚至不需要完全看懂每条指令,直接 x/s 目标地址就能把 flag 拿走。
4. 实战中的坑与排查方法
4.1 在解码前打断点导致分析失败
我第一次做这类题时犯过一个很蠢的错误:直接在数据段地址上下断点,结果程序停住后我看内存仍是一堆乱码,还以为解密失败了。后来才反应过来,断点下得太早,解码循环根本还没执行。这个问题在 SMC 题目里极其常见。
解决办法是断点位置一定要选在解码循环结束之后、跳转目标之中。我总结了两个实用的操作:
- 用
until跳过循环。比如当前停在decode_loop上,执行until *0x401300,程序会一直运行到指定地址,中间不会因为循环多次触发断点。 - 或者直接在第二段 shellcode 的某条指令地址上,通过循环跳转目标推算出来后再下断。这种方法更稳,因为你明确知道程序一定会经过这里。
另外调试时建议打开一个独立终端,配合 pwndbg 或 GEF 这类插件,内存变化、寄存器高亮、栈回溯都直观得多,比裸 gdb 更容易发现断点位置选错的问题。
4.2 PIE 与 ASLR 对地址的影响
double_code 如果开启了 PIE(Position Independent Executable),程序每次运行的加载基址都会变化,你在 gdb 里看到的地址和 IDA 里看到的地址对不上。这种情况静态分析和动态调试的地址需要换算:实际地址 = 加载基址 + 文件中相对偏移。用 gdb 里的info proc mappings可以直接看到当前进程的加载基址:
info proc mappings如果不想每次都做加减法,我建议直接在 gdb 里执行set disable-randomization on,把 ASLR 关闭,地址就稳定下来了。这条命令在多数 Linux 发行版上对 gdb 子进程有效,但不影响系统其他进程,调试完不需要额外恢复。
还有一个容易忽略的点:如果你用starti进入程序最开头,此时映射可能还没有完全建立,最好先start或者运行到 main 再查看映射。否则你看到的基址和后续执行的地址可能差一截,排查起来很头疼。
4.3 解密密钥与长度不明确时怎么处理
有时候第一层代码的解密逻辑没那么直观,你可能看不出异或密钥是0x66还是别的常数。这种情况下,我建议退回到“人脸识别”模式:观察循环里的立即数和索引方式。常见的解密循环有两种:
xor byte ptr [rsi+rcx], 0x66,密钥是立即数,直接能看到。mov al, byte ptr [rdi]; xor byte ptr [rsi], al; 类似流密码,密钥来自另一段缓冲区,这时候需要跟踪那个缓冲区的内容来自哪里。
如果程序把密钥藏在更深的地方,你可以先随便运行到解码结束,直接对比解码前后的内存差异。具体操作:在解码前记下目标地址的十六进制内容,等解码完成后再次查看同一地址,前后两个值按字节异或,结果就是密钥。这个方法不需要理解任何算法逻辑,实测非常稳。
还有一个实用技巧:用 Python 脚本对密文做爆破。如果猜测是单字节异或而密钥范围未知,直接写个循环尝试所有 256 种可能,用可打印字符率和反汇编成功率判断哪一组最像明文代码。但我不建议一上来就爆破,先通过调试器观察,爆破只是兜底手段。
4.4 常见排查速查表
我把前面提到的排查思路整理成一张表,方便实际分析时快速定位问题。
| 症状 | 可能原因 | 处理方法 |
|---|---|---|
| 断点停在未知区域,内存是乱码 | 断点下在解码前 | 延后断点位置,使用 until 或跳转目标地址 |
| IDA 地址和 gdb 不一致 | 开启了 PIE | gdb 关闭随机化,或使用基址换算 |
| 第二段 shellcode 反汇编不成样子 | dump 范围不对或密钥错误 | 检查 RSI/RDI 指向,扩大 dump 范围对比 |
| syscall 分析遇到不认识的调用 | 架构或 syscall 表不符 | 确认是 x64 还是 x86,按对应表核对 |
| 循环卡住不退出 | 循环变量初始化出错或断点位置在循环体内 | 检查 RCX/计数器语义,改用条件断点 |
这张表不敢说覆盖所有情况,但应对 double_code 这类 SMC 型 shellcode 题目,基本够用了。
5. 从 CTF 到真实世界:shellcode 分析能力怎么用
5.1 真实恶意代码里的 shellcode 形式
分析完 double_code,你可能觉得这只是比赛里的一个小把戏。但 shellcode 分析能力,在现实安全场景中用途非常广泛。漏洞利用中的 shellcode、恶意文档里释放的载荷、APT 组织植入的内存马、供应链攻击里的加载器,底层原理都和这道题极为相似。
我见过一个真实样本用类似的结构:最开始一小段 loader 代码通过 Windows API 的VirtualAlloc申请一段可执行内存,然后对一段加密的数据逐字节解码,最后跳转执行。分析思路与 CTF 里几乎一模一样——只不过要把断点从xor byte ptr换成对VirtualProtect或memcpy的追踪,syscall 分析变成了对 Windows API 调用的分析。
还有一个常见场景是 egghunter。攻击者在 shellcode 前加上特定标记字节,egghunter 会扫描内存查找这个标记并跳过去。double_code 里的跳转目标计算,本质上和 egghunter 的“扫描后跳转”是同一个思维方式:通过寄存器或标记位定位并执行一段动态构造的代码。
5.2 分析工具链推荐
如果你想把 shellcode 分析练熟,我推荐按下面的工具链搭建自己的环境:
- 静态分析首选 Ghidra 或 IDA Pro。Ghidra 免费且自带 SMC 辅助脚本,IDA 在做交叉引用和类型恢复时更顺手。对于 shellcode 裸二进制,Ghidra 可以直接把二进制文件按指定架构加载反汇编,非常方便。
- 动态调试用 gdb + pwndbg 插件。pwndbg 对堆、栈、寄存器的高亮显示很友好,而且自动识别 glibc 结构,调试 shellcode 时可以明显提升效率。
- 如果想在无系统环境下模拟执行 shellcode,可以试试 uQuark 或 Unicorn Engine。它们能在 Python 里模拟 CPU 执行,不依赖真实操作系统,适合对恶意样本做隔离分析。
- 自动化反混淆工具可以用 flare-emu,它基于 Unicorn 封装了一层模拟执行 API,适合批量处理解码类样本。
工欲善其事,必先利其器。这些工具不需要一次性全部装好,我建议你先用 gdb + Ghidra 组合跑通 double_code 的完整流程,再逐步加其他工具。
5.3 进阶方向:从“会分析”到“会编写”
分析了一堆 shellcode 之后,我强烈建议你试着写一段自己的双层 shellcode。原因很简单:只有自己踩过“编出来的代码跑不起来”的坑,才能真正理解分析时某些特征为什么存在。
写 shellcode 时要注意几个常见问题。一是避免空字节。有些漏洞利用场景会把 shellcode 当作字符串拷贝,一旦出现\x00就会被截断,直接导致执行失败。二是保持位置无关。不能用绝对地址访问数据,要尽量用call-pop或lea rip相对寻址来拿地址。三是理解系统调用约定,Linux x64 下参数顺序是 rdi、rsi、rdx、r10、r8、r9,和普通函数调用不太一样,写错了就是段错误。
我个人的练习方法是:先用 C 写一个能输出字符串的程序,再用汇编重写,最后手工提取机器码并加上一层 XOR 编码器。这个过程走下来,你对 double_code 这类题的理解会从“照着步骤做”变成“知道每一步在解决什么问题”。
说实话,我第一次做这类题时也习惯全程静态硬啃,后来被一个真实的恶意样本折磨过之后才彻底转变思路:把执行权交给调试器,反而能更快拿到真相。技巧其实不复杂,核心是改变思维——有加密就有解码,解码发生的地方永远是最值得下断点的突破口。你在分析时不需要害怕那些看不懂的字节流,先把执行流程跑通,把真正执行的代码取出来,剩下的分析基本就是常规工作。希望这篇记录能帮你少走一点弯路。