接手逆向题的时候,很多人的习惯是一上来就扔进调试器,遇到跳转就改标志位,遇到调用就单步进去。这条路不是完全没用,但非常容易把自己绕晕,尤其是当你面对的是一个带反调试、带自定义加密算法、还包了好几层脚本的样本时。最近我把 [FlareOn2]very_success 和 [FlareOn3]Challenge1 这两道经典题目重新翻出来完整做了一遍,发现它们摆在一起刚好形成了一条特别舒服的入门进阶曲线:第一道题逼你把静态分析和动态调试结合起来,第二道题逼你从二进制世界切换到脚本世界。这篇文章不是答案速递,而是完整记录我从拿到一个陌生样本开始,怎么摸清文件底细、定位核心算法、绕过反调试、追完整条多层调用链的思考过程。如果你正准备从零开始学逆向,或者已经在逆向门口徘徊了一段时间,这篇内容应该能帮你少走不少弯路。
1. 为什么把这两道题放在一起拆
1.1 先看题目形态和考点分布
这两道题虽然都顶着一个系列赛事的外壳,但它们的侧重点完全不同。我习惯在动手之前先列一张表,把自己要面对的东西是什么形态、大概要考什么、容易卡在哪,一次性摊开。
| 题目代号 | 文件形态 | 核心考点 | 最容易卡住的地方 |
|---|---|---|---|
| [FlareOn2]very_success | 32位PE可执行程序 | 反调试识别与绕过、TEA变体算法还原、固定密钥提取 | 标准delta被魔改,直接搜常量搜不到;动态调试会被反调试干扰 |
| [FlareOn3]Challenge1 | .NET程序加多层内嵌脚本 | 托管代码反编译、资源提取、脚本调用链追踪 | 被"最外层是.NET"误导,忽略真正干活的脚本层 |
先看 very_success 这个样本。它的文件形态是一个32位Windows GUI程序,没有加壳,用常见的MSVC工具链编译。这意味着我不需要先去处理脱壳这个环节,可以把精力全部放在逻辑分析上。但没壳不意味着好欺负,它把反调试检测和加密验证绑定在一起,如果你在调试器里正常往下跑,它会直接把你带进一个错误的执行路径,让你以为是自己算法没搞对,其实是被反调试摆了一道。
Challenge1 又完全是另一种画风。如果只看到文件后缀或者用PE识别工具瞄一眼,很容易以为它是一个普通的.NET程序,简单看一下就能结束。但实际上,那个.NET主程序只是一个壳,它从嵌入资源里取出脚本,然后依次调用脚本引擎去执行。真正产生flag的逻辑分布在多层脚本之间。这个设计对刚接触恶意样本分析的人来说非常真实,因为大量真实世界的加载器就是这种结构:最外层是一个看似正常的编译程序,里面的资源全是脚本和加密数据。
1.2 两题之间的难度是怎么衔接的
有人可能会觉得,既然Challenge1是"第一届之后的第三届题目",那难度一定更高。但实际做下来,它的门槛不在二进制底层,而在"你会不会跳出舒适区"。
very_success 的知识点集中在PE结构、Windows调试API、C语言编译产物、加解密算法识别。这些都是纯二进制分析的基本功,做完它,你会对"程序加载后怎么被系统调用""反调试API怎么影响执行流""循环移位和异或组合在汇编里长什么样"有具体体感。Challenge1 的知识点则集中在托管程序分析、脚本语言互操作、资源提取与还原。它不需要你理解堆栈平衡,也不需要你关心寄存器窗口,但需要你在没有源码的情况下,通过反编译和动态调试快速判断"哪个函数才是真正的入口"。
把这两道题连续做完,你会自然形成一个完整的工作闭环:先静态梳理程序骨架,再动态验证关键分支,最后把分析过程脚本化复现。这个闭环在后面的真实样本分析里会反复用到。
2. 拿到 very_success 后,先做静态摸排再谈其他
2.1 不要急着下断点,先把文件底细摸清
我见过不少新手拿到样本的第一反应是直接打开调试器,然后对着入口断点一路F8。这其实非常低效。入口点附近的代码往往是编译器生成的初始化代码,跟核心逻辑一点关系都没有。正确做法是先做一轮静态摸排,把文件的基本信息记录清楚。
我会先做三件事:第一,用文件识别工具看PE位宽、子系统、编译特征;第二,用字符串提取工具把文件里的可打印字符串全部拉出来;第三,用反汇编工具看导入表。对 very_success 这个样本,摸排结果是32位PE、GUI子系统、MSVC编译、没有壳。这个结果帮我确认了两件事:一是动态调试时可以预期程序可能通过窗口或者对话框来交互,二是既然没壳,导入表里看到的函数基本就是程序真正调用的函数,我可以放心信任导入表。
字符串提取这一步提供的信息量往往被低估。哪怕程序对关键字符串做了简单加密,提示语句通常还是会以明文存在。把这些字符串当作路标,从"输入提示""成功提示""错误提示"这些目标串出发,用交叉引用功能去定位引用它们的代码,通常能直接把你带到验证逻辑附近。very_success 里就有类似的提示串,顺着它跳过去,基本就找到了加密与比较的那段函数。
2.2 导入表里已经写明了反调试的存在
在整理导入表的时候,我很确定自己看到了跟调试检测相关的API,同时还有一个高精度的计时相关函数。看到这两样东西同时出现,可以提前判断这个程序大概率有两种反调试手段:一种是直接通过调试API返回结果来判断是否被调试;另一种是通过测量代码前后执行的时间差来检测单步跟踪和断点。
知道这一点之后,我的动态调试策略就不是"一路硬跟",而是先把静态反汇编里跟这两个API相关的调用点全部标出来。具体做法是:在反汇编工具里定位导入函数的调用位置,然后看这些调用的返回值被用在哪里,通常是紧跟一个测试指令和一个条件跳转。把这些跳转全部找出来,等会儿动态调试时就可以有针对性地处理,而不是等到被干扰了才回头查。
静态摸排做到这里,我已经可以画出一张粗糙的逻辑图:程序入口附近有一个读取输入的动作,然后进入一个包含反调试检测的模块,再往后是一个看起来像加解密算法的函数,最后是比较和输出。这个图不需要精确到每一条指令,但它能保证我在后面动态调试时不会迷失方向。
3. 核心算法是TEA,但delta被改掉了
3.1 TEA加密的指纹长什么样
顺着验证分支追进去,代码陷入了一组循环:左右两个32位块被反复更新,每轮都有一个累加变量在增加,然后做了左移右移、异或、加法的组合。看到这个结构,我第一反应是TEA。TEA是Tiny Encryption Algorithm的简称,特点是分组大小64位,密钥128位,循环次数通常为32轮,每一轮的操作序列非常整齐。
为了对比,我把标准TEA实现的大致结构贴在下面。实际题目里不会长这样,但如果你脑子里有这个参照物,就很容易在反汇编里认出它的影子。
void tea_encrypt(uint32_t *v, uint32_t *k) { uint32_t v0 = v[0], v1 = v[1]; uint32_t sum = 0; uint32_t delta = 0x9e3779b9; for (int i = 0; i < 32; i++) { sum += delta; v0 += ((v1 << 4) + k[0]) ^ (v1 + sum) ^ ((v1 >> 5) + k[1]); v1 += ((v0 << 4) + k[2]) ^ (v0 + sum) ^ ((v0 >> 5) + k[3]); } v[0] = v0; v[1] = v1; }这个算法的指纹很明显:左右两半轮流更新,key切成了4个32位段,sum变量每轮固定累加delta,加密和解密是同一个算法的镜像。在汇编层面,你会看到一连串的shl、shr、xor、add指令,并且这段指令在循环体内重复出现。只要见过一次,后面再遇到就不容易认错。
3.2 识别变体时的关键操作:不要死搜标准常量
大多数入门资料讲到TEA时都会提到标准delta是0x9E3779B9,这个常量也被戏称为黄金分割比相关的一个值。于是很多人拿到题目后第一件事就是去二进制里搜这个常量,结果搜不到,就开始怀疑自己判断错了。
在 very_success 这道题里,标准delta确实被改了。程序用的是一个非标准的累加步进值。如果只依赖常量搜索,很容易在这个环节卡住。我的做法是绕开常量搜索,直接从密钥的加载点入手:在代码里找到引用那4个32位密钥字的位置,下硬件断点,然后让程序运行到那里。因为密钥在加密前一定会被加载到寄存器或栈上,我们只要在加载点观察寄存器,就能把密钥和sum的步进值一次抓全。
这里有个经验可以分享:遇到自研变体的加密算法,先别急着用标准库函数去套,因为标准实现里往往硬编码了轮数和delta,套错了只会得到乱码。更好的方式是自己在脚本里写一个参数可调的TEA解密函数,把delta、轮数、密钥都作为参数传进去。这样即使题目换了delta或者缩减了轮数,我只需要调整参数就能继续解,不需要反复改代码结构。
def tea_decrypt_block(block, key, delta, rounds=32): v0, v1 = block[0], block[1] k0, k1, k2, k3 = key mask = 0xFFFFFFFF # 求和时要模拟32位无符号溢出 total = (delta * rounds) & mask for _ in range(rounds): v1 = (v1 - (((v0 << 4) + k2) ^ (v0 + total) ^ ((v0 >> 5) + k3))) & mask v0 = (v0 - (((v1 << 4) + k0) ^ (v1 + total) ^ ((v1 >> 5) + k1))) & mask total = (total - delta) & mask return [v0, v1]在样本里提取到了密钥数组和delta之后,把这个脚本跑一遍,很快就能确认明文是不是可读字符串。这一步的结果也反过来证明了我之前的判断:核心算法是TEA,但它的delta被替换成了另一个自定义值。破解思路本身不需要很高深,关键是你愿不愿意多做一步"查参数"而不是死背标准值。
4. 动态调试里的反调试:三种典型检测的绕过思路
4.1 改标志位还是patch代码
第一次动态运行 very_success 的时候,我直接在调试API的调用点下了断点,然后观察返回值和分支走向。程序用的第一种检测方式是通过调试API的返回值判断自己是否处于被调试状态。如果检测到被调试,它会走一条假路径,让你以为是自己解密逻辑没写对。
面对这种检测,处理方式有两种。第一种是运行时修改标志位或者返回值:断在比较指令之前,把寄存器改成0,让条件跳转走向正常分支。第二种是静态patch:直接在反汇编里把调用点改成mov eax, 0,或者把后面的条件跳转改成无条件跳转,保存成新的二进制文件再运行。
我个人的建议是,在CTF场景下直接用调试器改标志位就够了,不用急着patch文件。因为后续可能还要多轮验证,在调试器里保留原始文件的完整性会方便回退。但如果你是准备把这道题当作恶意样本分析练习,那可以尝试第二种方式:patch一个副本出来,感受一下怎么修改指令字节才能让程序稳定走通。
4.2 时间差检测比想象中常见
绕过第一种检测之后继续运行,没过多久就发现了第二种反调试手段:程序在解密前记录了一次时间,解密过程中如果时间差超过某个阈值,就直接输出失败信息。这种手段针对的就是调试器单步跟踪,因为单步跟踪会让代码执行速度慢几个数量级,很容易被识别出来。
我看这个思路的思路是:用条件断点配合日志输出,不要在循环里傻傻地单步。具体做法是在计时结束之后的比较指令处下断点,观察两个时间值,然后手工把比较结果改成"时间差正常"。如果你用的是支持脚本的调试器,还可以直接把该跳转分支写成固定跳转。
这里要强调一个细节:在题目里当然可以简单粗暴地改掉判断,但在真实样本分析中,你应该多想一想它是用什么API计时的。如果是高精度计时,那它可能不是想检测单步,而是想检测断点修改内存带来的缓存变化。这时候单纯patch往往不够,还需要配合内存断点的使用方式,但这个属于进阶话题,这里先不展开。
4.3 怎么确认反调试真的绕过了
绕过反调试之后,很容易遇到一个尴尬问题:你并不知道自己是不是已经成功进入了正确分支。我的习惯是看标志性输出。如果程序显示进入了解密后的结果处理逻辑,那基本说明前面的反调试判断已经被摆平。如果没有,那就回头检查跳转是否改对了位置。
另一个更可靠的确认方式是在关键解密函数下条件断点,把函数的参数直接打印出来。比如我可以在TEA解密函数的入口设置一个条件断点,让它每次进来时输出传入的数据块和密钥。这样我不但能确认程序已经进入核心算法,还能顺便把加密用的原始数据抓出来,省得之后再回去查找数据来源。
5. 从二进制切到脚本世界:Challenge1的多层调用链
5.1 .NET主程序只是个加载器
做完 very_success 之后立刻转到 Challenge1,第一感受是"画风突变"。
用.NET反编译工具打开 Challenge1 的主程序之后,我几乎没有看到什么复杂逻辑,Main函数非常短,无非是读取嵌入资源、初始化某个脚本引擎、调用引擎执行脚本。很多人做到这里会以为题目结束了,但实际上,真正的内容全部被藏在了资源里。
我建议你在这一步做的事情很简单:把所有嵌入资源dump出来,逐个看资源名和文件头。Challenge1 的资源里有不止一个脚本文件,有JavaScript,也有其他脚本语言的代码。看到资源名的时候,基本就能猜出这是一条"程序里套脚本,脚本里再调脚本"的调用链。
5.2 逐层剥洋葱的通用分析套路
分析这种多层脚本链,最忌讳的是直接读整段代码,因为脚本之间会互相生成字符串,直接从头读很难理清关系。我的做法是"分层剥洋葱":先把最外层的脚本单独保存,找到它最后调用了什么函数、执行了什么字符串,然后把执行结果dump出来,这个结果往往就是下一层脚本的内容。
具体来说,可以分三步走。
第一步,识别脚本类型。看文件头、扩展名和脚本里的特征函数。JavaScript看有没有eval、Function、ActiveXObject;PowerShell看有没有-enc、IEX;Python看有没有exec、base64.b64decode。这些特征函数说明这一层脚本做的事情很可能是"解密字符串并继续执行"。
第二步,做探针而不是瞎猜。在每一层脚本入口加一段打印逻辑,把变量值输出来。比如在JavaScript里可以用console.log,在Python里可以用print。很多时候中间字符串已经是一个可读的脚本了,只是被包了一层Base64或者反转,你打印出来就什么都明白了。
第三步,注意编码坑。脚本从不同层传下来时,经常会发生UTF-16LE和UTF-8的混淆,尤其是从.NET资源里取出来的字符串,默认可能是UTF-16LE。如果你解码后看到一堆夹杂空字节的ASCII字符,不要怀疑算法错了,先转换编码再继续。
5.3 一个脚本链示例:看似无关的片段如何串起来
用Challenge1里那种典型结构举例,最外层脚本可能只是一段看似无关紧要的Base64字符串拼接,中间层脚本里再定义一个函数,函数里执行了一段PowerShell命令,那段命令又是经过反转和十六进制编码的。如果你一层层手算,效率非常低;正确做法是把每一层当作一个黑盒,先跑完这一层拿到输出,再对输出做类型判断,再决定下一步怎么解析。
import base64 def try_decode(s): # 先看是不是base64 try: dec = base64.b64decode(s).decode('utf-8', errors='ignore') if dec.isprintable(): return dec except Exception: pass # 再看是不是反转 rev = s[::-1] if rev.isprintable(): return rev return None这个模板虽然简单,但在初期非常管用。它能帮你快速筛选大量中间字符串,把注意力集中在真正需要精读的脚本逻辑上。Challenge1 之所以对新手有价值,就在于它逼你用这种"管道式"思维去分析问题,而不是逐行读代码。
6. 把整个分析流程沉淀成一个脚本化工作流
6.1 为什么一定要写脚本收尾
手工调试拿到一个flag,只能证明这道题被你解出来了。但如果把整个分析流程脚本化,你得到的是可以复用的能力。下次遇到一个类似的TEA变体,你可以直接把解密脚本改改用;下次遇到一个多层脚本样本,你可以直接套用分层解码模板。
以 very_success 为例,我会把最终脚本设计成模块化的:一个函数负责接收输入文件,一个函数负责定位密钥,一个函数负责解密,一个函数负责输出校验。这样即使样本换了,我也可以把中间步骤替换掉,而不需要推倒重来。
6.2 没有符号、没有源码时怎么减少试错
这两个题目在分析过程中都没有符号信息,所有函数名都是我根据行为后标上去的。面对这种环境,我有几个习惯可以分享。
第一个习惯是善用交叉引用。在看一个跳转指令时,不要只盯着它本身,要看看哪些地方跳转到了这里,哪些地方引用了同一块数据。多个引用指向同一块数据,往往说明这块数据是关键状态或密钥。
第二个习惯是遇到动态解密时不要硬追。有些解密的中间数据只在运行时存在于内存里,从静态代码里完全看不出来。这时候可以把某一时刻的内存区间dump下来,用脚本分析。Challenge1里其实也用到了类似原理,只不过dump的对象从内存变成了脚本解释器的输出。
第三个习惯是建立排查顺序。脚本输出不对的时候,先检查编码,再检查字节序,再检查密钥顺序,最后才怀疑算法理解错了。如果这个顺序颠倒过来,你很容易在完全正确的算法上浪费几个小时,只因为忘了大小端问题。
6.3 具体脚本结构参考
下面这段是一个综合脚本的骨架,它不完全属于某一道题,而是把两步分析结合起来:先把二进制TEA解密流程跑通,再把脚本链解码流程接在后面。
def solve_binary_sample(filepath): key = extract_key_from_file(filepath) delta = extract_delta_from_file(filepath) encrypted = extract_ciphertext_from_file(filepath) plain_parts = [] for block in split_blocks(encrypted, 8): plain_parts += tea_decrypt_block(block, key, delta) return join_bytes(plain_parts) def solve_script_chain(script_path): current = open(script_path, 'r', encoding='utf-16-le').read() for _ in range(MAX_DEPTH): current = try_decode(current) if looks_like_final_text(current): return current current = detect_and_execute_next_layer(current) raise RuntimeError("exceed max script depth")把这两套逻辑放在同一个脚本里,再配合日志输出,你就能一次性把从PE文件到最终明文的完整链条跑通,中间每一步都可以单独验证。
7. 复盘:这两道题练完,能带走什么
7.1 这两类手法在真实样本里都找得到
很多人觉得CTF题跟现实脱节,但实际上,生活远比想象中更接近CTF。very_success 里的反调试和时间差检测,在真实恶意软件里非常常见,尤其是那些需要长期驻留的样本,几乎都会检查自己是否运行在分析环境里。Challenge1 里的多层脚本调用链,更是在勒索软件加载器和窃密木马加载器里频繁出现:外层是编译程序,内嵌脚本动态生成下一阶段负载,每一层都只揭示下一层需要的少量信息。
从这个角度看,做这两道题的意义不只是为了"通过某个挑战",而是在一个安全的、确定有解的环境里,把真实分析中可能遇到的核心问题先演练一遍。等到你在真实样本里重新看到IsDebuggerPresent,或者看到脚本引擎被恶意利用时,你会有一种"这题我见过"的底气。
7.2 给新手的下一步训练路线
如果你把这两道题完整做下来,并且真的理解了每一步为什么这么做,下一步就可以尝试几条线路:一是找更多带反调试的CrackMe来练动态调试能力,重点练习如何在不patch文件的情况下通过标志位修改绕过检测;二是找一些多级脚本样本做追踪练习,重点练习资源提取和分层解码;三是开始接触带壳样本,把内存dump和模拟执行这两个技能补上。
7.3 一个小建议
做题不是目的,养成分析习惯才是。每次拿到新样本,先问自己三个问题:它的执行骨架是什么?数据从哪里流到哪?哪些分支是我可以暂时忽略的?把这三个问题回答完,再决定要不要下断点、要不要写脚本。
做完这两道题,我自己最深的感受是:逆向工程里最宝贵的不是某一条命令或者某一个工具,而是"我猜这里是这样,怎么去验证"的循环。very_success 逼我把验证动作放到了调试器里,Challenge1 逼我把验证动作放到了脚本解释器里。两轮下来,面对一个新样本,你会不自觉地先去梳理执行骨架,而不是一头扎进细节。把这个习惯养成后,后面再遇到真正混淆的样本,至少不会两眼一抹黑。