☰
AI 自动化逆向(一)最贵的错误是「看起来像结论的错答案」
2026/10/14 2:07:28 网站建设 项目流程

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 在这个项目里做成的事,是人力做不到的:
九万多个条目、七八种私有格式、上千个脚本、几十轮自我推翻 —— 这个体量人工做要几年。

但它的产出必须有人审。我在这套流程里的动作,说白了只有五件:

  1. 设目标—— 比如"把剩下那些没结论的函数全部结题"。这一句下去,AI 会自己分解、执行、自我推翻。
  2. 看产物—— 不看它的总结,看它产出的东西。图表、渲染结果、可复现的命令。
  3. 指出方向—— 这个项目里最典型的一次:我看了 AI 渲染出来的一批图,只说了一句话,
    大意是「形状轮廓像,但配色不像」。就这一句话,让它推翻了自己已经写进文档的像素格式结论。
  4. 喊停—— 有些做法即使技术上成立也不该做(涉及真实账号、涉及第三方权益)。
    这类判断 AI 做不了,也不该让它做。
  5. 要求它自证—— 这是最常用的一招。不直接说"你错了",而是要求它给出一个能被机器证伪的检查。
    上面 17 条里几乎每一条的"人应该加的检查",都是这么来的。

注意第 5 条:人不需要懂那些技术细节,只需要知道"这个结论该被什么东西证伪"。


五、边界声明

  • 本文只讨论AI 自动化逆向中的判据设计与审核方法,不涉及任何具体产品、厂商或服务的分析结论。
  • 文中所有地址、偏移、常量、字段名一律为占位符或示意值,
    不代表任何真实目标中的取值;示例代码为说明判据而编写,不构成可运行的针对特定目标的工具。
  • 文中提到的比例与数字均来自作者项目内的测量记录,仅用于说明方法失效的方式。
  • 请勿将本文内容用于未授权的用途。

如果这 17 条能帮你少返工一轮,这篇文章就值了。

下一篇计划聊:AI 会相信它自己上一轮写的表。
上面陷阱 4/5 只是一笔带过,那一条值得单独讲 ——
它引出了这个项目里最长的一段自我否定链:AI 连续推翻自己五次,
从"这段是数据"一路改到"这段是夹层",中间每一步都曾经写进过文档。


转载请注明出处:
https://lizhiyong.blog.csdn.net/article/details/167813620

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询