2026 年 3 月 12 日,Hermes Agent 发布 v2026.3.12。按官方 release 说明,这是 Hermes Agent v0.2.0,也是继 v0.1.0 这个 pre-public foundation 之后的首个正式 tagged release,不是严格意义上的"第一个版本"。截至 2026-05-30 查询官方 GitHub 仓库,NousResearch/hermes-agent 已在 17 万 Star 左右;一个月内破十万这类增长叙事和 OpenClaw 的热潮相互映照,但真正让安全圈记住它的,不是 star 数,而是一个有点"灵异"的故事。
Hermes 的卖点其实并不花哨:一个能长时间自主跑任务的通用 agent,真正的差异化全压在"记得住"和"会自己改进"这两件事上。官方文档把这套能力拆成几块:一层是记忆(memory),核心是 ~/.hermes/memories/ 下的 MEMORY.md 和 USER.md,分别保存环境/项目经验与用户偏好,会在会话开始时注入上下文;另一层是技能(skill),反复用到的操作流程被固化成可复用的 SKILL.md,需要时 agent 可用 skill_manage 创建、更新、删除。换句话说,它不是把所有经验塞回模型参数,而是把经验外置成可读、可改、可复用的本地资产 —— 这恰好是下面那个故事的伏笔。
公开安全文章里转述过这样一个案例:有人拿自动化引擎反复攻击 Hermes,反弹 shell 一个变体接一个变体地打。一开始次次得手;可打着打着,Hermes 忽然就不执行了,像是凭空对这种攻击免疫了。这个故事本身不是官方 release note 里的正式结论,更适合当作安全测试转述来看;官方文档能直接佐证的是它确实具备自管 memory、创建/修改 skill、以及后台复盘的机制。若这个案例成立,它的技术解释并不神秘:攻击样本被当成"下次别再犯同一个错"的经验沉淀了下来。
官方文档对这套"复盘"循环的描述更具体:run_agent.py 侧维护 _turns_since_memory 和 _iters_since_skill 两类计数。默认逻辑是每 10 个用户 prompt 触发一次 memory review,每 10 个 tool iteration 触发一次 skill review;达到阈值后调用 _spawn_background_review(messages_snapshot=..., review_memory=..., review_skills=...) fork 出后台 Review Agent。它不接手原任务,而是回看刚刚那段对话,判断"这一段里有什么是下次该记住的?"以及"有什么操作值得固化成 skill?"。复盘完成后,经验被写入 memory 或通过 skill_manage 落盘成 skill,主 agent 继续原任务。
关键在于,这个循环对"什么算经验"并不挑食:用户纠正过的偏好是经验,踩过的坑是经验,复杂任务里反复出现的操作模式也是经验。因此,如果上面那个攻击案例的日志链路完整,它的解释就不必诉诸"灵异":同一种 payload 反复出现、某次被识破后,Review Agent 可能把"这类输入要警惕"当成普通经验存下来。所谓"进化出免疫",更保守的说法是:一套通用复盘机制,可能恰好作用在了攻击样本上。它没学会抽象意义上的"安全",它只是学会了"别再犯同一个错" —— 拒绝执行可疑 payload,刚好是这条通用规律的一个特例。
这个故事迷人,但它还有另一面:让 Hermes 这类 agent 变得有用的那套能力 —— 会读代码、会调工具、能在数百步里不丢线索地复盘 —— 换个方向用,就是今天最锋利的逆向工具。当这样的 agent 经 MCP 接上 IDA、Ghidra、Frida,它能把一个加固 SO 从 JNI 注册表一路扒到风险汇聚点,速度远高于纯手工检索。本文通篇出现的"IDA MCP 实读",就是这么来的。
可问题恰恰也藏在这儿。agent 读伪代码太"顺"了 —— 顺到它会不知不觉把"看起来合理"当成"事实",把"hook 完那个方法、界面里风险项没了"当成"绕过成功"。在一个老实的 app 上,这种幻觉无伤大雅;但这一次我们要拆的是 Hunter —— 一个会用多进程、syscall 跳板、CRC 自检反过来咬你的家伙。在它面前,一条幻觉结论的代价是:你以为已经绕过,实际上 :hunter_server_iso 子进程正通过 Binder 把同一条风险项原样传回 UI。
所以这篇文章不是又一份"我把 Hunter 绕了"的炫技记录。它记录的是:当我们把 agent 放出去拆 Hunter v6.58(versionCode 658 / libhunter.so),怎么让它的每一条结论都不是"它觉得",而是"证据逼它承认"。办法说穿了很朴素 —— 给这只会读代码的 agent 也上一道证据门:每个检测点,都要静态、动态两条独立证据线对上才算数。静态线从 art_register_natives_batch 的 60 项注册表锚定 Java 方法到 native RVA,再用 IDA 伪代码 / 字符串 / xref 还原算法;动态线在汇聚点 build_native_ListItemBean_risk @ 0x278658 落 hook,用 ReportRiskItem 的 LR 反查到精确 callsite,用 MainActivity.n0 的 UI 三元组确认命中来源,最后用 bypass 让目标风险项消失来反向闭环。
Hermes Agent静态 IDA 反编译 + 动态 Frida hook 两条独立证据线,逐条还原 Hunter v6.58 的检测算法。每条结论都满足:
首先从 JNI_OnLoad 内 art_register_natives_batch(env, NativeEngine, off_2EF1F0, 60) 注册表建立 Java 方法到 so 函数的映射。Hunter 不依赖普通 JNI 导出名,而是通过 ArtMethod 直接 patch 的方式注册。运行时枚举 off_2EF1F0 表(每项 {name*, sig*, fn*} = 24 字节,共 60 项),完整 60 项见附录 D。本文 SO 层下沉 bypass 脚本 hook_native_bypass_so.js 明确分类并 Interceptor.replace 了其中 25 个 detection 入口(其余 35 项为 popen / MD5 / 工具方法 / 信息查询 / 还未细化分类的 detection 等,见附录 D)。25 项的 RVA:
getZhenxiInfoH 不在这 60 项表内 —— 它本身不是 native 方法,而是 Java wrapper:getZhenxiInfoH(key) -> getPropInline(key, true)。getPropInline 是混淆控制流函数,内部再进入 native getZhenxiInfoZ(key) fallback 或 /dev/__properties__ 属性区解析。这是实测 60 项表缺失 getZhenxiInfoH 后,结合 Java 反编译确认的链路。
随后以风险项字符串和 ReportRiskItem 汇聚点交叉定位:
动态 hook 这个汇聚点可以拿到 native 风险项标题、详情和 LR,例如:
LR = 0x2B4A88 落在 NativeEngine_getZhenxiInfo3 @ 0x2B476C 内,把 UI 文案与 native 检测函数闭环。
动态侧使用三类 hook:
典型动态证据:
前者证明检测命中,后者证明将该 native 返回值置空后 UI 风险项消失。
凡是可能触发 Hunter checksum、maps/RX 扫描或 ELF 段枚举路径的动态分析脚本,都应优先安装 block_bad_precheck hook。原因是 Hunter 的 checksum/mem scan 路径会把 maps 解析出的地址交给自己的可读性预检查 maps_precheck_readable @ 0xC6520,该预检查没有证明 [addr, addr+size) 被可读 mapping 完整覆盖;在当前 Frida 17 注入环境下,某些地址区间会跨越 unmapped hole 或不可读页,后续 NEON / 批量读取可能 SIGSEGV。
实测拦下的越界访问片段(Pixel 6 Pro / Android 15):
off 0xC68A4 位于 sub_C687C(parse_elf_rx_segments 内 ELF 文件读出后的可读性预检),off 0xC7828 位于 scan_rx_segment_neon_sum @ 0xC772C(RX 段扫描路径)。两个 LR 模式覆盖两条独立完整性检查链路的越界点 —— 这条数据反复出现在后面 §5 / §8 / §20 / §21 多条 detection 的实测中。
art_register_natives_batch @ 0xA5C54 静态伪代码(IDA 反编译还原):
libart.so 路径按 SDK 选(build_libart_path / sub_1AFA80 静态确认):
dlsym 目标符号(已 demangle):
libart_dlsym 不走 libdl,而是自己解析 ELF 取符号,目的有二:绕开 libdl hook;取 @hide 未导出符号(ArtMethod::RegisterNative 等并不在 libart 的 .dynsym 公开导出)。
四个 helper 的分工:
ClassLinker* 需要从 Runtime 实例取。Runtime 实例本身也是 @hide 符号,通过两条路径定位:
全局缓存:g_art_class_linker @ qword_2EF7C0,g_art_RegisterNative_fn @ off_2EF7C8,g_fid_Executable_artMethod @ qword_2EF7D0(SDK ≥ 30 反射拿 Executable.artMethod jfieldID 的缓存)。
art_method_set_native_entry 内 SDK > 30 分支调 ClassLinker::RegisterNative 的完整指令序列(IDA MCP 实读 sub_A4C80 在 0x0a5224..0x0a5254 区段):
12 条指令直接从 IDA MCP 实读拷贝,X1 错传由 0x0a524c MOV X1, X21 直接证明 —— X21 来源是 0x0a522c BL libart_dlsym 的返回值,中间只经过 MOV X21, X0(0x0a5230)和 null 检查(0x0a523c),从未被调用,直接作为实参传给了 ClassLinker::RegisterNative 的第二个参数槽位。
X2/X3 是从栈上一次性 LDP 拿到的(art_method、fnPtr,在函数早期被存进栈),X8(RegisterNative 函数指针缓存)也是从栈上 LDR 拿到的。X0 = class_linker 直接来自上一条 BL sub_A3CCC 的返回。4 个参数装载位置全部可见。
ClassLinker::RegisterNative(this, Thread *self, ArtMethod *method, const void *native) 的第二个参数语义是当前线程的 Thread* 对象,需要通过调用 Thread::Curren
...(内容过长,已截断)
---
来源: 看雪论坛
原文链接: https://bbs.kanxue.com/thread-291411.htm
[原创]利用hermes agent 详细分析hunter检测器
1 浏览
0 回复
暂无回复,快来抢沙发吧!