上周处理一批从公开威胁情报库拉回来的JS样本时,我差点把键盘拍碎。正则匹配URL、提取API路径的老套路全部失效:控制流平坦化把函数调用关系搅成一锅粥,字符串加密把所有敏感的API名字变成了一串下标引用。我被迫把AST和LLM大语言模型组合起来,搭了一套能穿透现代代码混淆的“组合透视镜”——先用AST把代码拆成结构化骨架,再用LLM去理解骨架背后的真实意图。实际跑下来,之前要花一下午人工逆的样本,现在半小时内能出结论。这篇文章就把这套思路的原理、完整流程和踩过的坑都摊开来讲。
1. 为什么传统反混淆手段“看不动了”
1.1 一次真实分析任务:正则和AST各碰了一鼻子灰
那批样本的情况其实不复杂,就是一个带下载行为的网页脚本,但作者做了三层混淆:第一层是变量名全部改成 _0x3f2a 这种无意义标识符;第二层把字符串常量抽到一个大数组里,运行时通过下标取回;第三层用控制流平坦化把原本顺序执行的逻辑打散到一个 switch-case 分发循环中。
我先用正则硬扫,想提取里面的URL和API路径。结果发现所有字符串都是 _0x9d4c[0x3] 这种形式,根本看不到明文。接着我用现成的AST解析库把代码解析成语法树,确实能看到每个数组下标引用,但问题是节点数量膨胀到了几万个,且大量分支都在做无意义的自增和跳转。人眼扫了一遍,只确认了“这是一个循环”,却说不清循环在干什么。
这次经历让我意识到一个核心问题:AST不是不够强,而是“信息量太大”。混淆在结构上制造噪音,让真正的语义被淹没在无数无关节点里。传统工具擅长告诉你“代码长什么样”,但很难告诉你“代码在干什么”。要补上“干什么”这一环,需要把结构信息交给一个能做高层语义推断的系统,LLM恰好是 candidate。
1.2 正则、符号执行、去混淆插件的天花板
很多人遇到混淆第一反应是用正则。正则适合匹配“模式规整”的文本,但现代混淆几乎都是故意打破规整性的产物:字符串可以是运行时拼接、下标引用、甚至经过AES解密后才出现。正则无法追踪数据流,也无法处理嵌套结构,遇到 ${String.fromCharCode(...)} 这类动态构造直接傻眼。
符号执行是另一条路。它能把程序路径抽象成约束条件,理论上可以还原出每个变量取何值时走哪条分支。可现代混淆工具会在代码里注入大量无意义的循环和条件分支,符号执行在这些地方容易路径爆炸。更麻烦的是,很多样本会检测运行环境,一旦发现不是真实浏览器,就会走另一条错误路径或者直接退出。符号执行在分析这类带环境依赖的样本时,大量时间都耗在求解环境约束上。
市面上的自动化反混淆插件,比如针对某个混淆器的 deobfuscator 脚本,确实能解决一部分问题。但它们的适用边界很窄:换一个混淆器版本、调整一组参数,规则可能就全部失效。因为这类工具依赖“已知模式”,而混淆的本质就是不断制造新模式。我并不是说这些手段完全没用,而是它们在处理“组合混淆”时已经明显吃力。我们需要一个不靠死规则、能理解高层次语义的层,这就是LLM能发挥价值的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. AST:给代码做“结构CT”的第一步
2.1 AST到底长什么样:用“拆乐高”的方式理解抽象语法树
抽象语法树(Abstract Syntax Tree,AST)是把源码解析成树状结构,树上的每个节点对应一个语法元素。和源码相比,AST去掉了空格、分号、注释这些纯格式信息,只保留“代码是怎么组织起来”的结构关系。
举个例子,let x = a[1] + 2; 这行代码,在AST里大致是:顶层是一个 VariableDeclaration 节点,里面有一个 VariableDeclarator,它的 id 是标识符 x,init 是一个 BinaryExpression,表示加法,左侧是 MemberExpression(取 a[1]),右侧是数字字面量 2。
理解AST最简单的方式,是把它想象成“乐高说明书”。源码是一堆积木拼好的成品照片,AST则是说明书上那套分解图,标明每个积木块的类型和连接方式。混淆在做的,就是打散成品、换一些外观相似的积木块,但说明书上的“连接关系”本质上还在。我们后续要做的是,从这份被涂改过的说明书里,找到那些不变的关系。
2.2 现代混淆到底改了AST的哪些地方
不同混淆器作用在AST上的手法差别很大,但常用招式其实就几类。
第一类是控制流平坦化。它会把原本的 if-else、while 循环拆分成多个基本块,再把这些块放进一个大型的 switch-case 分发循环里。AST上的直观变化是:原来树状的嵌套结构变成了扁平的线形列表,外加一个全局分发器。这样做的目的是破坏代码块之间的直接跳转关系,让静态分析难以看出哪个分支对应哪个逻辑。
第二类是字符串加密与数组化。原始字符串字面量被替换成数组元素引用,再在运行时调用某个解密函数还原。AST上表现为字符串节点变成 MemberExpression + CallExpression 的组合。第三类是死代码注入和垃圾指令填充。混淆器会在每个真实操作之间插入大量不会执行的节点,直接导致AST节点数量翻倍甚至更多。
我整理过一张对比表,可以帮助理解混淆前后AST的差异:
| 特征 | 正常代码 | 混淆后代码 |
|---|---|---|
| 节点总数 | 少而清晰 | 膨胀2~10倍 |
| 变量名 | 有语义标识符 | _0x1234 这类无意义命名 |
| 字符串节点 | 明文 | 数组下标或函数调用 |
| 控制流 | 嵌套清晰 | 扁平化+分发循环 |
| 可读性 | 高 | 极低 |
关键在于,无论AST怎么膨胀,程序需要完成的行为——下载文件、读取配置、发送请求——不会因为混淆而消失。这些行为最终还是要通过API调用、字符串常量、函数调用关系体现出来。所以,AST仍然是目前最稳定、最可靠的第一手信息源。
2.3 遍历AST提取稳定特征的三个关键操作
要让AST从“信息噪音”变成“有用情报”,需要做三步特征提取。
第一步,提取节点类型序列。把每个节点的类型(如 Identifier、CallExpression、Literal)按深度优先遍历顺序拼接成序列。正常代码的节点类型序列有很强的规律,混淆代码虽然会插入额外节点,但API调用附近的节点类型组合仍然保留了某些模式。这个序列比变量名稳定得多,适合用来训练分类模型或做相似性聚类。
第二步,追踪字符串解密引用链。当遇到 _0x9d4c[0x3] 这种节点时,不能只看字面量,要向前追踪数组定义,找到它初始化的位置。如果能静态还原出数组元素,就直接还原明文;如果数组内容也是动态生成的,就把整条解密调用链抽出来,作为LLM的上下文片段。这个方法能解掉大部分字符串数组混淆。
第三步,识别分发循环并还原基本块。通过遍历AST找到 switch-case 结构,解析每个 case 对应的基本块。很多混淆器会在每个基本块末尾给分发变量赋新值,追踪这个赋值链,就能把基本块的执行顺序排列出来。排列之后,代码就不再是一团乱麻,而是一条接近原始逻辑的线性序列。
这三步做完,AST已经被“瘦身”成了只保留关键行为节点的伪代码。这个伪代码依然不完美,但它是LLM最适合消化的输入格式。
3. LLM大模型:从“看懂结构”到“读懂意图”
3.1 为什么LLM能理解被搅乱的代码:语义不是靠“顺不顺”判断的
传统静态分析工具判断一段代码的语义,强依赖语法规则和数据流。而大语言模型理解代码的方式完全不同:它在海量开源代码、文档、Stack Overflow问答上做过预训练,学到了大量“代码形态”与“行为意图”之间的统计规律。
有个很反直觉的点:LLM对“变量名可读性”的敏感度没有我们想象中高。你把 let secret_url = "http://x.com/a"; 改成 let a = b[0x2];,变量名和字符串都是乱的,但只要周围还有 fetch、XMLHttpRequest、sendBeacon 这类API调用节点,模型就能推断出“这段代码在发送请求”。因为它学到的不是某个固定变量名,而是“调用网络API”“处理响应”“拼接URL”这组概念的组合模式。
注意力机制在这里起了关键作用。即使字符串定义在文件开头、真正使用在几百行之后,Transformer也能通过自注意力把这两个相隔很远的token关联起来。这就让LLM能够跨越控制流平坦化造成的“物理距离”,理解数据依赖关系。
3.2 把AST喂给LLM的三种姿势:纯文本、JSON序列化、代码片段
我在实际项目里试过三种把AST信息交给LLM的方式,效果差别很大。
第一种是纯文本缩进树。把AST以缩进文本形式输出,比如:
text复制Program
VariableDeclaration
VariableDeclarator
Identifier x
BinaryExpression
MemberExpression
Identifier a
Literal 1
Literal 2
这种格式对LLM的tokenizer非常友好,模型能清楚看到层级关系,但缺点是节点多时文本量巨大,而且缺少代码原生语法节奏,理解效率偏低。
第二种是JSON序列化。直接把AST对象转成JSON字符串,信息完整,但token消耗是三种方案里最高的,对超长代码不友好。我只有在小段代码、需要精确节点级信息时才用。
第三种是把AST还原成“伪代码片段”。这不是简单反混淆,而是基于提取出的特征,重新生成一段结构清晰、保留关键调用和字符串明文的代码。例如把数组下标引用替换成还原后的字符串,把分发循环展开成顺序执行的基本块。这种伪代码最接近LLM预训练语料的分布,模型理解最准,实测效果也最好。
我给个提示词模板,大家可以参考:
text复制你是一名恶意代码分析专家。下面是一段经过混淆处理后的代码片段,我已经通过AST还原了部分结构。请分析它执行的关键行为,并输出统一JSON:
{"behavior_summary":"一句话总结","external_interactions":[{"type":"http_request","url":"...","method":"..."}],"hidden_flags":[...],"risk_level":"high/medium/low"}
要求:只基于代码片段判断,不要臆测;如果无法确定,risk_level置为unknown。
关键点在于约束输出格式。LLM如果自由发挥,生成的是“看起来合理但很难自动化处理”的长文本。规定好JSON结构,后续就能直接接入告警系统或威胁情报平台。
3.3 垂域LLM的数据准备:样本清洗、指令构造与效果评估
有些场景下通用LLM不够用,需要微调一个“代码混淆分析专用模型”。这一步的数据准备很关键。
第一,样本清洗。从开源平台和公开数据集收集混淆前后的代码对,确保没有敏感内容。最理想的情况是:同一段业务逻辑,既有清晰的原始版本,又有经过不同混淆器处理后的版本。清洗时要把无意义变量名统一替换为占位符,还需要去除注释中可能带来的标签污染。
第二,指令构造。训练数据不能只给代码和标签,要构造成指令-回答对。比如:
text复制指令:分析以下混淆代码的威胁行为。
代码:[AST还原后的伪代码片段]
回答:{"behavior_summary":"试图从远程服务器下载可执行文件并写入临时目录","risk_level":"high"}
回答最好也结构化,这样模型在推理时会自然输出结构化结果。人工标注时,要让标注员遵循同样的JSON格式,减少标签风格的漂移。
第三,效果评估。不要只看模型能不能复述训练集答案,要用一组“全新混淆样本”做测试。我常用的指标有三个:行为还原准确率,指摘要描述和人工结论一致的比例;误报率,指把正常代码判为恶意的比例;输出JSON合法率,这个看似基础却直接影响自动化程度。
如果团队没有算力微调大模型,完全可以先用通用LLM+提示词。我做过对比,用GPT-4级别的通用模型加上精心设计的提示词,在常见混淆器样本上能达到微调小模型八成的效果。门槛低很多。
4. AST+LLM组合透视镜:一套可落地的检测流水线
4.1 流水线总览:预处理、推理、验证三段式
把AST和LLM串起来,不能只写一个脚本就完事。我落地时是把整个过程拆成三段:
- 预处理:输入原始样本,输出“AST还原伪代码+关键行为特征”。
- 推理:调用LLM,输出行为摘要、外联信息、风险等级。
- 验证:用AST特征和动态沙箱结果校验LLM输出,收敛误报。
为什么必须加验证?因为LLM是生成模型,不是证明器。它可能因为上下文不足而出错,也可能把正常代码判成恶意。如果我们直接把LLM输出当结论,那在安全告警场景里会产生大量低质告警,迟早被人拉黑。让AST作为“静态证据”,让LLM作为“语义推断器”,最后再做一次交叉验证,这个组合才算完整。
4.2 预处理阶段:从源码到AST再到“关键帧”
不同语言要选不同的AST解析库。以最常见的JS为例,我推荐 espree 或 tree-sitter-javascript。espree的AST跟ESLint兼容度高,生态成熟;tree-sitter更擅长容错解析,遇到语法错误也能返回部分树。Python脚本则直接用标准库 ast,不需要额外依赖。
拿到原始AST后,别急着全部丢给LLM。先做三件事:
第一,剪枝。把无意义的 EmptyStatement、DebuggerStatement、回调函数中的空分支删掉。目标是把节点规模压缩到可处理的量级,同时保留所有 CallExpression、AssignmentExpression、MemberExpression。
第二,字符串还原。遍历AST中所有字符串数组引用节点,尝试解析解密函数。如果遇到运行时解密,就保留完整的解密调用链。
第三,生成“关键帧”。所谓关键帧,不是全部代码,而是按功能切分后的关键行为片段。比如检测到 eval、Function、fetch、WebSocket 等敏感调用时,将相关基本块连同上下文提取出来,拼成一个独立片段。这一步很像视频抽帧,只取关键动作,丢弃无意义过渡。
预处理代码我通常写成Python脚本。核心逻辑大致是:
python复制import ast
def extract_key_frames(tree):
frames = []
for node in ast.walk(tree):
if isinstance(node, ast.Call):
# 筛选敏感调用
if get_call_name(node) in SENSITIVE_APIS:
frames.append(build_frame(node))
return frames
这里的 build_frame 会向上回溯作用域,把调用点所在的函数体截取出来,替换掉已解析的字符串数组引用。
4.3 推理阶段:提示词模板、上下文窗口与输出约束
预处理产出的关键帧,会作为提示词喂给LLM。我建议把单次请求控制在模型上下文窗口的60%以内。为什么?因为LLM要留出足够的空间生成结构化输出,如果上下文塞满,容易生成不完整JSON。
一个实际可用的提示词模板长这样:
text复制请扮演代码分析助手。以下是经过AST静态分析提取的代码片段,可能不完整。
任务:
1. 找出所有外部网络请求
2. 找出数据持久化操作
3. 判断是否有代码执行行为
4. 输出json,格式严格如下:
{"https": ["url1", "url2"], "write_file": ["path"], "code_exec": true, "summary": "一句话"}
代码:
...
如果代码片段超过上下文长度,不能简单截断,因为可能把关键调用截掉。我会按“函数完整性”切分,先给LLM一个全局摘要,再逐段传入,最后让它合并结果。这个过程类似分治法,实现起来不复杂,但对结果质量提升明显。
4.4 验证阶段:行为还原与置信度校准
LLM输出一个风险结论后,我们要回到AST上去找证据。比如它说“存在外联请求”,那就在AST里搜索 http 或 https 字符串节点,或者搜索URL构造相关表达式。如果找到,这条告警标记为“LLM分析+静态证据确认”;如果没找到,就要看看是不是LLM从某个解密字符串中推断出来的,此时可以降级为“待确认”。
我还会做多次采样投票。把同一个关键帧用不同温度跑3次,温度设置在0.2左右,让输出在稳定基础上带有少量随机性。如果3次的behavior_summary高度一致,置信度就上调;如果结果南辕北辙,说明代码本身语义模糊,或者提示词缺少关键上下文,需要回到预处理阶段补数据。
另外,置信度不要只看LLM接口返回的logprob,那个数值不能直接用于安全决策。更可靠的做法是让LLM在输出里给出“证据片段”,再用AST去校验。有证据链支撑的结论才值得进入告警系统。
5. 实测过程中踩过的坑与对应的调优方案
5.1 AST解析器选择不一致导致的“白折腾”
我第一次做跨语言分析时,用 tree-sitter 解析JS样本,后来又看另一个模块用 espree 生成AST,两边节点命名规则完全不同,导致后续特征提取逻辑要对两套字段。更麻烦的是,某些混淆代码会在语法树里生成 AssignmentPattern 或 ExperimentalSpreadProperty 这类边缘节点,不同解析器的处理方式不一样。
解决办法是统一解析器,并且在预处理阶段做一层“AST节点归一化”。比如把 Literal、StringLiteral、NumericLiteral 统一映射成 “const_value” 类型。这样下游逻辑不用关心具体用了哪个解析器。如果你只是自己写脚本分析,建议从项目一开始就确定语言栈,不要混用。
5.2 超长代码片段把LLM上下文撑爆
混淆样本动辄几千行,AST剪枝后依然可能超过上下文限制。我开始时图省事,只取前2048个token给LLM,结果漏掉了文件后半部分的恶意下载逻辑,误判为安全。后来改成“函数级切分+全局摘要”策略:先把每个函数的AST关键帧单独分析,再把所有函数的摘要合并成全局视角再问一次。
分治法的token总量反而更低,因为避免了把重复的混淆模板反复塞给模型。另外,对某些解密函数体特别长的片段,我会先静态判断它是否只负责字符串解密,如果是,就执行“解密结果替代”,不把解密算法本身交给LLM。这能大幅减少无效token。
5.3 误报集中区:变量重命名与垃圾代码注入
实测下来,误报主要来自两个地方。一是变量重命名。当一个函数里全是 _0x2f、_0x3a 这种变量时,LLM偶尔会脑补出一些并不存在的威胁行为。解决办法是在提示词中明确写“如果代码只是进行无害的字符串拼接,不要判定为恶意”,同时对输出要求“必须有外部API调用作为证据”。
二是垃圾代码注入。混淆器塞入大量空循环和无效分支,LLM可能被这些表面上的“密集计算”误导,认为是在做加密或反调试。AST预处理时,我会用一个简单的“可达性分析”剔除明显不可达的分支,并在关键帧生成时优先选择包含敏感API调用的路径。这样喂给LLM的代码已经过滤掉大部分垃圾。
5.4 与textcnn、bert等小模型方案的对比结论
有人会问,为什么不用textcnn或bert做意图识别,非要上LLM?这两个路线我都测过。
textcnn和bert做“代码片段风险分类”的优势是延迟低、显存占用小,适合在流量入口做实时过滤。但它们需要固定标签体系,比如“恶意/正常”或者“下载器/窃密器”,对开放式的行为描述支持很差。而且它们对输入长度有限制,遇到长代码需要滑动窗口,切得不巧会把关键语义拦腰截断。
对比下来,我的建议是级联使用:第一层用textcnn或bert对AST关键帧做粗筛,得分较低的片段直接放行;得分较高的片段交给LLM做深入分析。这样既控制成本,又能获得开放式语义理解能力。下面是简化对比:
| 维度 | textcnn/bert | LLM |
|---|---|---|
| 推理延迟 | 毫秒级 | 秒级 |
| 标签体系 | 需要预定义 | 自由文本 |
| 对长代码 | 需要窗口切分 | 可分段摘要 |
| 输出解释 | 难 | 可提供证据 |
| 成本 | 低 | 高 |
| 适合角色 | 前置过滤器 | 深层研判 |
我刚把这套组合透视镜跑通时,最大的感受是用AST给LLM“划重点”实在太重要了。没有AST,LLM面对几千行混淆代码照样无从下手;没有LLM,AST给出来的结构信息也只是一堆等待翻译的骨架。两者加起来,才真正把代码混淆从“人眼噩梦”变成了“可自动化的分析问题”。后续我计划在预处理阶段引入更多动态分析数据,让LLM不仅看到静态结构,还能看到运行时行为,那样推断出来的意图会更接近真相。
