分析对象:飞书 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 均为空)。所以单纯改前端的布尔分支只能得到空气泡——防撤回必须「渲染即存档、撤回时还原」

完整链路:

  1. 发起方:前端菜单 → JSB 命令 RECALL_GROUP_MESSAGE / RECALL_MESSAGE → Rust 核心 liblark.dylib(lark-message 模块)→ 长连接(pipe/websocket protobuf,非 REST)→ 服务端。日志串:recall_message callback for id: / recall message success: id=
  2. 接收方:服务端推送更新后的 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 等)→ 更新本地库 → 通知前端。
  3. 本地库:SQLCipher 加密的 SQLite,messages 表同一行内:content BLOB(原始内容)+ is_recalled BOOLEAN + recaller_id BIGINT + recaller_identity INTEGER + recall_type INTEGER + recall_user_id BIGINT
  4. 前端渲染(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 依然存在,只是被布尔位挡住(但值已被掏空)。
  5. 通知/红点同步收尾: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_MESSAGESRECALL_GROUP_MESSAGEHANDLE_PIPE_PACKETHANDLE_WEBSOCKET_PACKET);服务端长连接推送走 pipe/websocket 包,protobuf 编码。

二、本地存储

  • 根目录:~/Library/Application Support/LarkShell/
  • 当前活跃账号:sdk_storage/<md5(user_id)>/,目录名就是 md5(user_id) 的十六进制串(例:数字 user_id 1234… → 目录 a1b2c3…;多账号各有目录,消息正在写入的那个就是当前登录账号)
  • 关键库:messages.db(消息正文)、im.db(会话)、pipeline.db(同步 changelog)、resource.db(资源索引)、search_v2_*.db(本地全文检索索引,撤回时也更新)、cipher.dbsettings.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=4kdf_iter=4000cipher_use_hmac=OFFcipher_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-walpipeline.db-walresource.db-walsearch_v2_5260_0000.db(-wal) —— 与上述链路完全吻合(消息行更新 + 会话/索引/检索同步)。

apollo 文本日志(debug 级)不记录撤回事件,无内容泄露。

五、可行的防撤回路径(按代价排序)

  1. 零改动验证(立即可做):在飞书搜索框搜刚被撤回消息里的关键词。若本地 FTS 索引(search_v2)未清条目,可直接搜出 → 证明本地曾保留原文。
  2. 官方导出通道:JSB 命令表中存在 DATA_EXPORT_* 系列(设置里的聊天记录导出),导出流程会 ATTACH … sqlcipher_export 生成弱参数副本,可验证导出文件里是否含已撤回消息原文。
  3. 前端 asar 补丁(需重启生效,✅ 现行方案):撤回推送会把本地 content 一并抹空,故采用「渲染即存档、撤回时还原」+ 引擎 renderReeditTag 门槛补丁(editVersion||isRecalled),让「(已撤回)」标记与原生「(已编辑)」走同一条渲染路径——完整方案见下一节。
  4. 动态资源热更(无需重启,但仅限资源键值):LarkShell/dynamic_resource/…dynamic_resource.zip 会在运行中被热替换,但它只支持颜色/文案/feature-switch 资源,不能注入任意 JS。
  5. 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。

存档四层架构:存档通道按消息进入客户端的路径分层,缺一层就有对应盲区:

  1. 渲染层:挂在行组件 getByContext({messageItem:e}) 点——只覆盖虚拟列表实际渲染过的消息(可见窗口)。
  2. 推送层:推送服务 K 处理器(cmd 5065||im.v1.PushMessageResponse||PUSH_MESSAGES_V2)——所有推送消息的全局必经点,未打开的会话也覆盖。根因是推送 handler 把新消息按 position 区间过滤后才送生成器,且按会话挂载,未打开会话的推送既不渲染也不进生成器。
  3. 生成器层:hook 消息生成器模块定义处的三个导出(实例 getter / 同步 / 异步),覆盖打开会话、滚动拉取的历史批次(不产生推送的消息)。每个 webpack runtime 一份定义,必须全打。
  4. 传输层:原生 → 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 messageSDKIMGroupAdminRecallMsgText 等)、Rust 模块路径(lark-message/src/logic/dependencies/processor.rs)均可 strings 检索
  • 前端渲染分支:messenger asar 内 isRecalled 上下文(见第一节代码)