这篇的边界先划清

第五次课的作业,四条课程目标:

1、了解带脚本的安装包基本结构和执行流程特点。
2、获取内部资源解压算法并还原安装包内部的脚本。
3、寻找该类安装的特征识别算法。
4、通过代码实现包识别和解包流程。

对象是真实恶意样本(杀软报 BAT/Ransom.BleachGap 一类的勒索投放器)。所以这篇的取舍是明确的:

  • 函数地址、密钥、算法、手法,全都不脱敏。 恶意软件没有软件版权,把 key 藏起来只会让这份分析和这份代码都无法复核,那才是真的没意义。
  • 载荷内容一行不放。 解出来的脚本、它的标签名、具体命令行,一律不贴。
  • 这篇也不放截图。 OD 和 IDA 的截图恰恰是脚本内容最容易一整屏漏出去的地方,而讲方法不需要那个。

样本本体在仓库里,是 tagent 签名、用我公钥加密的容器,只有我能解开,但任何人都能验证它是我交付的那一份。

一、从行为倒推断点

拿到包,不要从代码第一行开始读。先问一句:它总得干某件具体的事,那件事必然经过哪个 API?

安装包会执行脚本 —— 这是这类包的定义。所以第一个断点打在 ShellExecute 上。

一断就中,脚本直接出现在参数里。这一步没花什么力气,但它把后面所有工作的方向定住了:我已经知道"脚本"是这个包的控制流,接下来只要搞清楚脚本从哪来、怎么被解出来,而不是在一堆释放逻辑里瞎撞。

这是我最想留下来的一个习惯:**从"它必须做什么"倒推 API,而不是从代码里正推行为。**前者一次断点就有结论,后者可能读三天。

二、拿到加密 buf

脚本是资源,那资源是怎么进内存的?断 loadResource。

断下来之后做堆栈回溯,就拿到了加密状态的 buf。到这里,“包内部信息存储结构"这条目标已经开了个头:资源不是明文躺在包里,中间有一道变换。

三、硬件断点为什么放两个

要跟踪这块 buf 什么时候被读、被谁读,用内存访问断点(硬件断点)。

这里有个坑:**buf 可能分段处理,程序也可能跳过开头、从中间开始读。**只在一个位置设一个断点,很容易出现"断在中间、前面已经解过一段、你没看到"的情况,然后你会以为算法少了一步。

我的做法是同时放两个:一个在 buf 头部,一个在中间某个随机位置。两个断点的命中顺序和次数会互相印证,分段和跳读就藏不住了。

这个手法很便宜,但省掉的时间是成倍的。

四、判定"就地解密"的三条证据

断下 buf 之后观察到三件事,凑在一起就足够下结论:

  1. 密文 buf 反复被断读写 —— 不是读一次就丢,而是原地进出;
  2. 逐字节被修改为明文,并覆盖掉密文 —— 输出位置就是输入位置;
  3. 解密完成后密文就不再被需要 —— 所以作者不需要额外的输出缓冲。

第 3 条尤其值得记:它解释了"为什么要就地覆盖”。因为解密后密文已经没有价值,空间充分利用而已。当时我看到明文紧随密文出现在同一块内存里,第一反应是"这写法有点浪费",想通第 3 条之后才明白这是刚好相反 —— 是最省的写法。

单看任何一条都不足以定论,三条一起才把"就地解密"钉死。

五、用代码形状区分算法和流程

往上追的时候会遇到一个判断题:眼前这个函数,是算法还是流程?

我的判据是看代码形状:

  • 算法:局部变量多、有循环嵌套、有查表或位运算、控制流密集,读起来"绕";
  • 流程:平铺的调用序列,一行一个动作,中间夹少量判断,读起来"直"。

当时追到一层全是子函数的地方,我的笔记原话是:

平铺的代码不像是算法,更像流程。

这个直觉后来被证明是对的 —— 那片"平铺"的代码就是资源释放的主干。在没源码的情况下,代码形状是比函数名更可靠的信号,因为函数名可以被抹掉,但控制流的密度抹不掉。

六、用一个二值实验回答结构问题

追到 sub_403855(文件释放)和 403CD7(单文件释放)时,卡在一个结构问题上:

