ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从SMC到shellcode:CTF逆向double_code动态分析实战

从SMC到shellcode:CTF逆向double_code动态分析实战 HDCTF 2023 的 double_code算是我整理 shellcode 逆向知识时绕不开的一道题。题目名里的 double 基本就把考点说透了——程序里会有一段代码先做引导真正输出 flag 的 shellcode 藏在后面需要你去把它从内存里“捞”出来再分析。很多第一次接触这类题的朋友会栽在一个地方花大量时间试图纯静态把整段字节流解出来结果越解越乱。其实这类题有更高效的路子就是用调试器让它自己跑完解密过程再直接 dump 内存。这篇文章我打算从拿到二进制文件开始把 shellcode 分析常用的思路、SMC自修改代码识别方法、动态调试取码的完整流程都过一遍。目标是让刚接触 CTF 逆向的新手看完之后能自己复现一遍这类“数据段藏代码、运行时再解码”的题目。文末我还会把分析过程中踩过的坑整理成清单方便你以后直接对照查。1. 拿到题目先别急建立分析基线1.1 文件类型与保护机制排查不管题目多花哨我拿到二进制文件后的第一轮排查基本固定先看文件信息再看安全属性最后看节区特征。对 double_code 这类题这三个检查已经能透露出不少关键信息。file double_code checksec --filedouble_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 [rbpvar_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, [ripxxx]这样可以保证放到任何内存位置都能跑。机器码短小精悍但往往包含大量异或、按位运算特别是加载器形式的 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 识别“写入后执行”模式SMCSMC全称 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。你不要直接跑这个样例本身而是要把它当分析方法练手——理解了这个小例子再回头分析题目就顺了。; 迷你双层shellcodeGNU汇编语法 ; 这段代码先解码自身后面的数据再跳过去执行 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 [rsircx-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然后随便下个断点让程序启动起来我习惯先断在 mainb main r启动后反汇编 main找到那个跳转到未知地址的调用。注意看有没有call rax、jmp [reg]或者对栈上指针的调用disassemble main确认目标地址后在跳转指令上下一个断点。比如跳转指令地址是0x401234b *0x401234 c等程序停在这个断点时先用si单步执行跳转进入第一层 shellcodesi x/20i $rip这个时候你会看到解码循环的汇编代码。别急着逐条走过整个循环那样太慢。你可以观察循环变量寄存器我习惯的做法是在循环最后一条跳转指令处再下一个临时断点。比如循环是loop decode_looploop指令既会判断计数寄存器 RCX 是否为 0又负责跳转那就在它刚执行完、跳出循环后的下一条指令处断下b *0x401300继续执行程序会跑完所有解码迭代停在跳转到 payload 之前的那个位置。这时查看寄存器尤其是 RSI、RDI 这种存放目标地址的寄存器然后确认解码结果x/30i $rsi如果这 30 条指令已经不像是随机字节而是有意义的汇编说明解码成功。这时候就可以把第二段 shellcode 从内存里导出来dump binary memory /tmp/second_shell.bin $rsi $rsi0x200我建议 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 号名称参数含义0readrdifd, rsibuf, rdxcount1writerdifd, rsibuf, rdxcount2openrdipath, rsiflags, rdxmode10mprotectrdiaddr, rsilen, rdxprot59execverdifilename, rsiargv, rdxenvp分析第二段 shellcode 时先找syscall指令再往上看它之前对寄存器做了什么赋值。如果看到mov eax, 1; mov edi, 1; lea rsi, [ripstr],尤其是那个lea rsi, [ripstr]说明它在准备一个相对地址的字符串缓冲后面大概率就是把这个缓冲区的数据写到 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 如果开启了 PIEPosition 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 [rsircx], 0x66密钥是立即数直接能看到。mov al, byte ptr [rdi]; xor byte ptr [rsi], al; 类似流密码密钥来自另一段缓冲区这时候需要跟踪那个缓冲区的内容来自哪里。如果程序把密钥藏在更深的地方你可以先随便运行到解码结束直接对比解码前后的内存差异。具体操作在解码前记下目标地址的十六进制内容等解码完成后再次查看同一地址前后两个值按字节异或结果就是密钥。这个方法不需要理解任何算法逻辑实测非常稳。还有一个实用技巧用 Python 脚本对密文做爆破。如果猜测是单字节异或而密钥范围未知直接写个循环尝试所有 256 种可能用可打印字符率和反汇编成功率判断哪一组最像明文代码。但我不建议一上来就爆破先通过调试器观察爆破只是兜底手段。4.4 常见排查速查表我把前面提到的排查思路整理成一张表方便实际分析时快速定位问题。症状可能原因处理方法断点停在未知区域内存是乱码断点下在解码前延后断点位置使用 until 或跳转目标地址IDA 地址和 gdb 不一致开启了 PIEgdb 关闭随机化或使用基址换算第二段 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 这类题的理解会从“照着步骤做”变成“知道每一步在解决什么问题”。说实话我第一次做这类题时也习惯全程静态硬啃后来被一个真实的恶意样本折磨过之后才彻底转变思路把执行权交给调试器反而能更快拿到真相。技巧其实不复杂核心是改变思维——有加密就有解码解码发生的地方永远是最值得下断点的突破口。你在分析时不需要害怕那些看不懂的字节流先把执行流程跑通把真正执行的代码取出来剩下的分析基本就是常规工作。希望这篇记录能帮你少走一点弯路。
返回列表