首页
社区
课程
招聘
[原创]某旅行 App 基于 LR 劫持的 ARM64 控制流混淆逆向分析
发表于: 8小时前 187

[原创]某旅行 App 基于 LR 劫持的 ARM64 控制流混淆逆向分析

8小时前
187

某旅行 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(跳表基址)

这一步是整个混淆的核心——第二个 BLX30 覆盖成了紧跟其后的地址,而这个地址恰好就是跳表的起始位置。

第三步,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 原本的 LR0x158014)就此丢失,执行流不会返回到 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 自动化反混淆

整体思路

  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

完整脚本

import struct
import idc
import idaapi
import idautils
import ida_bytes
import ida_segment

DRY_RUN     = True   # 改 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)
        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()

使用步骤

  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 这个固定的查表结构——无论变换参数怎么变,这三条指令的组合不变,因此可以可靠地自动化识别和还原。分析过相同类型对这套模式应该不陌生,脚本稍作调整即可复用。


[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
免费 0
打赏
分享
最新回复 (1)
雪    币: 104
活跃值: (8672)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
tql
8小时前
0
游客
登录 | 注册 方可回帖
返回