脚本的运行在不在这个函数里面?还是 sub_403855 只负责文件的释放?

这个问题不需要通读函数来回答。第一节已经埋好了答案的验证手段 —— 我在 ShellExecute 上有断点。

所以做法是:跳到返回处,看 ShellExecute 断不断。

没断。 于是结论立刻成立:执行不在这个函数里,还得往上找。

一个二值实验,替掉了半天的阅读。这类"用一个已经布好的探针回答下一个结构问题"的机会,在逆向里比想象中多,前提是你一开始就按后面的问题去布点。

七、函数表定型

反复断、推翻、重猜之后,主流程定成这张表(地址不脱敏):

403DF3   文件循环、文件执行调用 —— 主流程算法
403855   未知,只被调用了一次
403CD7   以资源为单位的解密流程算法  →  调用 004026B8 加载资源
4011DE   以 <不确定> 为单位的解密流程算法
00403D51 中途记录的本层地址

两个附带的实操记录:

  • 403855 到现在没定性,因为它只被调用一次、且不影响还原结果。分析允许留白,不必把每个函数都命名。
  • 为了让 OD 能顺利跳回去,我把一些 sub_ 删掉了(笔记原话「sub 删一删方便 od 跳过去」)。这是在调试器里改反汇编显示,不改内存里的真身。

八、切入点:挑最线性的那条

有了三个值得看的函数,先喂给 AI 看一遍 —— 2024 年 6 月的结论是「ai 不成器,还是自己分析好了」。

(两年后我让 AI 干这类活已经顺手很多了,但那次确实不行。)

真正选切入点的判据是这句:

逐个资源解密的流程非常线性,感觉很适合作为切入点。

**线性 = 状态少 = 可以逐段验证。**如果一上来啃那个带文件循环的主流程,你会同时面对"循环边界、资源定位、解密、释放路径"四件事,出错根本不知道错在哪一步。挑最线性那条进去,大概二十分钟摸清了三件事:

  1. buf 从哪来 —— 就是资源本身的内容;
  2. buf 多大 —— 长度参数直接给;
  3. 关键解密函数是哪个 —— 一个函数、三个参数。

第三个当时让我怀疑自己看错了:

就一个函数,传入三个参数,就解密了?这也太简单了吧?

九、算法是 RC4,密钥是固定的

那个函数的第三个参数 lpWideCharStr 值固定,是运行期从栈上抓到的:

0018FEE8  01ED4888  L"A2CD6BDBA138C7E2077C181F3390FD48"

当年的笔记只写了「看起来是解密的密码」和「又是密码学的部分了」,然后跳过了。这次我把它补完:那个算法就是 RC4。

判据是结构,三处对得上:

  • 两张 256 项表,一张 S[i] = i 初始化,一张 K[i] = key[i mod keylen] 循环填充;
  • 第二个循环是标准 KSA:j = (j + S[i] + K[i]) & 0xFF,然后交换 S[i]、S[j];
  • 第三个循环是标准 PRGA:i、j 推进并交换,取 S[(S[i]+S[j]) & 0xFF] 与数据异或 —— 而且解密函数是原地异或,正好对上第四节"就地解密"的观察。

参数里那个 v10 = 32 就是密钥长度,和上面那串 32 字符完全一致。

这次还做了两件实测验证(原文没有):

  1. 拿包体里抠出的一个 174 字节资源,用 RC4 + 这把 key 解,可打印比例 99%,解出来的是一段批处理子程序(作用是调外部程序、用临时文件捕获它的 stdout)。密钥和算法成立。
  2. 把两个资源密文拿去在包体里做字节级搜索,都原样命中(偏移 0x18088 和 0xf82c0)。这证明那 1MB 的 PE 就是父包本身、两个 .bin 是从它里面抠出来的资源,而不是并列的另一堆文件。

顺带一个观察:这把 key 在包体里搜不到明文。所以它是运行期从解密函数的参数上抓的,不是静态扫出来的 —— 这也是为什么必须先有第八节那个"找到最线性的函数"。

十、“感觉要遇到坑了”,然后真的遇到了

解密函数内部还有一层可疑的东西:这个程序自己定义了一套 malloc 和 free。

创建:__stdcall sub_40DB6A(int a1, int a2, int a3, int a4, int* a5)
释放:BOOL    __stdcall sub_40DCBD

