首页
社区
课程
招聘
[原创]逆向 HanaAgent:一次 Electron 组件自还原机制的定位与禁用
发表于: 2026-9-14 13:46 705

[原创]逆向 HanaAgent:一次 Electron 组件自还原机制的定位与禁用

2026-9-14 13:46
705

本文仅用于安全研究与技术学习,请勿用于任何违法用途。

手上在用 HanaAgent 这个开源 AI Agent,Electron 写的,本机 0.450.0。用着用着发现它有个毛病:内置了一个叫 file-history 的模块,专门记录工作区文件的历史版本。这模块会把整个工作目录递归扫一遍,而我那个工作目录里攒了 26.8 万个文件,光一个子目录就 23.5 万,每次它启动的时候光是这一步就要花 70 秒。

慢也就算了,更难受的是它那个文件监听器,在多个任务并行往工作区写文件的时候,会不停地刷 workspace watch event without filename,刷着刷着 server 就崩了,退出码 0xC0000409,fail-fast。

于是想把这块干掉。

file-history 的逻辑打包在 server 的 bundle 里,就是 bundle/index.js。它的服务类只有一个监听入口 syncWorkspaces(),翻源码的时候还看到头顶注释写着"可整块摘除",那还不简单,方法开头直接 return 就行。

改完立竿见影,server 启动从 70 秒掉到 0.9 秒。我以为完事了。

改完四十多个小时后,某天早上启动,发现文件又变回去了,启动时间也回到原来那个数。

这场景太熟了。以前跟别的客户端自校验死磕过,就是这么个循环:改客户端逻辑,被还原;把本地校验值也改了,还是被还原;兜了一圈才明白校验值根本不归本地管。看来这次又是一个客户端自校验的坑,只是从 native 换成了 Electron。

Electron 的业务代码都在 app.asar 里。这个格式挺简单,头部是一串 pickle 字段:头 4 字节是 pickle 长度,然后依次是两个内层 pickle 长度字段和 JSON 字符串长度,之后才是 JSON 索引,索引之后才是文件数据。用 Python 拆开:

这里得留意对齐:JSON 索引和文件数据之间还隔着一段 padding,要补齐到 4 字节边界。本机这个 asar 的 JSON 长度正好是 4 的倍数,所以 16 + jss 碰巧也对;换个 asar 就可能差 1 到 3 字节,marker 就永远找不着了。

扒开看,顶层是 node_modules、desktop、package.json、两个 release-digest、shared。业务逻辑主要在 desktop/main.bundle.cjs,982KB,被压成了一整行。

动手之前有件事得先确认。Electron 有一组叫 fuse 的开关,其中一个是 EnableEmbeddedAsarIntegrityValidation,它要是开着,你动 app.asar 一个字节应用就直接拒绝启动,所以先把它读出来。

fuse 数据藏在 exe 里,有个固定的起始标记(32 字节),标记之后紧跟 version 和 count,再后面才是一个个 fuse 值。每个值占一个字符,'0' 关、'1' 开:

读出来是这样(这版 Electron 的 wire 是 9 个):

那个完整性校验是 0,关着的,那 app.asar 就可以随便改,不用担心起不来。另外 CliInspectArguments(也就是上面第 3 项 EnableNodeCliInspectArguments)也是开的,理论上能直接挂 DevTools 单步调试,比硬看压缩代码强多了。

回过头想它是怎么发现我改过文件的。.hanako\artifacts\ 下有三个地方都记着 server 的 sha256:pointers\stable.current.json、seed 那份清单 seed-train-win32-x64.json,还有解包目录里的 .verified。

一开始我理所当然地认为,这个 sha256 是拿来校验"解包后的 server 目录"的,我改了目录里的文件,哈希对不上,所以被判损坏、触发了还原。

验证下来不是这么回事。我把内置的那个归档 server-0.450.0-win32-x64.tar.gz 拿过来算了一遍 sha256,正好等于清单里写的那个 a75af633...。这三个地方记的,全是归档文件的哈希,跟解包出来的目录没关系。

那我改解包目录,它自然发现不了。

在 main.bundle.cjs 里翻到 decideBootAction 的时候,找到了关键。压缩后的名字是 v,参数按位置一一对应 {resolved, seedEntry, crashFallback},我按逻辑重写了一下:

调用它的地方长这样。整个文件里有两处调用(一处 server、一处 renderer,逻辑一样),下面贴 server 这处:

只要返回 "activate-seed",它就从 seed 归档重新解包,把 versionDir 整个覆盖一遍,补丁就是这么没的。

触发它返回 "activate-seed" 的有三种情况:一是解析不出当前版本,比如换了 channel、新 channel 还没指针;二是 seed 里的版本比当前高;三是 train 为 0 而且指针里的 sha256 跟 seed 对不上。

我那次是第一种。之前跑的是 beta channel,还原前一天晚上我把 channel 从 beta 手动切到了 stable,切完之后 stable 这边还没有 resolved,于是直接走进 activate-seed,从 seed 解包覆盖。

activateFromArchive 里也有 sha256 校验,不过校验的同样是归档,官方归档必然通过,通过后照样覆盖。干掉补丁的是这次覆盖,跟哈希校验无关。

只堵 decideBootAction 不够。搜 artifact-ota 的时候发现另一条路:它还会定时去远端拉 channel 清单,有新版本就下载下来激活。清单地址写死在代码里:

这里有个容易踩的点:设置里那个"自动检查更新"开关,只管应用自身的更新,管网不了这套组件 OTA。我明明关着自动更新,ota-state.json 还是照常更新,就是因为它走的不是同一个开关。

改 app.asar 有个省事的做法:不解包重打包,而是在原始字节上做等长替换。只要替换前后长度一样,main.bundle.cjs 的大小就不变,asar 索引里的 offset 和 size 都不用动,直接原地换字节就行。

第一处,把 decideBootAction 从 seed 激活这条路废掉。它函数体里有三处 "activate-seed",全部等长换成 "seed-disabled":


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

最后于 2026-9-14 13:48 被ranshaodexia编辑 ,原因:
收藏
点赞 ・2
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回