用 Agent 逆向 IDA:定位 emulator 报错

2026–08–18

在研究 Windows ARM 二进制时,F5 报错 emulator:,搜索了一圈无果,故尝试逆向 IDA 相关二进制对这个问题进行溯源和解决。

Decompilation failure:
1800F45F0: emulator: 

有个玄学方法,先点击一个小函数,成功 F5 了后,再点击 1800F45F0 函数就可以成功反编译。不过这样也挺烦的,需要一个一个函数的点。

先搜索字符串:

/opt/ida-pro
> rg -a "emulator:" -l -g '*.so'
plugins/hexx64.so
plugins/hexrv.so
plugins/hexmips.so
plugins/hexarc.so
plugins/hexppc.so
plugins/hexarm.so
plugins/hexv850.so

我正在研究 arm 的二进制,那就直接调查 hexarm.so 了,发现字符串有两处函数调用,分别 hook 函数。

const printf = console.log;

function hook() {
    var so = Process.findModuleByName("hexarm.so").base;

    Interceptor.attach(so.add(0x331470), {
        onEnter: function(args) {
            printf("0x331470");
        }
    });

    Interceptor.attach(so.add(0x336B50), {
        onEnter: function(args) {
            printf("0x336B50");
        }
    });
}

setTimeout(hook, 5000);
frida -l hook.js -f /usr/bin/ida KernelBase.dll.i64 > frida-hooklog-$(date +%Y%m%d-%H%M%S).txt

-> 0x331470

打印堆栈

const printf = console.log;

function hook() {
    var so = Process.findModuleByName("hexarm.so").base;

    Interceptor.attach(so.add(0x331470), {
        onEnter: function(args) {
            printf("0x331470");
            printf(Thread.backtrace(this.context, Backtracer.FUZZY)
                .map(DebugSymbol.fromAddress)
                .join("\n"));
        }
    });
}

setTimeout(hook, 5000);
0x7f31d173c755 hexarm.so!0x33c755
0x7f31d1795846 hexarm.so!0x395846
0x7f322d905c75 libida.so!0x505c75
0x7f31d179b644 hexarm.so!0x39b644
0x7f31d17ba435 hexarm.so!0x3ba435
0x7f31d17bd71c hexarm.so!0x3bd71c
0x55fa23f61e80 ida!0x277e80
0x55fa23e68fff ida!0x17efff
0x55fa24162119 ida!0x478119
0x55fa23e6e78d ida!0x18478d
0x55fa2421addb ida!0x530ddb
0x55fa23fee93b ida!0x30493b
0x7f323040089e libQt6Core.so.6!0x20089e
0x7f323125b3f6 libQt6Gui.so.6!_ZN2QT7QAction9triggeredEb+0x46
0x7f323125e405 libQt6Gui.so.6!_ZN2QT7QAction8activateENS0_11ActionEventE+0xf5
0x7f323125f4b0 libQt6Gui.so.6!_ZN2QT7QAction5eventEPNS_6QEventE+0x70

调用 Agent 开始逆向。

现在我在分析,为什么我在 ida-pro 中按 F5 了之后会报错:

Decompilation failure:
1800F45F0: emulator:

我搜索 emulator: 找到了 hexarm.so 中的 0x331470 函数,里面有对一个字符串列表的调用,其中一个项就是 "emulator: %s",我用 frida hook 了之后打印堆栈。请你调用 ida-mcp 对程序进行分析,并找到根本原因,如果你需要更多的运行上下文,请修改 hook.js 让我 hook。将报告写入 report/

0x7f31d173c755 hexarm.so!0x33c755
0x7f31d1795846 hexarm.so!0x395846
0x7f322d905c75 libida.so!0x505c75
0x7f31d179b644 hexarm.so!0x39b644
0x7f31d17ba435 hexarm.so!0x3ba435
0x7f31d17bd71c hexarm.so!0x3bd71c
0x55fa23f61e80 ida!0x277e80
0x55fa23e68fff ida!0x17efff
0x55fa24162119 ida!0x478119
0x55fa23e6e78d ida!0x18478d
0x55fa2421addb ida!0x530ddb
0x55fa23fee93b ida!0x30493b
0x7f323040089e libQt6Core.so.6!0x20089e
0x7f323125b3f6 libQt6Gui.so.6!_ZN2QT7QAction9triggeredEb+0x46
0x7f323125e405 libQt6Gui.so.6!_ZN2QT7QAction8activateENS0_11ActionEventE+0xf5
0x7f323125f4b0 libQt6Gui.so.6!_ZN2QT7QAction5eventEPNS_6QEventE+0x70

