|
Download docs/deepseek-android-reverse-research.md from a3216/ds2api: direct link, hf CLI and curl.
- Browser
- Download file 38.1 kB
-
https://huggingface.co/spaces/a3216/ds2api/resolve/ce5a019598af8a3a8fa70d3880098df3e850ecd7/docs/deepseek-android-reverse-research.md
- Command line
-
hf download hf://spaces/a3216/ds2api@ce5a019598af8a3a8fa70d3880098df3e850ecd7/docs/deepseek-android-reverse-research.md
-
curl -L -o deepseek-android-reverse-research.md https://huggingface.co/spaces/a3216/ds2api/resolve/ce5a019598af8a3a8fa70d3880098df3e850ecd7/docs/deepseek-android-reverse-research.md
38.1 kB
| # DeepSeek Android 客户端逆向研究报告 | |
| > 研究对象:`https://download.deepseek.com/apk/deepseek.apk`(官方 Android 客户端) | |
| > 包名:`com.deepseek.chat` | 版本:`2.6.2`(versionCode 281) | |
| > 样本时间:APK `Last-Modified: 2026-10-13`,本次分析时间 2026-10-19 | |
| > 样本校验: | |
| > - `SHA-256: 1468cae414245e6bd447b0ae79c277958a6da1c25f4e4798ffc04e42127fa14a` | |
| > - `MD5: 784159bc8c1e161d80b72377f076814c` | |
| > - 大小:`22,217,501` 字节 | |
| > | |
| > 研究工具:jadx 1.5.3(Java 反编译)、apktool 2.11.1(资源/smali)、Ghidra 12.1.3 + ghidra-mcp(原生库分析)、OpenJDK 21/25 | |
| > 对照资料:本仓库 `deepseek-recall-research.md`(Web 端 SSE 撤回机制)、`docs/web-endpoint-upgrade.md`、`docs/shumei-device-id-research.md` | |
| --- | |
| ## 0. 摘要(TL;DR) | |
| 本次逆向的目标是弄清三件事:**消息如何存储、消息如何被撤回(内容过滤)、以及“修改/重新生成次数上限”的机制和“复制粘贴重发绕过”的行为**。核心结论: | |
| 1. **消息存储是“服务端权威 + 本地 WCDB 缓存”的双层结构。** | |
| 本地是腾讯 WCDB(SQLite 内核)数据库 `deepseek_chat_<uid>.db`,按会话分表(`chat_session_messages_<sessionId>`),字段与 Web 端 SSE 的 `response/AppStaticMessage` 模型一一对应。**未发现应用层设置数据库加密密钥**,即本地缓存基本是明文 SQLite。服务端才是持久化权威,`/api/v0/chat/history_messages` 拉取的就是服务端已落库(可能已被撤回改写)的消息。 | |
| 2. **撤回(内容过滤)仍是“模式 B:先流式原文、后覆盖替换”。** | |
| 客户端的 `AppStaticMessage` 状态机包含 `WIP / FINISHED / INCOMPLETE / INTERRUPTED / CONTENT_FILTER`,片段类型包含 `THINK / RESPONSE / SEARCH / TOOL_SEARCH / TOOL_OPEN / TEMPLATE_RESPONSE`(TIP 走独立的 `tips` 字段)。撤回时服务端通过 SSE JSON-Patch 下发 `status=CONTENT_FILTER`、`ban_edit=true`、`ban_regenerate=true`,并把 `fragments` 整体覆盖为 `TEMPLATE_RESPONSE`。客户端只是“照单全收”,因此 App 内**没有**防撤回能力。 | |
| 3. **“修改/重新生成次数上限”是服务端配额,客户端只负责展示。** | |
| `ModelConfig`(`com.deepseek.chat.settings.ModelConfig`)中携带 `edit_quota`、`regenerate_quota`、`regenerate_options` 三个由服务端下发的字段;客户端把它们读进来,在消息操作栏(“修改 / 重新生成”)判断是否已超额,超额时弹出 `edit_quota_exceed_toast`(“修改输入次数超过限制”)或 `regenerate_quota_exceed_toast`(“重新生成次数超过限制”)。**反编译代码中没有任何“本地计数 + 自增”的逻辑**——次数完全由服务端记录并判定。 | |
| 4. **“复制粘贴重发绕过并顶替上一条”的本质:换了一个 endpoint,而不是突破了配额。** | |
| “修改”走 `POST /api/v0/chat/edit_message`,“重新生成”走 `POST /api/v0/chat/regenerate`,二者都针对**已存在的消息**(`parent_message_id`/上下文),因而受 `edit_quota`/`regenerate_quota` 约束;而“复制文本重新发送”走的是 `POST /api/v0/chat/completion`,这会创建**一条全新的用户消息**(新 `message_id`、新父链),根本不进入 edit/regenerate 配额。新消息成为会话的最新叶节点,旧的那条(已撤回/失败)在 UI 上被“顶替/越过”。这不是客户端的漏洞,而是**动作语义与配额作用域不同**导致的必然结果。 | |
| > 说明:本报告只做**静态逆向分析**与机制解释,不包含可直接执行的绕过脚本,也不包含对线上服务的压测/滥用操作。 | |
| --- | |
| ## 1. 研究方法与工具链 | |
| ### 1.1 环境 | |
| 沙箱内已具备 OpenJDK 25。工作流使用以下工具(均可从 GitHub Release 经 `gh-proxy.com` 镜像加速下载): | |
| | 工具 | 版本 | 用途 | 获取方式 | | |
| |------|------|------|----------| | |
| | jadx | 1.5.3 | DEX→Java 反编译(主力) | `https://gh-proxy.com/https://github.com/skylot/jadx/releases/...` | | |
| | apktool | 2.11.1 | 资源解码、smali 反汇编、manifest | `https://gh-proxy.com/https://github.com/iBotPeaches/Apktool/releases/...` | | |
| | Ghidra | 12.1.3 | 原生 `.so` 分析(PoW/加密/风控) | `https://gh-proxy.com/https://github.com/NationalSecurityAgency/ghidra/releases/...` | | |
| | ghidra-mcp | 7.0.0 | 用 MCP/REST 驱动 Ghidra headless 分析 | `ghidra-mcp`(bethington)一键脚本 | | |
| > 关于 `Ghidra (2).zip`:该压缩包实际内容是 **ghidra-mcp 的一键环境搭建脚本**(`setup-ghidra-mcp.sh` / `start-ghidra-mcp.sh` / `README-ghidra-mcp.md`),并不内含 Ghidra 本体。脚本会安装 JDK 21、Maven、下载 Ghidra 12.1.3(约 500MB)、构建并部署 ghidra-mcp headless 服务(默认 `127.0.0.1:8089`)。注意该脚本会重写 `PATH`(写 `/etc/profile.d/ghidra-env.sh`),在与既有 JDK 共存的沙箱里会因覆盖 `PATH` 导致 `head` 等基础命令找不到,故本次改为**手动安装 Ghidra**(`curl` 下载 release zip → `unzip` → `analyzeHeadless`)。 | |
| ### 1.2 分析流程 | |
| ``` | |
| deepseek.apk | |
| ├─ apktool d → AndroidManifest.xml / res/ / smali (资源与字符串证据) | |
| ├─ jadx -d → 14152 个 .java(随 R8 混淆为 defpackage/*) | |
| └─ unzip lib/ → arm64-v8a/*.so(Ghidra 分析对象) | |
| ``` | |
| R8 把大量 Kotlin 类合并进 `defpackage` 下的“上帝类”,但**字符串常量、资源 ID、字段名、JSON 键名均未被抹除**,这为还原协议与数据模型提供了可靠锚点。本报告所有结论均以“文件 + 行号 + 代码片段”为证据,见附录 A。 | |
| --- | |
| ## 2. 样本概况 | |
| ### 2.1 应用信息(来自 `AndroidManifest.xml` 与 `apktool.yml`) | |
| | 项 | 值 | | |
| |----|----| | |
| | 包名 | `com.deepseek.chat` | | |
| | versionName | `2.6.2` | | |
| | versionCode | `281` | | |
| | minSdk / targetSdk | `23` / `36` | | |
| | 编译 SDK | `36`(Android 16) | | |
| | Application 类 | `com.deepseek.chat.App` | | |
| | 网络安全配置 | `network_security_config`,`usesCleartextTraffic="false"` | | |
| | 备份 | `allowBackup="false"` | | |
| 关键组件: | |
| - `com.deepseek.chat.MainActivity`(LAUNCHER;App Links:`dpsk://chat/new`、`dpsk://chat/share`、`com.deepseek.chat://authorized_android`) | |
| - `com.deepseek.chat.ShareProxyActivity`(接收系统分享:`ACTION_SEND`/`SEND_MULTIPLE`,覆盖大量 MIME) | |
| - `com.deepseek.chat.wxapi.WXEntryActivity`(微信登录/分享回调) | |
| - `com.deepseek.chat.system.AppFileProvider`(`authority=com.deepseek.chat.provider`) | |
| - `com.deepseek.chat.system.ShareResultReceiver`(`exported=true`) | |
| `<application>` 内嵌第三方 SDK 元数据: | |
| ```xml | |
| <meta-data android:name="custom-AppKey" android:value="3b199b7a06468"/> | |
| <meta-data android:name="custom-AppSecret" android:value="98c6a376bdf6b632fda2a316b99b7ea4"/> | |
| <meta-data android:name="custom-Https" android:value="yes"/> | |
| ``` | |
| 权限以网络/媒体/相机/录音为主(`INTERNET`、`ACCESS_NETWORK_STATE`、`CAMERA`、`RECORD_AUDIO`、`POST_NOTIFICATIONS`、`FOREGROUND_SERVICE*` 等),未申请读取外部存储以外的敏感权限。 | |
| ### 2.2 代码构成 | |
| - `classes.dex`(12.5MB)、`classes2.dex`(0.55MB,多为 Compose/协程/序列化)、`classes3.dex`(2.7MB) | |
| - 应用是 **Jetpack Compose + Kotlin + kotlinx.serialization + Ktor/Retrofit(HTTP)+ WCDB(本地库)** 的典型组合。 | |
| ### 2.3 原生库清单与用途(`lib/arm64-v8a/`) | |
| | `.so` | 大小 | 归属与用途 | | |
| |-------|------|-----------| | |
| | `libWCDB.so` | 4.33MB | 腾讯 WCDB(微信数据库内核)——**本地聊天消息存储** | | |
| | `libmmkv.so` | 0.30MB | 腾讯 MMKV——本地键值设置存储 | | |
| | `librscrypto.so` | 0.34MB | DeepSeek 自研:**PoW 工作量证明哈希**(`com.deepseek.crypto.PowCalculator`) | | |
| | `libEncryptorP.so` | 0.08MB | 字节跳动 AppLog/APM 的加密器(`com.bytedance.applog.encryptor`) | | |
| | `libapminsighta/b.so`、`libvolc_log.so` | — | 监控/日志上报(火山引擎 APM) | | |
| | `libsmsdk.so` | 0.84MB | **数美(Shumei)风控 SDK**(设备指纹 `device_id`),详细协议见 `docs/shumei-device-id-research.md` | | |
| | `libliteavsdk.so`、`libtxffmpeg.so`、`libtxsoundtouch.so` | — | 腾讯 LiteAV/TRTC:**语音通话**音视频 | | |
| | `libc++_shared.so`、`libflipped.so`、`libandroidx.graphics.path.so` 等 | — | 运行库/图形辅助 | | |
| > 值得注意:真正与“消息存储”相关的只有 `libWCDB.so`;`libEncryptorP.so`(字节跳动)与 `librscrypto.so`(PoW)都**不用于本地消息库加密**。 | |
| --- | |
| ## 3. 网络协议层 | |
| ### 3.1 聊天相关 endpoint 全清单 | |
| 从 smali 中提取的 `/api/v0/...` 常量(`apktool-out/smali*` 内多处出现),按模块归类: | |
| **聊天流(SSE,撤回协议同源)** —— 见 `smali/gt3.smali`: | |
| | 路径 | 方法 | 用途 | | |
| |------|------|------| | |
| | `/api/v0/chat/completion` | POST | 新消息发送 → 生成回复 | | |
| | `/api/v0/chat/regenerate` | POST | 重新生成上一条回复 | | |
| | `/api/v0/chat/continue` | POST | 续写/继续生成 | | |
| | `/api/v0/chat/resume_stream` | POST | 恢复中断的流 | | |
| | `/api/v0/chat/edit_message` | POST | 编辑用户消息后重新生成 | | |
| | `/api/v0/chat/stop_stream` | POST | 停止当前生成(`smali/d31.smali`) | | |
| | `/api/v0/chat/message_feedback` | POST | 消息反馈/举报(`smali/d31.smali`) | | |
| | `/api/v0/chat/history_messages` | GET | 拉取历史消息(普通 JSON,非 SSE) | | |
| | `/api/v0/chat/create_pow_challenge` | POST | PoW 挑战(`smali/d31.smali`) | | |
| | `/api/v0/chat/tts/{...}`、`/api/v0/chat/tts/voice`、`/api/v0/chat/tts/voices` | POST/GET | 语音朗读 | | |
| **会话管理** —— 见 `smali/jb2.smali`、`smali/qx0.smali`: | |
| | 路径 | 用途 | | |
| |------|------| | |
| | `/api/v0/chat_session/create` | 新建会话 | | |
| | `/api/v0/chat_session/delete` / `delete_all` | 删除会话 | | |
| | `/api/v0/chat_session/fetch_page` | 会话列表分页 | | |
| | `/api/v0/chat_session/update_title` / `update_pinned` / `batch_update_pinned` | 标题/置顶 | | |
| **用户与设置**: | |
| | 路径 | 用途 | | |
| |------|------| | |
| | `/api/v0/users/current` | 当前用户信息 | | |
| | `/api/v0/users/settings` / `update_settings` | 用户设置 | | |
| | `/api/v0/client/settings` / `client/settings/report` | **客户端(远端)配置**——模型/配额下发处 | | |
| | `/api/v0/check_client_update` | 版本检查 | | |
| | `/api/v0/users/login`、`register`、`login_by_mobile_sms`、`oauth/google/login`、`oauth/wechat/login` … | 登录/注册 | | |
| **其他**:`/api/v0/file/upload_file`、`/api/v0/file/fetch_files`、`/api/v0/file/fork_file_task`;`/api/v0/share/{create,list,content,fork,delete}`;`/api/v0/call/{start,update,stop,rating,config}`;`/api/v0/index/{prepare,query}`;`/api/v0/ip_to_country_code`。 | |
| ### 3.2 通用请求头(`defpackage/jv5.java` case 3) | |
| Android 端每个请求带: | |
| ```java | |
| je7.t(hn3Var, "Referer", "https://chat.deepseek.com"); | |
| je7.t(hn3Var, "x-client-platform", "android"); | |
| je7.t(hn3Var, "x-client-version", "2.6.2"); | |
| je7.t(hn3Var, "x-client-locale", <locale>); | |
| je7.t(hn3Var, "x-client-bundle-id", "com.deepseek.chat"); | |
| ``` | |
| 响应头被解析用于观测:`x-ds-served-by`、`x-ds-trace-id`(`defpackage/p5b.java`)、`cf-ray`、`hwc-ray`。 | |
| > 对照 `docs/web-endpoint-upgrade.md`:Web 端额外需要 `x-hif-dliq` / `x-hif-leim`(HIF 轮询),**Android 端不需要**这两个头,UA 为 `DeepSeek/2.5.2 Android/32` 一类。 | |
| ### 3.3 聊天请求模型(`ChatFullCompletionRequest`) | |
| `defpackage/rv1.java` 是 `com.deepseek.chat.network.chat.model.chat.ChatFullCompletionRequest` 的 kotlinx.serialization 描述符,字段如下(顺序即 JSON 字段序): | |
| | JSON 字段 | 类型 | 必填 | 说明 | | |
| |-----------|------|------|------| | |
| | `chat_session_id` | String | ✔ | 会话 ID | | |
| | `parent_message_id` | Int? | ✔ | 父消息 ID(决定分支) | | |
| | `prompt` | String | ✔ | 用户输入文本 | | |
| | `ref_file_ids` | List | ✗ | 引用文件 | | |
| | `thinking_enabled` | Bool | ✗ | 深度思考 | | |
| | `search_enabled` | Bool | ✗ | 联网搜索 | | |
| | `audio_id` | String? | ✗ | 语音输入 ID | | |
| | `preempt` | Bool | ✔ | 抢占/打断进行中的流 | | |
| | `model_type` | String? | ✗ | 模型类型(default/expert/vision…) | | |
| | `action` | Enum? | ✗ | 动作标记 | | |
| `completion` / `regenerate` / `edit_message` / `continue` / `resume_stream` 共享同一套流式响应与 patch 协议,差异只体现在路径与部分字段。 | |
| ### 3.4 流式响应模型(`AppStaticMessage`) | |
| `defpackage/cy.java` 是 `com.deepseek.chat.domain.chat.model.completion.message.AppStaticMessage` 的描述符: | |
| ``` | |
| message_id, parent_id, role, inserted_at, | |
| ban_edit, ban_regenerate, status, quasi_status, | |
| thinking_enabled, search_enabled, search_triggered, | |
| accumulated_token_usage, extra_search_providers, | |
| feedback, tips, fragments, has_pending_fragment, | |
| conversation_mode, incomplete_message | |
| ``` | |
| 与 Web 端抓包一致(见 `deepseek-recall-research.md` 第 5 节):`ban_edit` / `ban_regenerate` 是撤回后置位的关键布尔;`status`/`quasi_status` 是状态机;`fragments` 是内容数组;`tips` 单独存放提示横幅。 | |
| 运行时的消息对象为 `defpackage/hy.java`(`extends lr implements qy2`),其字段 `g5a`(LiveData 风格)包括: | |
| - `o`/`p`:`status` / `quasi_status`(`y12` 包装的字符串) | |
| - `n`:`mv1` 片段列表 | |
| - `r`:`ban_regenerate`,`s`:`ban_edit`(布尔流) | |
| - `v`:`accumulated_token_usage`,`w`:`search_triggered`,`z`:`conversation_mode` | |
| - `y`:`has_pending_fragment`,`B`:`incomplete_message` | |
| ### 3.5 SSE / JSON Patch 协议与客户端 patch 应用 | |
| DeepSeek 用“类 JSON Patch”的增量协议更新 `response` 对象(详见 `deepseek-recall-research.md` 第 6 节)。Android 端的应用方就是 `defpackage/hy.java` 的 `k(rw5, String)` / `m(rw5, String)` 两个方法(重写自 `qy2` 接口)。关键分支: | |
| ```java | |
| case -892481550: if (str.equals("status")) → 写 this.o = status | |
| case 425478872: if (str.equals("ban_regenerate")) → 写 this.r | |
| case 381249571: if (str.equals("fragments")) → this.n.clear(); addAll(list) // 整体覆盖 | |
| case -233917914: if (str.equals("incomplete_message"))→ 写 this.B | |
| case 1550380638: if (str.equals("accumulated_token_usage")) → 写 this.v | |
| case 757362655: if (str.equals("conversation_mode")) → 写 this.z | |
| ``` | |
| `m(...)` 是**追加语义**(`APPEND`):对 `fragments` 追加新片段、对 `tips` 追加提示、对 `extra_search_providers` 合并。**注意 `fragments` 在 `k(...)` 中是“清空 + 覆盖”**——这正是撤回事件得以“抹掉原文”的关键:撤回 patch 把 `fragments` 换成 `TEMPLATE_RESPONSE`,客户端整体替换,原文在内存模型里即刻消失。 | |
| 片段类型(`defpackage/rr4.java` 的子类型反序列化 + `e3c.java` 的判定): | |
| | type | 含义 | | |
| |------|------| | |
| | `THINK` | 深度思考 | | |
| | `RESPONSE` | 正文 | | |
| | `SEARCH` | 联网搜索卡片(非思考流程) | | |
| | `TOOL_SEARCH` | 联网搜索卡片(思考流程) | | |
| | `TOOL_OPEN` | “打开网页”卡片 | | |
| | `TEMPLATE_RESPONSE` | **撤回模板话术(仅撤回事件出现)** | | |
| | `link` / `""` | 引用/占位 | | |
| > TIP(“本回答由 AI 生成,内容仅供参考…”)不是 fragment,而是 `tips` 字段,`defpackage/e7a`/`o37` 单独序列化。 | |
| ### 3.6 客户端远程配置与模型配额(`ModelConfig`) | |
| `defpackage/w97.java` + `ya7.java` 定义了 `com.deepseek.chat.settings.ModelConfig`,由 `/api/v0/client/settings` 下发,字段: | |
| ``` | |
| model_type, name, description, welcome_msg, is_default, | |
| enabled, switchable, show_model_name_in_session, | |
| input_character_limit, think_feature, search_feature, tts_feature, | |
| file_feature, call_feature, camera_mode, prompt_feature, | |
| regenerate_options, tips, | |
| edit_quota, ← Integer?(可空) | |
| regenerate_quota ← Integer?(可空) | |
| ``` | |
| `regenerate_quota` / `edit_quota` 为 **可空 Integer**,即“服务端可下发一个上限值,也可不设”。可空意味着:**是否限额、上限多少,完全由服务端配置决定**;客户端只做读取和展示。 | |
| 本地缓存这些远端设置使用 MMKV(键名如 `kv_settings_*` / `kv_remote_settings_*`),见 `defpackage/bu2.java`、`jc2.java`、`lb3.java`、`pn5.java`、`y62.java` 等。 | |
| ### 3.7 错误与提示映射 | |
| `defpackage/pwa.java` 的 `M(int)` 把聊天错误种类映射到提示串(节选): | |
| | 序号 | 提示资源 | | |
| |------|----------| | |
| | 0 | `hint_network_error` | | |
| | 1 | `hint_server_error` | | |
| | 2 | `hint_max_message_count`(消息数量达到上限) | | |
| | 3 | `hint_last_message_is_w_i_p`(请在其他消息生成结束后重试) | | |
| | 4 | `hint_prompt_empty` | | |
| | 6 | `hint_invalid_params` | | |
| | 8 | `hint_rate_limit_reached`(消息发送过于频繁) | | |
| | 9/13/14/15 | `hint_fallback`(消息未能发送) | | |
| | 12 | `completion_ref_file_audit_unsatisfied` | | |
| 聊天流错误的“原因”类型由 `defpackage/oq1.java` 家族表达(`iq1..nq1`,其 `toString()` 是原因名): | |
| ``` | |
| ContextLength, Rating, NoAudio, Quota, ServiceError, Superseded | |
| ``` | |
| 消息级“被拦截”类型由 `defpackage/ey1.java` 家族表达(其 `a()` 返回原因码): | |
| | 类 | 返回码 | | |
| |----|--------| | |
| | `by1` | `CONTENT_EMPTY` | | |
| | `dy1` | `CONTENT_TOO_LONG` | | |
| | `cy1` | `CONTENT_FILTER` | | |
| > 注意:**没有** `EDIT_QUOTA_EXCEED` / `REGENERATE_QUOTA_EXCEED` 这类“服务端错误码”;配额超额在客户端只体现为“操作被禁用 + 点击弹 toast”,见第 6 节。 | |
| ### 3.8 PoW(工作量证明) | |
| - 挑战:`POST /api/v0/chat/create_pow_challenge`(`smali/d31.smali`)。 | |
| - 计算:`com.deepseek.crypto.PowCalculator` 通过 JNI 调 `liblibrscrypto.so`,JNI 方法签名 `calculateDeepSeekHashV1Pow(Ljava/lang/String;Ljava/lang/String;J)J`(`defpackage/jc2.java` 可见)。 | |
| - 预取:客户端可通过 MMKV 设置 `kv_settings_pow_prefetch` / `kv_settings_pow_prefetch_count` 预取挑战(`jc2.java`)。 | |
| - `completion` 请求在发出前会带上 PoW 结果(`jc2.k(...)` 构造含挑战上下文与 `/api/v0/chat/completion` 的请求对象)。 | |
| --- | |
| ## 4. 本地消息存储机制 | |
| ### 4.1 数据库:WCDB(`deepseek_chat_<uid>.db`) | |
| 数据库实例在 DI 图中构造,见 `defpackage/cs9.java:119`: | |
| ```java | |
| return new rbb(new Database( | |
| ((Context) ...).getDatabasePath( | |
| "deepseek_chat_" + ((xy) ...).b + ".db" // 每个账号一个文件 | |
| ).getAbsolutePath())); | |
| ``` | |
| - **文件**:`<app>/databases/deepseek_chat_<accountKey>.db`(`<accountKey>` 取自 `xy.b`)。 | |
| - **打开方式**:`com.tencent.wcdb.core.Database(String path)` → `createDatabase(path,false,false)`。 | |
| - **是否加密**:全仓库检索 `setCipherKey` / `Cipher` / `passphrase`,**未发现应用侧调用 WCDB 的加密接口**(`setCipherKey` 只有 native 声明本身)。结论:**本地库未加密,是普通 WCDB/SQLite**。这也解释了为何“消息存储”可被本地取证工具(如微信 WCDB 生态的工具)直接读取。 | |
| `rbb` 是消息仓储(Repository):持有 `Database`,并用 `ConcurrentHashMap<String, abb>` 按会话缓存“会话分表(Table)”,见 `defpackage/rbb.java:46-59`。 | |
| ### 4.2 会话元数据表(`eh3` / 模型 `lbb`) | |
| `defpackage/eh3.java` 定义了“会话”表的 ORM 绑定(模型类 `lbb`): | |
| | 列 | 类型 | 备注 | | |
| |----|------|------| | |
| | `id` | TEXT | 主键(chat_session_id) | | |
| | `title` | TEXT | 标题 | | |
| | `titleType` | TEXT | 标题类型 | | |
| | `cache_version` | INT | 缓存版本(用于并发写入去旧) | | |
| | `cache_reset_at` | INT | 缓存重置时间 | | |
| | `inserted_at` | REAL | 创建时间 | | |
| | `updated_at` | REAL | 更新时间 | | |
| | `current_message_id` | INT | 当前叶消息 ID(决定显示到哪条) | | |
| | `schema_version` | INT | 本地 schema 版本 | | |
| | `pinned` | INT | 是否置顶 | | |
| | `model_type` | TEXT | 会话所用模型 | | |
| ### 4.3 会话消息表(`chat_session_messages_<sessionId>` / `dh3` / `zab`) | |
| 每个会话一张表,表名由 `defpackage/abb.java:13` 生成: | |
| ```java | |
| String strM = sq3.M("'chat_session_messages_", str, "'"); // 形如 'chat_session_messages_<id>' | |
| database.g(strM, dh3.a); // 注册 ORM | |
| ``` | |
| 表结构由 `defpackage/dh3.java` 定义(模型类 `zab`): | |
| | 列 | 类型 | 对应 `AppStaticMessage` 字段 | | |
| |----|------|------------------------------| | |
| | `message_id` | INT(主键) | message_id | | |
| | `parent_id` | INT? | parent_id | | |
| | `role` | TEXT | role(USER/ASSISTANT) | | |
| | `thinking_enabled` | BOOL? | thinking_enabled | | |
| | `status` | TEXT | status(含 `CONTENT_FILTER`) | | |
| | `inserted_at` | REAL | inserted_at | | |
| | `feedback_type` | TEXT? | feedback | | |
| | `accumulated_token_usage` | INT | accumulated_token_usage | | |
| | `ban_edit` | BOOL | ban_edit | | |
| | `ban_regenerate` | BOOL | ban_regenerate | | |
| | `tips` | TEXT? | tips(JSON) | | |
| | `fragments` | TEXT? | fragments(JSON) | | |
| | `conversation_mode` | TEXT? | conversation_mode | | |
| | `extra_search_providers` | TEXT? | extra_search_providers | | |
| > 重点:**`ban_edit`、`ban_regenerate`、`status=CONTENT_FILTER` 都会被持久化到本地库**。也就是说,一条被撤回的消息即使你回看本地缓存,看到的仍是“已被撤回”的模板话术与禁止编辑/再生的标记。 | |
| ### 4.4 写入/合并逻辑(`rbb.b(...)`,`defpackage/rbb.java:61-135`) | |
| 一次事务内: | |
| 1. 读取会话缓存条目 `mbb`(含 `cache_version`); | |
| 2. 若本地 `cache_version <= 传入版本` → **接受更新**:命中 `eh3.c == sessionId` 则 `UPDATE`,否则 `INSERT`; | |
| 3. 更新 `cache_version`/`cache_reset_at`、把 `eh3.k`(`schema_version`)置 5、必要时更新 `eh3.j`(`current_message_id`); | |
| 4. 对会话分表: | |
| - 若 `str2 == "MERGE"` → **UPSERT/追加**(保留旧消息,插入新消息); | |
| - 否则 → **先删空该会话所有消息,再整体插入**(`ci7Var2.P()` 清表 + 批量 INSERT)。 | |
| 这解释了 App 的两种同步语义:增量“合并”与全量“覆盖”。`current_message_id` 决定了 UI 把哪条视为“当前叶”——这就是“新消息顶替旧消息”的**数据层根因**:当新 `completion` 写入后,`current_message_id` 指向新消息,旧消息虽在库里但不再是叶节点。 | |
| ### 4.5 键值存储(MMKV) | |
| 除 WCDB 外,App 大量使用 MMKV 存设置/状态:`kv_settings_*`(本地偏好)、`kv_remote_settings_*`(远端下发缓存),如 `kv_settings_session_prefetch_count`、`kv_settings_pow_prefetch`、`kv_settings_tts_resume_max_times` 等(`bu2.java`/`jc2.java`/`lb3.java`/`y62.java`)。会话切换到本地也会记录 `chat_session_id`/`model_type`(`defpackage/fb2.java:24`)。 | |
| ### 4.6 存储小结 | |
| | 层 | 载体 | 加密 | 权威性 | | |
| |----|------|------|--------| | |
| | 消息 | WCDB `deepseek_chat_<uid>.db`,按会话分表 | ❌(未见 cipher key) | 缓存,镜像服务端 | | |
| | 会话元数据 | 同库 `eh3` 表 | ❌ | 缓存 | | |
| | 设置/状态 | MMKV | ❌ | 本地 + 远端缓存 | | |
| | **真源** | 服务端(`history_messages`) | — | 服务端持久化,撤回后不可逆 | | |
| --- | |
| ## 5. 消息撤回 / 内容过滤机制 | |
| ### 5.1 状态机 | |
| `defpackage/e3c.java:486` 的 `O(status, quasiStatus)` 给出客户端状态组合逻辑(简化): | |
| ```java | |
| return status=="WIP" | |
| ? (quasi=="WIP" ? c32.a : quasi=="INCOMPLETE" ? new a32(true) | |
| : quasi=="CONTENT_FILTER" ? new a32(false) : new a32(false)) | |
| : status=="INCOMPLETE" ? b32.d | |
| : status=="CONTENT_FILTER" ? b32.b | |
| : status=="INTERRUPTED" ? (...) | |
| : b32.c; // 正常完成 FINISHED | |
| ``` | |
| 由此可得客户端认可的状态集合: | |
| | status | 含义 | | |
| |--------|------| | |
| | `WIP` | 生成中 | | |
| | `FINISHED`(映射到 `b32.c`/正常态) | 完成 | | |
| | `INCOMPLETE` | 未完成(上下文超限等) | | |
| | `INTERRUPTED` | 被打断 | | |
| | `CONTENT_FILTER` | **内容过滤/撤回** | | |
| `quasi_status`(准状态)在流未结束时先行指示。`defpackage/c50.java:28` 与 `ns.java:909` 都有 `"CONTENT_FILTER"` 判定分支,用于 UI 与业务分支。 | |
| 另有文件/附件解析状态枚举 `defpackage/cv1.java`:`PENDING/PARSING/SUCCESS/FAILED/CONTENT_FILTER/CONTENT_TOO_LONG/CONTENT_EMPTY/CANCELED/UNKNOWN`(用于文件与内容审校,不与消息 status 混用)。 | |
| ### 5.2 撤回的 SSE 事件形态(与 Web 端一致) | |
| Web 抓包已证实撤回是“模式 B”,其 patch 形态为: | |
| ```json | |
| {"v":[ | |
| {"p":"ban_regenerate","v":true}, | |
| {"p":"status","v":"CONTENT_FILTER"}, | |
| {"p":"fragments","v":[{"id":5,"type":"TEMPLATE_RESPONSE","content":"你好,这个问题我暂时无法回答,让我们换个话题再聊聊吧。"}]}, | |
| {"p":"quasi_status","v":"CONTENT_FILTER"} | |
| ]} | |
| ``` | |
| Android 端的 `hy.java` 对这四个键的处理(`ban_regenerate`→`this.r`;`status`→`this.o`;`fragments`→清空覆盖;`quasi_status`→`this.p`)**完全等价**。因此: | |
| - 撤回发生时,App 内存模型里 `fragments` 被替换为 `TEMPLATE_RESPONSE`,`status` 变 `CONTENT_FILTER`; | |
| - `ban_edit` / `ban_regenerate` 被置真,操作栏的“修改 / 重新生成”随之被禁用; | |
| - 撤回状态随后被写入本地 WCDB(`dh3` 的 status/ban_* 列)。 | |
| ### 5.3 撤回的触发与持久化 | |
| - 触发时机:`FINISHED` 之后追加覆盖事件(`deepseek-recall-research.md` 附录 A 的时间线)。 | |
| - 触发位置:服务端(内容审核策略),客户端无法预判、也无法阻止。 | |
| - 持久化:服务端 `/api/v0/chat/history_messages` 返回的就是撤回后的状态;本地库同步的也是撤回后状态。 | |
| - **客户端不具备“防撤回”能力**:与浏览器扩展不同,App 里的 patch 应用是编译进 `hy.java` 的,且原文在被覆盖的瞬间即从内存碎片数组中移除,除非在**收到 SSE 字节流的第一时间**做过滤,否则无法保留原文。 | |
| --- | |
| ## 6. 修改次数 / 重新生成次数限制机制 | |
| ### 6.1 配额来源 | |
| 配额来自服务端下发的 `ModelConfig.edit_quota` / `ModelConfig.regenerate_quota`(`defpackage/w97.java:36-37`、`ya7.java:189-191`)。它们是**可空 Integer**:服务端可按模型/账号/风控策略灵活设置是否限额及上限。 | |
| ### 6.2 提示与触发点 | |
| 两条针对性提示: | |
| | 资源名 | 文案 | ID | | |
| |--------|------|----| | |
| | `edit_quota_exceed_toast` | 「修改输入次数超过限制」 | `0x7f0f00fa` | | |
| | `regenerate_quota_exceed_toast` | 「重新生成次数超过限制」 | `0x7f0f02b6` | | |
| 触发点位于**消息操作栏(footer/menu)**的 Compose 逻辑: | |
| - `defpackage/w40.java`:`case 0`(footer)当 `z2` 为真时执行 | |
| `ds0.L(nsVar, lrVar, "footer"); r0a.a(r0aVar, null, new cv(8), 3);` | |
| 其中 `cv(8)` 返回 `R.string.regenerate_quota_exceed_toast`。 | |
| - `defpackage/x40.java`:`default` 分支 `r0a.a(r0aVar, null, new uab(11), 3)`(`uab(11)` 为朗读相关提示;`uab(2)` 为 `edit_quota_exceed_toast`)。 | |
| - 构造签名 `w40(boolean 配额超额, ns 会话VM, lr 消息, r0a 提示器, boolean ..., Object obj, su4, int)`:**第一个布尔即“是否因超额而禁止该动作”**。 | |
| 即:**当服务端判定该消息的修改/重新生成次数已达上限时,客户端把对应动作置为“禁用态”,用户点击时弹上述 toast**。 | |
| ### 6.3 相关但不同的限额(不要混淆) | |
| - `hint_max_message_count`「消息数量达到上限」:**单会话消息条数上限**(`completion_max_message_count_toast`),与编辑/再生次数无关。 | |
| - `hint_rate_limit_reached`「消息发送过于频繁」:**全局频率限制**,与单消息次数无关。 | |
| - `hint_last_message_is_w_i_p`「请在其他消息生成结束后重试」:并发约束(`preempt` 字段相关)。 | |
| ### 6.4 客户端没有本地计数器(关键证据) | |
| 全量检索 `edit_count`、`regenerate_count`、`regenerate_times`、`editQuota/regenerateQuota` 的使用点: | |
| - 除 `ModelConfig` 的定义(`w97.java`/`ya7.java`)与其 `toString` 外,**没有任何“本地计数自增/比较”的逻辑**; | |
| - 消息模型 `AppStaticMessage`/`hy`/`dh3` 中**没有**“已修改次数/已再生次数”字段; | |
| - 因此“第 6~7 次触发上限”这一现象**完全由服务端统计与判定**,客户端只是被动接收“超额”信号并弹 toast。 | |
| > 结论:**次数限制不可通过改客户端绕过**(改了客户端也不会改变服务端的计数与判定)。 | |
| --- | |
| ## 7. “复制粘贴重发绕过并顶替上一条”行为分析 | |
| ### 7.1 三个动作的语义差异 | |
| | 动作 | 端点 | 目标 | 是否受 `edit_quota`/`regenerate_quota` 约束 | 结果 | | |
| |------|------|------|---------------------------------------------|------| | |
| | 修改 | `POST /api/v0/chat/edit_message` | 对**已存在**的用户消息改写并重生成 | ✅ 受 `edit_quota` | 在父子链上替换/分支 | | |
| | 重新生成 | `POST /api/v0/chat/regenerate` | 对**已存在**的回复再生成 | ✅ 受 `regenerate_quota` | 同级“变体/兄弟” | | |
| | **复制文本重发** | `POST /api/v0/chat/completion` | **创建一条全新用户消息** | ❌ 不受上述配额,仅受全局频率限制 | 新叶节点 | | |
| 请求模型 `ChatFullCompletionRequest` 中,`completion` 与 `edit_message` 的差别正是:前者产生**新的 `message_id`**(`parent_message_id` 指向当时叶节点),后者作用于既有消息。服务端对 `completion` 只做“新消息”的常规校验(会话条数、频率、PoW 等),**不会**去校验“这条文本是不是之前那条的复制”。 | |
| ### 7.2 为什么会“又增加次数 / 顶替上一条” | |
| 1. **不进入配额作用域**:因为走的是 `completion`,配额(edit/regenerate)只对相应的两个端点生效,故“重发”不被计数。 | |
| 2. **新叶节点顶替旧消息**:本地库 `eh3.current_message_id` 与 UI 都指向“当前叶”。新 `completion` 落库后,`current_message_id` 更新为新消息;旧消息(已撤回/失败)在库中仍存在,但**不再是叶节点**,在会话视图中表现为“被顶到上面/被新消息取代”。 | |
| 3. **不是“删除/修改旧消息”**:旧消息并未被改写成新内容,只是它的位置被新分支越过了。用 Web 术语说,两者是同一会话中的不同叶子,UI 默认只呈现当前叶路径。 | |
| ### 7.3 边界与注意 | |
| - 该行为**不是客户端漏洞**,而是“动作语义 + 配额作用域”的自然结果;服务端若收紧策略(例如对 `completion` 也做文本去重/频率/内容策略校验),现象会消失。 | |
| - `preempt` 字段决定是否打断进行中的生成;重发时若前一条仍在 `WIP`,可能命中 `hint_last_message_is_w_i_p`。 | |
| - 会话条数上限(`hint_max_message_count`)与频率上限(`hint_rate_limit_reached`)仍会生效,因此“重发”并非无限。 | |
| --- | |
| ## 8. 其他相关机制 | |
| ### 8.1 数美(Shumei)风控与 device_id | |
| `libsmsdk.so` + `com.ishumei.smantifraud.*`。Android 端登录/请求携带数美签发的 `device_id`,伪造会被判 `RISK_DEVICE_DETECTED`。完整协议(RSA/DES/AES/Gzip 流水线、`tn`/`er` 完整性校验)见 `docs/shumei-device-id-research.md`,本报告不重复。 | |
| ### 8.2 埋点与监控(字节跳动 AppLog / 火山 APM) | |
| - `com.bytedance.applog.encryptor.EncryptorUtil` 加载 `libEncryptorP.so`(`System.loadLibrary("EncryptorP")`),用于本地埋点日志加密。 | |
| - `libapminsighta/b.so`、`libvolc_log.so` 用于性能/崩溃监控上报。 | |
| - 埋点会记录聊天动作,例如 `switch_session`(`fb2.java`)、`regenerate_menu_show`(`ds0.java:238`)、`deep_think_toggle`(`el0.java`)、`session_reload_click` 等,可作为行为分析线索。 | |
| ### 8.3 语音通话 / TTS / 文件 / 分享 | |
| - 通话:`/api/v0/call/{start,update,stop,rating,config}`,本地错误枚举 `defpackage/o01.java`(`ConnectionUnavailable`、`DailyQuotaExhausted`、`ConcurrentCallLimit`、`RtcJoinFailed`…),音视频由腾讯 TRTC/LiteAV 提供。 | |
| - TTS:`/api/v0/chat/tts/*`,设置项 `kv_settings_tts_resume_*`。 | |
| - 文件:上传/引用/审计(`completion_ref_file_audit_unsatisfied`),文件解析状态用 `cv1` 枚举。 | |
| - 分享:`/api/v0/share/*`,含“生成链接数上限”(`share_create_link_exceed_limit_toast`)。 | |
| ### 8.4 客户端设置同步 | |
| `/api/v0/client/settings` 下发远端配置,写入 MMKV `kv_remote_settings_*`;`client/settings/report` 回传使用情况。`ModelConfig`(含配额)即由此同步,App 冷启动/切前台会刷新。 | |
| --- | |
| ## 9. 与 Web 端的对照 | |
| | 维度 | Web 端 | Android 端(本报告) | | |
| |------|--------|----------------------| | |
| | 传输 | XHR + SSE(axios) | Ktor/Retrofit + SSE | | |
| | 端点 | 同名 `/api/v0/chat/*` | 同名 `/api/v0/chat/*` | | |
| | 撤回协议 | JSON-Patch:status=CONTENT_FILTER + fragments↦TEMPLATE_RESPONSE + ban_regenerate | 完全一致(`hy.java` 逐键处理) | | |
| | 额外请求头 | `x-hif-dliq`/`x-hif-leim` | 无;有 `x-client-platform/version/locale/bundle-id` | | |
| | 本地存储 | 浏览器缓存/IndexedDB | WCDB `deepseek_chat_<uid>.db`(未加密) | | |
| | 配额来源 | 会话设置下发 | `ModelConfig.edit_quota/regenerate_quota` | | |
| | 防撤回可行性 | 可拦截 SSE 事件块 | 需拦截 SSE 字节流,App 内不可行 | | |
| --- | |
| ## 10. 研究局限与后续验证建议 | |
| 1. **配额的具体阈值未在客户端出现**:`edit_quota`/`regenerate_quota` 是服务端下发的可空值,客户端不含“6 次/7 次”之类常量。若要确证阈值,只能在**服务端配置或真实会话统计**层面验证。 | |
| 2. **本地库未加密的结论基于“未发现 cipher key 调用”**:属“证据缺失型”结论,建议在真机 root 后导出 `deepseek_chat_<uid>.db` 并用 SQLite 工具直接打开以最终确认(若可打开即证实)。 | |
| 3. **撤回事件的字节级时序**:本报告依据 Web 抓包 + 客户端 patch 逻辑推断 Android 端等价,建议对 Android 端做一次真实 SSE 抓包交叉验证。 | |
| 5. **混淆还原**:`defpackage` 下大量类被 R8 合并,个别“上帝类”(如 `vt2`、`p5b`)难以完全还原;本报告优先采用“字符串/资源 ID/JSON 键名”这类未被混淆的稳定锚点。 | |
| --- | |
| ## 附录 A:证据索引(文件:行) | |
| | 主题 | 证据 | | |
| |------|------| | |
| | 请求头 | `defpackage/jv5.java:65-73` | | |
| | 聊天端点点 | `apktool-out/smali/gt3.smali:1428,1638,1843,2038,2249`;`d31.smali:1957,2257,2603,2953`;`jb2.smali`;`qx0.smali` | | |
| | 完成请求模型 | `defpackage/rv1.java:16-27`(`ChatFullCompletionRequest`) | | |
| | 消息响应模型 | `defpackage/cy.java:17-37`(`AppStaticMessage`) | | |
| | 运行时消息对象/patch | `defpackage/hy.java:280-352, 355-382` | | |
| | 状态机 | `defpackage/e3c.java:486` | | |
| | 片段类型 | `defpackage/rr4.java`;`e3c.java:673-721`;`ir4/lr4/gr4`(TOOL_OPEN/TOOL_SEARCH) | | |
| | 拦截原因码 | `defpackage/cy1.java`(CONTENT_FILTER)、`by1.java`、`dy1.java` | | |
| | 流错误原因 | `defpackage/iq1..nq1`(ContextLength/Rating/NoAudio/Quota/ServiceError/Superseded) | | |
| | 提示映射 | `defpackage/pwa.java:345-381` | | |
| | 配额字段 | `defpackage/w97.java:34-37`;`ya7.java:185-192` | | |
| | 配额 toast 触发 | `defpackage/w40.java:41-65`;`x40.java:19-27`;`cv.java:50`;`uab.java:39` | | |
| | 本地库构造 | `defpackage/cs9.java:119` | | |
| | 会话表 ORM | `defpackage/eh3.java:24-91` | | |
| | 消息表 ORM | `defpackage/dh3.java:28-82` | | |
| | 会话分表命名 | `defpackage/abb.java:11-21` | | |
| | 仓储/写入合并 | `defpackage/rbb.java:46-135` | | |
| | WCDB 打开无密钥 | `com/tencent/wcdb/core/Database.java:82-83`;`setCipherKey` 仅 native 声明(同文件 283) | | |
| | PoW | `defpackage/jc2.java:290,324`;`com/deepseek/crypto/PowCalculator.java:13` | | |
| | 数美 | `docs/shumei-device-id-research.md` | | |
| | 撤回协议对照 | `deepseek-recall-research.md` | | |
| ## 附录 B:关键 JSON 字段速查 | |
| ``` | |
| // 请求:POST /api/v0/chat/completion | |
| { chat_session_id, parent_message_id, prompt, ref_file_ids?, thinking_enabled?, | |
| search_enabled?, audio_id?, preempt, model_type?, action? } | |
| // 流式响应对象(AppStaticMessage) | |
| { message_id, parent_id, role, inserted_at, ban_edit, ban_regenerate, | |
| status, quasi_status, thinking_enabled, search_enabled, search_triggered, | |
| accumulated_token_usage, extra_search_providers, feedback, tips, | |
| fragments[], has_pending_fragment, conversation_mode, incomplete_message } | |
| // 撤回 patch | |
| { "v":[ {"p":"ban_regenerate","v":true}, {"p":"status","v":"CONTENT_FILTER"}, | |
| {"p":"fragments","v":[{"type":"TEMPLATE_RESPONSE","content":"..."}]}, | |
| {"p":"quasi_status","v":"CONTENT_FILTER"} ] } | |
| // 远端模型配置(/api/v0/client/settings → ModelConfig) | |
| { model_type, ..., regenerate_options?, tips?, edit_quota?, regenerate_quota? } | |
| ``` | |
| ## 附录 C:本地库 schema 速查 | |
| ```sql | |
| -- 会话表(eh3 / lbb) | |
| CREATE TABLE session( | |
| id TEXT PRIMARY KEY, title TEXT, titleType TEXT, | |
| cache_version INT, cache_reset_at INT, | |
| inserted_at REAL, updated_at REAL, | |
| current_message_id INT, schema_version INT, | |
| pinned INT, model_type TEXT | |
| ); | |
| -- 每个会话一张消息表(dh3 / zab):chat_session_messages_<sessionId> | |
| CREATE TABLE 'chat_session_messages_<sessionId>'( | |
| message_id INT PRIMARY KEY, parent_id INT, role TEXT, | |
| thinking_enabled INT, status TEXT, inserted_at REAL, | |
| feedback_type TEXT, accumulated_token_usage INT, | |
| ban_edit INT, ban_regenerate INT, | |
| tips TEXT, fragments TEXT, conversation_mode TEXT, | |
| extra_search_providers TEXT | |
| ); | |
| ``` | |
| --- | |
| > 本报告基于对 `deepseek.apk`(v2.6.2)的静态逆向,所有结论均标注了代码级证据;涉及服务端行为的部分(配额阈值、撤回触发策略)基于协议对照与客户端逻辑推断,已在正文中明确区分。 | |