AI 自动化逆向(一)最贵的错误是「看起来像结论的错答案」
本文是「AI 自动化逆向」系列第 1 篇,也是本系列的方法论总纲。
后续篇目围绕四条线往下写:AI 的工作模式与人的位置、失效模式、审核协议、已结题的实战案例。
前言
我把一个二进制逆向项目交给了 AI。
它做得很多:解开了九万多个资源条目、反推了七八种私有格式、验证了一条加密链、
把受保护代码的边界量化到了百分比、写了上千个脚本、留下八十多篇专题文档。
但它出错的方式,和人有本质区别 —— 这也是我决定写这个系列的唯一理由。
人做逆向时,错了通常自己心里有数:拿不准的地方,语气是犹豫的,结论是留着余地的。
AI 不是。它给你错答案的时候,格式是漂亮的表格,语气是确定的,
多行输出彼此一致,退出码是 0,甚至还会主动总结一句"扫描完成"。
这篇文章把我在这个项目里抓到的17 类静默失效整理出来。
它们的共同特征是:工具不报错。脚本正常退出、打印正常,只是答案是错的。
(文中所有地址、常量、字段一律用占位符表示,只讲判据设计,不给任何具体目标的取值。
文中"AI"指执行分析的模型,"我"指设定目标、审结果的人。)
一、为什么 AI 的错误比人的错误更危险
先把这个说清楚,否则后面 17 条会被读成"一些编程小技巧"。
| 人犯错 | AI 犯错 | |
|---|---|---|
| 语气 | 通常附带"可能"“大概”“我不确定” | 确定。它会把猜测写成结论 |
| 格式 | 潦草的、半成品的 | 完整、整齐、多行一致 |
| 自检 | 会本能地觉得"这里怪怪的" | 不会。它没有那种不舒服的感觉 |
| 规模 | 错一个点 | 错一整个批次,而且批量错得一模一样 |
| 可发现性 | 复查时容易看出来 | 复查时看起来最权威 |
最后一行是要害:AI 的错误恰好长得像最可靠的输出。
这个项目里我抓到的最典型一次:AI 从一批「函数体」里恢复出一组字符串引用,用来支持「这些函数引用了某张表」。
它们格式完整、输出整齐,混在真证据里肉眼分不出 ——
因为那些字节根本不是代码:那是碎片恢复的假阳性,从垃圾里撞出来的。
如果没有人拦这一步,这批「证据」会作为事实被写进文档,然后再被后续会话引用。
所以这个系列的主题定成一句:
AI 的产能是真的,AI 的自信是假的。
用 AI 做逆向的关键技术不是提示词,是审核协议。
二、17 类静默失效
陷阱 1:解码迭代器遇到第一个无法解码的字节就停止,不报错
这是最经典的一个。很多反汇编库的迭代器语义是流式的:
forinsninmd.disasm(code,base):...它内部遇到无法解码的字节会直接结束循环,而不是抛异常。
于是 AI 写的扫描脚本高高兴兴跑完,打印「扫描完成」,
实际上只覆盖了目标代码段的0.55%。
后果:AI 因此把某个目标里 1600 多处保护性转移数成了 24 处——低估 68 倍。
而那个数字后来被写进了结论。
人应该加的检查:如果用的是这种流式接口,要求它必须自己写逐字节重同步循环,
或者至少在所有扫描结束后核对一句话:覆盖字节数 / 节区大小 = ?
这个比值不打出来,就不知道是扫完了还是半路停了。
陷阱 2:跳过数据的选项会造成「地址对、语义错」
x86 是变长指令集(1–15 字节)。线性扫描时,只要起点错一个字节,或者跳过一段嵌在代码里的数据,
之后每一条指令都会解码成完全不同的东西——而地址依然合法、依然对得上。
这类输出看起来最权威(每条都有地址、有助记符、格式整齐),所以也最危险。
人应该加的检查:跨模块下结论之前,凡是扫描结果都要求重推一遍。
一旦输出里出现明显不符合编译器产出的指令,立刻怀疑起点而不是怀疑目标。
陷阱 3:AI 会把「这段读不出来」升级成错误的全局前提
当目标里有一段被保护/加密的代码时,AI 会看到:
- 该段熵值接近理论上限;
- 反编译器跟控制流跟到一个不存在的地址,然后报「无法读取字节」。
这两件事都是工具层面的正常反应,不是姿势不对。但 AI 很容易从"读不出来"直接跳到
“这个目标整体不可静态分析”,然后基于这个错前提去规划整条路线。
人应该加的检查:先量保护面有多大。
这个项目里量出来是代码段的一个很小的比例(3% 量级)。被保护字节数 / 代码段字节数这个比值必须先算出来,它决定了该绕过还是该硬啃。
陷阱 4:AI 会相信它自己上一轮生成的那张表
这一条是这个项目里栽得最深的一次,也最能说明 AI 的特征。
AI 生成了一张「受保护函数清单」,每个条目带起点和大小。
然后它默认[起点, 起点+大小)就是函数体,跑到里面去提取字符串、提取 API 引用,用来给函数命名。
关键在于:那张表是 AI 自己造的,所以它默认表是对的。人不会这样——人对自己手抄的表有天然的不信任。
后来做了一次审计:对全部条目逐字节解码,统计能解码的字节比例。结果是:
| 解码率 | 条目数 |
|---|---|
| ≥90% | 84 |
| 50–90% | 1 |
| 20–50% | 10 |
| <20% | 12 |
最差的一条,数千字节的区间,解码率0.4%。它的实际内容是什么?
FUNC_VA e9 xx xx xx xx jmp <保护段> ← 入口被换成了一个跳转 FUNC_VA+5 70 0f jo ... FUNC_VA+7 a5 movsd FUNC_VA+8 13 65 45 adc esp,[ebp+0x45] ... FUNC_VA+n cf iretd ← 编译器几乎不可能产出这个跳转之后全是垃圾。那数千字节的「函数体」根本不存在——它是桩 + 数据。
这直接导致一批结论被整批撤回重审:从这些「函数体」里提取出的 41 条字符串证据,先是被全部打回。
重审的结果分两路——其中 3 个函数后来被证明是误撤、已恢复;真属于垃圾的那部分,
是碎片恢复的假阳性:占全部恢复片段的近四分之一,加过滤器后清掉,真实证据零损失。
(这条判据连翻五次的完整历史,下一篇专门讲。)
有多像真的?一条假证据长这样:一个短乱码串(形如!Q+,示意)混在证据列表里——
格式、形态和其他真证据没有区别。
人应该加的检查:任何「某函数体内有 X」的结论,先过一关——
单位从「区间」换成「片段」:逐偏移恢复 + 结构闭合判据(见陷阱 6),
再对恢复出的片段量化假阳性率(用「编译器不会产出的指令」「目标不在独立指令缓存里」这两条过滤)。
这个前置检查只要几分钟,能省掉一整轮返工。
陷阱 5:AI 用一张表给函数定归属会静默错配
「某函数引用了 X」这个结论需要三步:找到 X 的引用点 → 把引用点归属到某个函数 → 下结论。
第二步最容易错,而且错了不报错,只是归错了。
同一个族的问题在这个项目里出现了四次,四次都是 AI 自己写的映射逻辑出错:
| 次 | 错法 |
|---|---|
| 1 | 只看立即数操作数,漏了内存操作数 |
| 2 | 把{addr: idx}的映射写成了{idx: addr}(enumerate用反) |
| 3 | 只看导入表槽,漏了导入跳转表 |
| 4 | 把清理好的锚点表里的[起点, +大小)当成函数体(就是陷阱 4) |
人应该加的检查:凡「某个函数引用了 X」的结论,必须先复核区间。
而且——别让 AI 用一张通用映射表去给受保护区间定归属,那种区间本来就不是齐次的(见陷阱 6)。
陷阱 6:AI 有分类冲动,非要找「函数级」的二元判据
陷阱 4 之后,AI 提出了一个"修复方案":用函数级解码率当闸门,低于阈值的判定为数据,撤回其证据。
这个方案直接导致它撤回了 41 条证据。然后下一轮它自己发现:模型本身是错的。
那些区间不是「代码 or 数据」,它是夹层:
[ 保护桩 ][ 壳字节流 ][ 幸存的原始指令 ][ 保护桩 ][ 壳字节流 ][ 幸存的原始指令 ] ...原指令被逐条替换成跳进保护段的桩,但原始字节留在原地。
于是存活的真指令被夹在桩与桩之间,被字节流隔断——线性解码必然失步。
反例极其刺眼:解码率最低(0.005)的那个区间,
里面有一整条完整的 if-else 链,逐个比较 8 个中文名字,jne串联,语义完全自洽。
解码率与「含真代码多少」完全不成正比。
而最关键的线索其实早就摆在眼前:那 22 个区间的解码率在 0.005 与 0.45 之间没有天然断层。
AI 当时注意到了,但把它解释成"阈值有任意性"——其实那是模型不对的信号。
人应该加的检查:阈值选不出来的时候,先怀疑模型,别怀疑阈值。
具体做法:单位从「函数」换成「片段」——逐偏移解码,
保留以ret或间接跳转结尾、且不含跳进保护段的转移的连续段。
陷阱 7:AI 找到的指标是「系统性偏高」,不是「有噪声」
顺着陷阱 6 往下,还有一个更阴的。
AI 有一批函数被判为「解码率 100%、代码完全可读、只是不引用任何字符串」,准备从调用者那边反向推语义。
逐个做结构检验后发现:这一类的判定是错的。它们全部含 2–9 条跳进保护段的jmp,
零可恢复片段,无标准函数序言。它们是纯桩。
看反汇编就更清楚了:
FUNC_VA mov eax, dword ptr [ebp + 4] FUNC_VA+3 adc cx, bp FUNC_VA+6 shl ecx, 0xce FUNC_VA+9 btr ecx, esp FUNC_VA+0c shr ch, cl FUNC_VA+0f rcr cx, 0xda ← 这不是编译器会产出的东西这段是垃圾,但它解码率 100%。
原因很简单:x86 极少遇到无效操作码。给它一段随机字节,它只是持续产出无意义但合法的指令。
所以「线性解码率」这个量偏高,而且——它在最难的情形(纯保护桩)上给出最漂亮的值。
人应该加的检查:判「是不是桩」要用合取,不能用任何单一量:
可恢复片段 == 0且跳进保护段的 jmp > 0。
单看任何一个都会翻车:单看解码率会漏掉纯桩(本条);单看片段数会把干净函数误判成"无代码"。
陷阱 8:AI 写的代码会安静地吞掉 AI 自己的错误
AI 写过一个探针脚本,要枚举目标进程。写法大意是:
try:out=subprocess.run(["tasklist"],capture_output=True,text=True).stdoutexceptException:return[]在这个环境里,管道式 stdio 是被禁止的,会抛EPERM。
于是异常被吞掉,函数返回空列表,脚本打印:
no running client found. Start it outside this sandbox first而目标进程当时正在跑。这一轮因此整个白绕。
人应该加的检查:
- 枚举类调用不要经子进程,用系统 API 自枚举;
except里不要写return []或return None。要么让异常冒出去,
要么把异常文本放进返回值里。- 「空列表」和「真的没有」必须能区分。它们在输出上完全同形,而含义相反。
陷阱 9:节名带点,"text" not in section_table会静默跳过整段比对
AI 的节表键是.text、.data(带前导点)。而它的循环写的是:
forsecin("text","data"):ifsecnotinsection_table:continue两个键都匹配不上,continue让整段文件比对一行都不打印。
脚本正常exit 0,正常输出wrote ...json,没有任何异常迹象。
人应该加的检查:输出行数本身就是判据。
该打 10 行却打了 3 行,说明有分支被跳过了,不是「没什么可比」。
解析完节表先print(sorted(secs)),别假设键名。
陷阱 10:repr套repr会把 int 显示成字符串
AI 写了这么一行调试输出:
'%r'%(repr(v[0]),)双重repr:一个 int 被打成带引号的字符串(123→'123',示意值)。
它据此判定"这些地址是十进制字符串",然后用int(s, 10)去转,
崩在TypeError: int() can't convert non-string——因为它本来就是 int。
更麻烦的是:同一个项目里,不同缓存文件的类型是不一致的。
有的地方地址是int,有的地方是str。AI 跨文件推断了类型。
人应该加的检查:别跨文件推断类型,每个缓存各测一次。
用之前先print(type(x), repr(x))。
陷阱 11:锚点取在+16,差值却按+0算 → 所有偏移虚高 16
AI 写的一个地址翻译脚本里:签名取自func_va + 16,但计算增量时用的是pos - (va - base)。
于是算出来的delta = 真值 + 16。
后果:拿它去翻译地址,得到106/106 全部不匹配的假结论;
还伪造出一个"delta == 0的区间数为 0"的统计——实际上开头那一整段的真偏移就是 0。
暴露它的是内部矛盾:「+16处签名完全匹配」和「函数头 16 字节里差 14 个字节」
这两件事不可能同时为真。
人应该加的检查:结论自相矛盾的时候,先查自己的下标,别先怀疑目标。
以及:写地址翻译时,把「锚点偏移」和「差值基准」写成两个有名字的常量,不要都用字面量。
陷阱 12:AI 会把硬编码的读取长度当成「测出来的大小」
AI 按目标的节区声明大小硬编码了 dump 长度,然后宣布
「运行时镜像的该节与磁盘文件大小相同」——这是循环论证:它读的长度就是它以为的长度。
同理,某几个节区的 size 它在脚本里写成了 0,于是它们"从来没有被比过",
但输出里看不出这一点。
人应该加的检查:大小和布局一律从目标自己的头里读。
运行时进程的头是可读的,读一下就知道了——这个项目照做之后,
立刻查出当时跑的根本不是静态分析的那个可执行文件(分析对象一开始就选错了)。
陷阱 13:AI 会记住错的公共常量,而且不报错
TYPE={0x1000000:"IMAGE",0x20000:"MAPPED",0x40000:"PRIVATE"}正确值是MAPPED = 0x40000、PRIVATE = 0x20000。AI 写反了。
于是 13 个私有可执行内存区域被报成"文件映射",它据此写下"13/13 全是映射"——结论错。
最要命的是:查询内存信息的那个 API 永远成功。常量写错不会有任何提示,
它只会给你一个错误但完整、格式正确的答案。
人应该加的检查:涉及系统常量时,「用两个独立脚本各测一遍并比对名称」不是冗余,是唯一防线。
换一份常量写对的脚本重扫同一批地址,立刻就能报出PRIVATE。顺带:私有可执行内存没有来源文件,问"有没有 backing file"这个问题,
答案只能来自类型字段,不能靠文件名。
陷阱 14:AI 编错自己数据结构的字段名,会让整批结果变成「空」
AI 写:
size=prot[va].get("declared_size")而真实字段名是size,并且它是字符串。
.get()不报错,返回None→size = 0→ 后续全部返回空 →
5 个目标 + 3 个控制组全部打印fragments=0。
这份输出读起来完全像「这些函数都没有可恢复片段」这个真实结论:8 行,格式一致,
数字一致,没有任何可疑之处。
是控制组把它戳破的:控制组里有一个函数本该有 90 个片段,报 0 ——
说明是取材环节错了,不是目标的性质。
人应该加的检查:
- 任何「某集合里一个都没有」的结论,必须至少有一个已知非空的同类样本作对照。
空结果与取材失败在输出上完全同形。- 跨文件用字段名前,先
print(list(d[0].keys()))。
这里还有一个姊妹陷阱:做假阳性率量化时,需要一块"肯定不是代码"的对照区域。
第一次选了保护段——恢复出 0 个片段。第二次换了数据段里的字符串区——还是 0 个。
看起来是"规则不乱报",其实是规则根本起不了步:恢复算法只在"已知指令边界"起步,
而那两块区域里没有任何已知边界。
对照区域必须落在被测规则的「作用域」内。
否则测到的是"规则没跑",不是"规则很干净"。
陷阱 15:OpenProcess(pid)成功 ≠ 进程还活着
AI 用"按 pid 打开进程成功"来判断存活,于是把一个已经自尽的目标报成SURVIVED——
结论方向完全反了。
而同一次输出里,用原始句柄查退出码,拿到的早已是一个明确的异常退出码(示意值0xXXXXXXXX)。
原因是:PID 会被回收复用。
人应该加的检查:判活的唯一可靠手段是原始句柄上的退出码查询(返回「仍在活动」的魔数),
或比对进程创建时间。PID 只是索引,不是身份。凡是「对象还在不在」的判断,先问「我拿什么标识它」。按名字、按 pid 都会骗人。
陷阱 16:AI 跨运行比较两个数时,会忽略原点不同
AI 用「第一轮布点成功 26 个」与「第二轮布点成功 60 个」作比较,
推断代码段是被惰性按页解密的(先布点的看得少)。
这条推断有两个致命问题:
第一,两个数不在同一个时间轴上。两处报的「0.03s」和「0.000s」
都是从各自脚本的轮询循环开始算的,不是从进程创建算的——创建时刻它从来没测过。
两个数不可比。
第二,它把两种成因合并成了一句话:
ifcurisNoneorcur[0]!=0xE9:skipped.append({...,"why":"not an E9 entry"})# ← 读失败 与 字节非 E9 共用「页面没提交、读失败」和「内容是密文」在输出里完全同形。
换成以进程创建为轴、探针先于目标启动后测出来:
60 个入口全部在创建后约 1 秒量级的一个窄窗里变成明文,窗口之前一个都不是。
「惰性分页解密」作废。
人应该加的检查:
- 报出一个「耗时/早晚」之前,先写下原点是什么。
自己定义的零点(循环开始、脚本启动、第一次采样)不能跨运行比较。or连接的多个条件如果共用一个错误信息,那条信息就不是证据。
陷阱 17:AI 重构时会把既有逻辑悄悄吞掉
AI 原来有一个「跳板」实现,已经正确处理 5 字节入口。
它不是原样复放被搬运的字节,而是重算位移:目标 - (桩地址 + 入口偏移 + 5)。
在做「通用跳板」时,它定了一条更严的规则:「被搬运的字节里如果含相对转移就跳过」。
结果:覆盖面从 63 掉到 32。而被拒掉的那 70 个,
恰恰是几乎全部有意思的被保护函数(它们的入口就是一个jmp到保护段)。
原来那条「特例」代码,就是它在起作用的原因。
而且还有更严重的一次:为了"覆盖更全",它还删掉了一条检查——
「入口必须是jmp且目标在保护段内」。而那道检查顺带拒绝了还没解密的页。
删掉它 =允许往密文页写补丁。而"代码段要到创建后约 1 秒才明文"这个事实,
是上一轮它自己测出来的。
人应该加的检查:
- 泛化前,先把旧实现的每一条分支列出来,逐条问:「新规则会不会让它变成不可达?」
- 正确做法不是"跳过含相对转移的字节",而是按转移类型分别处理:
相对jmp/call重算位移、短条件跳转放宽成长位移、只有loop/jcxz这类才真跳过。
改对之后覆盖面102/102。- 在测量中确立的约束,不能在优化指标的时候被顺手删掉。
三、把这 17 条归成四族
回头看,17 条不是散的,它们分成四族,而且各自对应一种 AI 的固有倾向:
| 族 | 包含 | AI 的什么倾向导致的 | 人的审核动作 |
|---|---|---|---|
| 甲 · AI 自己的工具在骗它 | 陷阱 8、9、10、11、12、13、14 | 它写代码时和做分析时,用的是两套脑子。分析时很谨慎,写胶水代码时很随意 | 要求"输出行数本身也是判据";要求空结果必须有对照 |
| 乙 · AI 把「解出来了」当成「解对了」 | 陷阱 1、2、7 | 它默认工具是可靠的。解码器说解完了,它就信 | 要求打出覆盖率比值;要求任何"体内有 X"先验证区间 |
| 丙 · AI 相信一张表 / 一个指标 / 一个数 | 陷阱 4、5、6、15 | 它对自己产出的表、自己找到的指标、自己手里的标识,都没有戒心 | 要求审计区间;要求"阈值选不出来时先怀疑模型" |
| 丁 · AI 的过度泛化与想当然 | 陷阱 3、16、17 | 它急着把局部读数放大成全局结论、把代码写得更「通用」——没写下来的前提,它默认是真的 | 要求先量占比、再下全局判断;要求"先把旧实现的每条分支列出来";要求比大小前先写原点 |
甲族是最容易被忽视的,因为它看起来像"普通的编程 bug"。
但它在这个项目里造成了最大的一次白绕 ——
一个except: return []让 AI 得出的结论是"环境的限制",而真相是"它自己的代码有问题"。
丙族是最需要人介入的。AI 不会怀疑自己生成的表,也不会怀疑自己找到的指标、自己手里的标识——
这是它和人最大的区别之一。人对手抄的表天生不信任;AI 对自己生成的表天生信任。
四、人在这套流程里到底做什么
写到这里必须说清楚,否则这篇会读成"AI 很菜"。
AI 在这个项目里做成的事,是人力做不到的:
九万多个条目、七八种私有格式、上千个脚本、几十轮自我推翻 —— 这个体量人工做要几年。
但它的产出必须有人审。我在这套流程里的动作,说白了只有五件:
- 设目标—— 比如"把剩下那些没结论的函数全部结题"。这一句下去,AI 会自己分解、执行、自我推翻。
- 看产物—— 不看它的总结,看它产出的东西。图表、渲染结果、可复现的命令。
- 指出方向—— 这个项目里最典型的一次:我看了 AI 渲染出来的一批图,只说了一句话,
大意是「形状轮廓像,但配色不像」。就这一句话,让它推翻了自己已经写进文档的像素格式结论。 - 喊停—— 有些做法即使技术上成立也不该做(涉及真实账号、涉及第三方权益)。
这类判断 AI 做不了,也不该让它做。 - 要求它自证—— 这是最常用的一招。不直接说"你错了",而是要求它给出一个能被机器证伪的检查。
上面 17 条里几乎每一条的"人应该加的检查",都是这么来的。
注意第 5 条:人不需要懂那些技术细节,只需要知道"这个结论该被什么东西证伪"。
五、边界声明
- 本文只讨论AI 自动化逆向中的判据设计与审核方法,不涉及任何具体产品、厂商或服务的分析结论。
- 文中所有地址、偏移、常量、字段名一律为占位符或示意值,
不代表任何真实目标中的取值;示例代码为说明判据而编写,不构成可运行的针对特定目标的工具。 - 文中提到的比例与数字均来自作者项目内的测量记录,仅用于说明方法失效的方式。
- 请勿将本文内容用于未授权的用途。
如果这 17 条能帮你少返工一轮,这篇文章就值了。
下一篇计划聊:AI 会相信它自己上一轮写的表。
上面陷阱 4/5 只是一笔带过,那一条值得单独讲 ——
它引出了这个项目里最长的一段自我否定链:AI 连续推翻自己五次,
从"这段是数据"一路改到"这段是夹层",中间每一步都曾经写进过文档。
转载请注明出处:
https://lizhiyong.blog.csdn.net/article/details/167813620