当时的判断是「有点在意,但是难以推测其中的信息」,而且注意到解密主体并没有用这些特别的函数、只在完成后才调用释放,于是决定先不探究,跳过,用普通函数替换掉。

然后写下了一句非常值钱的话:

感觉要遇到坑了。

坑确实来了,但不在那个地方。复刻代码跑出来是:

解密寻找的是对的,但是用了 sizeof 来读文件,导致出问题了。

问题这一行:

mallocBufAndReadFile("E2945F56F524E04826EB15DA074392A7.bin", buf, size);
myDecryptRes2((BYTE*)buf, sizeof(buf));   // ← 错

buf 是 char*,sizeof(buf) 是指针宽度(4),不是文件长度 size。所以只解密了开头几个字节。

这个 bug 阴险的地方在于它的症状完全不像出错:

  • 输出文件长度是对的(RC4 是流密码,长度不变);
  • 程序不报错、不越界(读 4 个字节当然读得到);
  • 输出内容看起来仍是随机数据 —— 因为除了头几个字节,其余根本没被处理。

从文件大小上判断"解出来了没有",是零信息的。

实物对照我这次在仓里翻到了:当年同时留下了失败产物和成功产物两份,都是 6,840 字节,一份开头是乱码、另一份开头是 @echo off。长度完全一样。

正确写法就是把 sizeof(buf) 换成 size。至于那套自定义 malloc/free,最后确实和这个 bug 无关 —— 但"这里有个不理解的东西,先记一笔"的直觉值得留着,它不一定指向真正的坑,可它让你在下结论前多验一遍。

「你读到的长度不等于实际存在的长度」这件事,同一年我在 IDA 身上也撞过一次:x86 下 char tmp[20] 被反编译成 v5(8)+v6(4)+v7(4)+v8(1)=17,报给你的变量边界少了 3 字节。那篇单独记在《ida 的不可靠性简易探究》里:https://t1mmoc.github.io/posts/2026-10-16-ida-size-probe/

十一、鸽、回啃、以及"原来已经做完了"

后面的节奏很真实:

  • 2024-06-03 布点、追流程;
  • 2024-06-05 摸清解密、拿到 key;
  • 2024-07-07 复刻、踩 sizeof;然后写下「让我再鸽一会(鸽了好久)」;
  • 2024-08-10 回来继续啃,分析了两个 for、还差最后一个,笔记里说「学长说的删减应该就在这个地方了」;
  • 然后一句:「然后发现原来已经算做完了」

最后这个转折挺常见的:**你以为还有难点没解决,其实产出早就对了,剩下的只是你没去核对判据。**如果当时先做一遍"输出开头是不是 @echo off“这种最便宜的检查,就不用再鸽两个月去啃第三个循环。

顺带说,第三个循环(PRGA 交换回去那段)确实没有可删的 —— 它就是 RC4 本身。理论上也一样:本地混淆无论套多少层,只要它还是 RC4,套娃不增加信息量。

收尾

可以搬走的六招:

  1. 从"它必须做什么"倒推断点,不要从第一行正推。ShellExecute 一次就定住了方向。
  2. 内存断点放两个(头 + 中间随机位置),分段和跳读才藏不住。
  3. 一个结论要凑够多条独立证据再下。“就地解密"是三条观察合起来才成立的。
  4. 用代码形状区分算法与流程:局部变量密度和循环嵌套比函数名可靠。
  5. 把已经布好的探针当二值实验用:“这个函数里到底执行不执行"这种问题,往往不用读代码就能答。
  6. 挑最线性的那条路切入,一次只面对一个未知量。

再加两条纪律,都是这次踩出来的:

  • **别用文件大小判断解密对不对。**流密码下长度不变,这个判据是零信息的。要看内容开头。
  • **“有个东西我不理解"要记下来,但别指望它就是你踩的坑。**记下来是为了下结论前多验一遍,不是为了猜中原因。

代码与样本:https://cnb.cool/t1mmoc/tspace/pwngore/h9installer-script

样本明文不进仓库,仓里那份是 tagent 签名、我公钥加密的容器,签名指纹和明文 sha256 都写在 README 里,可以自行核验交付来源。