Agent 找到了是 hrtng.so 的锅,升级之后就没问题了!

Agent’s blog

背景

在 IDA Pro 中反编译 Windows ARM64 的 KernelBase.dll,对一个函数按 F5 时,弹出了如下错误:

Decompilation failure:
1800F45F0: emulator:

KernelBase.dll 的基址是 0x180000000,所以 1800F45F0 对应 RVA 0xF45F0 的一个函数。比较反常的是 emulator: 后面没有任何描述文本。本文记录排查过程:从这条报错出发,顺藤摸瓜,定位到报错的实际来源——一个第三方插件 hrtng.so(KasperskyLab 出品)在微码处理器链上的 handler。

报错信息的来源

先在反编译器二进制里搜索 emulator 字符串。在 hexarm.so(ARM64 反编译器)里找到唯一一处 emulator: %s,地址 0x414297,它被一个名为 error_str_list 的字符串表引用。

注意:hexarm.so 尽管名字带 arm,实际是 ARM64 反编译器。它内部包含 "ARM64 Decompiler""HEXARM64" 等字符串。

引用 emulator: %s 的函数是 sub_331470,反编译后关键逻辑如下:

__int64 sub_331470(__int64 a1 /*输出*/, __int64 a2 /*错误码指针*/, __int64 a3)
{
  const char *msg = "";              // 默认空
  if (*(a2 + 24) != 0)               // 若携带了额外消息
    msg = *(const char **)(a2 + 16);
  int idx = 1 - *(int *)a2;          // 错误码 -> 表下标
  qsnprintf(s, 1024, error_str_list[idx], msg);   // 查表拼字符串
}

sub_331470 是一个错误消息格式化器:读一个错误码,在 error_str_list 中查格式串,拼出最终消息。

先 hook 它,打印调用栈,确定是谁在报错:

Interceptor.attach(base.add(0x331470), {
  onEnter: function(args) {
    console.log(Thread.backtrace(this.context, Backtracer.FUZZY)
        .map(DebugSymbol.fromAddress).join("\n"));
  }
});

得到的调用栈如下(地址均为模块偏移):

0x7f31d173c755 hexarm.so!0x33c755
0x7f31d1795846 hexarm.so!0x395846
0x7f322d905c75 libida.so!0x505c75
0x7f31d179b644 hexarm.so!0x39b644
0x7f31d17ba435 hexarm.so!0x3ba435
0x7f31d17bd71c hexarm.so!0x3bd71c
0x55fa23f61e80 ida!0x277e80
...
0x7f323125b3f6 libQt6Gui.so.6!_ZN2QT7QAction9triggeredEb+0x46

路径是:F5 热键(Qt QAction)→ ida 主程序 → hexarm.so 的反编译流程。

错误码 -35(m_emulator)

error_str_list0x4DE080)完整 dump 出来,是一张 37 项的错误码到格式串的映射表,关键几项:

下标 2   错误码 -1    INTERR: %s
下标 3   错误码 -2    cannot convert to microcode
下标 8   错误码 -7    switch analysis failed: %s
下标 35  错误码 -34   cloud: %s
下标 36  错误码 -35   emulator: %s

因此 emulator: 对应的错误码是 -35,即 Hex-Rays 的 m_emulator%s 是报错时附带的具体描述。

sub_331470 的调用者 sub_33C710 是打印 Decompilation failure 的错误报告器:

// sub_33C710 —— 错误报告器
sub_331470(&buf, err + 8, err);                       // 拼出 "emulator: %s"
sub_6CAE0("%a: %s\n", *(err + 16), buf);              // 写进反编译注释
if (!batch)
  sub_2B5270("Decompilation failure:\n%a: %s\n\n"
             "Please refer to the manual...", *(err + 16), buf);

其中 %a 是函数地址 0x1800F45F0%s 是格式化后的 emulator: ...。到这里报错文本的结构已经清楚,剩下两个问题:

  1. 错误码 -35 是谁、在哪一步产生的?
  2. 为什么 %s 是空的?

