锦书在线
80.52M · 2026-03-21
以下是我和 Claude Code 的真实对话过程。你会看到,我只给出了非常简短、高层的指令,Claude 自己决定分析路径、选择工具、编写代码、调试问题——整个过程几乎不需要人工介入。
我只是把解包后的目录指给 Claude,让它"看看能分析出什么"。
Claude 自主完成的工作:
AndroidManifest.xml,提取包名、权限、组件、SDK 版本apktool.yml,获取版本信息assets/ 目录,发现配置文件lib/arm64-v8a/,识别出 Flutter 框架 + 自定义 native 库smali/ 目录,找到 SecurityUtil、MainActivity、MethodChannel 处理器res/values/strings.xml,确认应用名称一条模糊的指令,得到了一份专业级的 APK 分析报告。
我甚至没有说"逆向"或"反编译",只是问"有没有办法看 API 代码"。
Claude 自主完成的工作:
libapp.so 是核心目标strings 命令从 libapp.so 中提取出 93 个 API 路径smali 层的 Java 代码,找到更多线索仍然是一条非技术性的提问,Claude 自己提出了完整的工具链方案。
Claude 的回答:
我只告诉 Claude 工具装好了在哪里。
Claude 自主完成的工作:
dio_utils.dart、intercept.dart、http_api.dart)entity_factory.dart(90000 tokens 的巨型文件)中提取数据模型getSignature() 和 getDecryptString() 函数这是唯一一条稍微具体的指令——但我也只是说了"想法"和"你觉得可行吗"。
Claude 自主完成的工作:
pp.txt 批量提取 124 个 API 端点entity_factory.dart 提取 80+ 个数据模型的字段定义intercept.dart 获取认证规则"你先去试试"——就这五个字。
Claude 自主完成的工作(这是最精彩的部分):
第一次尝试 → 被拒绝:"參數不能為空: jdsignature"
定位签名代码 → 在 common_tools.dart 中找到 getSignature()
timestamp.part2.md5(timestamp+part1)逆向 native 库 → 分析 libsecurity.so
llvm-objdump 反汇编 20KB 的 C 库secret = "30820"破解解密算法 → 分析 getDecryptString() 的 0x2e8 字节汇编
i % 32 → 失败(InvalidSignature)min(i, 31) → 部分成功decodeBase64() → 完整成功!逐步调试 → 签名通过后还需要正确参数
"參數不能爲空: platform" → 添加 platform=android"參數不能爲空: app_channel" → 阅读 splash_page.dart 找到全部参数{"success": 1, "data": {...}} 批量验证 → 自动测试 20+ 个端点,全部成功
从"你先去试试"到全套 API 打通,Claude 独立完成了:
Claude 自主完成的工作:
| 指标 | 数值 |
|---|---|
| 人类发送的消息 | 7 条 |
| 人类最长的消息 | 约 80 字 |
| Claude 读取的文件 | 50+ 个 |
| Claude 执行的命令 | 100+ 条 |
| Claude 编写的代码 | ~5000 行(Python + JSON) |
| Claude 分析的汇编 | ~3000 行(ARM64) |
| 总耗时 | 约 3-4 小时(含 blutter 编译等待) |
| 人类实际操作时间 | < 5 分钟(打字发消息) |
传统的 APK 逆向工程需要:
而在这次实践中:
这不是"AI 辅助"——这是 AI 主导、人类指挥的逆向工程。
JavDB 是一个影视资料库应用,它的移动版本和网页版本功能并不一致,且 iOS 版本安装受限。我希望能逆向移动端 API,用 Web 技术重新实现移动端的功能。
目标:
挑战:
jdsignature),不是标准的 OAuth/JWT| 工具 | 用途 | 安装方式 |
|---|---|---|
| apktool | APK 解包、资源提取、smali 反编译 | brew install apktool |
| blutter | Flutter/Dart AOT 逆向,恢复类名、方法、字符串 | git clone + uv 管理 Python 环境 |
| llvm-objdump | ARM64 原生库反汇编 | brew install llvm |
| Claude Code | AI 辅助分析汇编、推导算法、编写代码 | npm install -g @anthropic-ai/claude-code |
| Python 3 | 编写测试脚本和 API 客户端 | 系统自带或 brew install python |
apktool d jdb_official_v1.9.35.apk -o jdb_official_v1.9.35
得到标准的 APK 目录结构:
jdb_official_v1.9.35/
├── AndroidManifest.xml # 应用配置
├── apktool.yml # apktool 元数据
├── assets/ # 资源文件
├── lib/arm64-v8a/ # 原生库(关键!)
│ ├── libapp.so # Flutter AOT 编译的 Dart 代码
│ ├── libflutter.so # Flutter 引擎
│ └── libsecurity.so # 自定义安全库(20KB)
├── res/ # Android 资源
├── smali/ # Java/Kotlin 反编译
└── original/META-INF/ # 签名证书
从 manifest 中提取到的关键信息:
<!-- 包名故意伪装 -->
<manifest package="xxx.pornhub.骂人">
<!-- Flutter 应用 -->
<meta-data android:name="io.flutter.embedding.android.FlutterVersion" />
<!-- 4个 activity-alias 用于切换图标(伪装功能) -->
<activity-alias android:name=".launcher1" android:icon="@mipmap/ic_launcher_1" />
<activity-alias android:name=".launcher2" android:icon="@mipmap/ic_launcher_2" />
<activity-alias android:name=".launcher3" android:icon="@mipmap/ic_launcher_3" />
<!-- 从最近任务列表中隐藏 -->
<activity android:excludeFromRecents="true" />
<!-- 深度链接 scheme -->
<data android:scheme="dvkft4" />
发现: 应用做了大量隐藏和伪装措施,包名伪装、图标切换、从最近列表隐藏。
在 smali/ 目录找到了 Java 层的关键类:
SecurityUtil.smali — 加载 native 库:
.method static constructor <clinit>()V
const-string v0, "security"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
.end method
.method public static native getSecret()Ljava/lang/String;
.end method
MethodChannel 处理器 (t5/b.smali) — Flutter 与原生层的桥梁:
getIKey → 调用 SecurityUtil.getSecret()setToken / getToken → SharedPreferences 存储getAppChannel → 返回应用渠道这告诉我们:Flutter 层通过 MethodChannel 调用 Java 层,Java 层再通过 JNI 调用 C 库获取密钥。
Flutter 应用的 Dart 代码被 AOT(Ahead-of-Time)编译成 ARM64 机器码,存放在 libapp.so 中。传统的 Java 反编译工具无法处理,需要专门的 Flutter 逆向工具。
blutter 能够:
libapp.so 中恢复 Dart 类名、方法名、字段名cd /path/to/blutter
uv run python3 blutter.py /path/to/apk/lib/arm64-v8a /path/to/output
️ 注意: 首次运行会自动编译对应版本的 Dart SDK(本例为 Dart 3.5.4),需要编译 264 个 C++ 文件,耗时约 10-15 分钟。
blutter_out/
├── asm/ # 反编译的 Dart 代码(100个子目录)
│ ├── astarte/net/ # 网络层
│ │ ├── http_api.dart # API 基础配置
│ │ ├── dio_utils.dart # HTTP 客户端
│ │ └── intercept.dart # 拦截器(请求头、错误处理)
│ ├── astarte/utils/ # 工具类
│ │ ├── common_tools.dart # ⭐ 签名算法所在文件
│ │ └── common_utils.dart # 密钥获取
│ └── astarte/main/ # 主页面
│ └── splash_page.dart # 启动页(startup API 调用)
├── pp.txt # 对象池(2.8MB,包含所有字符串常量)
├── objs.txt # 所有 Dart 对象/类
├── blutter_frida.js # Frida hook 脚本
└── ida_script/ # IDA Pro 辅助脚本
从 pp.txt 提取 API 端点:
grep '"/api/v' blutter_out/pp.txt
一次性获得 124 个 API 路径!包括:
"/api/v1/startup"
"/api/v2/search"
"/api/v4/movies/%s"
"/api/v1/actors/%s"
"/api/v1/rankings"
"/api/v1/sessions"
...
从 http_api.dart 获取默认域名:
// 默认 baseUrl
static late String baseUrl; // "https://staging.letidi.com"
// 从 SharedPreferences 读取 key_baseurl
从 intercept.dart 获取请求头:
Headers: "authorization" (Bearer token), "jdsignature", "accept-language"
Response envelope: {"success": 1, "action": null, "data": ..., "message": ""}
pp.txt 是 Dart 的对象池(Object Pool),包含了编译时的所有字符串常量。用简单的 grep 就能提取全部 API 路径:
grep -oP '"/api/vd+/[^"]*"' blutter_out/pp.txt | sort -u
得到 124 个端点,涵盖:
blutter 输出的是带注释的 ARM64 汇编,通过搜索 Dio HTTP 方法调用来确认每个接口的 HTTP 方法:
# 找到调用 get() 的地方
grep -B 20 "::get$" blutter_out/asm/astarte/main/splash_page.dart
# 找到调用 post() 的地方
grep -B 20 "::post$" blutter_out/asm/astarte/utils/common_tools.dart
这个文件巨大(90000+ tokens),包含了所有 API 响应的实体类定义,例如:
MovieDetailEntity: id, number, title, origin_title, cover_url, duration, score, actors, tags...
ActorEntity: id, name, avatar_url, birthday, height, cup, videos_count...
SearchResultEntity: movies[], current_page
这是整个逆向过程中最关键、最困难的部分。没有正确的签名,所有 API 调用都会被拒绝。
第一次尝试直接调用 API:
curl
返回:
{"success": 0, "action": "ParameterInvalid", "message": "參數不能為空: jdsignature"}
在 intercept.dart 中找到了签名头的设置:
headers["jdsignature"] = await getSignature();
在 common_tools.dart 中找到 getSignature() 函数(地址 0x58d2bc):
// 伪代码还原
async getSignature() {
key = await getKey(); // 从 native 获取密钥
part1 = getDecryptString(key, BASE64_STR_1); // 解密第一个常量
part2 = getDecryptString(key, BASE64_STR_2); // 解密第二个常量
timestamp = DateTime.now().microsecondsSinceEpoch / 1000 / 1000; // Unix 秒
hash = encodeMd5("$timestamp$part1");
return "$timestamp.$part2.$hash";
}
签名格式: timestamp.part2.md5(timestamp + part1)
getKey() 最终调用的是 SecurityUtil.getSecret(),这是一个 JNI 函数,实现在 libsecurity.so 中。
使用 llvm-objdump 反汇编:
/opt/homebrew/opt/llvm/bin/llvm-objdump -d lib/arm64-v8a/libsecurity.so
分析 Java_xxx_pornhub_骂人_SecurityUtil_getSecret 函数(1064 字节),发现它的逻辑是:
// 伪代码
char* getSecret(JNIEnv* env, jobject thiz) {
// 1. 获取应用上下文
// 2. 获取 PackageInfo
// 3. 取出 APK 签名证书
char* cert_hex = signatures[0].toCharsString(); // 证书的 DER 十六进制
// 4. 取前5个字符
char result[6];
strncpy(result, cert_hex, 5);
result[5] = ' ';
return result;
// 验证失败时返回 "astarte"
}
关键洞察: secret 是 APK 签名证书 DER 编码的十六进制字符串的前 5 个字符!
# APK 签名证书在 original/META-INF/CERT.RSA
openssl pkcs7 -in original/META-INF/CERT.RSA -inform DER -print_certs | openssl x509 -outform DER | xxd | head
证书 DER 十六进制以 3082036d30... 开头,取前 5 个字符:
secret = "30820"
这是最费脑子的部分。getDecryptString 在 blutter 输出中是 ARM64 汇编,需要人工还原算法。
函数位于地址 0x58d4bc,大小 0x2e8 字节,核心逻辑还原如下:
def getDecryptString(key, b64_str):
# Step 1: 对 key 做 MD5
key_md5 = md5(key) # 32 字符的十六进制字符串
# Step 2: Base64 解码,然后 JSON 解析为整数数组
encrypted = json.loads(base64.b64decode(b64_str))
# Step 3: 逐字节解密
result = []
for i in range(len(encrypted)):
idx = min(i, len(key_md5) - 1) # ️ 关键:不是取模,是 min!
result.append(encrypted[i] - ord(key_md5[idx]))
# Step 4: 转为字符串
decrypted_str = ''.join(chr(c) for c in result)
# Step 5: ️ 还要做一次 Base64 解码!
return base64.b64decode(decrypted_str).decode()
最关键的循环在 0x58d5cc-0x58d768:
0x58d5d4: cmp x8, x6 // 比较 i 与 key_length-1
0x58d5d8: b.le #0x58d5e4 // 如果 i <= key_length-1,跳转
0x58d5dc: mov x9, x6 // else: index = key_length-1
0x58d5e0: b #0x58d5e8
0x58d5e4: mov x9, x8 // index = i
这段汇编实现的是 index = min(i, key_length - 1)。这意味着当 i 超过 key_md5 长度(32)后,一直使用最后一个字节。
不是取模运算! 最初我以为是 i % 32(循环使用 key),但实际是 min(i, 31)(超出部分都用最后一个字节)。两种方式对前 32 字节结果相同,但 32 字节之后完全不同。
双重 Base64 解码! 函数末尾(0x58d77c)还调用了 decodeBase64()。解密出的字符串本身是 Base64 编码的,需要再解码一次才是最终值。这个很容易漏掉。
import hashlib, base64, json, time
# 密钥:APK 证书 DER 前5字符
secret = "30820"
key_md5 = hashlib.md5(secret.encode()).hexdigest()
# = "da97c8240e2ad99a2d331eed95c411f5"
key_bytes = [ord(c) for c in key_md5]
# 解密函数
def decrypt(b64_encrypted):
encrypted = json.loads(base64.b64decode(b64_encrypted))
raw = ''.join(
chr(encrypted[i] - key_bytes[min(i, 31)])
for i in range(len(encrypted))
)
return base64.b64decode(raw).decode() # 双重解码!
# 从 app 中提取的两个加密常量
part1 = decrypt("WzE3OCwyMTksMTI3LDE2MS...")
# = "71cf27bb3c0bcdf207b64abe..."(128字符十六进制)
part2 = decrypt("WzE5OCwxNjksMTIzLDEwNi...")
# = "lpw6vgqzsp"
# 生成签名
def make_signature():
ts = int(time.time())
md5_hash = hashlib.md5(f"{ts}{part1}".encode()).hexdigest()
return f"{ts}.{part2}.{md5_hash}"
# 示例输出:
# 1773580816.lpw6vgqzsp.6458561b88939e2093aff60948b58b86
破解过程中经历了多次失败:
| 尝试 | 结果 | 原因 |
|---|---|---|
| 无签名直接调用 | ParameterInvalid: jdsignature | 缺少签名头 |
用 i % 32 解密 | InvalidSignature | 取模算法错误,应该用 min |
用 min(i, 31) 但没做二次 base64 解码 | InvalidSignature | 漏掉了函数末尾的 decodeBase64 |
| 完整算法 | success: 1 | 终于对了! |
应用代码中有多个域名:
staging.letidi.com — 默认值,SSL 连接失败javdb.com — Cloudflare 保护,返回 403jdforrepam.com — 真正的 API 服务器jdforrepam.com 是在 dio_utils.dart 中发现的备用域名。
即使签名正确,还需要正确的请求参数:
# 第一次:缺 platform
{"message": "參數不能爲空: platform"}
# 第二次:缺 app_channel
{"message": "參數不能爲空: app_channel"}
# 第三次:完整参数,成功!
GET /api/v1/startup?platform=android&app_channel=official&app_version=official&app_version_number=1.9.35
{"success": 1, "data": {...}}
这些参数是通过阅读 splash_page.dart 的反编译代码获得的。
最终验证了 20+ 个核心端点:
/api/v1/startup — 启动配置、热搜词、应用设置
/api/v2/search — 搜索(fan好、演员名、关键词)
/api/v4/movies/{id} — 影片详情(评分、演员、标签、播放源)
/api/v1/movies/latest — 最新影片
/api/v1/movies/recommend — 推荐影片
/api/v1/movies/{id}/magnets — 磁力链接
/api/v1/rankings — 排行榜
/api/v1/search_magnet — 磁力搜索
/api/v1/actors/{id} — 演员详情
/api/v1/articles — 文章列表
/api/v1/about — 官方链接
/api/v1/ads — 广告配置
/api/v4/plans — VIP 套餐
/api/v1/series/letters — fan好系列索引
/api/v1/makers/{id} — 制作商详情
/api/v1/codes/{id} — fan好详情
/api/v1/sessions (POST) — 登录(参数已确认)
/api/v1/users (POST) — 注册(参数已确认)
...更多
生成了一个开箱即用的 javdb_api_client.py(19KB),内置签名算法:
from javdb_api_client import JavDBClient
client = JavDBClient()
# 搜索
results = client.search("STARS-838")
# 影片详情
movie = client.movie_detail("xv8XDg")
# 最新影片
latest = client.movies_latest()
# 排行榜
rankings = client.rankings("daily", "weekly")
# 磁力搜索
magnets = client.search_magnet("STARS-838")
命令行也能直接用:
python3 javdb_api_client.py search "STARS-838"
python3 javdb_api_client.py movie xv8XDg
python3 javdb_api_client.py latest
生成了 javdb_api_openapi.json(136KB):
可以直接导入 Swagger UI、Postman、或任何 OpenAPI 兼容工具使用。
APK 签名证书 (CERT.RSA)
↓ 提取 DER 十六进制
"3082036d30..."
↓ 取前5字符
secret = "30820"
↓ MD5
key_md5 = "da97c8240e2ad99a2d331eed95c411f5"
↓ 解密硬编码的加密常量
part1 = "71cf27bb3c0bcdf207b64abecddc9700..." (128字符)
part2 = "lpw6vgqzsp"
↓ 组合
jdsignature = "{timestamp}.{part2}.{md5(timestamp + part1)}"
GET /api/v2/search?q=STARS-838&page=1 HTTP/1.1
Host: jdforrepam.com
jdsignature: 1773580816.lpw6vgqzsp.6458561b88939e2093aff60948b58b86
accept-language: zh
User-Agent: Dart/3.5 (dart:io)
所有 API 统一返回:
{
"success": 1, // 1=成功, 0=失败
"action": null, // 错误类型(成功时为 null)
"message": null, // 错误信息(繁体中文)
"data": { ... } // 实际数据
}
Flutter 应用的逆向和传统 Android 应用完全不同。Java/Kotlin 用 jadx 就能得到可读源码,但 Flutter 的 Dart 代码被编译成 ARM64 机器码,需要专门工具(blutter、reFlutter)才能分析。
pp.txt(对象池)中的字符串常量是金矿。API 路径、密钥名、错误消息、硬编码值全在里面。善用 grep 可以快速定位关键信息。
libsecurity.so 只有 20KB,但包含了关键的密钥生成逻辑。ARM64 汇编需要逐条指令分析,理解 JNI 调用约定、字符串处理、内存布局。
签名算法中两个容易忽略的细节让我卡了很久:
min(i, 31) vs i % 32 — 看似微小的差异,产生完全不同的结果decodeBase64()应用中硬编码了多个域名,找到真正工作的那个需要逐个测试。staging.letidi.com 是开发环境,javdb.com 被 Cloudflare 保护,只有 jdforrepam.com 是真正的移动端 API 服务器。
整个过程中 Claude Code 的作用:
| 成果 | 描述 |
|---|---|
| 124 个 API 端点 | 从 pp.txt 提取,覆盖所有功能模块 |
| 20+ 验证通过 | 实际发送请求并获得正确响应 |
| 签名算法完整破解 | 从 APK 证书到最终签名的完整链路 |
| Python API 客户端 | 50+ 方法,内置签名,即拿即用 |
| OpenAPI 3.0 规范 | 98 个路径,19 个 Schema,可导入任意工具 |
| 完整技术文档 | CLAUDE.md 记录所有关键信息 |
整个过程从 APK 解包到全部 API 可用,在 Claude Code 的辅助下,一个下午的时间内完成。
本文记录于 2026-03-15,基于 JavDB v1.9.35 APK 分析。 使用工具:Claude Code (Claude Opus 4.6) + apktool + blutter + llvm-objdump