分析对象:飞书 macOS 客户端
/Applications/Lark.app(v131.0.6778.268 起分析,143.0.7499.203 复验,Electron/Chromium 内核)。 方法:静态逆向(strings / 反汇编 / asar 解包)+ 真实撤回的落盘观察 + 前端 asar 补丁注入的实测迭代。 本文讲原理与逆向过程;拿着结论去动手改客户端,见下一篇《飞书防撤回补丁复现指南》。
TL;DR:撤回是怎么实现的
撤回 = 服务端下发一条带撤回标记的消息资源更新,客户端把本地 SQLite 行打上 is_recalled=1 等标记列,前端按标记切换渲染成「撤回了一条消息」提示。而原始 content 会被一并抹掉(实测:绕过渲染门之后,被撤回的消息渲染成空气泡;DB 加载与撤回推送两路拿到的 content 均为空)。所以单纯改前端的布尔分支只能得到空气泡——防撤回必须「渲染即存档、撤回时还原」。
完整链路:
- 发起方:前端菜单 → JSB 命令
RECALL_GROUP_MESSAGE/RECALL_MESSAGE→ Rust 核心liblark.dylib(lark-message模块)→ 长连接(pipe/websocket protobuf,非 REST)→ 服务端。日志串:recall_message callback for id:/recall message success: id=。 - 接收方:服务端推送更新后的 Message protobuf(字段含
is_recalled / recaller_identity / recall_type / recall_user_id)→lark-message/src/logic/dependencies/processor.rs校验(chat recall message validate failed, recaller not found等)→ 更新本地库 → 通知前端。 - 本地库:SQLCipher 加密的 SQLite,
messages表同一行内:content BLOB(原始内容)+is_recalled BOOLEAN+recaller_id BIGINT+recaller_identity INTEGER+recall_type INTEGER+recall_user_id BIGINT。 - 前端渲染(messenger asar,已提取实证代码):
以及
let{isRecalled:r,isDeleted:a,recallerIdentity:n}=t; return r ? <E recallerIdentity={n}/> // "X 撤回了一条消息" 提示条 : a ? <S.A/> : <N {...e}/> // 正常消息function L(e){return !e.isRecalled && !!e.content.filePath}—— 撤回消息对象上content依然存在,只是被布尔位挡住(但值已被掏空)。 - 通知/红点同步收尾:
do not push notice to oneself when is recalled(自己撤回的不推通知)、thread 根消息标记root_message_is_recalled、回复计数修正update reply count for msg has recalled。
一、客户端架构
| 层 | 位置 | 作用 |
|---|---|---|
| Electron 主进程 | Contents/MacOS/Feishu |
壳、窗口、升级 |
| 渲染进程 | Lark Helper (Renderer) |
加载 webcontent/*.asar(messenger.asar = 聊天 UI) |
| aha-service 后台进程 | Lark Helper --type=backend(加载 liblark) |
Rust 核心:同步、存储、JSB 命令 |
| 核心库 | Libraries/liblark.dylib(84MB) |
全部业务逻辑,Rust 编写,字符串含 lark-message/src/*.rs 等源码路径 |
| DB 层 | liblark 内静态链接 SQLCipher 4 | 本地库全加密 |
前后端通信:JS Bridge 命令表(全大写,如 GET_CHAT_MESSAGES、RECALL_GROUP_MESSAGE、HANDLE_PIPE_PACKET、HANDLE_WEBSOCKET_PACKET);服务端长连接推送走 pipe/websocket 包,protobuf 编码。
二、本地存储
- 根目录:
~/Library/Application Support/LarkShell/ - 当前活跃账号:
sdk_storage/<md5(user_id)>/,目录名就是md5(user_id)的十六进制串(例:数字 user_id1234…→ 目录a1b2c3…;多账号各有目录,消息正在写入的那个就是当前登录账号) - 关键库:
messages.db(消息正文)、im.db(会话)、pipeline.db(同步 changelog)、resource.db(资源索引)、search_v2_*.db(本地全文检索索引,撤回时也更新)、cipher.db、settings.db - 旧版布局
database/<user_id>/message.db(2024 及以前) messages表核心列(从 liblark 内嵌 DDL 提取):CREATE TABLE messages ( id BIGINT PRIMARY KEY, chat_id BIGINT, from_id BIGINT, root_id BIGINT, type_ INTEGER, content BLOB, -- protobuf 编码的消息内容 position INTEGER, create_time BIGINT, update_time BIGINT, ... is_recalled BOOLEAN NOT NULL DEFAULT 0, is_edited BOOLEAN, is_deleted BOOLEAN, is_urgent BOOLEAN, recaller_id BIGINT NULL, recaller_identity INTEGER NULL, recall_type INTEGER NOT NULL DEFAULT 0, recall_user_id BIGINT NULL, ... ); ALTER TABLE threads ADD COLUMN root_message_is_recalled BOOLEAN NULL;
三、加密与密钥(逆向到指令级)
- 全部 .db 为 SQLCipher 加密(文件头非
SQLite format 3)。 - 参数线索(来自 liblark 内建导出逻辑):
cipher_compatibility=4、kdf_iter=4000、cipher_use_hmac=OFF、cipher_page_size=4096(导出副本用;主库参数待运行时确认)。 - 密钥派生模块:
lark-share/src/db/encryption_key/native.rs+init_key_check/native.rs。反汇编结论:- 哈希算法为 SHA3-256(Keccak-f[1600],rate=136 字节,0x06+0x80 填充,函数 @0x17a838,唯一业务调用点 @0x2248150)
- 输入串以
"db-encryption"(13 字节) 开头,后接 32 字节运行时全局 secret(__DATA:0x4f212e8指向,文件中初始为零,运行时由服务端/设备注册下发,即日志里的 “app secret”)+ 若干 8 字节段(疑似 user_id/device_id) - 产出 32 字节原始密钥,经大写 HEX 表(
0123456789ABCDEF)编码成x'…'交给PRAGMA key - 密钥分
legacy/new两代(标记文件db-newkey-mark;无标记 = legacy),app_secret缺失时走 legacy 并打日志app secret not init / not found / no init - 另有
device_factor(每账号目录下 35 字节文件)参与:device factor file not exists, use device id
- 已排除:md5/sha256 直接组合(实测 300+ 组合爆破失败)、钥匙串(钥匙串中无 Feishu Safe Storage 条目)。
- 结论:静态拿不到完整密钥,32 字节 secret 只存在于运行进程内存。(hardened runtime 阻止 lldb attach,
--remote-debugging-port未开)
四、撤回的实时落盘观察
实测:对某个群会话执行一次撤回,以下文件立即被写:
messages.db(-wal)、im.db-wal、pipeline.db-wal、resource.db-wal、search_v2_5260_0000.db(-wal) —— 与上述链路完全吻合(消息行更新 + 会话/索引/检索同步)。
apollo 文本日志(debug 级)不记录撤回事件,无内容泄露。
五、可行的防撤回路径(按代价排序)
- 零改动验证(立即可做):在飞书搜索框搜刚被撤回消息里的关键词。若本地 FTS 索引(search_v2)未清条目,可直接搜出 → 证明本地曾保留原文。
- 官方导出通道:JSB 命令表中存在
DATA_EXPORT_*系列(设置里的聊天记录导出),导出流程会ATTACH … sqlcipher_export生成弱参数副本,可验证导出文件里是否含已撤回消息原文。 - 前端 asar 补丁(需重启生效,✅ 现行方案):撤回推送会把本地 content 一并抹空,故采用「渲染即存档、撤回时还原」+ 引擎
renderReeditTag门槛补丁(editVersion||isRecalled),让「(已撤回)」标记与原生「(已编辑)」走同一条渲染路径——完整方案见下一节。 - 动态资源热更(无需重启,但仅限资源键值):
LarkShell/dynamic_resource/…dynamic_resource.zip会在运行中被热替换,但它只支持颜色/文案/feature-switch 资源,不能注入任意 JS。 - DB 离线解密:需先拿到运行时 secret(重启+调试器或内存 dump 后可按
SHA3-256("db-encryption"+secret32+…)复现)。
六、防撤回补丁方案总览
现行方案(补丁 v31c,适配 143.0.7499.203)的效果与核心设计:
效果:被撤回的文本/富文本消息在聊天中照常显示原文,正文最后一个字后面紧跟灰色「(已撤回)」——与原生「(已编辑)」同一条渲染路径、同款样式同位置;无有效存档的撤回消息回落原生「你撤回了一条消息」提示条,不出空气泡。
开关:设置 → 通知 → 「显示撤回的消息内容」开关行(asar 注入,克隆原生 Switch 样式;备用快捷键聊天页 Alt+Shift+R)。开(默认)= 显示原文 + 「(已撤回)」;关 = 完全恢复原生行为(原生居中灰字提示条,与未打补丁一模一样),存档继续进行,再开即恢复显示。
为什么必须「存档+还原」:撤回推送会把本地 DB 与前端 message 的 content 一并抹空(实测),单纯绕过渲染布尔分支只会得到空气泡。故消息正常渲染时快照存档,撤回时还原。
原生「(已编辑)」标记机制(逆向事实,补丁的接入点):
- 引擎在渲染尾部自挂 suffix 槽位,由
renderReeditTag门槛if(!t?.editVersion||!r||!i)return null驱动——标志驱动,不是 richText 数据元素(服务端对已编辑消息只下发 editVersion 标志;往 richText 数据塞 REEDIT 元素不会被渲染) - 段落形态消息:引擎算出最后段落 id,把
renderInlineSuffixSlot传给段落组件,段落把槽位渲染在自己 children 末尾(文字后行内) - 无段落形态(裸文本根):全部根渲染完后追加
- 引擎实例化带 message prop(HOC:
jsx(e,{richText,messageId,message:d,…})),标签组件可读到 isRecalled/editVersion。
存档四层架构:存档通道按消息进入客户端的路径分层,缺一层就有对应盲区:
- 渲染层:挂在行组件
getByContext({messageItem:e})点——只覆盖虚拟列表实际渲染过的消息(可见窗口)。 - 推送层:推送服务 K 处理器(cmd
5065||im.v1.PushMessageResponse||PUSH_MESSAGES_V2)——所有推送消息的全局必经点,未打开的会话也覆盖。根因是推送 handler 把新消息按 position 区间过滤后才送生成器,且按会话挂载,未打开会话的推送既不渲染也不进生成器。 - 生成器层:hook 消息生成器模块定义处的三个导出(实例 getter / 同步 / 异步),覆盖打开会话、滚动拉取的历史批次(不产生推送的消息)。每个 webpack runtime 一份定义,必须全打。
- 传输层:原生 → JS 第一站 emitJssdkPush 分发回调(全库 9 副本)——前三层都够不着的消息(如折叠会话的活推送)在此兜住,并顺带存 cmd 5130 的 feed digest,作为折叠会话撤回的摘要级兜底。
还原可见性的关键事实:富文本渲染器只画 richText.elements,不读 text 字段。撤回推送恰好「保留 text、抹空 elements」,只回填 text 会得到数据层还原成功但视觉空气泡。还原时若 elements 空而 text 有值,必须用 text 合成元素。
多开实例注意:双开的第二个 app 副本是独立 app + 独立数据目录,存档/开关与主实例完全隔离;它不跟随主实例升级,升级/换版后需单独重跑补丁脚本(脚本支持传 app 路径)。重启双开实例要按其可执行文件路径定向 kill,
pkill -x Feishu会误杀两个实例。
注入点定位、逐文件改法、语法校验、验证与排障的完整手册,见《飞书防撤回补丁复现指南》。
附:关键证据偏移(liblark.dylib v131.0.6778.268)
- Keccak-f1600:
0x17a838;密钥函数:0x2248010(SHA3 调用点0x2248150;gate 全局0x4f212f0==2;secret 指针0x4f212e8);“db-encryption” 字符串0x37a8750 log db key: kind=, userid=, keyhash=字符串0x370cd9d- messages 表 DDL、i18n 模板(
{{from_user}} recalled a message、SDKIMGroupAdminRecallMsgText等)、Rust 模块路径(lark-message/src/logic/dependencies/processor.rs)均可strings检索 - 前端渲染分支:messenger asar 内
isRecalled上下文(见第一节代码)
京公网安备11010602203188号