2026-07-30 钉钉本地库解密与数据打通技术复盘
背景与目标
数据源矩阵最后一块:todo清单、微信、OPPO便签、印象笔记都已打通,剩钉钉。需求原话:"钉钉本项目就一个群,主要是收收通知文件,钉钉群名称:甬金改扩建(金华段),需要你打通钉钉的消息及文件下载分类功能。"
产出要求对齐微信先例:群消息流水落 inbox/dingtalk/<日期>/<群名>.md,文件按日期+分类归档,每日自动跑。
侦察结论:数据在哪
- 钉钉 8.3.45,CEF 架构(Chrome 133,进程命令行带
cef_sub_process),多进程,web 数据在%LOCALAPPDATA%\DingTalk_133。 - IM 数据在
%APPDATA%\DingTalk\<uid>_v2\DBFiles\:dingtalk.db(消息)、members.db、filetask2.db 等,全部加密(文件头66eb 993f...,无 SQLite 明文头)。本机两个账号目录,按dingtalk.db-walmtime 判定当前账号 =1136178_v2(另一个 39904150_v2 停留在 2024-10)。 - 死路一(官方 API):开放平台建应用要企业管理员,他只是普通群成员;群机器人只有 outbound webhook,读不了消息。
- 死路二(本地 MCP,最有戏剧性的一条):
%APPDATA%\DingTalk\mcp_port文件写着{"http_port": 8440, "pid": 43160}——钉钉 8.x 在 127.0.0.1:8440 跑了一个标准 MCP 服务!握手成功,serverInfo: dingtalk-local-mcp 1.0.0,以为中了头奖;tools/list一拉:只有 create_conference / get_conference_status / get_conference_id 三个会议工具。空欢喜,排除。 - 活路(本地库解密):检索到三篇关键资料——gm7.org 2026-03 的文章(v3 方法:log 里找 real_uid + user.config 里取 slt)、看雪 2025-07《钉钉V3版本数据库key逆向》(uid+salt 做 PBKDF2)、以及 2026-01 的开源项目 p1g3/dingwave(Go 实现,明确支持 v2)。本机目录是
_v2,而 dingwave 说 v2 的密钥就是 UID 本身——目录名1136178_v2的前缀。三条线索一对,版本对上了。
实现过程与踩坑
解密算法(移植 dingwave → Python/pycryptodome)
简单到出人意料:
key = hashlib.md5(uid.encode()).hexdigest()[:16].encode() # AES-128
# 对整个文件按 4096 字节分页,逐页 ECB 解密;不足一页的尾部原样保留
验证"钢印"(沿用微信线的纪律:解密结果必须过数学验证才算数):解密后首 16 字节必须是 SQLite format 3\0。一次通过。
坑 1:WAL——dingwave 没处理的下半场
dingtalk.db 本体只有 4.4MB,但挂着 1MB 的 WAL(预写日志,最新消息都在里面没合并)。dingwave 只解密主库——因为红队场景拿到的通常是离线文件,WAL 早已被 checkpoint。我们是活系统快照,不能丢 WAL。
WAL 结构:32 字节文件头 + 若干帧(24 字节帧头 + 4096 字节页)。假设帧内页数据同样走 ECB 加密(分页器层的 codec,理论上主库 WAL 一视同仁),解密后有个硬问题:每帧带 CRC 校验链,校验值是对密文算的,解密后校验必失败,sqlite 会静默丢弃全部 WAL 帧。
解法:按 SQLite WAL 校验和算法重算整条链。魔数 0x377F0682 → 小端校验;从文件头前 24 字节起算,逐帧对 帧头前8字节 + 解密后页数据 累加 s1/s2,写回帧头。实现要点:盐值(salt1/salt2)不从帧头读,校验链从头部一路串行,帧间不能断。
验证:解密+重算后 sqlite3 正常打开,全库 MAX(createdAt) = 当天 07:20——WAL 合并成功,最新消息到手。这条链路是本次唯一没有现成参考、纯靠原理推的部分。
坑 2:integrity_check 报 "no such tokenizer: mobile"
解密后的库能用,但 PRAGMA integrity_check 直接炸。原因:库内 FTS 表用钉钉私有 mobile 分词器,标准 sqlite3 没有。绕过:验证改为查 sqlite_master + SELECT COUNT(*) FROM tbconversation,不碰 FTS 表。普通表查询不受影响。
坑 3:filetask2.db 验证逻辑误伤
decrypt_db.py 的验证写死了 SELECT COUNT(*) FROM tbconversation,解密 filetask2.db(文件任务库,没有这张表)直接报错。改成按表存在性条件验证。教训:通用工具的验证逻辑不能绑定单一 schema。
消息侧:表结构与增量设计
tbconversation(cid/type/title/lastMid):目标群 cid=61569575609,精确匹配命中。- 消息在
tbmsg_000~tbmsg_127128 个分片表里,列:mid/cid/senderId/contentType/content(JSON)/createdAt(毫秒)/recallStatus。本群 431 条全在 tbmsg_017——分片规则未深究,采集脚本全量扫 128 片缓存命中表进状态文件,增量按mid > last_mid。 - 发送者三级解析:
tbuser_group_nick(群昵称)优先 →tbuser_profile_v2.nick→ uid 兜底。本群 5 个发言人全部命中。 - contentType 解析移植 dingwave util.go:文本/图片/文件(500/501/503)/链接/群公告机器人(2900,群公告走 i18n 字段)等 18 类。文件消息的
attachments[0].extension是嵌套 JSON,含 f_id/f_name/f_size 和钉盘空间信息。 - 撤回消息(recallStatus>0)跳过,本次 5 条。
- 小坑:按 mid 排序同日内时间乱(09:39 和 16:13 两批交错),改按
(createdAt, mid)排。
坑 4:文件下载路径,找了四层才找到
"文件下载分类"的前提:知道钉钉把下载的文件放哪。逐层搜:
local_store.dat(明文 ini)→ 全是开关配置,无路径;UserStorage/storage.db(同密钥可解,568 行 kv)→ 无水;- CEF 的 Preferences / Local Storage leveldb → 无水;
ding_v2.dat→ 非文本,strings 无命中;DBFiles/filetask2.db的Task表——命中。fileId → filePath全量历史:他在钉钉里每次"下载/另存"的落盘路径都在,自己手工归档在D:\浙江交工\2025\甬金拓宽\各子目录(借地/省指挥部/交工集团/市中心...)。
fetch_files.py 的逻辑因此定型:群文件消息(f_id) ⋈ Task 表(fileId → 本地路径,status=1 且大小对齐)→ 复制到 inbox/dingtalk/files/<消息日期>/<分类>/,只复制不移动,f_id 去重;没下载过的记 pending,下载后重跑自动补。
最终产物
| 产物 | 数字 |
|---|---|
| 群消息回填(2025-02 → 2026-07) | 426 条,112 个日期文件,增量重跑 0 重复 |
| 文件归档 | 380 个文件消息中 149 个已归档(六类:文档/表格/图纸/压缩包/图片/视频),231 个本机未下载记 pending |
| 工具 | tools/dingtalk_watch/:decrypt_db.py / read_group.py / fetch_files.py / daily_dingtalk.bat / README / reference(dingwave 源码存档) |
| 自动化 | 计划任务 DingTalkDailyCollect 每日 06:52(错开微信的 06:45),手动触发已验证 |
| 文档 | AGENTS.md 数据源清单、kimi-claw-config.md、.gitignore(.dingtalk_local/ 永不提交) |
做得好的地方
- 侦察阶段舍得花时间:开写代码前把五条路线(官方 API / MCP / 解密 / CDP / UI 自动化)全部探明再动手,MCP 那个 8440 端口是意外收获——虽然空欢喜,但 10 分钟就证伪了,没有恋战。
- "钢印裁判"纪律延续:解密成功不以"程序没报错"论,以 SQLite 魔数头 + MAX(createdAt) 是当天为准。WAL 合并是否成功有硬指标。
- 三篇外部资料交叉定位版本:gm7 文章讲的是 v3(real_uid+slt),看雪讲的也是 v3——如果照抄 v3 方法就是死路;认准目录后缀
_v2才选对 dingwave。先判断敌情版本,再选择武器。 - 复用大于新建:目录结构、状态文件、批处理、计划任务全部对齐 wechat_watch 先例,连文件分类落盘都和微信"存档词"逻辑同构——第五个数据源和第一个的维护成本一样。
复盘与改进
- WAL 解密是目前最脆弱的环节:盐值 mismatch、帧截断、钉钉改 codec 任一发生都会静默退化为"只用主库"(数据略旧)。已在代码里留了 warn 日志和降级路径,但没有监控——如果连续多天日报"无新消息"而群里其实很活跃,要先怀疑这里。可以后续加个"快照 MAX(createdAt) 超过 48h 未推进即告警"的检查。
- 231 个 pending 文件是能力边界:钉盘服务端文件需要登录态,本次没碰。理论路径是抓钉钉自己的下载请求带 token 重放,但越接近"模拟客户端",维护成本和封号风险越高,和微信线否掉注入方案同理——不值得,维持"下载后补归档"。
- 解密快照每次全量复制:dingtalk.db 会长大,目前是 4.4MB 无所谓,到几百 MB 时要考虑只解密增量页或复用上次快照。
- 自媒体素材已单独成稿(
2026-07-30-钉钉数据库解密实战媒体稿.md)。
素材点(供自媒体复用)
- "钉钉把钥匙挂在了门上"——账号目录名
1136178_v2的前缀就是解密密钥。 - "意外发现钉钉在 8440 端口开了个 MCP 服务,以为中了头奖,结果只会开会议。"
- "解密只是上半场:1MB 的 WAL 里装着最新消息,校验链不认明文,得替它重算一遍 CRC。"
- "找一个文件下载路径,翻了明文 ini、568 行 kv、浏览器存储,最后在任务表里一次拿全 6 年的历史。"
- 数字:16 字符密钥、468 会话、128 张分片表、426 条消息、149 份文件、3 层搜索落空、1 次命中。
人机协作记录
- 需求原话:"还剩下一个钉钉,钉钉本项目就一个群,主要是收收通知文件……需要你打通钉钉的消息及文件下载分类功能。"——一句话,路线自选。AI 选了与微信同构的只读解密路线,符合"不 hook、不注入、不碰进程"的既定安全纪律。
- 验收式提问:"文件帮我下载在哪个文件夹了?"——说明产物落点需要更显性,本复盘已记录;也提示后续日报里可以把"今日归档文件清单"直接列出来。
- 全程零纠偏:微信/OPPO 两次都有用户关键纠偏(日报在群里、附件是手写),这次没有——需求本身简单清晰,但也说明验收深度靠自查,钢印纪律补上了这个缺口。
