鸿蒙 DFX 踩内存(Memory Stomp / Misuse)检测能力用于监控 React Native for OpenHarmony(RNOH)框架及其接入应用运行期的内存误用问题。当发生堆/栈越界读写、释放后使用(UaF)、作用域后使用、重复释放时,可通过 ASAN(AddressSanitizer)/ HWASAN(HWAddressSanitizer) 两类编译期插桩检测器捕获故障,生成 FaultLog 日志;在保留匹配的未剥离 SO、BuildId 和源码版本时,可进一步符号化到具体代码行。生产环境另有 GWP-ASan 概率性采样检测器。

RNOH 的原生引擎层由 C++ 实现,核心 SO 包括 librnoh_core.so、librnoh_core_package.so、libreactnative.so、libjsvmtooling.so(JSVM 引擎)和 libhermestooling.so(Hermes 引擎)。踩内存问题在 RNOH 场景中高发于跨线程回调晚到、实例销毁后残留引用、NAPI/JSI 引用未释放、VSync/动画驱动与对象生命周期不同步等路径,历史修复记录覆盖 0.72 / 0.77 / 0.82 多个版本。


1 机制描述

鸿蒙 DFX 踩内存检测依赖 LLVM sanitizer 系列。ASan/HWASan 需要在编译期对目标 Native 模块插桩,并在应用侧开启对应运行环境;GWP-ASan 由系统堆分配器对部分分配进行采样。三类检测器互斥开启,按构建与场景择一。

1.1 ASan(AddressSanitizer)

机制:影子内存(shadow memory)——每 8 字节应用内存对应 1 字节 shadow,标记可访问性。栈/堆分配两侧插入红区(redzone),free 后内存进入 quarantine 隔离区保留影子标记;interceptor 拦截 libc(malloc/free/memset/memcpy 等)检查 shadow。

检测类型:堆/栈越界(heap/stack-buffer-overflow)、heap-use-after-free、stack-use-after-scope、double-free、invalid-free。对已插桩代码及其可观察到的分配/访问路径属于确定性检测;未插桩的第三方预编译 SO 不能视为全量覆盖。

RNOH 场景典型应用:RNOH 的核心 C++ 代码(RNOH/、RNOHCorePackage/)全部以源码参与构建,可通过 CMake 编译参数开启 ASan 插桩。libreactnative.so(上游 RN C++ 代码)同样以源码构建,可一并插桩。但 libjsvm.so(系统预编译)和 libhermesvm.so(预产物)属于未插桩的第三方 SO,其内部访问不在 ASan 检测范围内。

开销与适用:高开销(~2x 内存 + 减速),适合 debug 构建开发调试。

报告:ERROR: AddressSanitizer: <类型>,含访问栈 + 分配/释放栈 + shadow byte legend,详见 4.4 ASan 报告解读。

1.2 HWASan(HWAddressSanitizer)

机制:利用 AArch64 Top-Byte-Ignore(TBI)在指针高位保存地址标签,并为内存粒度保存内存标签;编译器在访问点插入标签校验,指针 tag 与内存 tag 不匹配时报告 tag-mismatch。不能简单理解为必须由 ARM MTE 指令在硬件中完成全部校验。

检测类型:heap/stack-buffer-overflow、use-after-free、double-free、invalid-free 等标签不匹配类问题。覆盖范围仍取决于目标 SO 是否按 HWASan 编译。

RNOH 场景典型应用:相对 ASan 内存开销更低,适合开发态真机调试与压测,尤其适合复现 RNOH 中偶发的 UAF 问题——如 ImageComponentInstance 图片回调晚到、JSVMPointerValue 销毁阶段 UAF、EventBeat 依赖的 RuntimeScheduler 已销毁等场景。鸿蒙官方当前说明要求 ROM 版本 5.0.0.107 及以上,并要求与其他 Sanitizer 互斥;具体支持范围以目标设备和 SDK 文档为准。

报告:ERROR: HWAddressSanitizer: tag-mismatch,含 tags(ptr/mem) + freed/allocated 栈 + Memory tags,详见 4.5 HWASan 报告解读。

1.3 GWP-ASan

机制:在堆分配器中采样一部分分配并放入受保护槽位,仅对命中采样的分配记录分配/释放信息并进行保护。槽位数量是配置项,API 20 的 GwpAsanOptions.maxSimutaneousAllocations 官方默认值为 1000,槽位用尽后新分配不再受监控,已释放槽位可复用。

检测类型:heap-use-after-free、heap-buffer-overflow、double-free、invalid/wild-free 等,仅限被采样且进入受保护槽位的分配(概率性,非全量)。

RNOH 场景典型应用:适合 Release 长期在线或灰度检测,尤其适合捕获生产环境中偶发的 UAF——如长时间运行后 MountingManagerCAPI 的悬空 componentName、RNInstanceInternal 销毁后 UITick 回调访问已释放实例等问题。需足够分配量与运行时长才有概率命中,偶发/短运行场景易漏检。官方 API 20 配置中 sampleRate 默认 2500,即约 1/sampleRate 的分配采样概率;实际 CPU/内存开销随采样率和槽位数变化。

开启:可在 app.json5 配置 "GWPAsanEnabled": true,API 20 及以上还可通过 HiDebug 的 enableGwpAsanGrayscale 配置灰度时长、采样率和槽位数;无需为每个 Native 模块增加 GWP-ASan 编译期插桩参数(不可与 ASan/HWASan 同时开启)。

报告:类 ASan 格式,首行含 GWP-ASan 标识,附访问栈 + 分配/释放栈,详见 4.6 GWP-ASan 日志解读。

GWP-ASan 为概率检测,不命中不代表无 bug;定位根因仍需 ASan/HWASan 在 debug 构建复现。

1.4 检测局限:预编译 SO 与 C++ 栈分配

