Emul4nt/byepy-benchmark

GitHub: Emul4nt/byepy-benchmark

一个自包含的对抗式 Python 代码混淆与 LLM 反混淆基准实验,通过 16 轮迭代对抗评估混淆器与反混淆器的攻防能力。

Stars: 1 | Forks: 0

# byepy:对抗式 Python 混淆循环 这是一个关于对抗式混淆的独立实验。一个 agent 负责混淆一批固定的 Python 程序语料库;第二个 agent 在完全不知道混淆器工作原理的情况下,尝试还原这些程序。每一轮,混淆器都会根据反混淆器能够应对的策略进行自适应调整。该循环会一直运行,直到反混淆器在同一时间点未能通过语料库中所有级别的所有检查。 该循环运行了 16 轮,最终混淆器获胜。 ## 设置 语料库分为三个复杂度递增的级别: | 级别 | 文件 | 总 AST 节点数 | |------|-------|-----------------| | trivial | factorial.py, fizzbuzz.py, binary_search.py | 117 / 130 / 278 | | medium | text_stats.py, todo_cli.py | 672 / 890 | | hard | csvkit/(4 文件包) | 1746 | 每个文件都会运行三项评分检查。第一项是行为检查:还原后的文件必须产生与原始文件在字节上完全一致的 stdout。第二项是 AST 相似度:原始源代码与还原源代码之间节点类型多重集的 Jaccard 相似度必须至少为 0.6(分数为 1.0 表示结构完美匹配;0.6 表示大约恢复了 60% 的结构)。第三项是可读性,由一个 LLM 裁判进行评判,及格线为 6/10。在大多数轮次中并未评估可读性,而是将其视为默认通过。 一轮的流程如下: 1. 混淆器将新的混淆文件写入 `rounds/NNN/obfuscated/`。 2. 启动一个全新的反混淆子 agent,它没有任何关于混淆器设计的记忆。它仅接收混淆后的文件以及上一轮的反混淆工具(`cleanup.py`、`decompiler.py`、`extract.py`、`recover.py`)。它可以自由读取和修改这些工具。 3. 子 agent 将还原的文件写入 `rounds/NNN/recovered/`。 4. `judge.py` 对它们进行评分并写入 `report_objective.json`。 反混淆子 agent 是一个独立运行的 LLM,没有任何关于混淆器操作的外部指导。它可以检查混淆后的 Python 代码、追踪 bytecode、编写测试脚本、编辑其 pipeline。它只是不能直接询问混淆器做了什么更改。 ## 逐轮历史记录 ### 第 1 轮:基线测试(平均 AST 1.000) 简单的 AST 处理:使用 `shake_128` 进行标识符重命名,以及 base64 字符串编码。没有 bootstrap,没有 CFF,没有死代码。反混淆的应对非常简单:一个重命名映射表和一个 base64 解码器。几分钟内即实现了完全恢复。 ### 第 2 轮:死代码 + 作用域感知重命名(平均 0.995) 添加了作用域感知的局部变量和参数重命名(每个函数作用域使用独立的盐值)。添加了使用 `False` 和 `None is not None` 作为谓词的死代码注入。反混淆的应对立竿见影:对 `if False:` 和 `if None is not None:` 块进行模式匹配并将其剥离。通过将在相同赋值上下文中一起出现的变量进行分组来重命名标识符。 ### 第 3 轮:CFF + marshal bootstrap(平均 0.955) 添加了控制流平坦化(CFF):线性函数被转换为 while-True 状态机,其中状态变量 `_sXX` 被分配一个初始标签,每个 case 在中断或继续之前将状态变量更新为下一个标签。添加了单阶段 bootstrap:模块源代码被编译为 code object,经过 marshal、zlib 压缩、与 16 字节密钥进行 XOR 运算,结果是一个自包含的加载器,它会调用 `ctypes.pythonapi.PyMarshal_ReadObjectFromString`。Docstrings 被剥离。 `uncompyle6` 和 `decompyle3` 均在输出上失败(`pycdc` 也是如此)。它们期望的是特定的 CPython 3.x bytecode 指令序列,而这些序列在 Python 3.14 下编译后并不会出现。 反混淆器从零开始编写了一个自定义的 Python 3.14 bytecode 反编译器(后来增长到约 1400 行代码)。Bootstrap 的解密过程非常直接,因为 XOR 密钥和密文都是源代码中的字面常量。CFF 被 `CFFUnflatten` 逆转:检测带有以状态变量为键的嵌套 if 链的 `while True:`,映射标签到代码体,然后按标签顺序输出代码体。 ### 第 4 轮:双阶段 bootstrap + co_varnames 清理(平均 0.846) 双阶段 bootstrap:阶段 1 解密(zlib+XOR)得到阶段 2 的源代码,后者再解密(lzma+XOR)得到实际的 code object。`scrub_code()` 将 `co_varnames`、`co_cellvars`、`co_freevars` 和 `co_name` 替换为使用独立盐值派生的 `shake_128` 乱码。反汇编中的局部变量名看起来像 `lIllIlIlIlIlIlI`。反追踪防护(`sys.gettrace() is not None: raise SystemExit(1)`)防止其在调试器下运行。 反混淆器直接从源 AST 中读取了两个解密阶段(无需 exec;密钥和密文都是字面量)。构建了 `FreevarResolver`,通过追踪 `SET_FUNCTION_ATTRIBUTE` bytecode 序列并关联被捕获的 cell 对象来恢复原始 freevar 名称。factorial 的分数降至 0.65。最小的文件对任何残留的噪声最为敏感。 ### 第 5 轮:内建函数分派表 + 包合并(平均 0.909) 每个对内建函数的引用(`print`、`range`、`len`、`isinstance`,所有内建函数)都被替换为 `_ft[N]`,其中 `_ft` 是一个以稳定的 SHA-256 派生整数作为键的分派字典。该字典在阶段 2 的源代码中构建,使用 `getattr(__import__('builtins'), ...)` 以及经过 XOR 编码的名称字符串。整个 csvkit 包(四个 .py 文件)被拼接并作为一个单一单元进行混淆;相对导入被剥离。 反混淆器解析了阶段 2 的源代码以提取分派表。编写了 `decode_ft.py` 来遍历 bytecode 中的 `MAKE_FUNCTION` + XOR 常量模式。所有文件均成功恢复。 (本轮 `todo_cli.py` 未能通过行为测试,因为优先级显示逻辑中的分派表存在 bug。混淆后的版本错误地渲染了优先级标记。已在第 6 轮修复。) ### 第 6 轮:用户函数 trampoline + XOR 字符串编码器(平均 0.878) 用户定义的顶层函数通过 trampoline 技术转换为 `_ft`:每个函数定义变成了 `def _tmp_HASH(args): body; _ft[key] = _tmp_HASH; del _tmp_HASH`。在此之后,所有的调用(不论是内建函数还是用户函数)都通过 `_ft[key](...)` 进行。`co_names` 中不再包含任何可读内容。 针对字符串编码的按位置变化 XOR:位置 `i` 的密钥字节是 `(K + i*7 + 3) & 0xFF` 而不是常量,这破坏了那些提取单字节 XOR 密钥的工具。 反混淆器添加了 `TrampolineCleanup`,将 `def _tmp; _ft[k] = _tmp; del _tmp` 坍缩回 `def func_NNNNN`。添加了带有按位置计算公式的 `XorStringDecoder`。它必须通过读取 bootstrap XOR 生成器表达式(源代码中的一个字面量)来推导出该公式。 ### 第 7 轮:注解剥离 + 打乱的 CFF(平均 0.849) `AnnotationStripper`:剥离所有函数参数和返回值注解。Python 3.14 (PEP 649) 编译带有注解的函数时,会使用 `__annotate__` lambda 将每个参数名映射到其类型。如果在变量重命名之后依然保留注解,将会通过注解 lambda 暴露出原始的参数名。 打乱的 CFF:标签现在从很宽的范围内(1000 到 65535)进行采样,并且 if 链的 case 被打乱了,因此 `if sv == LABEL:` 块的文本顺序与执行顺序没有任何关系。 分派表从 code object 移到了阶段 2 的 bootstrap 源代码中,因此 `dis` 输出不包含任何表构建的 opcode。 反混淆器添加了 `ScrambledCFFUnflatten` 来处理打乱的标签变体(构建标签到代码体的映射,然后按状态机的执行顺序输出)。注解剥离对恢复没有影响;名称早已经被篡改了。 ### 第 8 轮:整数常量表(平均 0.804) 混淆源代码中每一个绝对值 `|v| >= 2` 的整数常量都被替换为 `_c[key]`,该表在阶段 2 的 bootstrap 中构建。阶段 2 的 XOR 变成了与位置相关的:`key[i%16] ^ ((i>>4) & 0xFF)`,而不是 `key[i%16]`。`_ft` 和 `_c` 表是通过遍历编码后的三元组和掩码对来构建的,而不是作为单独的赋值语句。这破坏了反混淆器一直使用的阶段 2 AST 遍历模式。 反混淆器更新了 `extract.py` 来解析基于循环的表构建逻辑(`_parse_ft_loop`、`_parse_c_loop`)。处理了与位置相关的阶段 2 XOR。整数常量恢复作为一个清理步骤被添加进来。 ### 第 9 轮:SHA-256 死谓词 + 多语句代码体(平均 0.814) SHA-256 死代码谓词:死分支使用 `sha256(str(var).encode()).digest()[:N] == b'\x00'*N`。SHA-256 永远不会产生全前缀零,因此该分支永远是死代码。但这无法通过模式匹配或 `ast.literal_eval` 来证明。反混淆器需要密码学推理,或者明确知道 SHA-256 具有抗原像性。 死代码块的主体被扩展为 2-4 条语句(赋值、raise、引用看似合理的局部变量的条件 raise),因此乍一看它们就像是真正的错误处理代码。 反混淆器添加了 `_is_sha256_dead_test` 来匹配 `hashlib.sha256(str(VAR).encode()).digest()[:N] == b'\x00'*N` 这种结构形式。它是通过识别调用链而不是通过评估表达式来匹配的,这是正确的做法。为第 10 轮引入的模运算家族添加了 `_is_algebraic_dead_test`。 ### 第 10 轮:阶段 2 作为 code object + try/except 包装器(平均 0.806) 阶段 1 现在生成 marshal 后的 code object,而不是阶段 2 的源代码文本。反混淆器必须先反编译阶段 2,然后才能提取 `_ft` 和 `_c`。在每个函数体周围添加了透明的 `try: except BaseException: raise` 包装器。在语义上是无效的(无影响的)。它们会产生反编译器尚无法处理的 CPython 3.14 异常表 bytecode。阶段 1 的 XOR 更改为 `(i*7+13) & 255`。 反混淆器更新了 `stage1_decode`,以检测 marshal 情况并在继续之前进行反编译。添加了 `TryExceptUnwrapper` 来剥离透明的包装器。为处理异常表对反编译器进行了修补(CPython 3.14 中的 COPY/POP_EXCEPT 模式)。 ### 第 11 轮:无混淆更改(平均 0.839) 反混淆 agent 的 csvkit 恢复产生了错误的 stdout,因为上一轮的异常表反编译器修复不完整。当 `JUMP_BACKWARD` 出现在分支中间时,`_if_else` 处理程序会提前截断 else 分支。本轮没有引入新的混淆。 反混淆器通过逐行比对原始输出和恢复后的输出,诊断出了行为失败的原因。发现了 `_if_else` 的提前返回 bug 并进行了修补。为 else 分支路径添加了栈快照处理。csvkit 通过了测试。分数略有上升。 ### 第 12 轮:ClosureCaptureTransformer(平均 0.843) 每个函数的最后一个 `return EXPR` 被重写为三重嵌套闭包: ``` _cl1234 = EXPR def _mid5678(): def _inner9012(): return _cl1234 # freevar, captured through two levels return _inner9012() return _mid5678() ``` `scrub_code()` 在四个独立加盐的位置中对 `_cl1234` 进行了乱码处理:`outer.co_cellvars`、`_mid.co_freevars`、`_mid.co_cellvars`、`_inner.co_freevars`。经过乱码处理后,内部函数的 freevar 名字在外部作用域中没有可见的绑定,因此调用它会引发 `NameError`。 阶段 1 的 XOR 更改为 `(i*7+13) & 255`(公式;在 extract.py 中更新了检测逻辑)。 这对于反混淆器来说是比较困难的轮次之一。Agent 必须追踪 `SET_FUNCTION_ATTRIBUTE closure` bytecode 序列,为每个函数构建一个 freevar 重命名映射,然后在反编译期间应用这些重命名。`FreevarResolver` 读取每个闭包设置序列之前的 `BUILD_TUPLE` 参数,并将子级的 `co_freevars[i]` 映射到外部作用域为相应 cell 变量使用的任何名称。csvkit 中发生了一次名称冲突(`TableFormatter.format` 中的一个 genexp 将同一个乱码名称用于来自两个不同作用域的两个不同捕获值),这要求按 code object 进行作用域解析,而不是使用单一的全局映射。Agent 正确处理了这一点。 ### 第 13 轮:lambda 默认参数闭包 + 四个新的死代码家族(平均 0.843) 闭包形式更改为带有默认参数的 lambda: ``` _cl1234 = EXPR _f5678 = lambda _x9012=_cl1234: _x9012 return _f5678() ``` 默认参数在 lambda 创建时进行评估。不会创建闭包 cell,`co_freevars` 为空,永远也不会发射 `SET_FUNCTION_ATTRIBUTE(closure)`。`FreevarResolver` 无需追踪任何内容。之前的 `ClosureUnwrapper` 模式(检测 Assign/FunctionDef/Return 三元组)在此形式上失败了,因为 FunctionDef 被替换为了 lambda 赋值。 添加了四个新的死代码谓词家族:银行家舍入法(`round(0.5) != 0`,在 Python 3 的“四舍六入五成双”中永远为 False)、abs/divmod 不变量(`abs(-N) > N`,`divmod(A,B)[0] != A//B`)、chr/ord 往返(`ord(chr(N)) != N`)、int 构造谓词(`int(True) != 1`)。阶段 1 的 XOR 更改为 `(i*11+7) & 255`。 反混淆器更新了 `ClosureUnwrapper` 来检测 lambda 默认参数形式(Assign + 带有一个默认参数的 lambda)。添加了四个新的 `_is_X_dead_test` 函数。本轮进展顺利;分数没有变化。 ### 第 14 轮:三重嵌套 def + 计算常量谓词(平均 0.842) 闭包回到了三重嵌套 def(源自第 12 轮),但是包含三个层级而不是两个。`_cl1234` 现在出现在四个名称槽中。`_mid` 包含两条语句(内部 def 和返回调用),因此 `ClosureUnwrapper` 的 `len(s1.body) == 1` 防护条件失败了。之前的展开器期望的是单语句的 `_mid` 函数体。 计算常量死谓词:`sum(range(N)) != K`(高斯公式给出预期总和)、`max([...]) != K`、`min([...]) != K`、`sorted([...]) != [...]`、`list(reversed([...])) != [...]`。这些都需要执行 Python 才能进行评估;`ast.literal_eval` 无法处理它们。 阶段 1 的 XOR 更改为 `(i*13+11) & 255`。 反混淆器修补了 `len(s1.body)` 防护条件,以接受 2 条语句的 `_mid` 代码体。添加了 `_is_computed_const_dead_test`,该函数在 sort/max/min/reversed 形式中对列表字面量调用 `ast.literal_eval`,并与嵌入的常量进行比较。这之所以有效,是因为在 `_c` 替换之后,列表字面量都是常量。sum/range 形式要求将 `sum(range(N))` 识别为 `N*(N-1)//2`。两者都得到了正确的处理。 ### 第 15 轮:按位恒等式 + 数值锚定二次方程(平均 0.843,停滞) 家族 10,使用被追踪的整数变量的按位恒等谓词: ``` (n & ~n) != 0 # n AND complement is always 0 (n ^ n) != 0 # n XOR n is always 0 (n | ~n) != -1 # n OR complement is always -1 (all bits set) ``` 它们包含 Name 节点,因此 `ast.literal_eval` 会失败。反混淆器必须对它们进行结构匹配。 家族 11,数值锚定二次谓词。如果已知变量 `n` 在插入点等于 `v`,那么 `(n - v) * (n - v)` 永远为 0,所以 `(n - v) * (n - v) != 0` 永远为 False。移除它需要进行常量传播:在同一个语句块中追踪赋值 `n = v`,并使用已知值来评估表达式。 死代码密度提升至每个间隙 3 个块。阶段 1 的 XOR 更改为 `(i*17+3) & 255`。 本轮停滞不前。由于流程错误,反混淆 agent 在混淆的第 015 轮文件生成之前,就获得了第 015 轮的 cleanup.py(该文件已经包含了 `_is_bitwise_identity_dead_test`、`_is_value_anchored_dead_test` 和 `ValueAnchoredDeadRemover`)。Agent 无事可做。分数保持在 0.843,与第 12-14 轮完全相同。 这暴露了核心问题:自第 12 轮以来,平均 AST 分数几乎没有什么变动(0.803、0.814、0.806、0.839、0.843、0.843、0.842、0.843),混淆器显然只是在原地踏步。 ### 为什么分数停滞在 0.84 `DeadCodeRemover` 有两条移除路径。 路径 1,`_is_dead_test(PRED)`:如果谓词在结构上被识别为永远为 False,则无论主体内容是什么,整个块都会被移除。 路径 2,`_matches_other_dead_check(node)`:处理边界检查谓词(`x < 0`、`x > NNN`、`len(v) > NNN`)。仅在 `_dead_body_only(BODY)` 也返回 True 时才移除该块。 如果代码体包含任何 `if` 语句,`_dead_body_only` 就会返回 False。四个主体模板中只有一个(模板 2)包含 `if`。因此,大约 75% 的主体轻易通过了主体检查,边界检查块被移除了。但是边界检查谓词仅占候选池的大约 15%。综合来看:边界检查块的存活率约为 25% * 15% ≈ 4%。由于每个间隙有 3 个块,这意味着每个间隙大约有 0.12 个存活块。 对于其他所有家族,一旦 agent 添加了结构匹配器,整个块就会通过路径 1 被移除。添加匹配器是一项范围明确的小任务:阅读谓词形式,编写一个识别它的函数,然后将其插入到 `_is_dead_test` 中。Agent 从未在这方面失败过。 真正的问题在于不对称性。混淆器陷入了一场在结构上根本赢不了的猫鼠游戏。每一个新的谓词家族都是可解决的,因为这些谓词都是某种数学上固定的恒等式(代数身份、密码学抗原像性以及一些类型系统规则),而反混淆 agent 可以为每一个家族在现有的模式匹配函数中编写一个新的 case。 ### 第 16 轮:致命一击(平均 0.247,循环终止) 洞察:彻底停止谓词游戏。两条移除路径都需要检查谓词。路径 1 识别特定形式;路径 2 需要特定的谓词形状加上简单的代码体。使用这两条路径都无法处理的谓词即可。 查看第 015 轮 cleanup 中的 `_is_dead_test`,它有 13 个命名的 case:`_is_sha256_dead_test`、`_is_algebraic_dead_test`、`_is_isinstance_dead_test`、`_is_sys_dead_test`、`_is_banker_rounding_dead_test`、`_is_abs_divmod_dead_test`、`_is_chr_ord_dead_test`、`_is_int_construction_dead_test`、`_is_computed_const_dead_test`、`_is_bitwise_identity_dead_test`、`_is_value_anchored_dead_test`,以及针对 `False` 和 `None is not None` 的基础 case。 这 13 个 case 没有一个能够处理 `issubclass` 谓词。也没有一个触及 `callable` 或 `bytes`。 引入了三个新的候选家族。家族 12 使用了 `issubclass` 的永远为 False 的配对。Python 的类型层级结构保证了诸如 `issubclass(int, str)`、`issubclass(list, dict)` 或 `issubclass(float, int)` 之类的判断永远为 False。`issubclass(A, B)` 调用在源代码中有三个名称(函数名和两个类型参数),并且它们都在 `_DISPATCH_TARGETS` 中,所以它们能在 `_ft[K]` 替换中存活下来,并在反混淆后以其原始名称出现。共有 12 个谓词。 家族 13 使用了 `not callable(BUILTIN)`。所有的 Python 内建对象都是可调用的,因此 `not callable(print)`、`not callable(len)` 等永远为 False。`callable` 在 `_DISPATCH_TARGETS` 中。共有 20 个谓词。 家族 14 使用了 `bytes(N)` 不变量。`len(bytes(4)) != 4` 永远为 False(bytes(N) 产生 N 个零字节)。`bytes(4) != bytes(4)` 永远为 False(内容相等)。共有 7 个谓词。 所有四个死代码体模板都被更新为包含一个 `if` 语句。这使得 `_dead_body_only()` 对所有主体都返回 False。因此,即使 agent 为其他家族之一添加了新的类似路径 2 的匹配器,主体检查也会阻止该块被移除。 块密度从每个间隙 3 个提升到了 5 个。 反混淆器的起点是第 014 轮的 cleanup.py(而不是 015 轮),这迫使 agent 从零开始重新发现家族 10 和 11,此外还是第一次遇到 12、13 和 14。 结果:100% 的块存活率,两条移除路径都没有触及任何一个块。 | 文件 | 原始节点数 | 恢复节点数 | AST 分数 | |------|----------------|-----------------|-----------| | factorial.py | 117 | 512 | 0.219 | | fizzbuzz.py | 130 | 373 | 0.327 | | binary_search.py | 278 | 771 | 0.352 | | text_stats.py | 672 | 3004 | 0.213 | | todo_cli.py | 890 | 4492 | 0.191 | | csvkit (pkg) | 1746 | 9078 | 0.182 | 每个层级都通过了行为检查(死分支永远不会执行),并且每个层级都未通过 AST 检查(所有分数均低于 0.6),因此循环在该轮终止。 ## 完整评分历史 | 轮次 | factorial | fizzbuzz | binary_search | text_stats | todo_cli | csvkit | 平均 | |-------|-----------|----------|---------------|------------|----------|--------|-----| | 001 | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 | | 002 | 1.000 | 1.000 | 1.000 | 0.975 | 1.000 | 0.997 | 0.995 | | 003 | 0.919 | 0.954 | 0.954 | 0.953 | 0.974 | 0.979 | 0.956 | | 004 | 0.650 | 0.823 | 0.932 | 0.814 | 0.943 | 0.912 | 0.846 | | 005 | 0.684 | 0.954 | 0.941 | 0.964 | 0.992 | 0.916 | 0.909 | | 006 | 0.880 | 0.803 | 0.928 | 0.798 | 0.951 | 0.911 | 0.879 | | 007 | 0.880 | 0.803 | 0.918 | 0.823 | 0.878 | 0.795 | 0.850 | | 008 | 0.689 | 0.812 | 0.918 | 0.830 | 0.778 | 0.799 | 0.804 | | 009 | 0.689 | 0.812 | 0.918 | 0.812 | 0.872 | 0.782 | 0.814 | | 010 | 0.689 | 0.812 | 0.918 | 0.816 | 0.818 | 0.785 | 0.806 | | 011 | 0.874 | 0.812 | 0918 | 0.818 | 0.818 | 0.792 | 0.839 | | 012 | 0.874 | 0.832 | 0.918 | 0.818 | 0.819 | 0.799 | 0.843 | | 013 | 0.874 | 0.832 | 0.918 | 0.820 | 0.819 | 0.799 | 0.843 | | 014 | 0.874 | 0.832 | 0.918 | 0.818 | 0.819 | 0.792 | 0.842 | | 015 | 0.863 | 0.832 | 0.918 | 0.820 | 0.819 | 0.804 | 0.843 | | **016** | **0.219** | **0.327** | **0.352** | **0.213** | **0.191** | **0.182** | **0.247** | 第 5 轮和第 11 轮除了显示的 AST 分数外,还存在行为失败(分别是 `todo_cli.py` 和 `csvkit`)。 ## 作为 agent 的反混淆器 关于反混淆 agent 在各轮中表现的几点观察。 Agent 从未在任何一轮放弃过,且每一轮它都为所有六个目标生成了恢复文件。第 12 轮是最复杂的,需要自定义 bytecode 追踪来解决跨作用域的 freevar 名称冲突,而 agent 依然在那里产生了正确的输出。 它极少在技术细节上产生幻觉。当它需要理解一种新结构时,它会直接通过 `dis.dis` 以及对 code-object 槽位(如 `co_consts`)的检查来阅读混淆的 bytecode,而不是主观臆断它知道混淆器做了什么。第 6 轮的反混淆笔记描述了 agent 是如何在编写 `decode_ft.py` 之前,从 bytecode 中追踪分派表构建过程的。 它犯下的一个重大错误是在第 11 轮(csvkit 的行为失败),其修复需要理解反编译器中的 `_if_else` 在遇到 `JUMP_BACKWARD` 时提前退出了 else 分支。Agent 最初将其诊断为死代码问题,并认为一些 else 分支语句被错误地识别为死代码。在两次失败的尝试产生了相同的错误输出之后,它更仔细地阅读了反编译器源代码,并发现了通过 `_if_else` 线程化返回值时的真正 bug,这比其他轮次花费了更长的时间,但最终得到了解决。 结构匹配是它的默认方法,且使用它是正确的。混淆器引入的每个谓词家族都有一个独特的结构签名。SHA-256 谓词看起来像是对 bytes 结果进行属性调用链。代数谓词是 BinOp(Mod, Eq, Const)。按位恒等式是 BinOp(BitAnd, ~Name, Ne, 0)。Agent 为每个家族编写一个识别器,并将它们链入 `_is_dead_test` 中。只要混淆器不断添加新的家族,这种方法就很高效。当混淆器使用一个看起来与函数中其他任何内容毫不相干的家族时,它就会停止工作。 第 12 轮和第 15 轮之间的停滞期(平均 0.843)表明 agent 达到了局部最优。它移除了所有结构上可识别的死代码,但无法做得更好。剩余的 AST 差异来自于被打乱的变量名,而不是死代码,并且该 agent 的重命名器已经接近最优。 第 16 轮是 agent 始料未及的。反混淆器被赋予了第 014 轮的 cleanup.py 作为起点,它要做的第一件事就是在新的混淆文件上运行 pipeline。它会看到 AST 分数暴跌至 0.2-0.35,并通过查看恢复的输出来了解哪些部分存活了下来。此时,它会看到文件中散布着类似 `if issubclass(int, str): ...` 和 `if not callable(print): ...` 的代码块,并且需要为这些形式添加匹配器。由于循环会在初始运行产生的结果上终止,因此它是否能足够快地做到这一点以影响本轮的得分,将永远不得而知。 ## 什么能打破第 16 轮的防御 如果游戏继续,agent 会将三个新的匹配器写入 `_is_dead_test`: ``` def _is_issubclass_dead_test(node): # match: issubclass(Name, Name) where the pair is in a known-false table ... def _is_not_callable_dead_test(node): # match: UnaryOp(Not, Call(callable, [Name])) where Name is a known builtin ... def _is_bytes_invariant_dead_test(node): # match: len(bytes(Const)) != Const and bytes(Const) != bytes(Const) ... ``` 这些是简单的结构匹配器,agent 会以与现有匹配器相同的风格编写它们,因此第 17 轮最终看起来会与第 9 轮非常相似。 下一步的对策将是使用需要评估可调用对象而不是匹配静态结构的谓词。`(lambda: False)()` 永远为 False,但证明这一点需要评估一个 lambda。`any(x > x for x in range(10))` 永远为 False(没有整数满足 `x > x`),但证明这一点需要评估一个生成器表达式。`os.getpid() < 0` 在任何真实系统上永远为 False(PID 是非负的),但证明这一点需要了解有关 OS API 的不变量。 或者是通过模式匹配无法逆转的方式改变程序结构的语义转换: - 将循环转换为递归函数 - 将条件判断替换为分派表 - 用连续传递风格重写函数体 如果不理解原始算法,反混淆器就无法逆转这些转换,而仅仅识别特定的 bytecode 模式是不足以达到目的的,这使得它成为比第 016 轮混淆器抛出的任何难题都要难得多的目标。 ## 仓库布局 ``` byepy/ obfuscator.py canonical obfuscator (round 16) corpus_meta.json corpus file list with run_as and tier metadata judge.py objective scoring (behavioural + AST) run.py round scaffolding harness corpus/ trivial/ factorial.py, fizzbuzz.py, binary_search.py medium/ text_stats.py, todo_cli.py hard/csvkit/ __init__.py, __main__.py, formatter.py, parser.py rounds/ 001/ ... 016/ obfuscator.py obfuscator snapshot for that round obfuscated// obfuscated files recovered// deobf agent output work/ deobf pipeline tools for that round cleanup.py AST cleanup passes decompiler.py custom Python 3.14 bytecode decompiler extract.py stage 1+2 decryption recover.py top-level recovery pipeline report_objective.json judge scores deobf_notes.md agent's notes (rounds 6-12) ```
标签:LLM Agent, Python, 人工智能, 代码混淆, 对抗生成, 无后门, 用户模式Hook绕过, 自动化payload嵌入, 逆向工具