ds2api / docs /deepseek-android-reverse-research.md
Bai Chen
Update deepseek-android-reverse-research.md
7c47499 unverified
|
Raw History Blame
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 元数据:

<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_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 接口)。关键分支:

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)

一次事务内:

  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) 给出客户端状态组合逻辑(简化):

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 为什么会“又增加次数 / 顶替上一条”

  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 抓包交叉验证。
  4. 混淆还原: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)的静态逆向,所有结论均标注了代码级证据;涉及服务端行为的部分(配额阈值、撤回触发策略)基于协议对照与客户端逻辑推断,已在正文中明确区分。