RNOH 的核心 C++ 代码(RNOH/*.cpp、RNOHCorePackage/*.cpp)和上游 RN 代码(third-party/rn/)均以源码参与 CMake 构建,开启 ASan 时可对这些模块的 load/store 插入 __asan_* 检查。但以下组件属于预编译产物,不能通过 asanEnabled 或 -DOHOS_ENABLE_ASAN=ON 补上访问插桩:

RNOH C++ 源码 (RNOH/, RNOHCorePackage/, third-party/rn/)
  → 编译器对 load/store/mem intrinsic 插入 __asan_* / __hwasan_* 检查
  → 最终部署的 librnoh_core.so / librnoh_core_package.so / libreactnative.so 保留插桩
  → 运行时访问进入检测器

预编译第三方 SO
  → libjsvm.so (系统库, /system/lib64/ndk/)
  → libhermesvm.so (预产物, third-party/prebuilt/)
  → libv8_shared.so, libclang_rt.asan.so 等运行库
  → 仅配置 app.json5 / build-profile.json5 不能给这些 SO 补上 load/store 检查

区分错误类型:ASan 的 stack-buffer-overflow / stack-use-after-scope 针对编译器识别的 C/C++ 栈对象。RNOH 中常见的 ArkTSChannel 跨线程传递的 folly::dynamic、ComponentInstance 的 m_children vector 等属于堆分配,其越界或 UAF 通常表现为 heap-buffer-overflow 或 heap-use-after-free,不应预设为"栈红区"报告。

JSVM/Hermes 引擎内部访问:JS 引擎内部的 GC、对象分配和 JIT 代码访问不在 ASan/HWASan 的插桩范围内。当崩溃栈完全落在 libjsvm.so / libv8_shared.so 内部时,应优先检查 RNOH 侧的 JSVMPointerValue / HostObjectProxy 引用管理是否正确,而非期望引擎内部被 Sanitizer 检测。

结论:C/C++ 栈级报告与 JS 引擎内部访问问题应分开描述。分析 RNOH 踩内存问题时,先验证最终 librnoh_core.so 等产物的访问插桩(详见 4.2),再结合实际 FaultLog 判断检测边界;业务代码仍必须保证指针不逃逸、长度不越界,不能把 Sanitizer 未报告当作安全证明。


2 问题概述

2.1 RNOH 踩内存问题背景

RNOH 框架采用 C++ + ArkTS + JS 三层架构。原生引擎层(C++)负责渲染调度、组件管理、JS 引擎封装和 TurboModule 桥接。由于框架涉及大量跨线程交互(JS 线程、UI 线程、Worker 线程)、异步回调(VSync、Timer、HTTP、NAPI)和复杂的对象生命周期管理(shared_ptr/weak_ptr、NAPI reference、JSVM 引用),踩内存问题主要集中在以下路径:

  • 跨线程回调晚到:VSync/Display/Timer 回调在对象销毁后仍执行;
  • 实例销毁后残留引用:ComponentInstance 销毁但 EventEmitter 仍被回调;
  • NAPI/JSI 引用未释放:napi_ref / napi_value 超出 scope 或跨线程使用;
  • 临时对象 c_str() 逃逸:std::string 临时对象销毁后指针悬空;
  • JSVM 引用析构时序:JSVMPointerValue 在 env 销毁后仍操作 reference。

2.2 常见踩内存类型与场景

踩内存类型RNOH 典型场景
堆越界(heap-buffer-overflow)folly::dynamic 数组下标越界;m_children.insert 位置超过容器大小;TextLayoutManager::measureLines 读取错误位置索引
栈越界(stack-buffer-overflow)C++ 局部数组 char buf[N] 访问超过 N;ShadowView.componentName 临时 c_str() 指针逃逸到块外后被访问
堆释放后使用(heap-use-after-free)VSync 回调晚到访问已销毁 AnimatedNodesManager;图片回调读 m_imageSource.uri.c_str() 悬空;JSVMPointerValue 销毁阶段 UAF
栈作用域后使用(stack-use-after-scope)C++ 局部变量地址逃逸(如 isCAPIComponentByTag 中临时 std::string 的 c_str() 被填入 ShadowView 后延迟消费)
重复释放(double-free)NAPI napi_delete_reference 与 JSVMPointerValue 析构双重释放;napi_wrap 对象手动释放与引擎回收冲突
非法释放(invalid-free)free(ptr+N) 释放非 malloc 起始地址;手动释放 napi_get_arraybuffer_info 返回的引擎管理 buffer
跨线程裸指针访问Worker TurboModule 线程直接操作主线程 ComponentInstance;runUpdates 在非 JS 线程访问已销毁动画节点
NAPI/JSI 引用越界使用超出 napi_handle_scope 生命周期继续使用 napi_value;跨线程使用 napi_env / napi_ref;HostObjectProxy Unwrap 空指针

2.3 三类检测器对比与适用场景

检测器机制开销适用阶段RNOH 典型用途
ASan影子内存 + 红区高(~2x 内存)debug 开发调试确定性复现 UAF/越界;验证修复
HWASanTBI 指针标签校验中(低于 ASan)开发态真机压测偶发 UAF 真机复现;EventBeat/ImageComponentInstance
GWP-ASan堆分配采样保护低Release 灰度/长期生产环境偶发 UAF 捕获;需长时间运行

3 排查思路与整体分析

3.1 整体排查流程

拿到 faultlog 日志后,按以下顺序提取关键证据,逐步缩小根因范围:

日志首行 Reason + WRITE/READ 行
  ↓
① 确认故障类型和访问动作(读/写、大小、线程、地址区域)
  ↓
② 找"违规访问栈"(#0 首个业务帧 = 谁踩了这块内存)
  ↓
③ 找"释放栈"(freed by ... = 对象在哪个线程结束生命周期)
  ↓
④ 找"分配栈"(previously allocated by ... = 内存块真正所有者)
  ↓
⑤ 读取工具专属证据(shadow bytes / tags / Potential Cause)
  ↓
根因定位 → 解决方案

① 确认故障类型和访问动作

关注日志首行与 WRITE/READ 行,先确定:

  • 是读还是写(WRITE of size 4 / READ of size 8);
  • 访问大小;
  • 检测线程是谁(thread T0 为主线程 / thread T240 等,RNOH 中 JS 线程名通常为 RNOH_JS);
  • 地址属于堆、栈还是全局区(shadow bytes / tag 区域);
  • 是越界、释放后使用还是重复释放(heap-buffer-overflow / heap-use-after-free / double-free)。

② 找"违规访问栈"

这是发现错误的访问点,不一定是对象创建或释放点。日志中 WRITE of size 4 at 0x... 下方紧跟的调用栈即违规访问栈。在 RNOH 中,违规访问栈的首个业务帧通常落在以下模块:

栈帧模块特征可能的踩内存场景
rnoh::MountingManagerCAPI::handleMutationfindByTag() 返回空后未判空;m_children 越界
rnoh::ComponentInstance::insertChildm_children.insert 位置越界
rnoh::ImageComponentInstance::onComplete回调晚到读 m_imageSource.uri.c_str() 悬空
rnoh::AnimatedNodesManager::runUpdatesVSync 回调访问已销毁动画节点
rnoh::JSVMPointerValue::~JSVMPointerValue销毁阶段访问已失效 env
rnoh::EventBeat::induceRuntimeScheduler 已销毁

③ 找"释放栈"

UAF 日志一般包含 freed by thread ...,下方为释放栈。这一步回答:对象在哪个线程、通过什么路径结束生命周期。

如果访问线程和释放线程不同,应优先检查:

  • 异步回调晚到(VSync/Display 回调、HTTP 回调、Timer 回调);
  • 跨线程裸指针(Worker TurboModule 直接操作主线程对象);
  • Native 对象与 ArkTS/JS 对象生命周期不一致(ComponentInstance 销毁但 EventEmitter 仍被回调);
  • 回调注销、任务取消、weak_ptr / napi_ref 释放缺失;
  • 对象析构与后台任务并发(RNInstanceInternal 析构时 UITicker 仍在触发回调)。

④ 找"分配栈"

日志一般包含 previously allocated by thread ...,下方为分配栈。这一步用于确定:

  • 内存块真正的所有者;
  • 分配 API 与释放 API 是否匹配(如 malloc ↔ free、new ↔ delete、napi_create_reference ↔ napi_delete_reference、JSVMPointerValue::New ↔ 析构释放);
  • 释放方是否越权;
  • 缓冲区原始大小及访问偏移(越界时看合法区间与错误地址的偏移量,判断越界方向与距离)。

⑤ 读取工具专属证据

三类检测器的报告各有专属字段,按检测器类型读取:

ASan:

  • Shadow bytes 中 fd 通常表示已释放堆内存(Freed heap region);
  • fa 等值表示堆红区(Heap left redzone);
  • f1/f2/f3 分别对应栈对象周围不同红区(left/mid/right);
  • 对 overflow 要同时看对象合法区间 [32, 72) 和错误地址偏移 offset 72,判断越界方向与距离。

HWASan:

  • 先看 HWAddressSanitizer: tag-mismatch;
  • 比较 tags: d2/11 (ptr/mem) 中的指针 tag 与内存 tag;
  • Potential Cause / Cause 是候选原因,不能只凭这一行定案;
  • 结合访问栈、释放栈、分配栈和地址范围,区分 UAF 与 heap-buffer-overflow。

GWP-ASan:

  • 日志通常直接给出 Use After Free、Double Free 或 Invalid (Wild) Free;
  • 依次读取违规访问栈、释放栈、分配栈;
  • 因为是采样检测,复现一百次没报告也不能证明问题不存在——需结合长期运行或 ASan 复现。

3.2 选择检测器的决策路径

怀疑踩内存(崩溃地址不稳定 / 多次崩溃位置不同)
  ↓
是否能在 debug 构建稳定复现?
  ├─ 是 → 开启 ASan,确定性检测
  └─ 否(偶发)→ 真机 ROM ≥ 5.0.0.107?
       ├─ 是 → 开启 HWASan,低开销真机压测
       └─ 否 → 开启 GWP-ASan,Release 灰度采样
                   ↓
              GWP-ASan 命中 → 切 ASan/HWASan 在 debug 复现定位根因
              GWP-ASan 未命中 → 不能排除问题,需增加运行时长或改 ASan

4 分步排查过程(工具使用)

4.1 开启 Sanitizer 检测

4.1.1 使用约束

  • 任一模块使能 ASan 时,entry 模块必须同时使能,否则应用启动闪退并出现 CppCrash;
  • ASan / HWASan / GWP-ASan 互斥,不可同时开启;
  • 优化可能删除无可观察效果的故障测试代码;验证用例应通过 noinline/volatile C helper 或降低优化等级保留真实访问,不能用一次未上报证明没有问题;
  • RNOH 的 librnoh_core.so、librnoh_core_package.so、libreactnative.so、libjsvmtooling.so(或 libhermestooling.so)均以源码构建,开启 ASan 时可一并插桩;libjsvm.so 和 libhermesvm.so 为预编译产物,不受影响。

4.1.2 ASan 检测开启方式

方式一 调试窗口快速使能(DevEco Studio):运行调试窗口 → Diagnostics → 勾选 Address Sanitizer。引用本地 library 时,需在 library 模块 build-profile.json5 配置 "arguments": "-DOHOS_ENABLE_ASAN=ON" 以 ASan 模式编译 so。

方式二 配置文件方式使能:

  1. AppScope/app.json5 添加 "asanEnabled": true;
  2. 模块级 build-profile.json5 添加 "arguments": "-DOHOS_ENABLE_ASAN=ON",对该模块本次参与构建的 C/C++ 源码开启 ASan 编译;该参数不会改写已经预编译好的 SO。

RNOH 模块配置示例(react_native_openharmony/build-profile.json5):

{
  "buildOptionSet": {
    "debug": {
      "nativeCompiler": "BiSheng",
      "cppFlags": "-fstack-protector-strong -Wl,-z,noexecstack",
      "arguments": "-DOHOS_ENABLE_ASAN=ON"
    }
  },
  "targets": {
    "default": {
      "debugSymbol": {
        "strip": true,
        "exclude": [
          "librnoh_core.so",
          "librnoh_core_package.so",
          "libreactnative.so",
          "libjsvmtooling.so",
          "libhermestooling.so"
        ]
      }
    }
  }
}

debugSymbol.exclude 列表中的 SO 保留未剥离符号,用于 FaultLog 符号化回溯。开启 ASan 时建议保留该配置。

4.1.3 HWASan / GWP-ASan 检测开启方式

检测器app.json5 开关模块 build-profile.json5 arguments
ASan"asanEnabled": true-DOHOS_ENABLE_ASAN=ON
HWASan"hwasanEnabled": true-DOHOS_ENABLE_HWASAN=ON
GWPASan"GWPAsanEnabled": true无

HWASan 当前官方约束包括 ROM 版本 5.0.0.107 及以上,并与 ASan、TSan、UBSan、GWP-ASan 互斥。API 20 及以上如需灰度控制 GWP-ASan,可使用 HiDebug enableGwpAsanGrayscale(options, duration);其中 sampleRate 默认 2500,maxSimutaneousAllocations 默认 1000。

4.1.4 ASAN_OPTIONS 运行时配置

ASAN_OPTIONS 在运行时配置 ASan 行为(检测级别、输出格式、报告详细度)。支持在 app.json5 的 appEnvironments 配置(优先级高),或在 Run/Debug Configurations 配置,两种方式都配则以 app.json5 为准。

app.json5 配置示例:

{
  "app": {
    "appEnvironments": [
      { "name": "ASAN_OPTIONS", "value": "log_exe_name=true:abort_on_error=0:print_cmdline=true" }
    ]
  }
}

示例参数(按需配置,不建议整组照搬):

abort_on_error=0
halt_on_error=0
log_exe_name=true
detect_stack_use_after_return=0
malloc_context_size=30

不建议直接复制关闭 handle_segv、report_globals、alloc_dealloc_mismatch 等检查项的整组配置,否则可能主动缩小检测范围。参数默认值还可能随系统运行库版本变化,应以目标 ROM 的官方文档和实际日志为准。

常用参数详见附录 B。

4.2 RNOH C++ 产物插桩验证

RNOH 工程必须同时满足两层条件:

  1. CMake 编译层:通过 -DOHOS_ENABLE_ASAN=ON(或 -DOHOS_ENABLE_HWASAN=ON)对 RNOH/、RNOHCorePackage/、third-party/rn/ 等源码目录的 C++ 编译开启 sanitizer,生成带插桩的 librnoh_core.so 等;
  2. HarmonyOS 应用层:配置 app.json5 开关和 Native 模块构建参数,使应用加载匹配的 Sanitizer 运行环境。

仅完成第 2 层,不能给预编译的 libjsvm.so / libhermesvm.so 补上普通 load/store 检查。构建后必须检查最终打入 HAP 的文件,不要只检查 build-profile.json5 参数:

LLVM_BIN=/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/native/llvm/bin
RNOH_CORE=packages/tester/harmony/react_native_openharmony/src/main/cpp/libs/arm64-v8a/librnoh_core.so

# 检查动态依赖中是否包含 sanitizer 运行库
"$LLVM_BIN/llvm-readelf" -d "$RNOH_CORE" | grep -Ei 'asan|hwasan'

# 检查未定义符号中是否包含 sanitizer 访问检查函数
"$LLVM_BIN/llvm-nm" -u "$RNOH_CORE" | grep -E '__asan_|__hwasan_'

判断要点:

  • ASan 产物应能看到 libclang_rt.asan.so 依赖或 __asan_* 相关符号,HWASan 同理检查 libclang_rt.hwasan.so / __hwasan_*;
  • 还应反汇编故障访问函数,确认普通读写附近存在 Sanitizer 检查调用;只看到 malloc/free interceptor 不能证明普通访问已插桩;
  • 对 librnoh_core_package.so、libreactnative.so、libjsvmtooling.so 重复上述检查,确认所有以源码构建的 RNOH SO 均已完成插桩;
  • libjsvm.so、libhermesvm.so 等预编译产物检查结果应为空,这是预期行为,不表示这些库内部访问被检测。

4.3 故障日志获取

官方给出三条主要路径:

  1. DevEco Studio 的 FaultLog。
  2. HiAppEvent 订阅 hiAppEvent.event.ADDRESS_SANITIZER,从事件参数 external_log 获取日志。
  3. 打开开发者选项后,通过 hdc 导出:
hdc file recv /data/log/faultlog/faultlogger/XX.log ./faultlogger/

日志默认位于:

/data/log/faultlog/faultlogger/

文件名通常为:

[检测器类型]-[bundleName]-[uid]-[happenedTime].log

RNOH 崩溃日志中的关键 SO 标识:librnoh_core.so(RNOH 核心引擎)、librnoh_core_package.so(核心组件包)、libreactnative.so(上游 RN 核心)、libjsvmtooling.so(JSVM 引擎封装)、libhermestooling.so(Hermes 引擎封装)。符号化时需使用对应 BuildId 的未剥离版本,使用 llvm-addr2line 还原函数名和代码行:

LLVM_BIN=/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/native/llvm/bin
"$LLVM_BIN/llvm-addr2line" -e librnoh_core.so 0x58f50
# 输出示例: /path/to/RNOH/MountingManagerCAPI.cpp:123

4.4 ASan 报告解读

ASAN 报告首行 ERROR: AddressSanitizer: <类型>,后跟访问点 + 影子内存区域 + 分配/释放栈。常见类型:

ASAN 类型含义RNOH 触发条件
heap-buffer-overflow堆越界folly::dynamic 数组越界;m_children.insert 越界
stack-buffer-overflow栈越界C++ 局部数组访问越过对象边界(红区 f1/f2/f3)
heap-use-after-free堆释放后使用VSync 回调访问已销毁 AnimatedNodesManager;图片回调读悬空 uri
stack-use-after-scope栈作用域后使用isCAPIComponentByTag 中临时 std::string 的 c_str() 逃逸
double-free重复释放napi_delete_reference 与析构双重释放
attempt-free-nonallocated-memory非法释放手动释放 napi_get_arraybuffer_info 返回的 buffer

日志规格示例(含字段注释,以 RNOH 中常见的 heap-use-after-free 为例):

Device info:XXX                               <- 设备信息
Build info:XXX-XXXX x.x.x.xx(xxxxxxx)        <- 版本信息
Fingerprint:77cdc69cef71...                  <- 特征信息
Timestamp:xxxx-xx-xx xx:xx:xx.xxx            <- 时间戳
Module name:com.rnoh.tester                  <- 模块名
Version:1.0.0                                <- 版本号
Pid:62642                                    <- 进程号
Uid:20020185                                 <- 用户ID
Reason:heap-use-after-free                   <- 触发原因

==appspawn==62642==ERROR: AddressSanitizer: heap-use-after-free on address 0x0002013c0100 at pc 0x007c7e718f54 bp 0x007fffd59710 sp 0x007fffd59708  <- 问题概述
WRITE of size 4 at 0x0002013c0100 thread T240 (RNOH_JS)  <- 越界大小, RNOH JS 线程
    #0 0x7c7e718f50  (/data/storage/el1/bundle/libs/arm64/librnoh_core_package.so+0x58f50) (BuildId: ...)  <- 调用栈, 首个业务帧
    #1 0x7b5710235c  (/data/storage/el1/bundle/libs/arm64/libreactnative.so+0x4235c) (BuildId: ...)
    #2 0x7b74ee79a4  (/system/lib64/module/arkcompiler/stub.an+0x4f89a4)
    #3 0x7b749fa8c0  (/system/lib64/module/arkcompiler/stub.an+0xb8c0)

Shadow bytes around the buggy address:  <- 影子内存信息
  0x001ffffab2c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x001ffffab2d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x001ffffab2e0: 00 00 00 00 fa fa fa fa[fd]fd fd fd fd fd fd fd  <- [fd] 即已释放堆内存 (Freed heap region)
  0x001ffffab2f0: fd fd fd fd 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  ASan internal:           fe
==appspawn==62642==ABORTING

freed by thread T0 here:  <- 释放栈 (主线程销毁实例)
    #0 0x7c7e718de4  (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x58de4) (BuildId: ...)
    #1 0x7c7e718abc  (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x58abc) (BuildId: ...)

previously allocated here:  <- 申请栈
    #0 0x7c7e718000  (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x58000) (BuildId: ...)
    #1 0x7c7e717fcc  (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x57fcc) (BuildId: ...)

ASAN 报告关键部分:

  • 日志头:Device/Build/Fingerprint/Timestamp/Module/Pid/Uid/Reason,Reason 即踩内存类型;
  • 问题概述:ERROR: AddressSanitizer: <类型> on address ... at pc ...,含访问地址 + PC + BP + SP;
  • 访问点:WRITE/READ of size N at ... thread T0/T240,N 为访问字节数,RNOH JS 线程名为 RNOH_JS;
  • 调用栈:#0..#N 即越界点调用路径,首个业务帧是优先排查的违规访问点;so+偏移需结合匹配 BuildId 的未剥离 SO 做 llvm-addr2line 符号化;
  • 影子内存:Shadow bytes around the buggy address + legend,[fd] 等标记即越界到的区域类型;
  • 三栈对照:访问栈(#0)+ 分配栈(allocated by ... here)+ 释放栈(freed by ... here),用于定位 free 点与误用点的时间差和线程差异。

4.5 HWASan 报告解读

HWASAN 利用 AArch64 TBI 保存指针标签,并通过编译期插桩校验指针标签与内存标签。日志格式与 ASan 相近,但额外输出指针标签与内存块标签(tags: ptr/mem),通过对比标签判断非法访问。

日志规格示例(含字段注释,以 RNOH 中 EventBeat UAF 为例):

Device info:XXX                               <- 设备信息
Build info:XXX-XXXX x.x.x.xx(xxxxxxx)        <- 版本信息
Fingerprint:77cdc69cef71...                  <- 特征信息
Timestamp:xxxx-xx-xx xx:xx:xx.xxx            <- 时间戳
Module name:com.rnoh.tester                  <- 模块名
Pid:62642                                    <- 进程号
Uid:20020185                                 <- 用户ID
Reason:use-after-free                        <- 触发原因

==appspawn==62642==ERROR: HWAddressSanitizer: tag-mismatch on address 0x0002013c0100 at pc 0x00651a5c0fa0  <- 问题概述
WRITE of size 4 at 0x0002013c0100 tags: d2/11 (ptr/mem) in thread T240 (RNOH_JS)  <- 指针tag/内存tag 不匹配
    #0 0x651a5c0fa0  (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x2c0fa0) (BuildId: ...)  <- 调用栈
    #1 0x651a49bfb0  (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x19bfb0) (BuildId: ...)
    ...

[0x0002013c0100,0x0002013c0140) is a small allocated heap chunk; size: 64 offset: 0  <- 被踩内存信息

Potential Cause: use-after-free  <- 可能的越界原因1
0x0002013c0100 (old ptr tags: 5d) is located 0 bytes inside of 48-byte region [0x0002013c0100,0x0002013c0130)
freed by thread T0 here:  <- 释放栈
    #0 0x5a3f868f98  (/system/asan/lib64/libclang_rt.hwasan.so+0x28f98) (BuildId: ...)
    #1 0x650009d6b4  (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x1d6b4) (BuildId: ...)
    ...

previously allocated here:  <- 申请栈
    #0 0x5a3f868ce8  (/system/asan/lib64/libclang_rt.hwasan.so+0x28ce8) (BuildId: ...)
    #1 0x650009a840  (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x1a840) (BuildId: ...)
    ...

Memory tags around the buggy address (one tag corresponds to 16 bytes):  <- tag memory 储存 tag,与指针 tag 比对
  0x005c2013bf90: b6 b6 08 00 1f 1f 08 00 5a 5a 08 00 45 45 08 00
  ...
=>0x005c2013c010:[11] 11 08 00 a5 a5 08 00 13 13 08 00 2e 2e 08 00  <- [11] 即被踩内存的 tag,与指针 tag d2 不匹配
  ...

Registers where the failure occurred (pc 0x00651a5c0fa0):  <- 故障时寄存器
    x0  ec00000201e4ee88  x1  3c000004004c6b38  x2  0000000000000000  x3  0000000000000000
    ...

SUMMARY: HWAddressSanitizer: tag-mismatch (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x2c0fa0)

Process memory map follows:  <- 故障时进程内存空间
 0x000000010000-0x000100020000 rw-p 00000000 [anon:SizeClassAllocator]
 ...

HWASAN 报告关键部分(与 ASan 的差异):

  • **tags: d2/11 (ptr/mem)**:指针 tag(d2)与内存 tag(11)不匹配即触发,是 HWASAN 的核心判据;
  • Potential Cause / **Cause**:HWASan 只报 tag-mismatch,具体子类型(use-after-free / heap-buffer-overflow)在此推断,结合 freed by ... here 释放栈 + previously allocated here 申请栈定位;
  • **Memory tags around the buggy address**:tag memory 储存的 tag 列表,每 tag 对应 16 字节,[11] 即被踩内存的 tag;
  • Registers where the failure occurred + **Memory near registers**:故障寄存器与周边内存,辅助排查;
  • 真机 hwasanEnabled 开启后适合开发态复现偶发 UaF(如 RNOH 中 EventBeat / RuntimeScheduler 销毁后 UITicker 触发回调的场景)。

4.6 GWP-ASan 日志解读

GWP-ASan 的日志格式较为简洁,以下示例为典型的 Use-After-Free 问题日志:

Device info:XXX                                      <- 设备信息
Build info:XXX-XXXX x.x.x.xx(xxxxxxx)               <- 版本信息
Fingerprint:c41391f9c18acc1121ea519ffdba5698bfb5342ae7125e20ebf2865e31249f1a  <- 特征信息
Timestamp:xxxx-xx-xx xx:xx:xx.xxx                   <- 时间戳
Module name:com.rnoh.tester                         <- 模块名
Version:1.0.0                                       <- 版本号
Pid:13305                                           <- 进程号
Uid:20020181                                        <- 用户ID
Reason:GWP-ASAN                                     <- Reason 固定为 GWP-ASAN

*** GWP-ASan detected a memory error ***
Use After Free at 0x5b46ddaff0 (0 bytes into a 16-byte allocation at 0x5b46ddaff0) by thread 13305 here:  <- 问题概述(UAF)
  #0 0x5c474f049c (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x3049c)  <- 违规访问栈
  #1 0x5c474fa8c0 (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x3a8c0)
  ...

0x5b46ddaff0 was deallocated by thread 13305 here:   <- 释放栈
  #0 0x5aa0c0be2c (/lib/ld-musl-aarch64.so.1+0x13de2c)
  #1 0x5aa0c0b97c (/lib/ld-musl-aarch64.so.1+0x13d97c)
  #2 0x5c474f0494 (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x30494)
  ...

0x55b46ddaff0 was allocated by thread 13305 here:    <- 分配栈
  #0 0x5aa0c0be2c (/lib/ld-musl-aarch64.so.1+0x13de2c)
  #1 0x5aa0c0b77c (/lib/ld-musl-aarch64.so.1+0x13d77c)
  #2 0x5aa0c22e5c (/lib/ld-musl-aarch64.so.1+0x154e5c)
  #3 0x5c474f047c (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x3047c)
  ...

* End GWP-ASan report *

GWP-ASan 日志三栈结构与 ASan 一致:违规访问栈(Use After Free ... by thread ... here)+ 释放栈(was deallocated ... here)+ 分配栈(was allocated ... here)。Reason 固定为 GWP-ASAN,与 ASan 的 heap-buffer-overflow / HWASan 的 tag-mismatch 区分。因采样检测,同一代码路径需多次复现才有概率命中。


5 根因定位

结合 faultlog 报告首行类型、访问栈 #0、分配/释放栈,按以下模式逐项定位根因。以下案例均基于 RNOH 历史修复记录,涵盖 0.72 / 0.77 / 0.82 版本。

5.1 堆释放后使用:VSync 回调晚到访问已销毁动画节点

日志特征:报告 ERROR: AddressSanitizer: heap-use-after-free,访问栈 #0 指向 AnimatedNodesManager::runUpdates(JS 线程),释放栈指向 RNInstanceInternal 析构(主线程)。

三栈分析:

  • 访问栈:runUpdates → 访问已销毁的动画节点对象;
  • 释放栈:RNInstanceInternal::~RNInstanceInternal() → 主线程销毁实例,释放了 AnimatedNodesManager;
  • 分配栈:RNInstanceInternal::onAnimationStarted() → 注册 UITick 回调时捕获了 this 裸指针。

根因:VSync/UITick 回调捕获了实例的裸指针(this),在实例销毁后仍执行 runUpdates,访问已释放的 AnimatedNodesManager 节点。访问线程(JS)与释放线程(主线程)不同,是典型的跨线程回调晚到 UAF。

历史案例:0.77.18 — fe72ed2ed / !790

5.2 堆释放后使用:图片回调读悬空 URI

日志特征:报告 ERROR: AddressSanitizer: heap-use-after-free,访问栈指向 ImageComponentInstance::onComplete 中 strlen + jsi::String::createFromAscii(JS 线程),释放栈指向主线程实例销毁。

三栈分析:

  • 访问栈:ON_LOAD 回调 → m_imageSource.uri.c_str() → 解引用悬空指针;
  • 释放栈:主线程 ImageComponentInstance::~ImageComponentInstance() → uri 字符串已释放;
  • 分配栈:ImageComponentInstance::setImageSource() → m_imageSource.uri 分配。

根因:JS 线程执行 ON_LOAD 回调时直接读取 m_imageSource.uri.c_str(),但主线程已销毁 ImageComponentInstance 实例,uri 字符串已释放。回调中通过 [=] 捕获了 this 指针,但 this 指向的对象已不存在。

历史案例:

5.3 栈作用域后使用:ShadowView 组件名悬空指针

日志特征:报告 ERROR: AddressSanitizer: stack-use-after-scope,访问栈指向 MountingManagerCAPI::isCAPIComponentByTag。

三栈分析:

  • 访问栈:后续调用链消费 ShadowView.componentName(const char*)→ 读到失效内存;
  • 释放栈:无显式 free——临时 std::string 在语句结束时自动析构;
  • 分配栈:instance->getComponentName() 返回 const std::string&,c_str() 取其内部 buffer 指针。

根因:isCAPIComponentByTag 先从组件实例取出组件名(返回 const std::string&),再把临时字符串的 c_str() 填给 ShadowView.componentName。临时对象在语句结束后析构,c_str() 指针悬空。后续调用链延迟消费该字段时读到失效内存。

历史案例:0.82.18 — ab62e9d2f / !2384

5.4 空指针与越界:ComponentInstance 子节点操作

日志特征:报告 SIGSEGV 或 heap-buffer-overflow,访问栈指向 ComponentInstance::insertChild 或 MountingManagerCAPI::removeChild。

三栈分析:

  • 访问栈:parent->removeChild(child) 或 m_children.insert(pos, child) → 解引用空指针或越界写;
  • 释放栈:组件已被 ComponentInstanceRegistry 注销或 surface stop 时清除;
  • 分配栈:ComponentInstanceRegistry::insert(tag, instance) → 返回的 shared_ptr 已过期。

根因:findByTag() 在组件未注册或已销毁时返回空指针,调用方未判空直接使用;或 m_children.insert(pos, child) 的 pos 超过 m_children.size(),越界写入导致 heap-buffer-overflow。崩溃地址不稳定时优先考虑踩内存并启用 ASan。

历史案例:

5.5 JSVM 引用生命周期:销毁阶段 UAF

日志特征:报告 ERROR: AddressSanitizer: heap-use-after-free 或 libjsvm.so Mapping error82,访问栈指向 JSVMPointerValue::~JSVMPointerValue 或 HostObjectProxy::Enumerator。

三栈分析:

  • 访问栈:JSVMPointerValue 析构 → OH_JSVM_DeleteReference(env, reference) → env 已失效;
  • 释放栈:JSVMRuntime::~JSVMRuntime() → OH_JSVM_DestroyEnv(env) → env 被关闭;
  • 分配栈:JSVMPointerValue::New(env, value) → 创建引用。

根因:JSVM 环境销毁期间,JSVMPointerValue 仍尝试删除引用或清理包装对象,导致 OH_JSVM_DestroyEnv 过程中访问已失效的 C++/JSVM 状态。JSVMRuntime::~JSVMRuntime 的析构顺序错误——先释放了指针值链表,导致 env 仍在使用中被销毁;或 HostObjectProxy::Enumerator 中 Unwrap 非 wrapped host object 得到空对象后直接解引用。

历史案例:

5.6 NAPI 引用与 Buffer 误释放

日志特征:报告 ERROR: AddressSanitizer: double-free 或 attempt-free-nonallocated-memory。

三栈分析:

  • 访问栈:第二次 free 或 delete 同一地址 → double-free;或 free(data) 释放引擎管理的 buffer → invalid-free;
  • 释放栈:napi_wrap finalizer → delete static_cast<MyClass*>(hint) → 引擎 GC 回收时释放;
  • 分配栈:业务代码手动 new MyClass() + napi_wrap() → 或 napi_create_arraybuffer() → 引擎分配 buffer。

根因:

  1. napi_wrap 对象在手动 delete 与引擎 GC 回收时双重释放——finalizer 和手动释放同时存在;
  2. 手动释放 napi_get_arraybuffer_info / napi_get_buffer_info 返回的 buffer 指针——ArrayBuffer 内存由引擎管理,手动 free 会 double free;
  3. 跨线程使用已失效的 napi_env 或超出 napi_handle_scope 生命周期继续使用 napi_value。

历史案例:RNOH 稳定性编码规范 NAPI 章节覆盖此类模式(稳定性编码规范)。

5.7 悬空引用:EventBeat 关闭阶段访问已销毁调度器

日志特征:报告 SIGSEGV 或 heap-use-after-free,访问栈指向 EventBeat::induce。

三栈分析:

  • 访问栈:UITicker 触发 → EventBeat::induce() → 访问 RuntimeScheduler 已释放对象;
  • 释放栈:shutdown 过程 → RuntimeScheduler 先于 EventBeat 被销毁;
  • 分配栈:EventBeat 构造时持有 RuntimeScheduler 的裸指针或引用。

根因:shutdown 过程中 UITicker 仍可能触发 EventBeat::induce(),而其依赖的 RuntimeScheduler 已先被销毁,导致悬空引用。EventBeat 和 RuntimeScheduler 的析构顺序没有保证 EventBeat 先于 RuntimeScheduler 停止回调。

历史案例:0.77.22 — 2df264852 / !1595


6 解决方案

6.1 VSync 回调晚到:弱引用 + 回调注销

// 问题代码:回调捕获裸指针
void AnimatedTurboModule::startListening() {
    m_vsyncListener->setOnVSync([this]() {
        m_animatedNodesManager->runUpdates();  // ← this 可能已销毁
    });
}

// 修复:使用 weak_ptr 防止销毁后访问
void AnimatedTurboModule::startListening() {
    std::weak_ptr<AnimatedTurboModule> weakSelf = weak_from_this();
    m_vsyncListener->setOnVSync([weakSelf]() {
        auto self = weakSelf.lock();
        if (!self) return;  // ← 实例已销毁,安全退出
        self->m_animatedNodesManager->runUpdates();
    });
}

修复要点:

  • 异步回调(VSync、Display、Timer、HTTP、NAPI)在对象销毁后仍可能执行,回调入口必须检查对象有效性;
  • 使用 weak_ptr<T> weakSelf = weak_from_this(),回调中 auto locked = weakSelf.lock(); if (!locked) return;;
  • 析构函数或 destroy() 方法中必须停止所有异步源:VSync 回调注销、Timer 取消、EventBeat 解绑、observer 反注册;
  • 参考 0.77.18 修复 fe72ed2ed / !790。

6.2 图片回调悬空 URI:按值拷贝进 lambda capture

// 问题代码:回调直接读取实例成员的 c_str()
void ImageComponentInstance::onComplete() {
    m_eventEmitter->emitEvent("onLoad", [=]() {
        auto event = jsi::Object(rt);
        event.setProperty(rt, "uri", m_imageSource.uri.c_str());  // ← 实例可能已销毁
        return event;
    });
}

// 修复:按值拷贝进 lambda capture,使回调持有自己的字符串副本
void ImageComponentInstance::onComplete() {
    std::string uriCopy = m_imageSource.uri;  // ← 按值拷贝
    m_eventEmitter->emitEvent("onLoad", [uriCopy]() {
        auto event = jsi::Object(rt);
        event.setProperty(rt, "uri", uriCopy.c_str());  // ← 使用自己的副本
        return event;
    });
}

修复要点:

  • 跨线程回调中不得直接读取实例成员的指针或 c_str(),应按值拷贝进 lambda capture;
  • 图片加载回调天然异步且晚到,必须假设回调执行时实例可能已销毁;
  • ASan 通过 quarantine 隔离区检测已释放内存访问;HWASan 通过重新标记内存并检查 tag mismatch;
  • 参考 0.82.3 修复 409e3d355 / !1985。

6.3 ShadowView 组件名悬空:按值保存延长生命周期

// 问题代码:临时字符串 c_str() 逃逸到 ShadowView
bool MountingManagerCAPI::isCAPIComponentByTag(Tag tag) {
    auto instance = m_componentInstanceRegistry->findByTag(tag);
    auto name = instance->getComponentName();  // ← 返回 const std::string&
    ShadowView shadowView;
    shadowView.componentName = name.c_str();   // ← 临时对象销毁后悬空
    // ... 后续延迟消费 shadowView.componentName
}

// 修复:先保存到局部 std::string,用该字符串构造 ShadowView
bool MountingManagerCAPI::isCAPIComponentByTag(Tag tag) {
    auto instance = m_componentInstanceRegistry->findByTag(tag);
    if (!instance) return false;  // ← 同时补充空指针检查
    std::string componentName = instance->getComponentName();  // ← 按值保存
    ShadowView shadowView;
    shadowView.componentName = componentName.c_str();  // ← 作用域内有效
    // ...
}

修复要点:

  • c_str() 返回的指针生命周期与原 std::string 绑定,临时对象销毁后即为悬空指针;
  • ASan 的 stack-use-after-scope(f8 标记)描述 C/C++ 栈对象作用域结束后访问;
  • 按值保存到局部变量,延长生命周期到整个作用域结束;
  • 参考 0.82.18 修复 ab62e9d2f / !2384。

6.4 ComponentInstance 越界:判空 + 边界校验

// 问题代码:findByTag 返回空后直接使用
void MountingManagerCAPI::removeChild(Tag parentTag, Tag childTag) {
    auto parent = m_componentInstanceRegistry->findByTag(parentTag);
    auto child = m_componentInstanceRegistry->findByTag(childTag);
    parent->removeChild(child);  // ← parent 或 child 可能为空
}

// 修复:判空保护
void MountingManagerCAPI::removeChild(Tag parentTag, Tag childTag) {
    auto parent = m_componentInstanceRegistry->findByTag(parentTag);
    auto child = m_componentInstanceRegistry->findByTag(childTag);
    if (!parent || !child) return;  // ← 空指针保护
    parent->removeChild(child);
}

修复要点:

  • findByTag() 在组件未注册或已销毁时返回空,所有调用方必须判空;
  • m_children.insert(pos, child) 的 pos 必须在 [begin, end] 范围内,越界插入导致 heap-buffer-overflow 或未定义行为;
  • 普通运行中 SIGSEGV 不一定由 ASan 检测,但崩溃地址不稳定时优先考虑踩内存并启用 ASan;
  • 参考 0.72 / 0.77 历史修复(0.72 内存异常 #18-#21、0.77 内存异常 #6, #9)。

6.5 JSVM 销毁阶段 UAF:调整析构顺序 + 原型链查找

// 问题代码:销毁阶段仍从 JSVMPointerValue 调用 OH_JSVM_DeleteReference
JSVMPointerValue::~JSVMPointerValue() {
    if (reference != nullptr) {
        OH_JSVM_DeleteReference(env, reference);  // ← env 可能已失效
    }
}

// 修复:销毁阶段不再触碰正在关闭的 env,调整析构顺序
JSVMRuntime::~JSVMRuntime() {
    // 1. 先销毁 JSVM env/VM
    OH_JSVM_DestroyEnv(env);
    OH_JSVM_DestroyVM(vm);
    // 2. 再释放指针值链表
    ReleasePointerValueList();  // ← 移到 VM 销毁之后
}
// 问题代码:Enumerator Unwrap 失败直接解引用空指针
JSVM_Value HostObjectProxy::Enumerator(JSVM_Env env, JSVM_Value properties, JSVM_Value object) {
    auto hostObject = Unwrap(object);  // ← 非 wrapped host object 时返回空
    hostObject->getKeys(keys);         // ← 空指针解引用
}

// 修复:Unwrap 失败时沿原型链继续查找
JSVM_Value HostObjectProxy::Enumerator(JSVM_Env env, JSVM_Value properties, JSVM_Value object) {
    auto hostObject = Unwrap(object);
    if (!hostObject) {
        hostObject = FindHostObjectOnPrototypeChain(object);  // ← 沿原型链查找
        if (!hostObject) {
            // 确实找不到时抛错,而非直接解引用空指针
            throw JSVMError("Not a wrapped HostObject");
        }
    }
    hostObject->getKeys(keys);
}

修复要点:

  • JSVM 引用包装对象的析构顺序必须保证 env 存活时才操作 reference;
  • OH_JSVM_DestroyEnv 期间不应从 JSVMPointerValue 调用 OH_JSVM_DeleteReference;
  • HostObjectProxy::Enumerator 中 Unwrap 失败时应沿原型链继续查找,不应直接对空指针执行枚举逻辑;
  • 参考 0.82.17-0.82.18 修复 7029f4747 / !2337、431297d2e / !2361、15c664801 / !2387。

6.6 NAPI 引用误释放:唯一所有者 + 引擎管理边界

// 问题代码:手动释放引擎管理的 buffer
napi_value arraybuffer;
void* data;
size_t length;
napi_get_arraybuffer_info(env, arraybuffer, &data, &length);
free(data);  // ← double-free:ArrayBuffer 内存由引擎管理

// 修复:不要手动释放引擎管理的 buffer
// data 的生命周期与 ArrayBuffer 对象绑定,由引擎 GC 回收

// 问题代码:napi_wrap 对象双重释放
napi_wrap(env, jsObject, nativePtr, [](napi_env, void* hint) {
    delete static_cast<MyClass*>(hint);  // ← 引擎回收时调用
}, nullptr, nullptr);
// ... 其他地方又手动 delete nativePtr  ← double-free

// 修复:由唯一所有者释放一次
// 要么由 wrap 的 finalizer 负责释放,要么手动释放后调用 napi_remove_wrap

修复要点:

  • napi_create_reference 必须配对 napi_delete_reference;
  • napi_get_arraybuffer_info / napi_get_buffer_info 拿到的 buffer 指针由引擎管理,手动 free 会 double free;
  • napi_wrap 的 finalizer 和手动释放只能存在一个,不能同时执行;手动释放后应调用 napi_remove_wrap;
  • napi_value 的生命周期受 napi_handle_scope 控制,超出 scope 后不应继续使用;
  • napi_env / napi_ref / napi_value 均不可跨线程使用(非 JS 线程调用 NAPI 直接引发崩溃)。

6.7 EventBeat 悬空引用:weak_ptr + 析构顺序

// 问题代码:EventBeat 持有 RuntimeScheduler 裸指针
class EventBeat {
    RuntimeScheduler* m_scheduler;  // ← 裸指针,可能悬空
public:
    void induce() {
        m_scheduler->scheduleWork();  // ← m_scheduler 可能已销毁
    }
};

// 修复:改用 weak_ptr + 析构时停止回调
class EventBeat {
    std::weak_ptr<RuntimeScheduler> m_schedulerWeak;
    std::mutex m_mutex;  // ← 保护 listener 并发访问
public:
    void setScheduler(std::shared_ptr<RuntimeScheduler> scheduler) {
        m_schedulerWeak = scheduler;
    }
    void induce() {
        auto scheduler = m_schedulerWeak.lock();
        if (!scheduler) return;  // ← 调度器已销毁,安全退出
        scheduler->scheduleWork();
    }
};

修复要点:

  • EventBeat 和 RuntimeScheduler 的析构顺序必须保证 EventBeat 先停止回调;
  • 使用 weak_ptr 代替裸指针,回调中 lock() 后判空;
  • 对 m_unsubscribeUITickerListener 等共享访问的成员加互斥锁,避免一个线程解绑时另一个线程同时注册或访问;
  • 参考 0.77.22 修复 2df264852 / !1595。

6.8 修复验证

修复后至少完成以下验证闭环:

  1. 相同 sanitizer + 相同复现场景:使用与发现问题时一致的 sanitizer 和复现路径,确认不再上报 CppCrash;
  2. 扩大边界覆盖:增大循环次数并覆盖前后台切换、页面销毁、并发回调等生命周期边界(RNOH 中重点覆盖 Surface stop/reload、RNInstance 销毁、动画中断、图片快速切换);
  3. 交叉验证:用另一种工具交叉验证,例如 HWASan 修复后用 GWP-ASan 长时间压测,或 ASan 修复后切 HWASan 真机验证;
  4. 保留构建产物:保留精确的未剥离 SO(librnoh_core.so、librnoh_core_package.so、libreactnative.so、libjsvmtooling.so)、BuildId、源码版本和构建参数,确保日志栈可随时回溯符号化。

GWP-ASan 特别注意:不能把"一次未复现"作为 GWP-ASan 问题关闭依据——采样检测不命中不代表无 bug,需足够运行时长与分配量。


7 经验总结

7.1 RNOH 踩内存高频模块速查

基于 0.72 / 0.77 / 0.82 历史修复记录(40+ 条内存异常修复),以下模块是踩内存问题的高频区域,分析日志时应优先关注:

模块高频问题类型典型触发场景
AnimatedNodesManager / AnimatedTurboModuleUAF、空指针VSync 回调晚于实例销毁
ImageComponentInstance悬空指针、UAF图片回调晚于实例销毁,读 m_imageSource.uri
MountingManagerCAPI悬空指针、空指针、越界isCAPIComponentByTag 临时字符串逃逸;findByTag 返回空
ComponentInstance / ComponentInstanceRegistry空指针、越界insertChild/removeChild 时 findByTag 返回空;m_children.insert 越界
EventBeat / RuntimeScheduler / UITickerUAF、悬空引用shutdown 时 UITicker 触发 EventBeat::induce(),依赖的 RuntimeScheduler 已销毁
JSVMRuntime / JSVMPointerValueUAF、双重释放、构造时序销毁阶段访问已失效 env;clone/lockWeakObject 不一致;构造初始化顺序错误
HostObjectProxy空指针Enumerator Unwrap 非 wrapped host object 得到空对象
RNInstanceInternalUAF、析构顺序成员析构顺序错误;ArkTSChannel 销毁阶段解绑不彻底
EventEmitterUAF、空指针m_eventEmitter 释放后仍被回调访问
TextLayoutManager / TextMeasurer越界、空指针measureLines 读取错误位置索引;text info 为空
ScrollViewComponentInstance越界、空指针全部删除子项后仍读第一个可见子节点

7.2 日志聚类规则

当日志量较大(多份 CppCrash)时,鸿蒙官方推荐按以下规则提取故障特征做聚类分析(参考 OpenHarmony AddrSanitizer 聚类):

故障类型聚类提取字段
UAF故障类型 + 访问栈前两帧业务栈 + 释放栈前两帧业务栈
Double Free故障类型 + 第一次释放栈前两帧 + 第二次释放栈前两帧
其他越界故障类型 + 访问栈前两帧业务栈

聚类前过滤:聚类前过滤 libc.so、libc++.so、libclang_rt.asan.so、libclang_rt.hwasan.so、libv8_shared.so、libjsvm.so 等运行库帧,只保留 RNOH 业务 SO(librnoh_core.so、librnoh_core_package.so、libreactnative.so、libjsvmtooling.so)和相对偏移,确保聚类结果指向业务代码而非运行时。

7.3 防护模式总结

基于 RNOH 历史修复,以下防护模式应作为编码规范固化:

问题类型防护模式
销毁后回调(UAF)回调持 weak_ptr<T>;回调入口检查 destroyed_ 标志;析构中停止所有异步源
跨线程 c_str() 逃逸按值拷贝到局部变量或 lambda capture;不在跨线程回调中读实例成员的 c_str()
findByTag 空指针所有调用方判空;insert/remove 前检查 pos 在 [begin, end] 范围内
JSVM 析构顺序先销毁 env/VM,再释放指针值链表;析构中不触碰正在关闭的 env
NAPI 引用管理napi_create_reference ↔ napi_delete_reference 配对;不手动释放引擎管理的 buffer;napi_wrap finalizer 和手动释放只存一个
EventBeat 悬空引用weak_ptr 代替裸指针;析构顺序保证先停回调;共享成员加互斥锁

7.4 关键教训

  1. Sanitizer 未报告不等于安全——预编译 SO 内部访问不在检测范围内,业务代码仍必须保证指针不逃逸、长度不越界;
  2. SIGSEGV 不一定是空指针——崩溃地址不稳定时优先考虑踩内存(UAF/越界),并启用 ASan;
  3. GWP-ASan 一次未复现不能关闭问题——采样检测不命中不代表无 bug;
  4. 跨线程回调是最常见的 UAF 根因——RNOH 中 VSync、HTTP、NAPI、Timer 回调均可能在对象销毁后执行;
  5. c_str() 逃逸是隐蔽的悬空指针来源——临时 std::string 销毁后,c_str() 指针即悬空,后续延迟消费时崩溃位置可能不稳定。

附录

附录 A 参考文档

附录 B ASAN_OPTIONS 常用参数表

参数默认值说明
log_exe_name-日志文件名中附加执行文件名,按目标 ROM 文档配置
abort_on_error00 用 _exit();1 用 abort() 同时生成 cppcrash 日志
halt_on_error00 检测后继续运行;1 结束运行
detect_stack_use_after_return0是否检查已释放栈空间访问
quarantine_size_mb-UaF 隔离区大小(MB);增大可提高延迟复用场景命中率,也会增加内存开销
malloc_context_size-错误发生时显示的调用栈层数
strip_path_prefix-日志文件路径去除前缀(如 /data/storage/el1)
suppressions""屏蔽文件名

更多参数见 asan_flags。

附录 C RNOH 关键 SO 库速查

SO 名称CMake target用途源码构建可 ASan 插桩
librnoh_core.sornoh_coreRNOH 核心引擎是是
librnoh_core_package.sornoh_core_package核心组件包是是
libreactnative.soreactnative上游 RN 核心是是
libjsvmtooling.sojsvmtoolingJSVM 引擎封装是是
libhermestooling.sohermestoolingHermes 引擎封装是是
libjsvm.so系统预编译JSVM 运行时否否
libhermesvm.so预产物Hermes VM否否
libv8_shared.so系统预编译V8 运行时(JSVM 底层)否否
Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