首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
7
14
[原创]某旅行 App 基于 LR 劫持的 ARM64 控制流混淆逆向分析
发表于: 2026-6-1 16:27
35986
[原创]某旅行 App 基于 LR 劫持的 ARM64 控制流混淆逆向分析
梧桐生
2026-6-1 16:27
35986
# 某旅行 App 基于 LR 劫持的 ARM64 控制流混淆逆向分析 ## 0x00 前言 在分析某旅行类 App 的 native 层时遇到这种混淆。so 名就不说了,分析过看到这个混淆模式应该会觉得眼熟——对,思路一脉相承,换了个马甲而已。本文重点在混淆原理和自动化处理,跟目标无关。 --- ## 0x01 混淆特征识别 在 IDA 中可以看到大量如下模式的调用: ```asm LDR W0, =0x98609D BL loc_14A8AC ``` 乍看像是普通的函数调用,但 `loc_14A8AC` 内容很可疑: ```asm loc_14A8AC: BL sub_14C1BC ``` 只有一条 `BL`,没有任何逻辑。继续跟进 `sub_14C1BC`: ```asm 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 并跳转: ```asm LDR W0, =0x98609D ; W0 = key BL loc_14A8AC ; X30 = 0x158014(此 LR 之后会被丢弃) ``` **第二步**,trampoline 再次 BL,X30 被覆盖: ```asm BL sub_14C1BC ; X30 = 0x14A8B0(跳表基址) ``` 这一步是整个混淆的核心——第二个 `BL` 把 `X30` 覆盖成了紧跟其后的地址,而这个地址恰好就是跳表的起始位置。 **第三步**,dispatcher 用 W0 计算跳表下标: ```asm index = ((0x98609D - 0x2D) ^ 0x70) >> 0xD + 1 = 1220 ``` **第四步**,查表并劫持返回地址: ```asm 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,变换指令序列各不相同,例如: ```asm 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 自动化反混淆 ### 整体思路 1. **找 dispatcher**:扫描所有函数,特征是 `LDR Wx,[X30,X0,SXTX#2]` + `ADD X30,X30,X0` + `RET` 连续出现 2. **模拟变换**:从 dispatcher 函数头开始逐条模拟对 X0 的算术/逻辑运算,算出 index 3. **patch**:把 `LDR W0, =key` 替换为 `B target`,原来的 `BL trampoline` 替换为 `NOP` ### 完整脚本 ```python import struct import idc import idaapi import idautils import ida_bytes import ida_segment DRY_RUN = False # 改 False 才真正 patch MAX_INSNS = 20 NOP_AARCH64 = 0xD503201F MASK64 = 0xFFFFFFFFFFFFFFFF REG_X0 = 129 # IDA 9.x ARM64 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) # 原来只处理了 ldr_addr,这里补上 bl_addr 和 target for ea in (ldr_addr, bl_addr): ida_bytes.del_items(ea, ida_bytes.DELIT_EXPAND, 4) idc.create_insn(ea) if not idc.is_code(ida_bytes.get_full_flags(target)): ida_bytes.del_items(target, ida_bytes.DELIT_EXPAND, 4) idc.create_insn(target) 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: func = idaapi.get_func(tramp_bl) if func: print(f" [i] trampoline {tramp_bl:#x} 位于函数 {func.start_ea:#x} 内部({idc.get_func_name(func.start_ea)})") # 关键改动 1:不再用 func.start_ea 覆盖 search_ea, # 外部 xref 引用的一定是 tramp_bl 这个 label 本身 search_ea = tramp_bl ok = fail = 0 pending = [] # (ldr_addr, bl_addr, imm) 统一收集,最后一起 resolve+patch # 关键改动 2:检测 trampoline 自身是否也是一个"自带 LDR"的调用点 own_ldr_addr = idc.prev_head(tramp_bl) own_imm = None if own_ldr_addr != idc.BADADDR: own_imm = read_ldr_immediate(own_ldr_addr) if own_imm is not None: # trampoline 自己也是一次真实调用,纳入待处理列表 pending.append((own_ldr_addr, tramp_bl, own_imm)) # 表基址要跳过这条自用 LDR 对应的字面量池 DWORD table_addr = tramp_bl + 4 print(f" [i] {tramp_bl:#x} 前有自带 LDR({own_ldr_addr:#x}, imm={own_imm:#x})," f"表基址修正为 {table_addr:#x}") else: table_addr = tramp_bl + 4 print(f" 跳表基址 {table_addr:#x}") # 原有:搜集所有真正从外部 BL/B 过来的调用点 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 pending.append((ldr_addr, bl_addr, imm)) # 统一 resolve + patch for ldr_addr, bl_addr, imm in pending: 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() ``` ### 使用步骤 1. 先保持 `DRY_RUN = True` 运行,核对输出的目标地址是否合理 2. 确认无误后改 `DRY_RUN = False` 再跑,IDA 会自动重分析 3. 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` 这个固定的查表结构——无论变换参数怎么变,这三条指令的组合不变,因此可以可靠地自动化识别和还原。分析过相同类型对这套模式应该不陌生,脚本稍作调整即可复用。
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于
2026-7-15 16:40 被梧桐生编辑 ,原因: 修复优化脚本
#逆向分析
收藏
・
7
点赞
・
14
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
mb_obrkxtbn
感谢你的积极参与,期待更多精彩内容!
2026-8-15 19:45
wx_Q道长
感谢你分享这么好的资源!
2026-7-12 10:50
mb_hsscwjpz
感谢你分享这么好的资源!
2026-7-9 14:26
GoKu123
你的分享对大家帮助很大,非常感谢!
2026-7-6 22:12
zhongdong
为你点赞!
2026-6-18 17:59
fei3ei
非常支持你的观点!
2026-6-5 17:26
零_721409
为你点赞!
2026-6-5 15:29
P1umH0
非常支持你的观点!
2026-6-5 11:30
mb_wyefudor
你的帖子非常有用,感谢分享!
2026-6-3 19:32
zz1syyd
谢谢你的细致分析,受益匪浅!
2026-6-3 09:38
清风jw
非常支持你的观点!
2026-6-2 23:25
s1nec-1o
为你点赞!
2026-6-2 19:09
bluegatar
你的帖子非常有用,感谢分享!
2026-6-2 14:59
sinker_
为你点赞!
2026-6-2 07:06
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
3
)
Imxz
雪 币:
112
活跃值:
(9405)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
750
粉丝
9
关注
私信
Imxz
2
楼
tql
2026-6-1 16:30
0
x1a0f3n9
雪 币:
37
活跃值:
(1630)
能力值:
( LV2,RANK:10 )
在线值:
发帖
5
回帖
59
粉丝
90
关注
私信
x1a0f3n9
3
楼
感谢分享
2026-6-2 11:27
0
hyz111
雪 币:
151
活跃值:
(1098)
能力值:
( LV2,RANK:10 )
在线值:
发帖
3
回帖
47
粉丝
2
关注
私信
hyz111
4
楼
感谢分享
2026-6-4 16:46
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
梧桐生
5
发帖
9
回帖
50
RANK
关注
私信
他的文章
[原创]自定义LCG洗牌算法的种子空间坍缩分析与反推实践
208
[原创] QQ QIMEI参数分析:从历史版本追溯加密方案
28824
[原创]某手反混淆识别与处理
32283
[原创]某旅行 App 基于 LR 劫持的 ARM64 控制流混淆逆向分析
35986
[原创]某手游逆向全流程复盘:从 IL2CPP Dump 到 TCP 握手协议还原
59050
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部