反编译调用链

sub_33C710 向上追,得到反编译的驱动链(均为 hexarm.so 偏移):

sub_3BD6C0 (插件动作) → sub_3BA420 → sub_39B5A0 (反编译驱动)
  → sub_394E60 (反编译主循环)
    → sub_14C840 → sub_14B9F0 (生成微码)

sub_14B9F0 是微码生成的主函数。问题被收敛到微码生成阶段。

一个误导:内层指令/操作数模拟器

搜索立即数 -35(0xFFFFFFDD),在二进制里找到 7 处 mov esi, 0FFFFFFDDh,都围绕着一个异常构造函数 sub_136DB0。它是 vd_failure_t(值定义失败)异常的构造函数:

// sub_136DB0 —— vd_failure_t 异常构造
*(int*)(a1 + 8)   = a2;      // 错误码
*(a1 + 16)        = a3;      // 地址
*(a1 + 24)        = copy(s); // 消息

这 7 处调用点分布在 4 个函数里,各自携带不同的消息:

sub_319160 (指令模拟器)   -> "no support for floating point"
sub_319160 (指令模拟器)   -> "unhandled opcode"
sub_319E50 (操作数模拟器) -> "unknown operand type"
sub_319E50 (操作数模拟器) -> "too big operand"

最初判断这就是答案:反编译器的指令/操作数模拟器遇到不支持的指令,抛出 m_emulator

但 hook sub_136DB0 后,发现整个反编译过程中它只在 0x3c44b 被调用了一次,而且消息为空。也就是说,上面那 4 处带消息的 throw 点根本没有执行。

这提醒了一点:静态分析能找到「能抛出这个错误的代码」,但不等于「本次实际抛出这个错误的代码」。需要运行时证据来确认。

实际路径:-35 是返回值,不是异常

重新看 hook 到的栈:sub_136DB0 的调用点 0x3c44b 位于 sub_14B9F0 的尾块,它上面的代码是:

0x14bd8d: call sub_33E5E0        ; 微码处理器链分发
0x14bd92: mov  r14d, eax         ; 返回值 -> r14d
0x14bd95: test eax, eax
0x14bd97: jz   <成功路径>
...
0x14bdd3: jz   0x3c422           ; 返回 -1 则跳 throw 块

关键在于 sub_33E5E0,反编译如下:

__int64 sub_33E5E0(a1, unsigned phase, ...)
{
    node = *(void**)(a1 + 120);              // 处理器链链表头
    while (node) {
        result = node->handler(node->ctx, phase, va);  // handler(ctx, phase, va_list)
        if (result) return result;           // 返回第一个非零错误码
        node = node->next;
    }
    return 0;
}

这是一个处理器链分发器:遍历链表,逐个调用 handler,谁先返回非零就用谁的。sub_14B9F0 在 phase=7 时调用它。-35 不是某个函数 throw 出来的,而是某个 handler 的返回值,它不带消息,因此 %s 为空。这也解释了报错中 emulator: 后面空白的原因。

处理器链上的模块

hook sub_33E5E0,把处理器链 dump 出来:

Interceptor.attach(base.add(0x33E5E0), {
  onEnter: function(args) {
    console.log("=== dispatcher phase=" + args[1].toInt32() + " ===");
    var node = args[0].add(120).readPointer(), i = 0;
    while (!node.isNull() && i < 64) {
      console.log("  [" + i + "] " + DebugSymbol.fromAddress(node.add(8).readPointer()));
      node = node.readPointer(); i++;
    }
  },
  onLeave: function(retval) {
    var r = retval.toInt32();
    if (r !== 0) console.log("=== dispatcher returns " + r + " ===");
  }
});

结果:这条链有 5 个 handler,分布在 4 个动态库:

[0] _ida_hexrays.so + 0x185020
[1] hrtng.so        + 0x49cc0
[2] goomba.so       + 0xe290
[3] hexarm.so       + 0x3eaa00
[4] hexarm.so       + 0x3d91a0

