关于 deobf-indirect
deobf-indirect 是用于分析和修补通过 CSEL 或 CSET 模式混淆的 ARM64 间接分支的逆向技能。其脚本采用符号执行和广度优先遍历,并对含有有效指令的跳转表情形单独处理。
主要功能
- 01识别 CSEL 与 CSET 分支模式
- 02通过符号执行推导分支目标
- 03使用 BFS 遍历代码块
- 04修补二进制文件中的间接分支
- 05保留 CSET 情形中的有效指令
- 06支持 Mach-O 和 ELF 变体
适用场景
- 01分析混淆的 ARM64 控制流
- 02还原间接跳转的分支目标
- 03修补基于 CSEL 的分支
- 04保留设置代码并处理 CSET 跳转表
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ p4nda0s/ bin-deobf-skills --skill deobf-indirect安装后在 Claude Code 中输入/deobf-indirect确认已加载。
附带文件script/deinbr-v3-csel.pyscript/deinbr-v3-cset.py
详细介绍
反混淆间接分支
第 1 部分:CSEL + BR 间接分支模式
模式识别
混淆器会将条件分支转换为间接跳转,其中 CSEL 和 BR 指令成对出现。CSEL 根据条件选择两个目标地址之一,随后执行一些无用指令,最后由 BR 跳转到选定的地址。
典型指令序列:
CMP W10, W11
CSEL W10, W13, W12, LS ; select W13 or W12 based on LS condition
...... ; junk code in between (address calculations, etc.)
ADD X8, X8, X14
BR X8 ; indirect jump to the computed target
主要特征:
CSEL从两个寄存器值中进行选择,它们分别代表两个分支目标CSEL与BR之间的代码用于计算地址或属于无用代码BR执行最终的间接跳转
分析方法
符号执行:遇到 CSEL 指令时,强制选择不同的分支,从而获得两个不同的 BR 目标地址。
步骤:
- 从同一个基本块入口开始运行两次符号执行
- 第一次运行:强制
CSEL选择第一个寄存器(条件为真分支) - 第二次运行:强制
CSEL选择第二个寄存器(条件为假分支) - 每次运行都会到达
BR,并得到不同的目标地址(A 和 B)
遍历策略
从函数入口块开始进行 BFS 遍历:
- 将函数入口地址加入工作队列
- 取出一个地址,分别使用两种
CSEL选择方式运行符号执行,直到遇到BR - 记录补丁信息(CSEL 地址、条件、两个目标地址)
- 将两个目标地址都加入工作队列
- 使用已访问集合避免重复访问
- 将以
RET结尾的块标记为返回块,不再从这些块继续遍历
补丁策略
CSEL 与 BR 之间的代码是无用代码——在 CSEL 位置直接补丁为条件分支。
例如,CSEL W11, W8, W9, CC:W8 是 CC 条件为真的分支目标(A),W9 是 CC 条件为假的分支目标(B)。
补丁如下:
BCC A ; if condition met, jump to A
B B ; otherwise jump to B
两条指令共 8 字节,会覆盖 CSEL(4 字节)和下一条无用指令(4 字节)。
第 2 部分:CSET + BR 间接分支模式(跳转表变体)
模式识别
与第 1 部分的 CSEL 变体不同,CSET 变体使用 0/1 索引查询跳转表来计算目标地址。CSET 与 BR 之间的代码并非全是无用代码,其中包含后续基本块所依赖的有效指令。
典型指令序列:
CMP X27, X8
CSET W8, EQ ; W8 = 0 or 1 (index)
STR W8, [SP, #offset] ; store index (junk)
LDR X9, [SP, #tbl_off] ; load jump table pointer (junk)
LDR X8, [X9, W8, UXTW#3] ; table[index] (junk)
ADRP X9, #page ; load encrypted constant (junk)
LDR W9, [X9, #off] ; (junk)
MOV W10, #imm ; XOR key (junk)
MOVK W10, #imm, LSL#16 ; (junk)
EOR W9, W9, W10 ; decrypt offset (junk)
NEG W9, W9 ; negate (junk)
ADD X8, X8, W9, SXTW ; final target address (junk, boundary)
; --- useful code below ---
ADRP X25, #0x100004000 ; register setup for successor blocks
ADD X25, X25, #0x250
MOV W28, #0xF065 ; constant init
MOVK W28, #0x611A, LSL#16
LDR X23, [SP, #0x50] ; load state for successor
BR X8 ; indirect jump (junk)
无用代码识别
ADD Xn, Xn, Wm, SXTW 是无用代码与有效代码的分界点。从 CSET 到 ADD(含 ADD)的所有内容都是无用代码:
| 指令 | 用途 | 分类 |
|---|---|---|
CSET Wd, cond | 设置 0/1 索引 | 无用代码(由补丁替换) |
STR Wd, [SP, #off] | 存储索引值 | 无用代码(仅供跳转表使用) |
LDR Xn, [base, #off] | 加载跳转表指针 | 无用代码 |
LDR Xm, [Xn, Wd, UXTW#3] | 查表 table[index] | 无用代码 |
ADRP + LDR Wn | 加载加密常量 | 无用代码 |
MOV + MOVK Wm | XOR 解密密钥 | 无用代码 |
EOR Wn, Wn, Wm | 解密偏移量 | 无用代码 |
NEG Wn, Wn | 取负 | 无用代码 |
ADD Xm, Xm, Wn, SXTW | 计算最终地址 | 无用代码(分界点) |
| 后续的 MOV/LDR/STR/ADRP+ADD | 初始化寄存器和栈状态 | 有效代码 |
BR Xm | 间接跳转 | 无用代码(由补丁替换) |
分析方法
与 CSEL 变体相同:符号执行分别强制 CSET 取 1 和 0,运行到 BR,以获得两个目标地址。
有一点不同:单个基本块可能包含多个 CSEL/CSET 指令(例如,先是数据选择 CSEL,随后是控制分支的 CSET)。脚本会强制选择每个遇到的 CSEL/CSET;记录的 csel_addr 是最后一个(即控制 BR 目标的那个)。
补丁策略
无法直接在 CSET 所在位置打补丁(这样会跳过有用代码)。正确做法:
- 定位
ADD Xn, Xn, Wm, SXTW(边界) - 提取 ADD+4 与 BR 之间的有用代码字节
- 将有用代码上移至 CSET 所在位置
- 紧接其后追加
Bcond A; B B
Before: [CSET][junk...][ADD][useful code][BR]
After: [useful code][Bcond A][B B][... dead code ...]
前提条件:有用代码必须与位置无关(SP 相对寻址、立即数赋值或同一页内的 ADRP)。在同一个 4KB 页面内移动 ADRP 无需调整立即数。
参考实现
针对不同混淆子模式的两个脚本变体:
script/deinbr-v3-csel.py — CSEL 变体(ELF)
适用于 CSEL 直接在两个目标地址之间进行选择,且 CSEL 与 BR 之间的代码纯属地址计算垃圾代码的二进制文件。在 CSEL 所在位置打补丁,覆盖 8 个字节(Bcond + B),替换 CSEL 和下一条垃圾指令。
script/deinbr-v3-cset.py — CSET 变体(Mach-O / ELF)
适用于 CSET 设置 0/1 索引,且 CSET 与 BR 之间的代码包含有用的副作用指令(寄存器设置、供后续代码块使用的栈存储),并与地址计算垃圾代码交错的二进制文件。
补丁策略:找到 ADD Xn, Xn, Wm, SXTW(最后一个地址计算步骤),将有用代码(ADD 与 BR 之间的代码)上移至 CSET 所在位置,然后追加 Bcond + B。这样可以保留后续代码块所依赖的寄存器/内存状态。移动的指令必须与位置无关(SP 相对寻址、立即数或同一页内的 ADRP)。
两个脚本共享相同的核心工作流:
analyze_br(proj, func_start)— BFS 遍历,收集所有补丁点run_until_br(proj, state, csel_selector)— 从给定状态执行至 BR,并强制选择 CSEL/CSET 分支do_patch(binary_path, save_path, proj, patch_list)— 使用 keystone 汇编补丁并写入二进制文件
关键 angr 选项:
CALLLESS— 忽略函数调用,防止分析结果偏离LAZY_SOLVES— 延迟约束求解,以提升性能ZERO_FILL_UNCONSTRAINED_MEMORY— 用零填充未约束内存ZERO_FILL_UNCONSTRAINED_REGISTERS— 用零填充未约束寄存器
脚本执行规则
所有非简单的 Python 代码(包括分析脚本和最终补丁脚本)都必须写入文件,并通过 python script.py 执行。切勿在 bash 中内联使用 python -c "..."。这样可以避免 shell 引号转义问题,也让脚本更易于调试和复用。
常见错误与调试
BR 目标是无效地址
block: 41fe20, next_1: <SimState @ 0x2908f8c3>, next_2: <SimState @ 0x2908f8c3>
SimEngineError: No bytes in memory for block starting at 0x2908f8c3.
原因:用户提供的地址不是函数入口,而是内部基本块。基于栈的跳转表基址和偏移常量尚未初始化(被 ZERO_FILL 填充为零),导致 BR 计算出无效目标。
解决方案:确认该地址是函数起始位置。使用符号表或 IDA/Ghidra 查找函数入口:
for sym in proj.loader.main_object.symbols:
if sym.rebased_addr <= target_addr < sym.rebased_addr + sym.size:
print(sym.name, hex(sym.rebased_addr))
两个分支目标相同
block: XXXXX, next_1: <SimState @ 0xABCD>, next_2: <SimState @ 0xABCD>
原因同上——从非入口点开始执行,意味着 CSEL 选中的两个寄存器值相同(均为零或相同的未初始化值),因此地址计算后得到相同的 BR 目标。
解决方案:使用正确的函数入口地址。
遇到非 CSEL 的条件选择指令
block XXXXX: expected 1 successor, got 0
或者脚本到达 BR 时未检测到 CSEL,导致全局变量中没有 csel_addr。
原因:基本块使用的是 CSET,而不是 CSEL。CSET 是 CSEL 的一种特殊形式,等价于 CSEL Rd, WZR, WZR, invert(cond),会选择 1 或 0 作为索引。
按处理 CSEL 的相同方式处理——识别条件并强制选择两个分支。脚本必须同时处理 csel 和 cset:
if insn.mnemonic == 'cset':
# cset Wd, cond is equivalent to csel Wd, #1, #0, cond (condition met=1, not met=0)
dst, cond = parse_cset(insn)
val = 1 if csel_selector == 1 else 0
setattr(state.regs, dst, val)
...