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
DeepSeek Android 客户端逆向研究报告
研究对象:
https://download.deepseek.com/apk/deepseek.apk(官方 Android 客户端) 包名:com.deepseek.chat| 版本:2.6.2(versionCode 281) 样本时间:APKLast-Modified: 2026-10-13,本次分析时间 2026-10-19 样本校验:
SHA-256: 1468cae414245e6bd447b0ae79c277958a6da1c25f4e4798ffc04e42127fa14aMD5: 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)
本次逆向的目标是弄清三件事:消息如何存储、消息如何被撤回(内容过滤)、以及“修改/重新生成次数上限”的机制和“复制粘贴重发绕过”的行为。核心结论:
消息存储是“服务端权威 + 本地 WCDB 缓存”的双层结构。 本地是腾讯 WCDB(SQLite 内核)数据库
deepseek_chat_<uid>.db,按会话分表(chat_session_messages_<sessionId>),字段与 Web 端 SSE 的response/AppStaticMessage模型一一对应。未发现应用层设置数据库加密密钥,即本地缓存基本是明文 SQLite。服务端才是持久化权威,/api/v0/chat/history_messages拉取的就是服务端已落库(可能已被撤回改写)的消息。撤回(内容过滤)仍是“模式 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 内没有防撤回能力。“修改/重新生成次数上限”是服务端配额,客户端只负责展示。
ModelConfig(com.deepseek.chat.settings.ModelConfig)中携带edit_quota、regenerate_quota、regenerate_options三个由服务端下发的字段;客户端把它们读进来,在消息操作栏(“修改 / 重新生成”)判断是否已超额,超额时弹出edit_quota_exceed_toast(“修改输入次数超过限制”)或regenerate_quota_exceed_toast(“重新生成次数超过限制”)。反编译代码中没有任何“本地计数 + 自增”的逻辑——次数完全由服务端记录并判定。“复制粘贴重发绕过并顶替上一条”的本质:换了一个 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 元数据:
<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 端每个请求带:
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_modey: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 接口)。关键分支:
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:
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 生成:
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)
一次事务内:
- 读取会话缓存条目
mbb(含cache_version); - 若本地
cache_version <= 传入版本→ 接受更新:命中eh3.c == sessionId则UPDATE,否则INSERT; - 更新
cache_version/cache_reset_at、把eh3.k(schema_version)置 5、必要时更新eh3.j(current_message_id); - 对会话分表:
- 若
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) 给出客户端状态组合逻辑(简化):
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 形态为:
{"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 为什么会“又增加次数 / 顶替上一条”
- 不进入配额作用域:因为走的是
completion,配额(edit/regenerate)只对相应的两个端点生效,故“重发”不被计数。 - 新叶节点顶替旧消息:本地库
eh3.current_message_id与 UI 都指向“当前叶”。新completion落库后,current_message_id更新为新消息;旧消息(已撤回/失败)在库中仍存在,但不再是叶节点,在会话视图中表现为“被顶到上面/被新消息取代”。 - 不是“删除/修改旧消息”:旧消息并未被改写成新内容,只是它的位置被新分支越过了。用 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. 研究局限与后续验证建议
- 配额的具体阈值未在客户端出现:
edit_quota/regenerate_quota是服务端下发的可空值,客户端不含“6 次/7 次”之类常量。若要确证阈值,只能在服务端配置或真实会话统计层面验证。 - 本地库未加密的结论基于“未发现 cipher key 调用”:属“证据缺失型”结论,建议在真机 root 后导出
deepseek_chat_<uid>.db并用 SQLite 工具直接打开以最终确认(若可打开即证实)。 - 撤回事件的字节级时序:本报告依据 Web 抓包 + 客户端 patch 逻辑推断 Android 端等价,建议对 Android 端做一次真实 SSE 抓包交叉验证。
- 混淆还原:
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 速查
-- 会话表(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)的静态逆向,所有结论均标注了代码级证据;涉及服务端行为的部分(配额阈值、撤回触发策略)基于协议对照与客户端逻辑推断,已在正文中明确区分。