某旅行 App 基于 LR 劫持的 ARM64 控制流混淆逆向分析
0x00 前言
在分析某旅行类 App 的 native 层时遇到这种混淆。so 名就不说了,分析过看到这个混淆模式应该会觉得眼熟——对,思路一脉相承,换了个马甲而已。本文重点在混淆原理和自动化处理,跟目标无关。
0x01 混淆特征识别
在 IDA 中可以看到大量如下模式的调用:
LDR W0, =0x98609D
BL loc_14A8AC
乍看像是普通的函数调用,但 loc_14A8AC 内容很可疑:
loc_14A8AC:
BL sub_14C1BC
只有一条 BL,没有任何逻辑。继续跟进 sub_14C1BC:
sub_14C1BC:
SUB X0, X0, #0x2D
EOR X0, X0, #0x70
LSR X0, X0, #0xD
ADD X0, X0, #1
LDR W0, [X30, X0, SXTX#2]
ADD X30, X30, X0
RET
这里开始出现异常:函数末尾是 RET,但在此之前 X30 已经被修改,也就是说 RET 实际跳回的地址并不是调用方。
0x02 原理分析
完整执行流追踪如下。
第一步,call site 设置 dispatch key 并跳转:
LDR W0, =0x98609D ; W0 = key
BL loc_14A8AC ; X30 = 0x158014(此 LR 之后会被丢弃)
第二步,trampoline 再次 BL,X30 被覆盖:
BL sub_14C1BC ; X30 = 0x14A8B0(跳表基址)
这一步是整个混淆的核心——第二个 BL 把 X30 覆盖成了紧跟其后的地址,而这个地址恰好就是跳表的起始位置。
第三步,dispatcher 用 W0 计算跳表下标:
index = ((0x98609D - 0x2D) ^ 0x70) >> 0xD + 1 = 1220
第四步,查表并劫持返回地址:
LDR W0, [X30, X0, SXTX#2] ; W0 = *(int32*)(X30 + 1220 * 4)
ADD X30, X30, X0 ; X30 = 跳表基址 + 表项值
RET ; 跳到真正目标
RET 执行时 X30 已经指向目标地址,call site 原本的 LR(0x158014)就此丢失,执行流不会返回到 LDR+BL 后面,而是直接跳到跳表计算出的目标。
一句话总结:LDR W0, =key + BL trampoline 这两条指令等价于一条 B <target>,目的是伪装成函数调用,破坏 IDA 的控制流图重建。
进一步分析发现,同一个二进制里存在多个 dispatcher,变换指令序列各不相同,例如:
sub_17E3F0:
EOR X0, X0, #0xC0
SUB X0, X0, #0x26
ADD X0, X0, #1
LDR W0, [X30, X0, SXTX#2]
ADD X30, X30, X0
RET
变换顺序和参数不同,但结构完全一致。这意味着不能硬编码参数,需要对每个 dispatcher 动态提取变换链。
0x03 自动化反混淆
整体思路
- 找 dispatcher:扫描所有函数,特征是
LDR Wx,[X30,X0,SXTX#2] + ADD X30,X30,X0 + RET 连续出现
- 模拟变换:从 dispatcher 函数头开始逐条模拟对 X0 的算术/逻辑运算,算出 index
- patch:把
LDR W0, =key 替换为 B target,原来的 BL trampoline 替换为 NOP
完整脚本
import struct
import idc
import idaapi
import idautils
import ida_bytes
import ida_segment
DRY_RUN = True
MAX_INSNS = 20
NOP_AARCH64 = 0xD503201F
MASK64 = 0xFFFFFFFFFFFFFFFF
REG_X0 = 129
REG_X30 = 159
def is_dispatcher(func_addr):
addr = func_addr
for _ in range(MAX_INSNS):
insn = idaapi.insn_t()
size = idaapi.decode_insn(insn, addr)
if not size:
return False
if insn.get_canon_mnem().upper() == "LDR":
if insn.ops[0].reg != REG_X0:
return False
insn2 = idaapi.insn_t()
if not idaapi.decode_insn(insn2, addr + size):
return False
if insn2.get_canon_mnem().upper() != "ADD":
return False
if insn2.ops[0].reg != REG_X30 or insn2.ops[1].reg != REG_X30:
return False
insn3 = idaapi.insn_t()
if not idaapi.decode_insn(insn3, addr + size + insn2.size):
return False
return insn3.get_canon_mnem().upper() == "RET"
addr += size
return False
def find_all_dispatchers():
result = []
for seg_ea in idautils.Segments():
seg = ida_segment.getseg(seg_ea)
if not seg:
continue
if idc.get_segm_attr(seg_ea, idc.SEGATTR_TYPE) in (
ida_segment.SEG_DATA, ida_segment.SEG_BSS,
ida_segment.SEG_XTRN, ida_segment.SEG_NULL):
continue
for func_ea in idautils.Functions(seg.start_ea, seg.end_ea):
if is_dispatcher(func_ea):
result.append(func_ea)
return result
def simulate_transform(dispatcher_addr, raw_imm):
x0 = raw_imm & MASK64
addr = dispatcher_addr
for _ in range(MAX_INSNS):
insn = idaapi.insn_t()
size = idaapi.decode_insn(insn, addr)
if not size:
return None
mnem = insn.get_canon_mnem().upper()
if mnem == "LDR":
return x0
if insn.ops[0].reg != REG_X0 or insn.ops[2].type != idaapi.o_imm:
addr += size
continue
v = insn.ops[2].value & MASK64
if mnem == "SUB": x0 = (x0 - v) & MASK64
elif mnem == "ADD": x0 = (x0 + v) & MASK64
elif mnem == "EOR": x0 = (x0 ^ v) & MASK64
elif mnem == "AND": x0 = (x0 & v) & MASK64
elif mnem == "ORR": x0 = (x0 | v) & MASK64
elif mnem == "LSR": x0 = (x0 >> v) & MASK64
elif mnem == "LSL": x0 = (x0 << v) & MASK64
elif mnem == "ROR":
v &= 63
x0 = ((x0 >> v) | (x0 << (64 - v))) & MASK64
addr += size
return None
def resolve_target(index, table_addr):
raw = ida_bytes.get_bytes(table_addr + index * 4, 4)
if not raw or len(raw) < 4:
return None
(entry,) = struct.unpack("<i", raw)
return (table_addr + entry) & MASK64
def read_ldr_immediate(addr):
insn = idaapi.insn_t()
if not idaapi.decode_insn(insn, addr):
return None
if insn.get_canon_mnem().upper() != "LDR":
return None
if insn.ops[0].reg != REG_X0:
return None
op = insn.ops[1]
if op.type == idaapi.o_imm:
return op.value & MASK64
if op.type == idaapi.o_mem:
raw = ida_bytes.get_bytes(op.addr, 4)
if raw and len(raw) == 4:
return struct.unpack("<I", raw)[0]
v = idc.get_operand_value(addr, 1)
return v if v != idc.BADADDR else None
def encode_b(src, dst):
offset = dst - src
if offset % 4:
return None
imm26 = (offset >> 2) & 0x3FFFFFF
signed = imm26 - (1 << 26) if imm26 & (1 << 25) else imm26
if signed * 4 != offset:
return None
return 0x14000000 | imm26
def patch_site(ldr_addr, bl_addr, target):
enc = encode_b(ldr_addr, target)
if enc is None:
print(f" [!] {ldr_addr:#x} → {target:#x}: 超出 ±128MB")
return False
if not DRY_RUN:
idc.patch_dword(ldr_addr, enc)
idc.patch_dword(bl_addr, NOP_AARCH64)
idc.create_insn(ldr_addr)
return True
def run():
print("=" * 60)
dispatchers = find_all_dispatchers()
print(f"[*] 找到 {len(dispatchers)} 个 dispatcher\n")
total_ok = total_fail = 0
for disp_addr in dispatchers:
print(f"── dispatcher {disp_addr:#x} ──")
trampolines = [
x.frm for x in idautils.XrefsTo(disp_addr, idaapi.XREF_ALL)
if idc.print_insn_mnem(x.frm).upper() in ("BL", "B")
]
if not trampolines:
print(" [?] 无 xref,跳过")
continue
for tramp_bl in trampolines:
table_addr = tramp_bl + 4
print(f" 跳表基址 {table_addr:#x}")
func = idaapi.get_func(tramp_bl)
search_ea = func.start_ea if func else tramp_bl
ok = fail = 0
for xref in idautils.XrefsTo(search_ea, idaapi.XREF_ALL):
bl_addr = xref.frm
if idc.print_insn_mnem(bl_addr).upper() not in ("BL", "B"):
continue
ldr_addr = idc.prev_head(bl_addr)
if ldr_addr == idc.BADADDR:
continue
imm = read_ldr_immediate(ldr_addr)
if imm is None:
fail += 1; continue
index = simulate_transform(disp_addr, imm)
if index is None:
fail += 1; continue
target = resolve_target(index, table_addr)
if target is None or not idaapi.getseg(target):
fail += 1; continue
tag = "[DRY]" if DRY_RUN else "[PATCH]"
print(f" {tag} {ldr_addr:#x} imm={imm:#010x} idx={index:<5d} → {target:#x}")
if patch_site(ldr_addr, bl_addr, target):
ok += 1
else:
fail += 1
print(f" 小计: {ok} ok {fail} fail\n")
total_ok += ok; total_fail += fail
print("=" * 60)
print(f"[*] 完成: {total_ok} 成功 {total_fail} 失败")
if not DRY_RUN:
idaapi.auto_wait()
run()
使用步骤
- 先保持
DRY_RUN = True 运行,核对输出的目标地址是否合理
- 确认无误后改
DRY_RUN = False 再跑,IDA 会自动重分析
- patch 完成后执行
Edit → Reanalyze Program 让 IDA 重建控制流图
注意:patch 写入 idb 后不易撤销,建议跑之前先备份 idb 文件。
0x04 效果
patch 前 IDA 伪代码充斥着无意义的函数调用,控制流图在每处 LDR+BL 处断裂,Hex-Rays 无法正确反编译;patch 后每处等价跳转还原为直接 B target,控制流图重新连通,反编译结果可读性大幅提升。
0x05 小结
这种混淆的本质是用两次 BL 把跳表基址藏进 X30,再通过修改 X30 劫持 RET 的目标,对反汇编器造成干扰。识别的关键在于 LDR Wx,[X30,X0,SXTX#2] + ADD X30,X30,X0 + RET 这个固定的查表结构——无论变换参数怎么变,这三条指令的组合不变,因此可以可靠地自动化识别和还原。分析过相同类型对这套模式应该不陌生,脚本稍作调整即可复用。
[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。