跳转至

Runtime Instrumentation of Qt6 Apps with Frida - Part 1: Getting Visibility

Ch01.010 Runtime Instrumentation of Qt6 Apps with Frida - Part 1: Getting Visibility

📊 Level ⭐ | 12.9KB | entities/runtime-instrumentation-of-qt6-apps-with-frida-part-1-getting-visibility.md

核心要点

  • QString 以 UTF-16 存储于堆缓冲区,标准 C 字符串 hook(如 strcmp)对其无效;需 hooking constData() / data() / utf16() 三个访问器
  • Qt 信号/槽分发经由 QMetaObject::activate,该函数无固定符号名,需枚举过滤匹配 PEB[UV]1 / PEAPEAX 模式的导出项
  • 符号被剥离的二进制中,方法名存在于 metaobject 的 stringdata 数组而非符号表,需通过 vtable 读取 QMetaObject* 再解析其内部数据结构
  • Q_INVOKABLE 方法可经由 qt_static_metacall 间接调用,跳过 QML UI 层直接触发业务逻辑

技术背景

Qt6 与传统 C/C++ 客户端有三处关键差异,使得通用逆向工具难以生效 : 1. QString 不是 C 字符串 — Qt 的字符串类型使用 UTF-16 堆内存储,带独立 size 字段,引用计数与写时复制通过 QArrayDataPointer 实现。strcmp / strncpy 这类 C 层面 hook 看不到任何有意义的数据 。 2. 方法分发隐藏于 QMetaObject::activate — UI 点击等事件最终转化为 activate 调用,但该函数没有 per-signal 符号可供断点,信号与槽的匹配在运行时通过 metaobject 完成 。 3. Qt 导出符号经过 name mangling,且目标二进制通常已stripped — 符号表被剥离后,函数名无法直接用于 hook,需通过枚举 Qt6Core.dll 导出并按模式匹配 。

四大技术路径

路径一:追踪 QString 读取

hook constData() / data() / utf16() 三个缓冲区访问器,它们在每次 QML 属性绑定或 meta-object 读取 QString 时触发 。实践中发现 toUtf8() / toLatin1() 这类公开转换方法并不从 app 代码直接执行,而是调用静态辅助函数如 ?toUtf8_helper@QString@@...,因此 hook 转换方法应寻找对应的 helper 而非 public 方法 。 在 Qt 6.11/Windows 构建中,mangled 符号名示例:

?constData@QString@@QEBAPEBVQChar@@XZ
?data@QString@@QEAAPEAVQChar@@XZ
?utf16@QString@@QEBAPEBGXZ
针对示例应用 HackPass 的实际效果:每次按键逐字增长密码时 constData 均会触发,最终捕获到 hackpass 完整密码、注册表路径 Software\HackPass\app、loopback 地址 127.0.0.1、UI 字符串等运行时数据 — 这些用 strings 命令无法提取 。

路径二:Tap Signals and Slots

hook QMetaObject::activate 的特定重载(通过枚举导出符号匹配 ?activate@QMetaObject@... 并过滤 PEB[UV]1 + PEAPEAX 模式),在回调中读取 sender 指针、sender 的 className() 与本地信号索引,再通过 QMetaMethod 解析信号名 。 关键设计:维护 Skip 列表过滤 QML 内部高频噪声类(QQuickTextQQuickRectangleQAnimationDriver 等),避免刷屏 。 信号追踪的价值在于:每个发射的信号都是后续拦截的候选目标 — vault 解密、网络请求、license 检查等业务逻辑的入口都可通过信号触发点顺藤摸瓜 。

路径三:Walk the Metaobject

给定一个 QObject*,通过 vtable slot 0 获取其 QMetaObject*,然后解析 stringdatadata 数组,枚举出所有方法、信号、槽与 Q_INVOKABLE,带上每个方法的 local index 。 local index 是后续调用 Q_INVOKABLE 的必要参数,继承方法与自有方法分别处理(通过递归遍历 superclass chain 累计 offset) 。 对 HackPass 的 VaultManager 实例枚举结果:

  • 继承自 QObject 的标准方法:destroyedobjectNameChangeddeleteLater
  • 自有信号:stateChangedunlockFailedtamperedShutdown
  • 可直接调用的 Q_INVOKABLEstateValuevaultPathselectFileunlockreUnlockAfterAutoLocksavelockautoLockclosecreateNew

路径四:Call Q_INVOKABLE from Outside

绕过 UI 层直接调用业务方法:通过 vtable 找到 qt_static_metacall 入口,构造参数列表后以 local index 触发任意 Q_INVOKABLE 方法 。 对 VaultManager 调用 callNoArg(ptr, 10) 即执行 lock 方法,效果等同于点击 UI 的 Lock Vault 按钮,但无需任何用户交互,可用于 fuzzing 和自动化测试 。

深度分析

为什么 QString hook 捕获的是运行时值而非静态字符串