这条链其实是 Hex-Rays 的微码扩展机制:核心、架构后端、以及第三方插件,都会把自己的 handler 注册进这条链,按 phase 依次被调用。其中 _ida_hexrays.so 是核心,hexarm.so 是 ARM64 架构后端,而 hrtng.so 是卡巴斯基(KasperskyLab)出品的第三方插件。

hexarm.so 里那两个 handler 在 IDA 中的入口分别是 cmp esi,15cmp esi,74h,只处理 phase 15 和 phase 116,不处理 phase 7。因此返回 -35 的 handler 只能在前三个模块里。

日志同时给出了失败阶段:

=== dispatcher phase=7 ===
=== dispatcher phase=18 ===        ← phase 7 内部嵌套
=== dispatcher returns -35 (0xffffffdd) ===

失败发生在 phase 7(微码 emulator/优化阶段)。

逐个 hook,定位到 hrtng.so

最后逐个 hook 5 个 handler,看谁在 phase 7 返回 -35:

function hookHandler(mod, off, label) {
  var target = Process.getModuleByName(mod).base.add(off);
  Interceptor.attach(target, {
    onEnter: function(args) { this._phase = args[1].toInt32(); },
    onLeave: function(retval) {
      var r = retval.toInt32();
      if (r !== 0)
        console.log(">>> handler " + label + " phase=" + this._phase + " returns " + r);
    }
  });
}
hookHandler("_ida_hexrays.so", 0x185020, "[0] core");
hookHandler("hrtng.so",        0x49cc0,  "[1] hrtng");
hookHandler("goomba.so",       0xe290,   "[2] goomba");
hookHandler("hexarm.so",       0x3eaa00, "[3] hexarm-15");
hookHandler("hexarm.so",       0x3d91a0, "[4] hexarm-116");

运行一次 F5,日志输出:

>>> handler [1] hrtng (hrtng.so+0x49cc0) phase=7 returns -35 (0xffffffdd)
!!! THIS handler returned m_emulator=-35 !!!

返回 -35(m_emulator)的是 hrtng.so 在 phase 7 的 handler,而不是 hexarm.so 的内层指令模拟器,也不是 goomba.so

根因与解决

hrtnghttps://github.com/KasperskyLab/hrtng)是卡巴斯基实验室开源的 IDA Pro 插件,定位是「逆向分析工具集」,包含大量基于 Hex-Rays 微码的功能——去混淆(控制流反扁平化)、微码优化、微码签名、AVX 提升等。它通过 Hex-Rays 的微码扩展机制,把自定义 handler 注册进了上面这条处理器链。

因此,报错的根因是:hrtng 插件自己的微码优化器在 phase 7(emulator 阶段)无法处理目标函数 KernelBase.dll!0xF45F0 里的某条指令,于是返回 m_emulator(-35)。Hex-Rays 核心拿到这个非零错误码后,重抛 vd_failure_t,最终以空消息的形式弹出 Decompilation failure

解决方式是更新 hrtng 插件(或临时禁用),更新后该函数即可正常反编译。hrtng.so 位于 ~/.idapro/plugins/hrtng.so

总结

  1. 「能抛出这个错误的代码」和「本次实际抛出这个错误的代码」是两回事。静态分析找到的 4 处带详细消息的 throw 点(浮点不支持、未知 opcode 等)实际并未执行,真正的错误是一条返回裸错误码、不带消息的路径。需要运行时证据来区分。
  2. 报错信息本身是线索。emulator: %s 中空的 %s 暗示错误来自「返回值」而非「抛异常」的路径,因为该路径不携带描述文本。
  3. Hex-Rays 的微码处理器链(sub_33E5E0 遍历的链表)是一个扩展点,核心、架构后端、第三方插件都会注册 handler 进去。这次的错误并非 Hex-Rays 本身的问题,而是第三方插件 hrtng 的优化器返回的 m_emulator
  4. 排查方法:字符串定位 → 错误码破译 → 调用链回溯 → 动态 hook 验证 → 处理器链枚举 → 逐个 handler 定位。静态分析提供地图,动态插桩提供事实,两者结合。

最终,这条 Decompilation failure: 1800F45F0: emulator: 被完整还原为:第三方插件 hrtng 在微码处理器链 phase 7 的 handler 返回 m_emulator(-35)sub_14B9F0 拿到后重抛 vd_failure_t,最终以空消息的形式弹出该报错。