传统 strings 工具扫的是可执行文件的 .rodata 段,只能提取编译时确定的常量。Qt6 应用大量字符串是运行时构造的:用户输入、配置文件路径、服务器响应解析出的字段名。这些数据以 UTF-16 堆缓冲区的形式存在于进程内存中,只有通过 QString 的三个内联访问器(constData / data / utf16)才能观测到,因为 Qt 的隐式共享机制绕过了 libc 字符串函数 。 constData 在每次 QML 属性绑定求值、property 阅读、signal 参数传递时均会触发,这使得单次按键也可以触发多次 hook(QML 层 + 绑定层 + meta-object 层),这是为什么 HackPass 密码的单个字符会被多个 constData 调用读取 。

Signal-tap 为什么需要过滤大量 QML 内部类

QML 引擎在渲染阶段会大量发射内部信号来驱动动画、材质、shader effect 等。QQuickTextQQuickRectangleQSGNode 相关类在正常用户操作(点击、输入)时会持续触发信号,如果不过滤会让输出被稀释到无法使用 。 值得注意的是,即使sender类被过滤,只要知道其地址,仍然可以通过 metaobject walker 枚举其完整接口 — signal-tap 的 skip 列表只是减少噪音,不影响深度分析 。

符号发现机制在不同 Qt6 版本间的可移植性

文章使用的符号匹配模式(?constData@QString@?activate@QMetaObject@ 等)基于 Qt 6.11 / MSVC 构建。不同 Qt 版本、不同编译器(GCC vs MSVC)、不同平台(macOS dylib 导出风格)的 mangled name 格式会有差异 。 qt-discover.js 的设计正是为了解决此问题:运行一次即可扫描当前 Qt6Core 中所有包含目标 pattern 的导出符号,实践者只需将扫描结果替换进 hook 脚本即可在不同版本间迁移 。

Metaobject 结构的逆向工程价值

QMetaObject 的内部布局(stringdata offset 16, data offset 24, methodOffset 通过递归计算而非固定字段)在 Qt 官方文档中不透明,但在逆向分析中极为关键:

  • mo.add(16).readPointer() → stringdata 基址
  • mo.add(24).readPointer() → data 节基址
  • data.add(20).readU32() → 方法表起始偏移
  • 每个方法 entry 占 6 bytes:stringdata 索引 这意味着无需任何符号,仅凭内存布局即可枚举任意 QObject 子类的完整接口。在 stripped binary 场景下,这几乎是唯一的接口发现手段 。

Invokable Call 的安全研究含义

通过 qt_static_metacall 绕过 UI 直接调用业务方法,在安全研究中对应两条攻击路径: 直接业务调用:无需 premium 账号即可调用 unlockfetch(PolicyClient)等方法,可能绕过付费墙(Part 2 将详述) Fuzzing 入口:传统 fuzzing 需要构造完整 UI 事件序列,通过 invokable call 可以直接以编程方式触发任意方法,绕过 QML 层的事件过滤和参数验证

与其他动态分析工具的能力边界对比

能力 Frida GDB/CDB 静态分析
QString 运行时读取 ✅ hook constData/data/utf16 ❌ 看不到 Qt 堆缓冲区 ❌ 无法捕获运行时值
Signal 追踪 ✅ hook activate + metaobject 解析 ⚠️ 需要符号
符号被剥离后方法枚举 ✅ vtable + metaobject 内存结构 ⚠️ 部分恢复
任意方法直接调用 ✅ invokable call via metacall
Frida 在 Qt6 场景下的优势来自其 JavaScript 运行时允许动态构造 Qt 类型(QString、meta-method 参数)并调用 mangled 符号,而传统调试器缺乏对 Qt 内部数据结构的理解。

实践启示

1. 环境准备时先运行符号发现脚本 — 不同 Qt 版本、平台、编译器的 mangled 符号名差异显著,正式开始分析前应先用 qt-discover.js 建立当前环境的符号映射,避免在错误符号上反复碰壁 。 2. 信号追踪与 metaobject 行走配合使用 — signal-tap 负责找到感兴趣的 QObject 地址,metaobject walker 负责从该地址枚举完整接口,二者构成"发现 → 枚举"的闭环,是分析未知应用的标准路径 。 3. 去重与过滤机制决定分析效率 — 无论是 QString trace 的 DEDUP 滑动窗口 + 路径过滤,还是 signal-tap 的 skip 类列表,都是防止刷屏淹没关键信号的手段。实际分析中应根据目标应用调整过滤规则 [raw/articles/runtime-instrumentation-of-qt6-apps-with-frida-part-1-getting-visibility:305-315]。 4. Invokable call 是绕过 UI 自动化的银弹 — 在需要大规模测试 Qt 应用业务逻辑的场景(如模糊测试、持续集成安全回归),加载三个脚本后通过 REPL 直接调用方法比模拟用户交互更可靠、更高效 。 5. Part 2 预告的攻击链值得持续关注 — 文章预告将覆盖 premium-gate bypass、五点 anti-debug 完整hook、vault 解密边界、SSL pinning bypass 等,说明第一部分只是可见性获取,后续还有完整的客户端防护突破路径 。

相关实体

原文存档