chore: initialize repository

This commit is contained in:
youfak 2026-07-30 20:25:56 +08:00
commit eacd7c8a23
183 changed files with 40652 additions and 0 deletions

550
.claude/逆向分析.md Normal file
View File

@ -0,0 +1,550 @@
你是一名资深逆向分析工程师,负责完成一个黑盒分析项目。
项目目标:
对目标程序进行完整分析,包括:
1. 静态分析
2. 动态分析
3. 保护/加固检测
4. 脱壳与运行时恢复
5. 算法逻辑还原
6. 通信协议还原
最终输出:
- Python纯算法实现
- Python纯协议实现
- 完整分析文档
==============================
一、工作环境
==============================
工具目录:
D:\decode-tools
首先扫描该目录,分析:
- 工具列表
- 工具版本
- 可执行路径
- 支持能力
- 推荐分析流程
可能使用:
静态分析:
- IDA Pro
- Ghidra
- Binary Ninja
- radare2
动态分析:
- Frida CLI
- Debugger
模拟执行:
- Unicorn Engine
- Capstone
- Keystone
Android
- jadx
- apktool
- baksmali/smali
- frida相关工具
辅助:
- Python脚本
- 十六进制分析工具
==============================
二、目标识别与保护检测
==============================
首先识别目标类型:
- APK
- DEX
- SO
- ELF
- DLL
- EXE
- 其他二进制
检测:
- 加壳
- 加固
- OLLVM混淆
- 虚拟化保护
- 动态加载
- 内存解密
- 字符串加密
- 自修改代码
根据结果选择分析路线:
普通程序:
静态分析
+
动态验证
存在混淆:
CFG恢复
+
函数还原
+
模拟执行
存在保护:
运行时分析
+
内存恢复
+
逻辑重建
不要默认目标一定存在壳,先检测。
==============================
三、静态分析
==============================
对目标进行完整静态分析。
分析内容:
文件:
- 文件类型识别
- 架构分析
- ARM
- ARM64
- x86
- x64
模块:
- so/dll/exe模块定位
- 入口点分析
- 导出符号分析
- 导入函数分析
代码:
- 字符串分析
- 常量分析
- 函数定位
- 函数调用关系分析
- 调用图分析
- 关键函数定位
重点:
OLLVM检测
- Control Flow Flattening
- Bogus Control Flow
- Instruction Substitution
混淆处理:
- CFG恢复
- 基本块分析
- 真实控制流程恢复
- 关键路径提取
输出:
- 模块结构
- 函数关系
- 可疑函数列表
- 初步算法流程
==============================
四、动态分析
==============================
使用:
Frida CLI
环境:
Frida 17.x
注意:
Frida 17 默认 agent 无 Java 全局。
动态分析优先使用 Frida CLI 注入方式。
连接:
adb forward tcp:27042 tcp:27042
动态分析目标:
Hook
- 关键函数
- JNI接口
- 导出函数
- 加密函数
- 网络函数
- 数据处理函数
记录:
- 输入参数
- 输出参数
- 返回值
- 调用栈
- 内存变化
- 参数变化
重点捕获:
- 算法执行流程
- 数据转换流程
- 网络协议字段
建立:
输入
函数调用链
数据处理
输出
==============================
五、脱壳与运行时恢复
==============================
如果发现:
- 加壳
- 加固
- 动态加载
- 内存释放代码
分析:
- 真实入口
- 加载流程
- 解密流程
- 代码释放位置
- 内存中的真实模块
目标:
恢复:
- 有效代码
- 真实执行逻辑
- 关键函数
如果无法直接恢复文件:
通过运行时:
- Hook
- 跟踪
- 输入输出分析
重建算法。
==============================
六、加密与编码分析
==============================
如果存在加密/编码逻辑:
重点分析:
Key
- Key来源
- Key生成方式
- Key派生流程
- Key存储位置
IV / Nonce
- IV来源
- Nonce来源
- 随机参数生成方式
算法:
- 加密模式
- Hash算法
- MAC算法
- 签名算法
数据处理:
- 编解码流程
- Base64/Hex
- 字节序
- 数据填充
- 参数排列方式
- 数据拼接规则
完整还原:
输入
编码
加密
签名
发送
以及:
接收
验签
解密
解析
==============================
七、协议分析
==============================
还原通信协议:
包括:
请求:
{
field:"",
value:"",
timestamp:"",
sign:""
}
响应:
{
code:"",
data:"",
extra:""
}
分析:
- 字段含义
- 数据类型
- 字段长度
- 字节序
- 编码方式
- 加密方式
- 签名规则
- 参数排序规则
输出完整协议文档。
==============================
八、Python实现
==============================
最终生成:
algorithm.py
要求:
- 纯Python
- 不依赖目标程序
- 实现核心计算逻辑
- 输入输出明确
protocol.py
要求:
- 协议封装
- 请求生成
- 数据解析
- 编码解码
- 签名计算
test.py
要求:
- 测试样例
- 输入输出验证
- 自动化测试
代码要求:
- 模块化
- 注释完整
- 保留算法推导过程
==============================
九、最终报告
==============================
生成:
README.md
包含:
1. 项目说明
2. 环境配置
3. 工具链说明
4. 文件分析
5. 静态分析结果
6. 动态分析过程
7. 保护分析
8. 算法流程
9. 加密流程
10. 协议格式
11. Python实现说明
12. 测试结果
==============================
执行规则
==============================
执行顺序:
1. 扫描 D:\decode-tools
2. 分析目标类型
3. 检测保护
4. 制定分析方案
5. 静态分析
6. 动态分析
7. 算法恢复
8. 协议恢复
9. Python实现
每一步输出:
- 当前发现
- 分析依据
- 下一步计划
最终目标:
得到一个完全独立运行的 Python 项目:
输入数据
Python算法
Python协议
输出结果

View File

@ -0,0 +1,550 @@
你是一名资深逆向分析工程师,负责完成一个黑盒分析项目。
项目目标:
对目标程序进行完整分析,包括:
1. 静态分析
2. 动态分析
3. 保护/加固检测
4. 脱壳与运行时恢复
5. 算法逻辑还原
6. 通信协议还原
最终输出:
- Python纯算法实现
- Python纯协议实现
- 完整分析文档
==============================
一、工作环境
==============================
工具目录:
D:\decode-tools
首先扫描该目录,分析:
- 工具列表
- 工具版本
- 可执行路径
- 支持能力
- 推荐分析流程
可能使用:
静态分析:
- IDA Pro
- Ghidra
- Binary Ninja
- radare2
动态分析:
- Frida CLI
- Debugger
模拟执行:
- Unicorn Engine
- Capstone
- Keystone
Android
- jadx
- apktool
- baksmali/smali
- frida相关工具
辅助:
- Python脚本
- 十六进制分析工具
==============================
二、目标识别与保护检测
==============================
首先识别目标类型:
- APK
- DEX
- SO
- ELF
- DLL
- EXE
- 其他二进制
检测:
- 加壳
- 加固
- OLLVM混淆
- 虚拟化保护
- 动态加载
- 内存解密
- 字符串加密
- 自修改代码
根据结果选择分析路线:
普通程序:
静态分析
+
动态验证
存在混淆:
CFG恢复
+
函数还原
+
模拟执行
存在保护:
运行时分析
+
内存恢复
+
逻辑重建
不要默认目标一定存在壳,先检测。
==============================
三、静态分析
==============================
对目标进行完整静态分析。
分析内容:
文件:
- 文件类型识别
- 架构分析
- ARM
- ARM64
- x86
- x64
模块:
- so/dll/exe模块定位
- 入口点分析
- 导出符号分析
- 导入函数分析
代码:
- 字符串分析
- 常量分析
- 函数定位
- 函数调用关系分析
- 调用图分析
- 关键函数定位
重点:
OLLVM检测
- Control Flow Flattening
- Bogus Control Flow
- Instruction Substitution
混淆处理:
- CFG恢复
- 基本块分析
- 真实控制流程恢复
- 关键路径提取
输出:
- 模块结构
- 函数关系
- 可疑函数列表
- 初步算法流程
==============================
四、动态分析
==============================
使用:
Frida CLI
环境:
Frida 17.x
注意:
Frida 17 默认 agent 无 Java 全局。
动态分析优先使用 Frida CLI 注入方式。
连接:
adb forward tcp:27042 tcp:27042
动态分析目标:
Hook
- 关键函数
- JNI接口
- 导出函数
- 加密函数
- 网络函数
- 数据处理函数
记录:
- 输入参数
- 输出参数
- 返回值
- 调用栈
- 内存变化
- 参数变化
重点捕获:
- 算法执行流程
- 数据转换流程
- 网络协议字段
建立:
输入
函数调用链
数据处理
输出
==============================
五、脱壳与运行时恢复
==============================
如果发现:
- 加壳
- 加固
- 动态加载
- 内存释放代码
分析:
- 真实入口
- 加载流程
- 解密流程
- 代码释放位置
- 内存中的真实模块
目标:
恢复:
- 有效代码
- 真实执行逻辑
- 关键函数
如果无法直接恢复文件:
通过运行时:
- Hook
- 跟踪
- 输入输出分析
重建算法。
==============================
六、加密与编码分析
==============================
如果存在加密/编码逻辑:
重点分析:
Key
- Key来源
- Key生成方式
- Key派生流程
- Key存储位置
IV / Nonce
- IV来源
- Nonce来源
- 随机参数生成方式
算法:
- 加密模式
- Hash算法
- MAC算法
- 签名算法
数据处理:
- 编解码流程
- Base64/Hex
- 字节序
- 数据填充
- 参数排列方式
- 数据拼接规则
完整还原:
输入
编码
加密
签名
发送
以及:
接收
验签
解密
解析
==============================
七、协议分析
==============================
还原通信协议:
包括:
请求:
{
field:"",
value:"",
timestamp:"",
sign:""
}
响应:
{
code:"",
data:"",
extra:""
}
分析:
- 字段含义
- 数据类型
- 字段长度
- 字节序
- 编码方式
- 加密方式
- 签名规则
- 参数排序规则
输出完整协议文档。
==============================
八、Python实现
==============================
最终生成:
algorithm.py
要求:
- 纯Python
- 不依赖目标程序
- 实现核心计算逻辑
- 输入输出明确
protocol.py
要求:
- 协议封装
- 请求生成
- 数据解析
- 编码解码
- 签名计算
test.py
要求:
- 测试样例
- 输入输出验证
- 自动化测试
代码要求:
- 模块化
- 注释完整
- 保留算法推导过程
==============================
九、最终报告
==============================
生成:
README.md
包含:
1. 项目说明
2. 环境配置
3. 工具链说明
4. 文件分析
5. 静态分析结果
6. 动态分析过程
7. 保护分析
8. 算法流程
9. 加密流程
10. 协议格式
11. Python实现说明
12. 测试结果
==============================
执行规则
==============================
执行顺序:
1. 扫描 D:\decode-tools
2. 分析目标类型
3. 检测保护
4. 制定分析方案
5. 静态分析
6. 动态分析
7. 算法恢复
8. 协议恢复
9. Python实现
每一步输出:
- 当前发现
- 分析依据
- 下一步计划
最终目标:
得到一个完全独立运行的 Python 项目:
输入数据
Python算法
Python协议
输出结果

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
KS_ACCOUNT=大号#kpn=NEBULA; kpf=ANDROID_PHONE; userId=...; did=...; kuaishou.api_st=...; token=...; client_key=2ac2a76d#client_salt
KS_AD_COUNT=1
KS_TREASURE_AD_COUNT=1
KS_DELAY_SECONDS=1
KS_TIMEOUT=30

7
.gitattributes vendored Normal file
View File

@ -0,0 +1,7 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.bin binary

44
.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# 本地配置与凭据
.env
.env.*
!.env.example
# Python 环境与缓存
.venv/
venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# 编辑器与系统文件
.idea/
.vscode/
.DS_Store
Thumbs.db
# 运行输出与抓包目录
out/
capture/
capture-*/
ecapture/
# 大体积样本、流量与日志产物
*.apk
*.apks
*.xapk
*.rar
*.zip
*.har
*.pcap
*.pcapng
*.log
*.pid
# 根目录临时分析输出
/_dec_*.txt
/*-ddecode-*.txt
/1.txt

BIN
bin/kwsg_10400_T1.bin Normal file

Binary file not shown.

BIN
bin/kwsg_10400_T2.bin Normal file

Binary file not shown.

BIN
bin/kwsg_10418_B_T1.bin Normal file

Binary file not shown.

BIN
bin/kwsg_10418_B_T2.bin Normal file

Binary file not shown.

42
core/README.md Normal file
View File

@ -0,0 +1,42 @@
# core 算法模块
已还原算法按职责拆分到以下入口:
- `core.sig`: `sig`, `build_sig_plaintext`, `body_md5`
- `core.tokensig`: `__NStokensig`,调用方显式传入账号的 `client_salt`
- `core.sig3`: KWSG `10418``__NS_sig3`
- `core.xfalcon`: `__NS_xfalcon` digest / `$TE_` / value
- `core.enc_data`: KWSG `10400``encData` / ZT envelope
- `core.atlas_sign`: 通用 `atlasSign` 短 ZT envelope
`head8 + xor16(24-byte digest)`
- `core.reward_sign`: reward body `sign`
- `core.dfp_sign`: DFP / unifiedId 的 `10405 atlasSign` 输入拼接与签名封装
- `core.device_id`: Java 层可见的 `did` / `oDid` / `rdid` 本地派生辅助和 `egid` 格式校验
- `core.constants`: 静态算法常量
- `core.captured_profile`: 当前抓到的本机/账号样本值
`core.kwsg` 只作为旧代码兼容聚合层,不再放算法实现。`out/*` 中的分析
和请求构造脚本优先从 `core` 分类模块导入。
注意:`did`、`oDid`、`rdid`、`egid`、`api_st`、`tokenClientSalt`
都不是通用算法常量。它们与设备、账号或登录态绑定,当前样本放在
`core.captured_profile`,换账号/设备时应替换 profile。`core.device_id`
只覆盖 APK Java 层已经定位到的本地派生路径;`egid` 仍走 DFP/KSecurity
链路,当前不要伪造成简单 hash。
`oDid` 当前已确认是初始化阶段保留下来的原始本地 DID
`FoundationInfoInitModule -> deviceid/i.e() -> ss9/a.b`,公共参数
`q01/g.getODid()` 最终读取 `ss9.a.b`。后续云端刷新 `did` 时只更新
`ss9.a.a`,不会覆盖 `ss9.a.b`
`egid` 链路当前已拆到:
```text
DFP lite kNN -> sq0.b -> core.enc_data.kwsg_10400_raw(deviceInfo)
DFP form -> core.dfp_sign.dfp_atlas_sign(sign)
```
`10405 atlasSign` 与已还原的 `10418` sign 分支共用
`innerFlag=true` digest 管线;区别主要在输入字符串由 DFP Java builder
决定。`core.dfp_sign` 固化了 `gdfp_report`、`unified_log_report`、
`unified_id_mapping`、`unified_repair/fetch/checkRepair` 的输入拼接规则。

40
core/__init__.py Normal file
View File

@ -0,0 +1,40 @@
"""Recovered signing/encryption algorithms exposed as focused modules.
Import from focused modules such as `core.sig`, `core.sig3`, `core.xfalcon`,
`core.enc_data`, and `core.tokensig`. Runtime sample values are in
`core.captured_profile`. The package initializer intentionally
does not import algorithm modules eagerly so CLI entrypoints can run cleanly.
"""
from .device_profile import (
DeviceProfile,
DeviceProfileGenerator,
load_device_profile,
save_device_profile,
)
__all__ = [
"DeviceProfile",
"DeviceProfileGenerator",
"load_device_profile",
"save_device_profile",
"device_id",
"device_profile",
"dfp_forms",
"dfp_cache",
"dfp_knn",
"dfp_sq0",
"sig",
"tokensig",
"sig3",
"sig3_shape",
"h5_sig3",
"ksse_sted",
"xfalcon",
"enc_data",
"mobile_encrypt",
"privacykit_encrypt",
"reward_sign",
"constants",
"captured_profile",
]

26
core/atlas_sign.py Normal file
View File

@ -0,0 +1,26 @@
"""Generic KWSG atlasSign helpers for 24-byte digest envelopes.
`MXSec.atlasSign` returns the short ZT form:
head8 + xor16(24-byte digest)
The recovered 10418 reward body sign and DFP 10405 atlasSign samples share this
envelope and the same `innerFlag=true` digest pipeline.
"""
from __future__ import annotations
from .reward_sign import (
KWSG_10418_SIGN_HMAC_KEY as ATLAS_SIGN_HMAC_KEY,
kwsg_10418_reward_sign as atlas_sign,
kwsg_10418_reward_sign_from_digest_hex as atlas_sign_from_digest_hex,
kwsg_10418_reward_sign_to_digest_hex as atlas_sign_to_digest_hex,
)
__all__ = [
"ATLAS_SIGN_HMAC_KEY",
"atlas_sign",
"atlas_sign_from_digest_hex",
"atlas_sign_to_digest_hex",
]

633
core/captcha_assist.py Normal file
View File

@ -0,0 +1,633 @@
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import tempfile
import time
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping
from urllib.parse import urlsplit
from .device_cookie import device_profile_cookie_fields
from .device_profile import DeviceProfile
KSECRET_VERIFY_PATH = "/rest/zt/captcha/sliding/kSecretApiVerify"
CAPTCHA_BIND_PATH = "/rest/wd/captcha/verify"
def _find_captcha_token(value: Any) -> str:
if isinstance(value, Mapping):
for key, item in value.items():
if str(key).lower() in {"captchatoken", "captcha_token"}:
token = str(item or "").strip()
if token:
return token
for item in value.values():
token = _find_captcha_token(item)
if token:
return token
elif isinstance(value, (list, tuple)):
for item in value:
token = _find_captcha_token(item)
if token:
return token
return ""
def _verify_result(payload: Any) -> int | None:
if not isinstance(payload, Mapping):
return None
value = payload.get("result")
if value is None and isinstance(payload.get("data"), Mapping):
value = payload["data"].get("result")
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
@dataclass
class CaptchaVerificationState:
captcha_token: str = ""
verify_result: int | None = None
verify_status: int = 0
verified: bool = False
def observe(
self,
url: str,
payload: Any,
*,
status: int,
request_payload: Mapping[str, Any] | None = None,
) -> None:
path = urlsplit(url).path
if path == KSECRET_VERIFY_PATH and 200 <= status < 300:
token = _find_captcha_token(payload)
if token:
self.captcha_token = token
return
if path != CAPTCHA_BIND_PATH:
return
self.verify_status = int(status)
self.verify_result = _verify_result(payload)
self.verified = 200 <= status < 300 and self.verify_result == 1
if self.verified and request_payload:
bound_token = str(request_payload.get("input") or "").strip()
if bound_token:
self.captcha_token = bound_token
@dataclass
class CaptchaBrowserResult:
verified: bool = False
captcha_token: str = ""
verify_result: int | None = None
verify_status: int = 0
cookies_synced: int = 0
browser_did: str = ""
identity_matched: bool = True
error: str = ""
def build_captcha_browser_cookies(profile: DeviceProfile) -> list[dict[str, Any]]:
"""按 APP WebView 注入顺序准备验证码页所需的匿名设备身份。"""
fields = device_profile_cookie_fields(profile)
values = {
"kpn": "NEBULA",
"kpf": "ANDROID_PHONE",
"userId": "0",
"did": fields["did"],
"didv": str(profile.install_time_ms),
"c": fields["c"],
"ver": fields["ver"],
"appver": fields["appver"],
"language": "zh-cn",
"countryCode": fields["countryCode"],
"sys": fields["sys"],
"mod": fields["mod"],
"deviceName": fields["deviceName"],
"net": "WIFI",
"client_key": "2ac2a76d",
"os": "android",
}
return [
{
"name": name,
"value": value,
"domain": ".kuaishou.com",
"path": "/",
"secure": True,
"httpOnly": False,
"sameSite": "Lax",
}
for name, value in values.items()
if value
]
def sync_browser_cookies(session: Any, cookies: Iterable[Mapping[str, Any]]) -> int:
count = 0
for cookie in cookies:
name = str(cookie.get("name") or "").strip()
if not name:
continue
value = str(cookie.get("value") or "")
kwargs: dict[str, Any] = {
"path": str(cookie.get("path") or "/"),
"secure": bool(cookie.get("secure", False)),
}
domain = str(cookie.get("domain") or "").strip()
if domain:
kwargs["domain"] = domain
expires = cookie.get("expires")
if isinstance(expires, (int, float)) and expires > 0:
kwargs["expires"] = int(expires)
session.cookies.set(name, value, **kwargs)
count += 1
return count
CaptchaBrowserDriver = Callable[..., list[Mapping[str, Any]]]
def _response_request_payload(response: Any) -> Mapping[str, Any] | None:
request = getattr(response, "request", None)
if request is None:
return None
try:
payload = request.post_data_json
except Exception:
payload = None
if isinstance(payload, Mapping):
return payload
try:
raw = request.post_data
except Exception:
raw = None
if not raw:
return None
try:
import json
payload = json.loads(raw)
except (TypeError, ValueError):
return None
return payload if isinstance(payload, Mapping) else None
def _update_captcha_assets(response: Any, assets: dict[str, Any]) -> None:
"""从 bg/cut/config 响应里抽字节, 供自动求解器喂 ddddocr。"""
try:
url = str(response.url)
except Exception:
return
if "/sliding/bgPic" in url:
try:
assets["bg"] = response.body()
except Exception:
pass
elif "/sliding/cutPic" in url:
try:
assets["cut"] = response.body()
except Exception:
pass
elif "/sliding/config" in url:
try:
data = response.json()
if isinstance(data, Mapping):
assets["config"].update(data)
except Exception:
pass
def _wait_captcha_assets(page: Any, assets: dict[str, Any], *, timeout: int) -> None:
deadline = time.monotonic() + max(2, min(int(timeout), 20))
while not (assets["bg"] and assets["cut"] and assets["config"].get("bgPicWidth")):
if time.monotonic() >= deadline:
raise TimeoutError("等待验证码 bg/cut/config 图片超时")
page.wait_for_timeout(200)
def _human_drag(page: Any, x0: float, y0: float, dx: float, *, seed: int | None = None) -> None:
"""拟人拖动: 余弦缓动 + 垂直微抖 + 小幅过冲后回正。"""
import math
import random
rnd = random.Random(seed)
page.mouse.move(x0, y0)
page.wait_for_timeout(rnd.randint(120, 260))
page.mouse.down()
steps, total_ms = 44, 820
peak = dx + rnd.uniform(3.0, 9.0)
base = total_ms / steps
for i in range(1, steps + 1):
t = i / steps
ease = 0.5 * (1 - math.cos(math.pi * t))
xi = x0 + peak * ease
yi = y0 + rnd.uniform(-2.0, 2.0)
page.mouse.move(xi, yi)
page.wait_for_timeout(int(base) + rnd.randint(0, 9))
for j in range(1, 7): # 过冲后回正到 dx
t = j / 6
xi = x0 + peak + (dx - peak) * t
page.mouse.move(xi, y0 + rnd.uniform(-1.5, 1.5))
page.wait_for_timeout(rnd.randint(14, 26))
page.wait_for_timeout(rnd.randint(90, 180))
page.mouse.up()
def _auto_solve_slider(
page: Any,
assets: dict[str, Any],
*,
timeout: int = 20,
offset: int = -48,
) -> None:
"""在 captcha iframe 内: ddddocr 定缺口 + 拟人拖 slider-btn。
几何全用 bounding_box() (自动换算到外层视口坐标, page.mouse 一致),
scale = 显示宽 / config.bgPicWidth(原生)offset 为对 target_x 的原生像素修正
offset=-48 为本验证码的经验常量偏置: ddddocr target_x 系统性偏右(拼图块模板
在其图像内有固定左内缩), 实测多张新鲜图 -48 均使 kSecretApiVerify result=1
扫描偏置时可显式传 offset 覆盖( tools/captcha_auto_test.py)
"""
import ddddocr
frame = None
for fr in page.frames:
if fr is not page.main_frame and "captcha" in fr.url:
frame = fr
break
if frame is None:
raise RuntimeError("未找到 captcha iframe")
frame.locator(".slider-btn").wait_for(state="visible", timeout=timeout * 1000)
bg_box = frame.locator("img[src*='bgPic']").bounding_box()
cut_box = frame.locator("img[src*='cutPic']").bounding_box()
btn_box = frame.locator(".slider-btn").bounding_box()
if not (bg_box and cut_box and btn_box):
raise RuntimeError(
f"滑块几何缺失 bg={bool(bg_box)} cut={bool(cut_box)} btn={bool(btn_box)}"
)
native_w = int(assets["config"].get("bgPicWidth") or 686)
scale = bg_box["width"] / native_w
det = ddddocr.DdddOcr(det=False, ocr=False, show_ad=False)
res = det.slide_match(assets["cut"], assets["bg"])
target_x = res.get("target_x") or (res.get("target") or [0])[0]
# 缺口在视口里的真实 x = 背景图左边沿(bg_box.x) + 原生缺口 x * 缩放;
# 拼图块需从其 home(cut_box.x) 移到该 x, 按钮与块 1:1 联动 -> drag 即为该差值。
# (早先漏了 bg_box.x 项, 块每次都落在 bg 左内缩 ~67px 处 -> 恒 350002)
gap_viewport_x = bg_box["x"] + (target_x + offset) * scale
drag = gap_viewport_x - cut_box["x"]
print(
f"[auto-captcha] target_x={target_x} offset={offset} scale={scale:.4f} "
f"gap_vp={gap_viewport_x:.1f} cut_x={cut_box['x']:.1f} drag={drag:.1f}"
)
_human_drag(
page,
btn_box["x"] + btn_box["width"] / 2,
btn_box["y"] + btn_box["height"] / 2,
drag,
)
# mouseup 后、verify+复位前 立刻读一次拼图块真实落点(竞态窗口约 100-300ms)。
landed_x = None
try:
landed_box = frame.locator("img[src*='cutPic']").bounding_box()
if landed_box:
landed_x = landed_box["x"]
delta = landed_x - gap_viewport_x
print(
f"[auto-captcha] landed cut_x={landed_x:.1f} "
f"vs gap_vp={gap_viewport_x:.1f} (Δ={delta:+.1f}px)"
)
except Exception:
pass
return {
"target_x": target_x,
"gap_vp": gap_viewport_x,
"landed_x": landed_x,
"drag": drag,
}
def _find_system_chromium() -> str:
candidates = [os.environ.get("KS_CAPTCHA_BROWSER", "")]
for command in ("msedge", "msedge.exe", "chrome", "chrome.exe", "chromium"):
candidates.append(shutil.which(command) or "")
if os.name == "nt":
for root_name, suffix in (
("ProgramFiles(x86)", "Microsoft/Edge/Application/msedge.exe"),
("ProgramFiles", "Microsoft/Edge/Application/msedge.exe"),
("LOCALAPPDATA", "Microsoft/Edge/Application/msedge.exe"),
("ProgramFiles", "Google/Chrome/Application/chrome.exe"),
("ProgramFiles(x86)", "Google/Chrome/Application/chrome.exe"),
("LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"),
):
root = os.environ.get(root_name, "")
if root:
candidates.append(str(Path(root) / suffix))
for candidate in candidates:
if candidate and Path(candidate).is_file():
return str(Path(candidate))
raise RuntimeError("未找到系统 Edge/Chrome可用 KS_CAPTCHA_BROWSER 指定浏览器路径")
def _free_local_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _wait_for_cdp_endpoint(port: int, *, timeout: int) -> str:
endpoint = f"http://127.0.0.1:{port}"
deadline = time.monotonic() + max(1, min(int(timeout), 15))
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(f"{endpoint}/json/version", timeout=0.5) as response:
if int(getattr(response, "status", 0) or 0) == 200:
return endpoint
except Exception:
time.sleep(0.1)
raise TimeoutError("系统浏览器 DevTools 端口启动超时")
def _run_system_browser_challenge(
error_url: str,
observer: Callable[..., bool],
*,
timeout: int,
channel: str,
initial_cookies: Iterable[Mapping[str, Any]],
auto_solve: bool = False,
) -> list[Mapping[str, Any]]:
try:
from playwright.sync_api import sync_playwright
except ImportError as exc:
raise RuntimeError("Playwright 未安装,请先执行 uv sync") from exc
browser_path = _find_system_chromium()
port = _free_local_port()
profile_dir = tempfile.mkdtemp(prefix="ksjsb-captcha-")
process: subprocess.Popen[bytes] | None = None
browser: Any = None
done = False
assets: dict[str, Any] = {"config": {}, "bg": b"", "cut": b""}
def on_response(response: Any) -> None:
nonlocal done
_update_captcha_assets(response, assets)
try:
payload = response.json()
except Exception:
return
done = bool(
observer(
response.url,
payload,
status=int(response.status),
request_payload=_response_request_payload(response),
)
) or done
try:
process = subprocess.Popen(
[
browser_path,
f"--remote-debugging-port={port}",
"--remote-debugging-address=127.0.0.1",
f"--user-data-dir={profile_dir}",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-mode",
"--window-size=430,920",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
endpoint = _wait_for_cdp_endpoint(port, timeout=timeout)
with sync_playwright() as playwright:
browser = playwright.chromium.connect_over_cdp(
endpoint,
timeout=max(1, min(int(timeout), 30)) * 1000,
)
if not browser.contexts:
raise RuntimeError("系统浏览器没有可用上下文")
context = browser.contexts[0]
context.add_cookies(list(initial_cookies))
page = context.pages[0] if context.pages else context.new_page()
page.on("response", on_response)
page.goto(
error_url,
wait_until="domcontentloaded",
timeout=max(1, min(int(timeout), 30)) * 1000,
)
page.bring_to_front()
if auto_solve:
_wait_captcha_assets(page, assets, timeout=timeout)
_auto_solve_slider(page, assets, timeout=timeout)
deadline = time.monotonic() + max(1, int(timeout))
while not done:
if page.is_closed():
raise RuntimeError("验证页已关闭,尚未观察到绑定成功响应")
if time.monotonic() >= deadline:
raise TimeoutError(f"等待验证码绑定超时({timeout}s")
page.wait_for_timeout(250)
return list(context.cookies())
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
shutil.rmtree(profile_dir, ignore_errors=True)
def _run_playwright_challenge(
error_url: str,
observer: Callable[..., bool],
*,
timeout: int,
channel: str,
initial_cookies: Iterable[Mapping[str, Any]],
auto_solve: bool = False,
) -> list[Mapping[str, Any]]:
try:
from playwright.sync_api import sync_playwright
except ImportError as exc:
raise RuntimeError("Playwright 未安装,请先执行 uv sync") from exc
done = False
assets: dict[str, Any] = {"config": {}, "bg": b"", "cut": b""}
def on_response(response: Any) -> None:
nonlocal done
_update_captcha_assets(response, assets)
try:
payload = response.json()
except Exception:
return
done = bool(
observer(
response.url,
payload,
status=int(response.status),
request_payload=_response_request_payload(response),
)
) or done
with sync_playwright() as playwright:
launch_args: dict[str, Any] = {"headless": False}
if channel:
launch_args["channel"] = channel
browser = playwright.chromium.launch(**launch_args)
try:
context = browser.new_context(
viewport={"width": 400, "height": 900},
screen={"width": 400, "height": 900},
device_scale_factor=2,
is_mobile=True,
locale="zh-CN",
)
context.add_cookies(list(initial_cookies))
page = context.new_page()
page.on("response", on_response)
page.goto(
error_url,
wait_until="domcontentloaded",
timeout=max(1, min(int(timeout), 30)) * 1000,
)
if auto_solve:
_wait_captcha_assets(page, assets, timeout=timeout)
_auto_solve_slider(page, assets, timeout=timeout)
deadline = time.monotonic() + max(1, int(timeout))
while not done:
if page.is_closed():
raise RuntimeError("验证页已关闭,尚未观察到绑定成功响应")
if time.monotonic() >= deadline:
raise TimeoutError(f"等待验证码绑定超时({timeout}s")
page.wait_for_timeout(250)
return list(context.cookies())
finally:
browser.close()
def complete_captcha_in_browser(
error_url: str,
session: Any,
*,
timeout: int = 180,
channel: str = "msedge",
browser_driver: CaptchaBrowserDriver | None = None,
initial_cookies: Iterable[Mapping[str, Any]] = (),
expected_did: str = "",
auto_solve: bool = False,
) -> CaptchaBrowserResult:
parsed = urlsplit(error_url)
if parsed.scheme != "https" or not parsed.netloc:
return CaptchaBrowserResult(error="验证码地址必须是有效的 HTTPS URL")
state = CaptchaVerificationState()
def observe(
url: str,
payload: Any,
*,
status: int,
request_payload: Mapping[str, Any] | None = None,
) -> bool:
state.observe(
url,
payload,
status=status,
request_payload=request_payload,
)
return state.verified
if browser_driver is not None:
driver = browser_driver
elif str(channel or "").strip().lower() == "system":
driver = _run_system_browser_challenge
else:
driver = _run_playwright_challenge
try:
cookies = driver(
error_url,
observe,
timeout=max(1, int(timeout)),
channel=str(channel or ""),
initial_cookies=list(initial_cookies),
auto_solve=bool(auto_solve),
)
except Exception as exc: # noqa: BLE001
return CaptchaBrowserResult(
captcha_token=state.captcha_token,
verify_result=state.verify_result,
verify_status=state.verify_status,
error=f"{exc.__class__.__name__}: {exc}",
)
browser_dids = {
str(cookie.get("value") or "").strip()
for cookie in cookies
if str(cookie.get("name") or "").strip() == "did"
and str(cookie.get("value") or "").strip()
}
browser_did = (
next(iter(browser_dids), "")
if len(browser_dids) == 1
else ",".join(sorted(browser_dids))
)
identity_matched = not expected_did or browser_dids == {expected_did}
if state.verified and not identity_matched:
actual = browser_did or "<missing>"
return CaptchaBrowserResult(
captcha_token=state.captcha_token,
verify_result=state.verify_result,
verify_status=state.verify_status,
browser_did=browser_did,
identity_matched=False,
error=f"验证码浏览器 DID 不一致: expected={expected_did} actual={actual}",
)
cookies_synced = sync_browser_cookies(session, cookies) if state.verified else 0
return CaptchaBrowserResult(
verified=state.verified,
captcha_token=state.captcha_token,
verify_result=state.verify_result,
verify_status=state.verify_status,
cookies_synced=cookies_synced,
browser_did=browser_did,
identity_matched=identity_matched,
error="" if state.verified else "未观察到验证码绑定成功响应",
)
__all__ = [
"CAPTCHA_BIND_PATH",
"KSECRET_VERIFY_PATH",
"CaptchaBrowserResult",
"CaptchaVerificationState",
"build_captcha_browser_cookies",
"complete_captcha_in_browser",
"sync_browser_cookies",
]

64
core/captured_profile.py Normal file
View File

@ -0,0 +1,64 @@
"""Captured local runtime profile values.
These values are not algorithm constants:
- `CLIENT_SALT` is account/login-state bound.
- `API_ST` is session/login-state bound.
- `DID`, `ODID`, `RDID`, and `EGID` are device/runtime bound.
Keep callers explicit about using this captured profile so the algorithms can
be reused with another account/device by replacing the profile.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class CapturedProfile:
client_salt: str
api_st: str
did: str
odid: str
rdid: str
egid: str
client_key: str
CLIENT_SALT = "b038748080d4cbca37c31dd04ab28347"
API_ST = (
"Cg9rdWFpc2hvdS5hcGkuc3QSoAHruwqKqtHqy9AfyGdOFYNACJ80v0TMYoQNeZ1INbsRZo94r6mt9M"
"58VJZX5pNQUix3Euqtsf8W3AOaWRCH_NWklCNumaTQFy6hxrZi8lp3y7p68SIxs6fYL1y17X--7xRAo"
"ICgzoB_nnFNrlkpZfFWeNb7v06hjOgbXg9-jKghvz7ynDibTmweN-saijRFyrmdG-hkspXD06BN_HdM"
"gjolGhJmQmJkX-pJEqgjQbtJv1UuVkwiIBjICjT6OhpE1pECamctILXHVG8-uA3qXY0e0wjkxe0RKAUwAQ"
)
DID = "ANDROID_e8dfd2f16b618053"
ODID = "ANDROID_46a032e0a2af8184"
RDID = "ANDROID_741de4351c44850d"
EGID = "DFPD710405C930763A0611ED55B32863566E86B8C7D3438B8BDE60EAB9F54037"
CLIENT_KEY = "2ac2a76d"
CAPTURED_PROFILE = CapturedProfile(
client_salt=CLIENT_SALT,
api_st=API_ST,
did=DID,
odid=ODID,
rdid=RDID,
egid=EGID,
client_key=CLIENT_KEY,
)
__all__ = [
"API_ST",
"CAPTURED_PROFILE",
"CLIENT_KEY",
"CLIENT_SALT",
"CapturedProfile",
"DID",
"EGID",
"ODID",
"RDID",
]

14
core/constants.py Normal file
View File

@ -0,0 +1,14 @@
"""Static algorithm constants.
Runtime profile values such as `did`, `egid`, `api_st`, and token client salt
are intentionally kept out of this module. Use `core.captured_profile` for the
currently captured local sample values.
"""
from .sig import SIG_SALT
REWARD_SDK = "95147564-9763-4413-a937-6f0e3c12caf1"
__all__ = ["REWARD_SDK", "SIG_SALT"]

72
core/device_cookie.py Normal file
View File

@ -0,0 +1,72 @@
from __future__ import annotations
from .device_profile import DeviceProfile
def _density_dpi(profile: DeviceProfile) -> str:
return str(int(round(profile.screen_density * 160)))
def _model_name(profile: DeviceProfile) -> str:
return f"{profile.manufacturer}({profile.model})"
def device_profile_cookie_fields(profile: DeviceProfile) -> dict[str, str]:
model_name = _model_name(profile)
return {
"did": profile.did,
"oDid": profile.o_did,
"rdid": profile.rdid,
"egid": profile.egid,
"oaid": profile.runtime_hints.oaid,
"cdid_tag": str(profile.cdid_tag),
"did_tag": "0",
"appver": profile.app_version,
"ver": ".".join(profile.app_version.split(".")[:2]),
"sys": f"ANDROID_{profile.android_release}",
"androidApiLevel": "36" if profile.android_release == "16" else profile.android_release,
"mod": model_name,
"deviceName": model_name,
"c": profile.brand,
"oc": profile.brand,
"newOc": profile.brand,
"isp": profile.isp,
"country_code": profile.country_code,
"countryCode": profile.country_code.upper(),
"sid": profile.sid,
"cold_launch_time_ms": str(profile.cold_launch_time_ms),
"did_gt": profile.runtime_hints.did_gt or str(profile.install_time_ms),
"boardPlatform": profile.board_platform,
"socName": profile.soc_name,
"max_memory": str(profile.max_memory),
"deviceBit": profile.device_bit,
"sw": str(profile.screen_width),
"sh": str(profile.screen_height),
"sbh": str(profile.status_bar_height),
"ddpi": _density_dpi(profile),
"totalMemory": str(profile.total_memory_mb),
"abi": "arm64",
"device_abi": "arm64",
}
def apply_device_profile_to_cookie(
cookie: dict[str, str],
profile: DeviceProfile,
) -> dict[str, str]:
out = dict(cookie)
out.update(device_profile_cookie_fields(profile))
if "ud" not in out and "userId" in out:
out["ud"] = out["userId"]
return out
def cookie_to_string(cookie: dict[str, str]) -> str:
return "; ".join(f"{key}={value}" for key, value in cookie.items())
__all__ = [
"apply_device_profile_to_cookie",
"cookie_to_string",
"device_profile_cookie_fields",
]

148
core/device_id.py Normal file
View File

@ -0,0 +1,148 @@
"""Device identifier helpers recovered from APK static analysis.
This module covers the local identifier formatting/derivation that is visible
in the Java layer. For offline simulation we also expose a deterministic
synthetic EGID candidate. That candidate is not the exact DFP VM algorithm;
online DFP bootstrap can still replace it when a server-issued value exists.
`oDid` is not a separate visible hash algorithm in the Java layer. The app
initializes it from the same local did reader before cloud DID refresh, then
keeps it as the "old/original DID" field.
"""
from __future__ import annotations
import hashlib
import re
import secrets
from collections.abc import Iterable
ANDROID_PREFIX = "ANDROID_"
HEX16_RE = re.compile(r"^[0-9a-fA-F]{16}$")
ANDROID_ID_RE = re.compile(r"^ANDROID_[0-9a-fA-F]{16}$")
EGID_RE = re.compile(r"^DFP[0-9A-F]{61}$")
OAID_RE = re.compile(r"^[0-9A-F]{64}$")
def java_long_hex16(value: int) -> str:
"""Mirror `Long.toHexString(value)` constrained to 16 hex chars."""
return f"{value & ((1 << 64) - 1):016x}"[-16:]
def java_signed_int_to_long_hex16(value: int) -> str:
"""Mirror Java `int -> long -> Long.toHexString()` for fallback rdid."""
value &= 0xFFFFFFFF
if value & 0x80000000:
value -= 0x100000000
return java_long_hex16(value)
def normalize_android_suffix(value: str) -> str:
suffix = value[len(ANDROID_PREFIX) :] if value.startswith(ANDROID_PREFIX) else value
if not HEX16_RE.fullmatch(suffix):
raise ValueError(f"expected 16 hex chars, got {value!r}")
return suffix.lower()
def format_android_id(suffix: str) -> str:
return ANDROID_PREFIX + normalize_android_suffix(suffix)
def did_from_android_id(android_id: str) -> str:
"""Model deviceid/i.l(): valid system android_id -> `ANDROID_<id>`."""
return format_android_id(android_id)
def did_from_random_long(value: int | None = None) -> str:
"""Model deviceid/i.a(): Random.nextLong() -> padded 16-hex suffix."""
if value is None:
value = secrets.randbits(64)
return ANDROID_PREFIX + java_long_hex16(value)
def odid_from_local_did(local_did: str) -> str:
"""Model AppEnv.O_DID: original local DID retained before cloud refresh."""
return format_android_id(local_did)
def rdid_from_gRdi2(rom_id: str) -> str:
"""Model deviceid/i.n(): md5(gRdi2())[16:32] -> `ANDROID_<suffix>`."""
digest = hashlib.md5(rom_id.encode("utf-8")).hexdigest()
return ANDROID_PREFIX + digest[16:32]
def rdid_from_random_int(value: int | None = None) -> str:
"""Model deviceid/i.b(): SecureRandom.nextInt() fallback."""
if value is None:
value = secrets.randbits(32)
return ANDROID_PREFIX + java_signed_int_to_long_hex16(value)
def is_android_device_id(value: str) -> bool:
return bool(ANDROID_ID_RE.fullmatch(value))
def is_valid_egid(value: str) -> bool:
"""Validate the Java callback constraint: prefix `DFP`, total len 64."""
return bool(EGID_RE.fullmatch(value))
def is_valid_oaid(value: str) -> bool:
"""Validate the uppercase 64-hex OAID shape used in task/deviceInfo fields."""
return bool(OAID_RE.fullmatch(value))
def egid_from_seed_material(parts: Iterable[object]) -> str:
"""Build a stable offline EGID candidate from local device seed material.
Runtime evidence only exposes the Java callback constraint (`DFP` prefix,
64 chars total). The real DFP value is produced by the KSecurity/DFP
native flow. This helper keeps generated profiles self-consistent without
depending on APP/RPC/online bootstrap, while preserving the same public
shape for cookie/deviceInfo testing.
"""
payload = "\x1f".join("" if item is None else str(item) for item in parts)
digest = hashlib.sha512(("ksjsb.egid.v1\x00" + payload).encode("utf-8")).hexdigest().upper()
return "DFP" + digest[:61]
def oaid_from_seed_material(parts: Iterable[object]) -> str:
"""Build a stable offline OAID candidate from local device seed material.
The current task chain consumes OAID as a public 64-char uppercase hex
device field. This keeps generated profiles reproducible without
depending on a vendor OAID service call.
"""
payload = "\x1f".join("" if item is None else str(item) for item in parts)
return hashlib.sha256(("ksjsb.oaid.v1\x00" + payload).encode("utf-8")).hexdigest().upper()
__all__ = [
"ANDROID_PREFIX",
"did_from_android_id",
"did_from_random_long",
"egid_from_seed_material",
"format_android_id",
"is_android_device_id",
"is_valid_egid",
"is_valid_oaid",
"java_long_hex16",
"java_signed_int_to_long_hex16",
"normalize_android_suffix",
"oaid_from_seed_material",
"odid_from_local_did",
"rdid_from_gRdi2",
"rdid_from_random_int",
]

568
core/device_profile.py Normal file
View File

@ -0,0 +1,568 @@
from __future__ import annotations
import hashlib
import json
import random
import re
import time
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from .device_id import egid_from_seed_material, is_valid_oaid, oaid_from_seed_material
ANDROID_ID_RE = re.compile(r"^[0-9a-f]{16}$")
ANDROID_PREFIX_RE = re.compile(r"^ANDROID_[0-9a-f]{16}$")
EGID_RE = re.compile(r"^$|^DFP[0-9A-F]{61}$")
@dataclass
class DfpRuntimeHints:
did_gt: str = ""
total_memory_bytes: str = ""
build_fingerprint: str = ""
build_product: str = ""
k4_native: str = ""
storage_available_bytes: int = 0
k51_native: str = ""
k84_native: str = ""
res_soc: str = ""
boot_id: str = ""
grdi: str = ""
ipv6_map: str = ""
lpss: str = ""
keeper_seed: str = ""
du: str = ""
sted_cache_json: str = ""
persisted_cache_m: str = ""
manus: str = ""
gaid: str = ""
oaid: str = ""
wifi_mac: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "DfpRuntimeHints":
data = data or {}
return cls(
did_gt=str(data.get("did_gt", "")),
total_memory_bytes=str(data.get("total_memory_bytes", "")),
build_fingerprint=str(data.get("build_fingerprint", "")),
build_product=str(data.get("build_product", "")),
k4_native=str(data.get("k4_native", "")),
storage_available_bytes=int(data.get("storage_available_bytes", 0)),
k51_native=str(data.get("k51_native", "")),
k84_native=str(data.get("k84_native", "")),
res_soc=str(data.get("res_soc", "")),
boot_id=str(data.get("boot_id", "")),
grdi=str(data.get("grdi", "")),
ipv6_map=str(data.get("ipv6_map", "")),
lpss=str(data.get("lpss", "")),
keeper_seed=str(data.get("keeper_seed", "")),
du=str(data.get("du", "")),
sted_cache_json=str(data.get("sted_cache_json", data.get("cache_m", ""))),
persisted_cache_m=str(data.get("persisted_cache_m", "")),
manus=str(data.get("manus", "")),
gaid=str(data.get("gaid", "")),
oaid=str(data.get("oaid", "")),
wifi_mac=str(data.get("wifi_mac", "")),
)
@dataclass
class DeviceProfile:
android_id: str
local_did: str
did: str
o_did: str
rdid: str
g_rdi2: str
cdid_tag: int = 0
egid: str = ""
install_time_ms: int = 0
cold_launch_time_ms: int = 0
sid: str = ""
package_name: str = "com.kuaishou.nebula"
app_version: str = "14.5.50.11631"
android_release: str = "16"
manufacturer: str = "OnePlus"
brand: str = "OPPO"
model: str = "PJZ110"
build_id: str = "BP2A.250605.015"
build_display: str = "PJZ110_16.0.8.301(CN01)"
build_product: str = "OP5D0DL1"
build_fingerprint: str = ""
build_tags: str = "release-keys"
build_type: str = "user"
board_platform: str = "sun"
soc_name: str = "Qualcomm Snapdragon 8750"
max_memory: int = 256
device_bit: str = "4"
screen_width: int = 1080
screen_height: int = 2376
status_bar_height: int = 120
screen_density: float = 3.0
screen_xdpi: str = "386.36618"
screen_ydpi: str = "381.96454"
total_memory_mb: int = 15107
country_code: str = "cn"
isp: str = "CUCC"
runtime_hints: DfpRuntimeHints = field(default_factory=DfpRuntimeHints)
def __post_init__(self) -> None:
self.validate()
def validate(self) -> None:
if not ANDROID_ID_RE.fullmatch(self.android_id):
raise ValueError(f"invalid android_id: {self.android_id!r}")
for name in ("local_did", "did", "o_did", "rdid"):
value = getattr(self, name)
if not ANDROID_PREFIX_RE.fullmatch(value):
raise ValueError(f"invalid {name}: {value!r}")
if self.o_did != f"ANDROID_{self.android_id}":
raise ValueError("o_did must equal ANDROID_<android_id>")
expected_rdid = hashlib.md5(self.g_rdi2.encode("utf-8")).hexdigest()[16:32]
if self.rdid != f"ANDROID_{expected_rdid}":
raise ValueError("rdid must equal ANDROID_<md5(g_rdi2)[16:32]>")
if not isinstance(self.cdid_tag, int) or self.cdid_tag < 0:
raise ValueError(f"invalid cdid_tag: {self.cdid_tag!r}")
if not EGID_RE.fullmatch(self.egid):
raise ValueError(f"invalid egid: {self.egid!r}")
if self.runtime_hints.oaid and not is_valid_oaid(self.runtime_hints.oaid):
raise ValueError(f"invalid oaid: {self.runtime_hints.oaid!r}")
if self.screen_width <= 0 or self.screen_height <= 0:
raise ValueError("screen size must be positive")
if self.total_memory_mb <= 0:
raise ValueError("total_memory_mb must be positive")
if self.max_memory <= 0:
raise ValueError("max_memory must be positive")
if not self.board_platform:
raise ValueError("board_platform must not be empty")
if not self.soc_name:
raise ValueError("soc_name must not be empty")
def apply_cloud_identity(self, did: str, cdid_tag: int, egid: str = "") -> None:
self.did = did
self.cdid_tag = cdid_tag
if egid:
self.egid = egid
self.sync_egid_cache()
self.validate()
def sync_egid_cache(self) -> None:
"""让 k112/STED JSON 与公开 EGID 保持同一设备身份。"""
if not self.egid:
return
kuaishou_egid = egid_from_seed_material(["KUAISHOU", self.egid, self.android_id])
self.runtime_hints.sted_cache_json = json.dumps(
{"NEBULA": self.egid, "KUAISHOU": kuaishou_egid},
separators=(",", ":"),
)
self.refresh_persisted_cache_m()
def refresh_persisted_cache_m(self) -> None:
"""刷新 rq0.d.cache_m即 c.s(Context) 的本地硬件摘要。"""
from .dfp_cache import build_persisted_cache_m
self.runtime_hints.persisted_cache_m = build_persisted_cache_m(self)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "DeviceProfile":
return cls(
android_id=str(data["android_id"]),
local_did=str(data["local_did"]),
did=str(data["did"]),
o_did=str(data["o_did"]),
rdid=str(data["rdid"]),
g_rdi2=str(data["g_rdi2"]),
cdid_tag=int(data.get("cdid_tag", 0)),
egid=str(data.get("egid", "")),
install_time_ms=int(data.get("install_time_ms", 0)),
cold_launch_time_ms=int(data.get("cold_launch_time_ms", 0)),
sid=str(data.get("sid", "")),
package_name=str(data.get("package_name", "com.kuaishou.nebula")),
app_version=str(data.get("app_version", "14.5.50.11631")),
android_release=str(data.get("android_release", "16")),
manufacturer=str(data.get("manufacturer", "OnePlus")),
brand=str(data.get("brand", "OPPO")),
model=str(data.get("model", "PJZ110")),
build_id=str(data.get("build_id", "BP2A.250605.015")),
build_display=str(data.get("build_display", "PJZ110_16.0.8.301(CN01)")),
build_product=str(data.get("build_product", "OP5D0DL1")),
build_fingerprint=str(data.get("build_fingerprint", "")),
build_tags=str(data.get("build_tags", "release-keys")),
build_type=str(data.get("build_type", "user")),
board_platform=str(data.get("board_platform", "sun")),
soc_name=str(data.get("soc_name", "Qualcomm Snapdragon 8750")),
max_memory=int(data.get("max_memory", 256)),
device_bit=str(data.get("device_bit", "4")),
screen_width=int(data.get("screen_width", 1080)),
screen_height=int(data.get("screen_height", 2376)),
status_bar_height=int(data.get("status_bar_height", 120)),
screen_density=float(data.get("screen_density", 3.0)),
screen_xdpi=str(data.get("screen_xdpi", "386.36618")),
screen_ydpi=str(data.get("screen_ydpi", "381.96454")),
total_memory_mb=int(data.get("total_memory_mb", 15107)),
country_code=str(data.get("country_code", "cn")),
isp=str(data.get("isp", "CUCC")),
runtime_hints=DfpRuntimeHints.from_dict(data.get("runtime_hints")),
)
@property
def screen_metrics(self) -> str:
content_height = self.screen_height - self.status_bar_height - 48
return (
f"[{self.screen_density:.1f},{self.screen_width},{content_height},"
f"{self.screen_density:.1f},{self.screen_xdpi},{self.screen_ydpi}]"
)
def to_env(self) -> str:
lines = [
f"KS_ANDROID_ID={self.android_id}",
f"KS_DID={self.did}",
f"KS_LOCAL_DID={self.local_did}",
f"KS_ODID={self.o_did}",
f"KS_RDID={self.rdid}",
f"KS_GRDI2={self.g_rdi2}",
f"KS_CDID_TAG={self.cdid_tag}",
f"KS_EGID={self.egid}",
f"KS_INSTALL_TIME_MS={self.install_time_ms}",
f"KS_COLD_LAUNCH_TIME_MS={self.cold_launch_time_ms}",
f"KS_SID={self.sid}",
f"KS_PACKAGE_NAME={self.package_name}",
f"KS_APPVER={self.app_version}",
f"KS_ANDROID_RELEASE={self.android_release}",
f"KS_MANUFACTURER={self.manufacturer}",
f"KS_BRAND={self.brand}",
f"KS_MODEL={self.model}",
f"KS_BUILD_ID={self.build_id}",
f"KS_BUILD_DISPLAY={self.build_display}",
f"KS_BUILD_PRODUCT={self.build_product}",
f"KS_BUILD_FINGERPRINT={self.build_fingerprint}",
f"KS_BUILD_TAGS={self.build_tags}",
f"KS_BUILD_TYPE={self.build_type}",
f"KS_BOARD_PLATFORM={self.board_platform}",
f"KS_SOC_NAME={self.soc_name}",
f"KS_MAX_MEMORY={self.max_memory}",
f"KS_DEVICE_BIT={self.device_bit}",
f"KS_SCREEN_WIDTH={self.screen_width}",
f"KS_SCREEN_HEIGHT={self.screen_height}",
f"KS_STATUS_BAR_HEIGHT={self.status_bar_height}",
f"KS_SCREEN_DENSITY={self.screen_density:.1f}",
f"KS_SCREEN_XDPI={self.screen_xdpi}",
f"KS_SCREEN_YDPI={self.screen_ydpi}",
f"KS_TOTAL_MEMORY_MB={self.total_memory_mb}",
f"KS_COUNTRY_CODE={self.country_code}",
f"KS_ISP={self.isp}",
f"KS_DID_GT={self.runtime_hints.did_gt}",
f"KS_TOTAL_MEMORY_BYTES={self.runtime_hints.total_memory_bytes}",
f"KS_K4={self.runtime_hints.k4_native}",
f"KS_K20={self.runtime_hints.storage_available_bytes}",
f"KS_K51={self.runtime_hints.k51_native}",
f"KS_K84={self.runtime_hints.k84_native}",
f"KS_RESSOC={self.runtime_hints.res_soc}",
f"KS_BOOT_ID={self.runtime_hints.boot_id}",
f"KS_K105={self.runtime_hints.grdi}",
f"KS_GRDI={self.runtime_hints.grdi}",
f"KS_IPV6_MAP={self.runtime_hints.ipv6_map}",
f"KS_LPSS={self.runtime_hints.lpss}",
f"KS_KEEPER_SEED={self.runtime_hints.keeper_seed}",
f"KS_DU={self.runtime_hints.du}",
f"KS_STED={self.runtime_hints.sted_cache_json}",
f"KS_DFP_CACHE_M={self.runtime_hints.persisted_cache_m}",
f"KS_MANUS={self.runtime_hints.manus}",
f"KS_GAID={self.runtime_hints.gaid}",
f"KS_OAID={self.runtime_hints.oaid}",
f"KS_WIFI_MAC={self.runtime_hints.wifi_mac}",
]
return "\n".join(lines) + "\n"
class DeviceProfileGenerator:
def __init__(self, seed: int | None = None) -> None:
self._random = random.Random(seed)
def new_profile(self) -> DeviceProfile:
now_ms = int(time.time() * 1000)
install_time_ms = now_ms - self._random.randint(10_000, 600_000)
android_id = self._hex16()
local_did = f"ANDROID_{self._hex16()}"
g_rdi2 = self._g_rdi2()
rdid_suffix = hashlib.md5(g_rdi2.encode("utf-8")).hexdigest()[16:32]
hardware = self._hardware_profile()
total_memory_bytes = str(
hardware.get("total_memory_bytes", int(hardware["total_memory_mb"]) * 1024 * 1024)
)
hardware.pop("total_memory_bytes", None)
sid = str(uuid.UUID(int=self._random.getrandbits(128)))
egid = self._egid(android_id, local_did, g_rdi2, install_time_ms, now_ms, sid, hardware)
rdid = f"ANDROID_{rdid_suffix}"
oaid = oaid_from_seed_material([android_id, local_did, rdid, g_rdi2, sid, egid])
profile = DeviceProfile(
android_id=android_id,
local_did=local_did,
did=local_did,
o_did=f"ANDROID_{android_id}",
rdid=rdid,
g_rdi2=g_rdi2,
egid=egid,
install_time_ms=install_time_ms,
cold_launch_time_ms=now_ms,
sid=sid,
runtime_hints=self._runtime_hints(
egid,
did_gt=str(install_time_ms + self._random.randint(1_000, 60_000)),
total_memory_bytes=total_memory_bytes,
build_fingerprint=str(hardware.get("build_fingerprint", "")),
build_product=str(hardware.get("build_product", "")),
oaid=oaid,
),
**hardware,
)
profile.sync_egid_cache()
return profile
def _hex16(self) -> str:
return f"{self._random.getrandbits(64):016x}"
def _random_mac(self) -> str:
"""随机本地管理 MAC首字节 bit1=1 locally administered, bit0=0 unicast"""
b = [0x02 | (self._random.getrandbits(6) << 2)]
b += [self._random.getrandbits(8) for _ in range(5)]
return ":".join(f"{x:02x}" for x in b)
def _g_rdi2(self) -> str:
parts = []
for _ in range(5):
left = self._random.choice((7, 8, 9)) * 100_000_000 + self._random.randint(0, 999_999)
right = self._random.choice((4741, 8641))
parts.append(f"{left}::{right}")
return "|".join(parts)
def _egid(
self,
android_id: str,
local_did: str,
g_rdi2: str,
install_time_ms: int,
cold_launch_time_ms: int,
sid: str,
hardware: dict[str, Any],
) -> str:
return egid_from_seed_material(
[
android_id,
local_did,
g_rdi2,
install_time_ms,
cold_launch_time_ms,
sid,
hardware.get("manufacturer", ""),
hardware.get("brand", ""),
hardware.get("model", ""),
hardware.get("build_id", ""),
hardware.get("screen_width", ""),
hardware.get("screen_height", ""),
hardware.get("total_memory_mb", ""),
]
)
def _hardware_profile(self) -> dict[str, Any]:
templates = [
{
"manufacturer": "OnePlus",
"brand": "OPPO",
"model": "PJZ110",
"build_id": "BP2A.250605.015",
"build_display": "PJZ110_16.0.8.301(CN01)",
"build_product": "OP5D0DL1",
"build_fingerprint": "OnePlus/PJZ110/OP5D0DL1:16/BP2A.250605.015/V.4e5c566-2a38f4c-2a4ca91:user/release-keys",
"board_platform": "sun",
"soc_name": "Qualcomm Snapdragon 8750",
"max_memory": 256,
"device_bit": "4",
"screen_width": 1080,
"screen_height": 2376,
"status_bar_height": 120,
"screen_density": 3.0,
"screen_xdpi": "386.36618",
"screen_ydpi": "381.96454",
"total_memory_mb": 15107,
"total_memory_bytes": 15841333248,
},
{
"manufacturer": "OPPO",
"brand": "OPPO",
"model": "PKB110",
"build_id": "BP1A.250305.019",
"build_display": "PKB110_15.0.1.601(CN01)",
"build_product": "PKB110",
"build_fingerprint": "OPPO/PKB110/PKB110:15/BP1A.250305.019/PKB110_15.0.1.601(CN01):user/release-keys",
"board_platform": "pineapple",
"soc_name": "Qualcomm Snapdragon 8 Gen 3",
"max_memory": 256,
"device_bit": "4",
"screen_width": 1080,
"screen_height": 2412,
"status_bar_height": 120,
"screen_density": 3.0,
"screen_xdpi": "394.215",
"screen_ydpi": "392.781",
"total_memory_mb": 12288,
"total_memory_bytes": 12884901888,
},
{
"manufacturer": "vivo",
"brand": "vivo",
"model": "V2408A",
"build_id": "BP1A.250205.007",
"build_display": "V2408A_A_15.1.9.6.W10",
"build_product": "V2408A",
"build_fingerprint": "vivo/V2408A/V2408A:15/BP1A.250205.007/V2408A_A_15.1.9.6.W10:user/release-keys",
"board_platform": "dimensity9400",
"soc_name": "MediaTek Dimensity 9400",
"max_memory": 256,
"device_bit": "4",
"screen_width": 1260,
"screen_height": 2800,
"status_bar_height": 132,
"screen_density": 3.0,
"screen_xdpi": "450.0",
"screen_ydpi": "450.0",
"total_memory_mb": 16384,
"total_memory_bytes": 17179869184,
},
]
selected = dict(self._random.choice(templates))
selected["isp"] = self._random.choice(["CUCC", "CTCC", "CMCC"])
return selected
def _runtime_hints(
self,
egid: str = "",
*,
did_gt: str = "",
total_memory_bytes: str = "",
build_fingerprint: str = "",
build_product: str = "",
oaid: str = "",
) -> DfpRuntimeHints:
storage_gb = self._random.randint(180, 460)
grdi_parts = []
for _ in range(5):
left = self._random.choice((5, 6, 7, 8, 9)) * 100_000_000 + self._random.randint(0, 99_999_999)
right = self._random.choice((3841, 4741, 5317, 8641))
grdi_parts.append(f"{left}::{right}")
ipv6_map = json.dumps(
self._ipv6_map(),
separators=(",", ":"),
)
manus = json.dumps(
{
"5": {
"1": "KWE_N",
"2": "KWE_N",
"3": "KWE_N",
"7": str(int(time.time() * 1000)),
"8": "KWE_N",
"10": "KWE_N",
}
},
separators=(",", ":"),
)
return DfpRuntimeHints(
did_gt=did_gt,
total_memory_bytes=total_memory_bytes,
build_fingerprint=build_fingerprint,
build_product=build_product,
k4_native=str(self._random.randint(1_000_000_000, 4_200_000_000)),
storage_available_bytes=storage_gb * 1024 * 1024 * 1024,
k51_native=self._hex16(),
k84_native=self._hex16(),
res_soc=f"soc-{self._hex16()}",
boot_id=str(uuid.UUID(int=self._random.getrandbits(128))),
grdi="|".join(grdi_parts),
ipv6_map=ipv6_map,
lpss=f"lp-{self._hex16()}",
keeper_seed=str(self._random.getrandbits(63)),
du=f"2@{self._hex16()}{self._hex16()}",
sted_cache_json=json.dumps({"NEBULA": egid}, separators=(",", ":")) if egid else "",
manus=manus,
gaid=str(uuid.UUID(int=self._random.getrandbits(128))),
oaid=oaid or (self._hex16() + self._hex16() + self._hex16() + self._hex16()).upper(),
wifi_mac=self._random_mac(),
)
def _ipv6_group(self) -> str:
return f"{self._random.getrandbits(16):x}"
def _ipv6_addr(self, prefix: str = "2408") -> str:
return (
f"{prefix}:{self._ipv6_group()}:{self._ipv6_group()}:{self._ipv6_group()}:"
f"{self._ipv6_group()}:{self._ipv6_group()}:{self._ipv6_group()}:{self._ipv6_group()}"
)
def _link_local(self, iface: str) -> str:
return f"fe80::{self._ipv6_group()}:{self._ipv6_group()}:fe{self._ipv6_group()[:2]}:{self._ipv6_group()}%{iface}"
def _ipv6_map(self) -> dict[str, str]:
ifaces = [
"rmnet_data1",
"",
"",
"wlan0",
"",
"",
"tun0",
"rmnet_data3",
"ifb0",
"",
"r_rmnet_data0",
"rmnet_data4",
"rmnet_data2",
"ifb1",
"dummy0",
"",
"ifb2",
"vgate0",
]
values: dict[str, str] = {"0": self._ipv6_addr()}
for index, iface in enumerate(ifaces, 1):
if iface:
values[str(index)] = self._link_local(iface)
else:
values[str(index)] = self._ipv6_addr(prefix=self._random.choice(["2408", "fd42", "2a00"]))
return values
def save_device_profile(profile: DeviceProfile, path: str | Path) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
json.dumps(profile.to_dict(), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def load_device_profile(path: str | Path) -> DeviceProfile:
source = Path(path)
try:
data = json.loads(source.read_text(encoding="utf-8"))
except Exception as exc:
raise ValueError(f"failed to load device profile: {source}") from exc
if not isinstance(data, dict):
raise ValueError(f"device profile must be a JSON object: {source}")
return DeviceProfile.from_dict(data)

131
core/dfp_cache.py Normal file
View File

@ -0,0 +1,131 @@
from __future__ import annotations
import hashlib
from collections.abc import Iterable, Mapping
from .device_profile import DeviceProfile
PERSISTED_CACHE_KEYS = (
"k6",
"k8",
"k16",
"k19",
"k23",
"k27",
"k29",
"k40",
"k105",
"k110",
)
def uq0_s_a(text: str) -> str:
"""复现 uq0.s.a(): 非空且非 KWE* 时返回 MD5 小写 hex 前 16 位。"""
if not text or text.startswith("KWE"):
return text
return hashlib.md5(text.encode("utf-8")).hexdigest()[:16]
def java_string_hashcode(text: str) -> int:
"""Java String.hashCode() 的 32-bit 结果。"""
value = 0
for char in text:
value = (31 * value + ord(char)) & 0xFFFFFFFF
return value
def java_hashmap_spread(hashcode: int) -> int:
"""OpenJDK HashMap.hash(): h ^ (h >>> 16)。"""
unsigned = hashcode & 0xFFFFFFFF
return (unsigned ^ (unsigned >> 16)) & 0xFFFFFFFF
def java_hashmap_to_string(entries: Iterable[tuple[str, object]], capacity: int = 16) -> str:
"""复现当前 DFP cache_m 使用场景下的 HashMap.toString() 顺序。
c.s(Context) put 10 个固定 String key默认 HashMap 首次扩容到 16
不会触发二次 resize这里按 bucket 下标递增bucket 内插入顺序输出
"""
buckets: list[list[tuple[str, object]]] = [[] for _ in range(capacity)]
for key, value in entries:
index = java_hashmap_spread(java_string_hashcode(key)) & (capacity - 1)
bucket = buckets[index]
for item_index, (existing_key, _) in enumerate(bucket):
if existing_key == key:
bucket[item_index] = (key, value)
break
else:
bucket.append((key, value))
pairs = [
f"{key}={'' if value is None else str(value)}"
for bucket in buckets
for key, value in bucket
]
return "{" + ", ".join(pairs) + "}"
def _dfp_non_empty(value: object) -> str:
text = "" if value is None else str(value)
return text if text else "KWE_N"
def _target_sdk_grdi(value: str, target_sdk_version: int) -> str:
if target_sdk_version < 30 or not value or value.startswith("KWE"):
return value
try:
parts = value.split("|")
return "".join(f"{part}|" for index, part in enumerate(parts) if index != 3)
except Exception:
return value
def persisted_cache_source_values(
profile: DeviceProfile,
*,
target_sdk_version: int = 30,
) -> dict[str, str]:
"""构造 com.kuaishou.dfp.c.c.s(Context) 写入 HashMap 的 10 个字段。"""
hints = profile.runtime_hints
return {
"k6": _dfp_non_empty("0"),
"k8": _dfp_non_empty(profile.build_type),
"k16": _dfp_non_empty(""),
"k19": _dfp_non_empty("sun"),
"k23": _dfp_non_empty(profile.manufacturer),
"k27": _dfp_non_empty(profile.model),
"k29": _dfp_non_empty(
f"Dalvik/2.1.0 (Linux; U; Android {profile.android_release}; "
f"{profile.model} Build/{profile.build_id})"
),
"k40": _dfp_non_empty(""),
"k105": _dfp_non_empty(_target_sdk_grdi(hints.grdi, target_sdk_version)),
"k110": _dfp_non_empty(hints.keeper_seed),
}
def build_persisted_cache_m_from_values(values: Mapping[str, object]) -> str:
entries = [(key, values.get(key, "KWE_N")) for key in PERSISTED_CACHE_KEYS]
return uq0_s_a(java_hashmap_to_string(entries))
def build_persisted_cache_m(
profile: DeviceProfile,
*,
target_sdk_version: int = 30,
) -> str:
values = persisted_cache_source_values(profile, target_sdk_version=target_sdk_version)
return build_persisted_cache_m_from_values(values)
__all__ = [
"PERSISTED_CACHE_KEYS",
"build_persisted_cache_m",
"build_persisted_cache_m_from_values",
"java_hashmap_spread",
"java_hashmap_to_string",
"java_string_hashcode",
"persisted_cache_source_values",
"uq0_s_a",
]

122
core/dfp_client.py Normal file
View File

@ -0,0 +1,122 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from typing import Any, Callable
from .device_id import is_android_device_id, is_valid_egid
from .device_profile import DeviceProfile
from .dfp_forms import DfpRequestSpec
@dataclass(frozen=True)
class BootstrapIdentity:
did: str = ""
cdid_tag: int = 0
egid: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class DfpHttpResponse:
status_code: int
ok: bool
data: Any
text: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def _as_mapping(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _int_tag(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def extract_bootstrap_identity(
fetch_response: dict[str, Any] | None,
report_response: dict[str, Any] | None,
) -> BootstrapIdentity:
fetch = _as_mapping(fetch_response)
report = _as_mapping(report_response)
did = str(fetch.get("cloud_did") or fetch.get("did") or "")
if not is_android_device_id(did):
did = ""
cdid_tag = _int_tag(fetch.get("did_tag") or fetch.get("didTag") or fetch.get("cdid_tag"))
egid = str(report.get("egid") or fetch.get("egid") or "")
if egid and not is_valid_egid(egid):
egid = ""
return BootstrapIdentity(did=did, cdid_tag=cdid_tag, egid=egid)
def apply_bootstrap_identity(profile: DeviceProfile, identity: BootstrapIdentity) -> None:
if identity.did:
profile.apply_cloud_identity(identity.did, identity.cdid_tag, identity.egid)
return
if identity.egid:
profile.egid = identity.egid
profile.sync_egid_cache()
profile.validate()
def post_request(
request: DfpRequestSpec,
*,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
) -> DfpHttpResponse:
if post_func is None:
import requests
post_func = requests.post
try:
response = post_func(
request.url,
data=request.body.encode("utf-8"),
headers=request.headers,
timeout=timeout,
)
except Exception as exc:
text = str(exc)
return DfpHttpResponse(
status_code=0,
ok=False,
data={"error_type": exc.__class__.__name__, "error": text},
text=text,
)
text = getattr(response, "text", "")
try:
data = response.json()
except Exception:
try:
data = json.loads(text)
except Exception:
data = text
return DfpHttpResponse(
status_code=int(getattr(response, "status_code", 0)),
ok=bool(getattr(response, "ok", False)),
data=data,
text=text,
)
__all__ = [
"BootstrapIdentity",
"DfpHttpResponse",
"apply_bootstrap_identity",
"extract_bootstrap_identity",
"post_request",
]

245
core/dfp_forms.py Normal file
View File

@ -0,0 +1,245 @@
from __future__ import annotations
import base64
import time
import uuid
from dataclasses import asdict, dataclass
from urllib.parse import quote_plus, urlencode
from .device_profile import DeviceProfile
from .dfp_knn import build_full_knn, build_lite_knn
from .dfp_sign import DFP_SDK_ID, sign_dfp_form
from .dfp_sq0 import encode_sq0_device_info
from .enc_data import KWSG_10400_DEFAULT_CFG9, kwsg_10400_raw, load_kwsg_10400_tables
UNIFIED_FETCH_URL = "https://gdfpsec.ksapisrv.com/rest/infra/unifiedId/fetch/android"
UNIFIED_CHECK_REPAIR_URL = "https://gdfpsec.ksapisrv.com/rest/infra/unifiedId/checkRepair"
GDFP_REPORT_URL = "https://gdfpsec.ksapisrv.com/rest/infra/gdfp/report/kuaishou/android"
UNIFIED_FETCH_FORM_ORDER = [
"aegon",
"appVersion",
"deviceInfo",
"did",
"didTag",
"hgidReportId",
"platform",
"productName",
"rdid",
"requestId",
"sdkVersion",
"sv",
"ts",
"sign",
]
UNIFIED_CHECK_REPAIR_FORM_ORDER = [
"appVersion",
"did",
"didTag",
"from",
"lastDidTs",
"platform",
"productName",
"sdkVersion",
"ts",
"sign",
]
GDFP_REPORT_FORM_ORDER = [
"productName",
"ts",
"deviceInfo",
"sign",
"sv",
"rdid",
"didtag",
]
@dataclass(frozen=True)
class DfpRequestSpec:
method: str
url: str
headers: dict[str, str]
form_order: list[str]
form: dict[str, str]
body: str
def to_dict(self) -> dict[str, object]:
return asdict(self)
def android_base64_default(data: bytes) -> str:
text = base64.b64encode(data).decode()
return "\n".join(text[i : i + 76] for i in range(0, len(text), 76)) + "\n"
def java_urlencode(text: str) -> str:
return quote_plus(text, safe="*-._")
def _headers(url: str) -> dict[str, str]:
host = url.split("/", 3)[2]
return {
"Content-Type": "application/x-www-form-urlencoded",
"Host": host,
"Connection": "Keep-Alive",
"Accept-Encoding": "gzip",
"User-Agent": "okhttp/3.12.13",
}
def _device_info_encoded(
sq0_raw: bytes,
*,
epoch_seconds: int | None,
sdk_id: str = DFP_SDK_ID,
) -> str:
t1, t2 = load_kwsg_10400_tables()
raw = kwsg_10400_raw(
sq0_raw,
sdk_id,
t1,
t2,
epoch_seconds=epoch_seconds,
cfg9=KWSG_10400_DEFAULT_CFG9,
)
return java_urlencode(android_base64_default(raw))
def _request_from_order(url: str, form: dict[str, str], form_order: list[str]) -> DfpRequestSpec:
ordered_pairs = [(key, form[key]) for key in form_order]
body = urlencode(ordered_pairs)
return DfpRequestSpec(
method="POST",
url=url,
headers=_headers(url),
form_order=list(form_order),
form={key: value for key, value in ordered_pairs},
body=body,
)
def _now_millis() -> str:
return str(int(time.time() * 1000))
def _request_id() -> str:
return uuid.uuid4().hex[:16]
def build_unified_fetch_request(
profile: DeviceProfile,
*,
counter: int,
unix_time: int,
session_seed: int,
ts_millis: str | None = None,
epoch_seconds: int | None = None,
) -> DfpRequestSpec:
sq0_raw = encode_sq0_device_info(build_lite_knn(profile), mode="lite")
device_info = _device_info_encoded(sq0_raw, epoch_seconds=epoch_seconds)
unsigned = {
"aegon": "false",
"appVersion": "14.5.50.11631",
"deviceInfo": device_info,
"did": profile.did,
"didTag": "-1",
"hgidReportId": _request_id(),
"platform": "1",
"productName": "NEBULA",
"rdid": profile.rdid,
"requestId": _request_id(),
"sdkVersion": "9.5.4lite.79.137a838b",
"sv": "2",
"ts": ts_millis or _now_millis(),
}
signed = sign_dfp_form(
"unified_fetch",
unsigned,
counter,
unix_time,
session_seed=session_seed,
)
form = {key: signed[key] for key in UNIFIED_FETCH_FORM_ORDER}
return _request_from_order(UNIFIED_FETCH_URL, form, UNIFIED_FETCH_FORM_ORDER)
def build_unified_check_repair_request(
profile: DeviceProfile,
*,
counter: int,
unix_time: int,
session_seed: int,
ts_millis: str | None = None,
from_value: str = "1",
last_did_ts: str | None = None,
) -> DfpRequestSpec:
unsigned = {
"appVersion": profile.app_version,
"did": profile.did,
"didTag": str(profile.cdid_tag),
"from": str(from_value),
"lastDidTs": str(last_did_ts if last_did_ts is not None else profile.install_time_ms),
"platform": "1",
"productName": "NEBULA",
"sdkVersion": "9.5.4lite.79.137a838b",
"ts": ts_millis or _now_millis(),
}
signed = sign_dfp_form(
"unified_check_repair",
unsigned,
counter,
unix_time,
session_seed=session_seed,
)
form = {key: signed[key] for key in UNIFIED_CHECK_REPAIR_FORM_ORDER}
return _request_from_order(UNIFIED_CHECK_REPAIR_URL, form, UNIFIED_CHECK_REPAIR_FORM_ORDER)
def build_gdfp_report_request(
profile: DeviceProfile,
*,
counter: int,
unix_time: int,
session_seed: int,
ts_millis: str | None = None,
epoch_seconds: int | None = None,
) -> DfpRequestSpec:
sq0_raw = encode_sq0_device_info(build_full_knn(profile), mode="full")
device_info = _device_info_encoded(sq0_raw, epoch_seconds=epoch_seconds)
unsigned = {
"productName": "NEBULA",
"ts": ts_millis or _now_millis(),
"deviceInfo": device_info,
"sv": "2",
"rdid": profile.rdid,
"didtag": "-1",
}
signed = sign_dfp_form(
"gdfp_report",
unsigned,
counter,
unix_time,
session_seed=session_seed,
)
form = {key: signed[key] for key in GDFP_REPORT_FORM_ORDER}
return _request_from_order(GDFP_REPORT_URL, form, GDFP_REPORT_FORM_ORDER)
__all__ = [
"DfpRequestSpec",
"UNIFIED_CHECK_REPAIR_FORM_ORDER",
"UNIFIED_CHECK_REPAIR_URL",
"GDFP_REPORT_FORM_ORDER",
"GDFP_REPORT_URL",
"UNIFIED_FETCH_FORM_ORDER",
"UNIFIED_FETCH_URL",
"android_base64_default",
"build_gdfp_report_request",
"build_unified_check_repair_request",
"build_unified_fetch_request",
"java_urlencode",
]

305
core/dfp_knn.py Normal file
View File

@ -0,0 +1,305 @@
from __future__ import annotations
import json
import zlib
from .device_profile import DeviceProfile
KWE_N = "KWE_N"
KWE_NPN = "KWE_NPN"
LITE_DFP_VERSION = "9.5.4lite.79.137a838b"
# 运营商 -> MCC-MNC从 profile.isp 派生,不再硬编码)
_ISP_MCC_MNC = {"CUCC": "46001", "CTCC": "46011", "CMCC": "46000"}
LITE_KEYS = [
"k5",
"k14",
"k22",
"k23",
"k27",
"k29",
"k31",
"k34",
"k35",
"k36",
"k39",
"k40",
"k46",
"k57",
"k61",
"k64",
"k66",
"k68",
"k83",
"k86",
"k93",
"k97",
"k101",
"k102",
"k105",
"k106",
"k107",
"k108",
"k109",
"k110",
"k111",
"k112",
"k113",
]
FULL_KEYS = [f"k{index}" for index in range(1, 120)]
FULL_DEFAULTS = {
**{key: KWE_NPN for key in ("k2", "k9", "k12", "k13", "k18", "k21", "k33", "k41", "k43")},
**{key: KWE_NPN for key in ("k55", "k57", "k62", "k65", "k76", "k79", "k80", "k81")},
**{key: KWE_NPN for key in ("k54", "k68", "k71", "k73", "k74", "k75", "k77", "k82", "k88")},
**{key: KWE_NPN for key in ("k106", "k114", "k115", "k116", "k117", "k118")},
**{key: KWE_N for key in ("k24", "k31", "k53", "k70", "k85", "k86", "k87")},
**{key: KWE_N for key in ("k90", "k91", "k94", "k96", "k101", "k103", "k104")},
"k63": "root",
"k95": "KWE_NS",
}
def _android_suffix(value: str) -> str:
return value.removeprefix("ANDROID_")
def _text(value: object, default: str = KWE_N) -> str:
if value is None:
return default
text = str(value)
return text if text != "" else default
def _mcc_mnc(profile: DeviceProfile) -> str:
return {
"CUCC": "46011",
"CTCC": "46003",
"CMCC": "46000",
}.get(profile.isp.upper(), KWE_NPN)
def _k93_json(profile: DeviceProfile) -> str:
return json.dumps(
{
"0": 1,
"1": "7",
"2": "true",
"4": KWE_N,
"8": "zz abcdabcdabcdabcdabcd",
"11": profile.model,
"12": KWE_N,
"19": "100",
"20": profile.install_time_ms,
"23": KWE_N,
"24": KWE_N,
"25": f"{profile.runtime_hints.wifi_mac or '02:00:00:00:00:00'}@{profile.install_time_ms}@{profile.model}@CTExcel@{_ISP_MCC_MNC.get(profile.isp.upper(), '46001')}",
"30": {
"op_old": profile.runtime_hints.oaid or KWE_N,
"cost_old": 5,
"cost_new": 22,
},
"27": True,
"28": profile.g_rdi2,
},
ensure_ascii=False,
separators=(",", ":"),
)
def _k93_lite_json(profile: DeviceProfile) -> str:
return json.dumps(
{
"19": "100",
"20": profile.install_time_ms,
"23": KWE_N,
"24": KWE_N,
"25": f"{profile.runtime_hints.wifi_mac or '02:00:00:00:00:00'}@{profile.install_time_ms}@{profile.model}@CTExcel@{_ISP_MCC_MNC.get(profile.isp.upper(), '46001')}",
"30": {
"op_old": profile.runtime_hints.oaid or KWE_N,
"cost_old": 6,
"cost_new": 23,
},
"27": True,
"28": profile.g_rdi2,
},
ensure_ascii=False,
separators=(",", ":"),
)
def _dalvik_vm(profile: DeviceProfile) -> str:
return (
f"Dalvik/2.1.0 (Linux; U; Android {profile.android_release}; "
f"{profile.model} Build/{profile.build_id})"
)
def _build_fingerprint(profile: DeviceProfile) -> str:
if profile.build_fingerprint:
return profile.build_fingerprint
product = profile.build_product or profile.model
return (
f"{profile.manufacturer}/{profile.model}/{product}:"
f"{profile.android_release}/{profile.build_id}/{profile.build_display}:"
f"{profile.build_type}/{profile.build_tags}"
)
def recompute_k14_crc(values: dict[str, str], ordered_keys: list[str]) -> str:
crc = 0
for key in ordered_keys:
raw = b"AND" if key == "k14" else str(values.get(key, "")).encode("utf-8")
crc = zlib.crc32(raw, crc)
return f"AND:{crc & 0xFFFFFFFF}"
def build_lite_knn(
profile: DeviceProfile,
overrides: dict[str, str] | None = None,
) -> dict[str, str]:
hints = profile.runtime_hints
values = {key: KWE_N for key in LITE_KEYS}
values.update(
{
"k5": str(profile.install_time_ms),
"k14": "AND",
"k22": profile.app_version,
"k23": profile.manufacturer,
"k27": profile.model,
"k29": _dalvik_vm(profile),
"k31": KWE_N,
"k34": profile.screen_metrics,
"k35": profile.android_release,
"k36": LITE_DFP_VERSION,
"k39": hints.did_gt or str(profile.install_time_ms),
"k40": _build_fingerprint(profile),
"k46": hints.total_memory_bytes or str(profile.total_memory_mb * 1024 * 1024),
"k57": KWE_NPN,
"k61": profile.brand,
"k66": _android_suffix(profile.o_did),
"k68": KWE_NPN,
"k83": profile.egid or "KWE_N",
"k93": _k93_lite_json(profile),
"k97": hints.oaid,
"k101": hints.res_soc,
"k102": hints.boot_id,
"k105": hints.grdi,
"k106": KWE_NPN,
"k107": str(profile.cdid_tag),
"k108": hints.ipv6_map,
"k109": hints.lpss,
"k110": hints.keeper_seed,
"k111": hints.du,
"k112": hints.sted_cache_json or "KWE_N",
"k113": hints.manus,
}
)
if overrides:
for key, value in overrides.items():
if key not in values:
raise KeyError(key)
values[key] = "" if value is None else str(value)
values["k14"] = recompute_k14_crc(values, LITE_KEYS)
return {key: _text(values.get(key), default="") for key in LITE_KEYS}
def build_full_knn(
profile: DeviceProfile,
overrides: dict[str, str] | None = None,
) -> dict[str, str]:
hints = profile.runtime_hints
values = {key: FULL_DEFAULTS.get(key, KWE_N) for key in FULL_KEYS}
values.update(
{
"k1": "isContent",
"k3": profile.package_name,
"k4": hints.k4_native,
"k5": str(profile.install_time_ms),
"k6": "0",
"k7": profile.did,
"k8": profile.build_type,
"k10": "0",
"k11": "0",
"k14": "AND",
"k15": "0",
"k16": "kvm-slave-build-s-system-11410184",
"k17": "192.168.8.34",
"k19": "sun",
"k20": str(hints.storage_available_bytes),
"k22": profile.app_version,
"k23": profile.manufacturer,
"k25": "1",
"k26": "arm64-v8a,",
"k27": profile.model,
"k28": "qcom",
"k29": _dalvik_vm(profile),
"k30": profile.build_display,
"k32": "[GMT+08:00,Asia/Shanghai]",
"k34": profile.screen_metrics,
"k35": profile.android_release,
"k36": LITE_DFP_VERSION,
"k37": profile.build_id,
"k38": "91",
"k39": hints.did_gt or str(profile.install_time_ms),
"k40": _build_fingerprint(profile),
"k42": "[5,100]",
"k44": profile.build_tags,
"k45": "isContent",
"k46": hints.total_memory_bytes or str(profile.total_memory_mb * 1024 * 1024),
"k47": "unknown",
"k48": "isContent",
"k49": "0",
"k50": "notExist",
"k51": hints.k51_native,
"k52": profile.model,
"k56": "notExist",
"k58": profile.build_product or hints.build_product or profile.model,
"k59": "1",
"k60": "unknown",
"k61": profile.brand,
"k66": _android_suffix(profile.o_did),
"k67": "gb",
"k69": "1",
"k72": profile.country_code,
"k78": _mcc_mnc(profile),
"k83": profile.egid or "KWE_FIRST",
"k84": hints.k84_native,
"k89": "160816899",
"k92": str(profile.cold_launch_time_ms),
"k93": _k93_json(profile),
"k97": hints.oaid,
"k101": hints.res_soc,
"k102": hints.boot_id,
"k105": hints.grdi,
"k107": str(profile.cdid_tag),
"k108": hints.ipv6_map,
"k109": hints.lpss,
"k110": hints.keeper_seed,
"k111": hints.du,
"k112": hints.sted_cache_json,
"k113": hints.manus,
"k119": hints.gaid,
}
)
if overrides:
for key, value in overrides.items():
if key not in values:
raise KeyError(key)
values[key] = "" if value is None else str(value)
values["k14"] = recompute_k14_crc(values, FULL_KEYS)
return {key: _text(values.get(key), default="") for key in FULL_KEYS}
__all__ = [
"FULL_KEYS",
"KWE_N",
"LITE_DFP_VERSION",
"LITE_KEYS",
"build_full_knn",
"build_lite_knn",
"recompute_k14_crc",
]

174
core/dfp_sign.py Normal file
View File

@ -0,0 +1,174 @@
"""DFP / unifiedId atlasSign input builders and 10405 sign wrappers."""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
from typing import Mapping
from .atlas_sign import atlas_sign, atlas_sign_to_digest_hex
from .enc_data import ZT_OUTER_CONFIGS
from .sig3 import kwsg_10418_digest24_unmix
DFP_SDK_ID = "7e46b28a-8c93-4940-8238-4c60e64e3c81"
DFP_SIGN_KEYS = {"sign", "__NS_sig3", "__NS_xfalcon", "__NStokensig", "sig"}
DFP_PAYLOAD_KEYS = ("deviceInfo", "carryInfo", "data")
@dataclass(frozen=True)
class DfpSignMaterial:
scheme: str
keys: tuple[str, ...]
sign_input: str
excluded_keys: tuple[str, ...] = ()
note: str = ""
@property
def input_len(self) -> int:
return len(self.sign_input)
@property
def input_sha256(self) -> str:
return hashlib.sha256(self.sign_input.encode("utf-8", errors="ignore")).hexdigest()
def _first_payload_key(params: Mapping[str, str], preferred: tuple[str, ...]) -> str:
for key in preferred:
if key in params:
return key
for key in DFP_PAYLOAD_KEYS:
if key in params:
return key
raise KeyError("missing DFP encrypted payload field")
def legacy_product_ts_sv_payload(params: Mapping[str, str]) -> DfpSignMaterial:
"""Build `productName + ts + sv + encryptedPayload`.
Used by `gdfp/report` and `unifiedId/logReport/android`.
"""
payload_key = _first_payload_key(params, ("deviceInfo", "carryInfo", "data"))
keys = ("productName", "ts", "sv", payload_key)
sign_input = (
params.get("productName", "")
+ params.get("ts", "")
+ params.get("sv", "2")
+ params.get(payload_key, "")
)
excluded = tuple(key for key in params if key not in keys and key not in DFP_SIGN_KEYS)
return DfpSignMaterial(
scheme="legacy_product_ts_sv_payload",
keys=keys,
sign_input=sign_input,
excluded_keys=excluded,
note="productName + ts + sv + encrypted payload; append rdid/didtag/ft after sign",
)
def id_mapping_data_only(params: Mapping[str, str]) -> DfpSignMaterial:
"""Build `data` only for `unifiedId/logReport/idMapping`."""
payload_key = _first_payload_key(params, ("data",))
return DfpSignMaterial(
scheme="id_mapping_data_only",
keys=(payload_key,),
sign_input=params.get(payload_key, ""),
note="idMapping signs only encoded data",
)
def tree_values_sorted_non_empty_except_sign(params: Mapping[str, str]) -> DfpSignMaterial:
"""Build TreeMap-style non-empty values without separators."""
keys = tuple(
key
for key in sorted(params)
if key not in DFP_SIGN_KEYS and params.get(key, "") != ""
)
sign_input = "".join(params[key] for key in keys)
excluded = tuple(key for key in params if key not in keys and key not in DFP_SIGN_KEYS)
return DfpSignMaterial(
scheme="tree_values_sorted_non_empty_except_sign",
keys=keys,
sign_input=sign_input,
excluded_keys=excluded,
note="TreeMap key order, non-empty values, no separators",
)
def build_dfp_sign_material(kind: str, params: Mapping[str, str]) -> DfpSignMaterial:
if kind in {"gdfp_report", "unified_log_report"}:
return legacy_product_ts_sv_payload(params)
if kind == "unified_id_mapping":
return id_mapping_data_only(params)
if kind in {"unified_fetch", "unified_repair", "unified_check_repair"}:
return tree_values_sorted_non_empty_except_sign(params)
raise ValueError(f"unsupported DFP sign kind: {kind}")
def dfp_atlas_sign(
sign_input: str | bytes | bytearray,
counter: int,
unix_time: int,
*,
session_seed: int,
sdk_id: str = DFP_SDK_ID,
) -> str:
"""Generate the 64hex DFP 10405 atlasSign value."""
return atlas_sign(
sign_input,
sdk_id,
counter,
unix_time,
session_seed=session_seed,
)
def sign_dfp_form(
kind: str,
params: Mapping[str, str],
counter: int,
unix_time: int,
*,
session_seed: int,
sdk_id: str = DFP_SDK_ID,
) -> dict[str, str]:
"""Return a signed copy of a DFP/unifiedId form."""
material = build_dfp_sign_material(kind, params)
signed = {str(key): str(value) for key, value in params.items()}
signed["sign"] = dfp_atlas_sign(
material.sign_input,
counter,
unix_time,
session_seed=session_seed,
sdk_id=sdk_id,
)
return signed
def parse_dfp_atlas_sign(sign_hex64: str, sdk_id: str = DFP_SDK_ID) -> dict:
digest_hex = atlas_sign_to_digest_hex(sign_hex64, sdk_id)
parsed = kwsg_10418_digest24_unmix(digest_hex)
parsed["digest24_hex"] = digest_hex
parsed["sdk_id"] = sdk_id
parsed["head8"] = ZT_OUTER_CONFIGS[sdk_id]["head8"].hex()
return parsed
__all__ = [
"DFP_PAYLOAD_KEYS",
"DFP_SDK_ID",
"DFP_SIGN_KEYS",
"DfpSignMaterial",
"build_dfp_sign_material",
"dfp_atlas_sign",
"id_mapping_data_only",
"legacy_product_ts_sv_payload",
"parse_dfp_atlas_sign",
"sign_dfp_form",
"tree_values_sorted_non_empty_except_sign",
]

127
core/dfp_sq0.py Normal file
View File

@ -0,0 +1,127 @@
from __future__ import annotations
from typing import Any
LITE_TAGS = {
"k5": 5,
"k14": 14,
"k22": 22,
"k23": 23,
"k27": 27,
"k29": 29,
"k31": 31,
"k34": 34,
"k35": 35,
"k36": 36,
"k39": 39,
"k40": 40,
"k46": 46,
"k57": 57,
"k61": 61,
"k64": 64,
"k66": 66,
"k68": 68,
"k83": 83,
"k86": 86,
"k93": 93,
"k97": 97,
"k101": 101,
"k102": 102,
"k105": 105,
"k106": 106,
"k107": 107,
"k108": 108,
"k109": 109,
"k110": 110,
"k111": 111,
"k112": 112,
"k113": 113,
}
FULL_TAGS = {f"k{index}": index for index in range(1, 120)}
def _varint(value: int) -> bytes:
if value < 0:
raise ValueError("varint value must be >= 0")
out = bytearray()
while value >= 0x80:
out.append((value & 0x7F) | 0x80)
value >>= 7
out.append(value)
return bytes(out)
def _read_varint(raw: bytes, offset: int) -> tuple[int, int]:
shift = 0
value = 0
while True:
if offset >= len(raw):
raise ValueError("truncated varint")
byte = raw[offset]
offset += 1
value |= (byte & 0x7F) << shift
if byte < 0x80:
return value, offset
shift += 7
if shift > 63:
raise ValueError("varint too long")
def _tags_for_mode(mode: str) -> dict[str, int]:
if mode == "lite":
return LITE_TAGS
if mode == "full":
return FULL_TAGS
raise ValueError(f"unsupported sq0 mode: {mode}")
def encode_sq0_device_info(values: dict[str, str], mode: str) -> bytes:
tags = _tags_for_mode(mode)
encoded = bytearray()
for key, value in values.items():
if key not in tags:
raise KeyError(key)
text = "" if value is None else str(value)
if text == "":
continue
payload = text.encode("utf-8")
encoded += _varint((tags[key] << 3) | 2)
encoded += _varint(len(payload))
encoded += payload
return bytes(encoded)
def decode_sq0_string_fields(raw: bytes) -> list[dict[str, Any]]:
fields: list[dict[str, Any]] = []
offset = 0
while offset < len(raw):
key, offset = _read_varint(raw, offset)
proto_tag = key >> 3
wire_type = key & 7
if wire_type != 2:
raise ValueError(f"unsupported wire type: {wire_type}")
size, offset = _read_varint(raw, offset)
end = offset + size
if end > len(raw):
raise ValueError("truncated length-delimited field")
payload = raw[offset:end]
offset = end
fields.append(
{
"proto_tag": proto_tag,
"wire_type": wire_type,
"value": payload.decode("utf-8", errors="replace"),
"raw_hex": payload.hex(),
}
)
return fields
__all__ = [
"FULL_TAGS",
"LITE_TAGS",
"decode_sq0_string_fields",
"encode_sq0_device_info",
]

237
core/enc_data.py Normal file
View File

@ -0,0 +1,237 @@
"""KWSG 10400 `encData` and ZT envelope helpers."""
from __future__ import annotations
from pathlib import Path
from struct import unpack_from
import time
import zlib
KWSG_STATIC_AES_KEY = b"5FDxA9ngHE723pYw"
KWSG_STATIC_AES_IV = b"12345678kuaishou"
ZT_OUTER_CONFIGS = {
"95147564-9763-4413-a937-6f0e3c12caf1": {
"head8": bytes.fromhex("5a54eecde4d4ea61"),
"xor_key": b"M70gN2gdHXA34uIc",
},
"bbd910da-fda5-49e7-8667-f57200dac474": {
"head8": bytes.fromhex("5a54eecdf04c4dad"),
"xor_key": b"lpYKvL0Mz9bEtHXO",
},
"7e46b28a-8c93-4940-8238-4c60e64e3c81": {
"head8": bytes.fromhex("5a54ebcd594b0dae"),
"xor_key": b"fGqSL6alaNcUyV9W",
},
"5bbcf3cd-727b-48ab-b4b4-5f01e61ee9a5": {
"head8": bytes.fromhex("5a54eecdd3ce5ab6"),
"xor_key": b"lealm6bxeMABH3rQ",
},
}
KWSG_266FC_PERM = (
0, 5, 10, 15,
4, 9, 14, 3,
8, 13, 2, 7,
12, 1, 6, 11,
)
KWSG_10400_T1_LEN = 0x24000
KWSG_10400_T2_LEN = 0xA100
KWSG_10400_NONCE_XOR = 0xDDCC0DEF
KWSG_10400_DEFAULT_CFG9 = bytes.fromhex("00cf07009d9ec1b102")
def load_kwsg_10400_tables(
t1_path: str | Path = "bin/kwsg_10400_T1.bin",
t2_path: str | Path = "bin/kwsg_10400_T2.bin",
) -> tuple[bytes, bytes]:
"""加载 `0x266fc` 所需运行期 T1/T2 表。"""
t1 = Path(t1_path).read_bytes()
t2 = Path(t2_path).read_bytes()
if len(t1) < KWSG_10400_T1_LEN:
raise ValueError(f"T1 too short: {len(t1)}")
if len(t2) < KWSG_10400_T2_LEN:
raise ValueError(f"T2 too short: {len(t2)}")
return t1, t2
def _u32le(buf: bytes, off: int) -> int:
return unpack_from("<I", buf, off)[0]
def _kwsg_266fc_permute(state: bytearray) -> bytearray:
return bytearray(state[i] for i in KWSG_266FC_PERM)
def _kwsg_266fc_lane_core(state: bytearray, t1: bytes, round_base: int, lane: int) -> None:
p = lane * 4
table_base = round_base + lane * 0x1000
a = _u32le(t1, table_base + 0x000 + state[p + 0] * 4)
b = _u32le(t1, table_base + 0x400 + state[p + 1] * 4)
c = _u32le(t1, table_base + 0x800 + state[p + 2] * 4)
d = _u32le(t1, table_base + 0xC00 + state[p + 3] * 4)
w = a ^ b ^ c ^ d
state[p + 0] = (w >> 24) & 0xFF
state[p + 1] = (w >> 16) & 0xFF
state[p + 2] = (w >> 8) & 0xFF
state[p + 3] = w & 0xFF
def kwsg_266fc_block(block16: bytes, t1: bytes, t2: bytes) -> bytes:
"""移植 `libkwsgmain.so+0x266fc` 的 16-byte block transform。"""
if len(block16) != 16:
raise ValueError("block16 must be exactly 16 bytes")
state = bytearray(block16)
for round_idx in range(9):
state = _kwsg_266fc_permute(state)
round_base = round_idx * 0x4000
for lane in range(4):
_kwsg_266fc_lane_core(state, t1, round_base, lane)
state = _kwsg_266fc_permute(state)
for i in range(16):
state[i] = t2[0x9000 + i * 0x100 + state[i]]
return bytes(state)
def kwsg_10400_ecb_encrypt(payload: bytes, t1: bytes, t2: bytes) -> bytes:
"""移植 `0x27534` 包装层的 ECB-like + PKCS#7 加密输出。"""
pad = 16 - (len(payload) % 16)
padded = payload + bytes([pad]) * pad
return b"".join(
kwsg_266fc_block(padded[i:i + 16], t1, t2)
for i in range(0, len(padded), 16)
)
def kwsg_10400_nonce9(epoch_seconds: int | None = None) -> bytes:
"""生成 `0x11b08` 当前实测分支的 9-byte nonce 字段。"""
if epoch_seconds is None:
epoch_seconds = int(time.time())
value = (int(epoch_seconds) & 0xFFFFFFFF) ^ KWSG_10400_NONCE_XOR
return str(value).encode("ascii")[:9]
def zt_outer_wrap(inner: bytes, head8: bytes, xor_key: bytes) -> bytes:
"""`sub_0x122e4 mode 1`: `head8 + repeating_xor(inner)`."""
if len(head8) != 8:
raise ValueError("head8 must be 8 bytes")
if len(xor_key) != 16:
raise ValueError("xor_key must be 16 bytes")
body = bytes(b ^ xor_key[i & 0x0F] for i, b in enumerate(inner))
return head8 + body
def zt_outer_unwrap(raw: bytes, xor_key: bytes) -> bytes:
"""反解 `sub_0x122e4 mode 1` 外层 body返回 inner ZT 数据。"""
if len(raw) < 8:
raise ValueError("raw too short")
if len(xor_key) != 16:
raise ValueError("xor_key must be 16 bytes")
body = raw[8:]
return bytes(b ^ xor_key[i & 0x0F] for i, b in enumerate(body))
def build_inner_zt_header(nonce9: bytes, cfg9: bytes, payload: bytes) -> bytes:
"""构造 `sub_0x11b08` 的 0x20-byte inner header。"""
if len(nonce9) != 9:
raise ValueError("nonce9 must be 9 bytes")
if len(cfg9) != 9:
raise ValueError("cfg9 must be 9 bytes")
return (
bytes.fromhex("dec0adde")
+ (0x20).to_bytes(2, "little")
+ nonce9
+ cfg9
+ (zlib.crc32(payload) & 0xFFFFFFFF).to_bytes(4, "little")
+ len(payload).to_bytes(4, "little")
)
def parse_inner_zt_header(inner: bytes) -> dict:
"""解析反 XOR 后的 inner ZT header。"""
if len(inner) < 0x20:
raise ValueError("inner too short")
return {
"magic": inner[:4],
"header_size": int.from_bytes(inner[4:6], "little"),
"nonce9": inner[6:15],
"cfg9": inner[15:24],
"crc32": int.from_bytes(inner[24:28], "little"),
"payload_len": int.from_bytes(inner[28:32], "little"),
"payload": inner[32:],
}
def derive_outer_xor_key(raw: bytes, inner_payload_first16: bytes) -> bytes:
"""由最终 raw 和 inner payload 前 16 字节反推 16-byte XOR key。"""
if len(raw) < 8 + 0x20 + 16:
raise ValueError("raw too short")
if len(inner_payload_first16) < 16:
raise ValueError("need 16 bytes of inner payload")
return bytes(raw[8 + 0x20 + i] ^ inner_payload_first16[i] for i in range(16))
def kwsg_10400_raw_with_inner_fields(
payload: bytes,
sdk_id: str,
nonce9: bytes,
cfg9: bytes,
t1: bytes,
t2: bytes,
) -> bytes:
"""生成当前已验证 `10400` raw 输出。"""
cfg = ZT_OUTER_CONFIGS[sdk_id]
encrypted = kwsg_10400_ecb_encrypt(payload, t1, t2)
inner = build_inner_zt_header(nonce9, cfg9, encrypted) + encrypted
return zt_outer_wrap(inner, cfg["head8"], cfg["xor_key"])
def kwsg_10400_raw(
payload: bytes,
sdk_id: str,
t1: bytes,
t2: bytes,
*,
epoch_seconds: int | None = None,
cfg9: bytes = KWSG_10400_DEFAULT_CFG9,
) -> bytes:
"""用当前已还原字段生成 `10400` raw。"""
return kwsg_10400_raw_with_inner_fields(
payload,
sdk_id,
kwsg_10400_nonce9(epoch_seconds),
cfg9,
t1,
t2,
)
__all__ = [
"KWSG_10400_DEFAULT_CFG9",
"KWSG_10400_NONCE_XOR",
"KWSG_10400_T1_LEN",
"KWSG_10400_T2_LEN",
"KWSG_266FC_PERM",
"KWSG_STATIC_AES_IV",
"KWSG_STATIC_AES_KEY",
"ZT_OUTER_CONFIGS",
"build_inner_zt_header",
"derive_outer_xor_key",
"kwsg_10400_ecb_encrypt",
"kwsg_10400_nonce9",
"kwsg_10400_raw",
"kwsg_10400_raw_with_inner_fields",
"kwsg_266fc_block",
"load_kwsg_10400_tables",
"parse_inner_zt_header",
"zt_outer_unwrap",
"zt_outer_wrap",
]

342
core/fap_request.py Normal file
View File

@ -0,0 +1,342 @@
"""纯 Python 复现 ``gdfp.gifshow.com/f/a/p`` 设备指纹上报端点。
该端点不是 ``passport_account_image`` 的生成源登录票据由客户端
``Engine.pr`` 本地生成本模块仅保留设备遥测协议研究和响应提取工具
静态定论``out/jadx/sources/com/kuaishou/weapon/ks/``
签名 ``h1.a(map)`` / ``h1.d(ctx)``
``sign = md5_hex(appkey + secretkey + timestamp)``
query = ``appkey=X&secretkey=Y&timestamp=Z&sign=S``
appkey/secretkey ``t.a(ctx)`` / ``h1.a(ctx)``
优先读 ``wcfg````z0.i()````"appkey-secretkey"`` 拆分为空回退硬编码
默认值 ``appkey="16"````secretkey="62c80c436b7547a68a12774c67519836"``
URL ``r1.a(payload, ctx)``
``{base}/f/a/p?{query}````x0.d`` = ``Base64("L2YvYS9w")`` = ``"/f/a/p"``
Body ``r1.a`` 三参路径注册上传 ``VIMG_`` 前缀
``{"data": "VIMG_" + WeaponHI.b(payload)}``
其中 ``payload`` JSON 字符串 ``build_default_payload``
响应 ``i.a`` 返回 JSON 字符串尚无证据表明响应会签发登录使用的
``VIMG_<base64>$AI_<32hex>``递归提取函数只用于兼容历史样本分析
两个设备遥测字段仍待确认代码内以 ``TODO(U1/U2)`` 标注
U1
payload 明文结构``build_default_payload`` 的字段集/取值候选来自
``t.a()`` / ``h1.b()``需对照抓到的请求 body 校验
U2
历史响应中若存在 ``a_y_q_z``确认其字段路径和业务用途
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import time
import urllib.parse
from dataclasses import dataclass
from typing import Any, Callable, Mapping
from .privacykit_encrypt import (
PASSPORT_ACCOUNT_IMAGE_AI_MARKER,
PASSPORT_ACCOUNT_IMAGE_PREFIX,
weaponhi_vimg_upload,
)
# ---------------------------------------------------------------------------
# 配置(可经环境变量覆盖,与 core/sms_login.py / core/dfp_client.py 风格一致)
# ---------------------------------------------------------------------------
DEFAULT_FAP_HOST = "https://gdfp.gifshow.com"
FAP_PATH = "/f/a/p"
# h1.java:52 硬编码默认值t.a() 返回空时 APP 自身回退用)。
DEFAULT_APPKEY = "16"
DEFAULT_SECRETKEY = "62c80c436b7547a68a12774c67519836"
WEAPON_SDK_VERSION = "7.2.1"
# a_y_q_z 票据形态VIMG_<base64>$AI_<32hex>。递归匹配兜底用。
_TICKET_RE = re.compile(
re.escape(PASSPORT_ACCOUNT_IMAGE_PREFIX)
+ r"[A-Za-z0-9+/]+={0,2}"
+ re.escape(PASSPORT_ACCOUNT_IMAGE_AI_MARKER)
+ r"[0-9a-f]{32}"
)
def _fap_host() -> str:
return os.environ.get("KS_FAP_HOST", DEFAULT_FAP_HOST).rstrip("/")
def _appkey() -> str:
return os.environ.get("KS_FAP_APPKEY", DEFAULT_APPKEY)
def _secretkey() -> str:
return os.environ.get("KS_FAP_SECRETKEY", DEFAULT_SECRETKEY)
# ---------------------------------------------------------------------------
# 签名 / queryh1.java纯 MD5与 atlasSign / kws 无关)
# ---------------------------------------------------------------------------
def build_sign(appkey: str, secretkey: str, timestamp: int) -> str:
"""``md5_hex(appkey + secretkey + timestamp)``。
对照 ``v0.a(String)`` = ``MessageDigest("MD5").digest(str.getBytes())``
"""
raw = f"{appkey}{secretkey}{timestamp}".encode("utf-8")
return hashlib.md5(raw).hexdigest()
def build_query(appkey: str, secretkey: str, timestamp: int) -> str:
"""``appkey=X&secretkey=Y&timestamp=Z&sign=S````h1.d`` 顺序)。"""
sign = build_sign(appkey, secretkey, timestamp)
# 显式保序,避免依赖 dict/HashMap 迭代顺序。
pairs = [
("appkey", appkey),
("secretkey", secretkey),
("timestamp", str(timestamp)),
("sign", sign),
]
return urllib.parse.urlencode(pairs)
def build_fap_url(timestamp: int | None = None, *, host: str | None = None) -> str:
ts = int(timestamp if timestamp is not None else time.time())
host = host or _fap_host()
return f"{host}{FAP_PATH}?{build_query(_appkey(), _secretkey(), ts)}"
# ---------------------------------------------------------------------------
# Body{"data": "VIMG_" + WeaponHI.b(payload_json)}r1.a 三参路径)
# ---------------------------------------------------------------------------
def build_fap_body(payload: Mapping[str, Any] | str) -> bytes:
"""构造 ``{"data": "VIMG_" + WeaponHI.b(payload_json)}``。
``payload`` 可传 dict自动 ``json.dumps````separators=(',', ':')`` 紧凑
或已序列化的字符串返回 UTF-8 JSON bytes
"""
if isinstance(payload, str):
payload_str = payload
else:
# WeaponHI.b(str) 上游拿的是 str.getBytes()JSON 紧凑序列化对齐 Java 侧 JSONObject。
payload_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
vimg = weaponhi_vimg_upload(payload_str)
return json.dumps({"data": vimg}, separators=(",", ":")).encode("utf-8")
# ---------------------------------------------------------------------------
# payload 明文U1 候选,需抓证校验)
# ---------------------------------------------------------------------------
def build_default_payload(device_info: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""构造 /f/a/p payload 候选结构。
候选字段来自 ``t.a()``userId/platform/channel/mod/globalId/sysver/
rdid/did_tag/cdid_tag ``h1.b(ctx)``k/hp/hv/pver/platform/device_id/
sdkver/piv/sysver/mod的并集值从 ``device_info``通常取自
``out/app_login_fields_latest.json`` query_params 快照映射
# TODO(U1):抓证后用真实请求 body 校验字段集/取值/嵌套结构,回填此函数。
"""
info = device_info or {}
get = lambda key, default="": str(info.get(key, default) or default or "")
return {
# t.a() 侧
"userId": "",
"platform": get("kpf", "ANDROID_PHONE"),
"channel": get("c", get("oc", "")),
"mod": get("mod", ""),
"globalId": "",
"sysver": get("sys", ""),
"rdid": get("rdid", ""),
"did_tag": get("did_tag", ""),
"cdid_tag": get("cdid_tag", ""),
# h1.b(ctx) 侧
"k": "",
"hp": get("kpn", "com.kuaishou.nebula"),
"hv": get("appver", ""),
"pver": "0.0.0",
"platform_h1": 1,
"device_id": get("did", get("oDid", "")),
"sdkver": WEAPON_SDK_VERSION,
}
# ---------------------------------------------------------------------------
# 响应解析U2需抓证定位精确字段路径
# ---------------------------------------------------------------------------
def _iter_str_values(node: Any):
"""递归遍历解析后的 JSON产出所有字符串值。"""
if isinstance(node, str):
yield node
elif isinstance(node, Mapping):
for v in node.values():
yield from _iter_str_values(v)
elif isinstance(node, (list, tuple)):
for v in node:
yield from _iter_str_values(v)
def extract_a_y_q_z(response: Any) -> str | None:
"""从 /f/a/p 响应里提取 ``a_y_q_z`` 完整票据。
# TODO(U2):抓证后改为精确字段路径(如 ``response["a_y_q_z"]``)。
当前用递归匹配 ``VIMG_...$AI_...`` 形态兜底能覆盖顶层/嵌套两种情况
"""
if response is None:
return None
if isinstance(response, str):
# 响应是裸字符串时直接匹配;否则尝试 JSON 解析后递归。
m = _TICKET_RE.search(response)
if m:
return m.group(0)
try:
response = json.loads(response)
except Exception:
return None
for value in _iter_str_values(response):
m = _TICKET_RE.search(value)
if m:
return m.group(0)
return None
# ---------------------------------------------------------------------------
# HTTP参考 core/dfp_client.post_request 的 requests 范式)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FapResponse:
ok: bool
status_code: int
data: Any
text: str
error: str = ""
def _default_post() -> Callable[..., Any]:
import requests
return requests.post
def call_fap(
payload: Mapping[str, Any] | str,
*,
timestamp: int | None = None,
host: str | None = None,
headers: Mapping[str, str] | None = None,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
) -> FapResponse:
"""POST ``/f/a/p`` 并返回解析后的响应。
headers 可传 k1 c/d``t.b()`` 设备串 / ``t.b(ctx)`` 加密设备头
默认不送若服务端校验 header 则从抓证固化后传入
"""
post = post_func or _default_post()
ts = int(timestamp if timestamp is not None else time.time())
url = build_fap_url(ts, host=host)
body = build_fap_body(payload)
hdrs = {"Content-Type": "application/json; charset=utf-8"}
if headers:
hdrs.update(dict(headers))
try:
resp = post(url, data=body, headers=hdrs, timeout=timeout)
except Exception as exc:
return FapResponse(ok=False, status_code=0, data=None, text=str(exc), error=exc.__class__.__name__)
text = getattr(resp, "text", "")
try:
data = resp.json()
except Exception:
try:
data = json.loads(text)
except Exception:
data = text
return FapResponse(
ok=bool(getattr(resp, "ok", False)),
status_code=int(getattr(resp, "status_code", 0)),
data=data,
text=text,
)
# ---------------------------------------------------------------------------
# 顶层入口
# ---------------------------------------------------------------------------
def fetch_fresh_a_y_q_z(
device_info: Mapping[str, Any] | None = None,
*,
payload: Mapping[str, Any] | str | None = None,
host: str | None = None,
headers: Mapping[str, str] | None = None,
timeout: int = 20,
post_func: Callable[..., Any] | None = None,
) -> str | None:
"""取新鲜 ``a_y_q_z`` 完整票据(``VIMG_...$AI_...``)。
默认用 ``build_default_payload(device_info)`` payload调用方可直接传
``payload``dict 或已序列化字符串覆盖失败返回 None由调用方决定回退
"""
if payload is None:
payload = build_default_payload(device_info)
resp = call_fap(
payload,
host=host,
headers=headers,
timeout=timeout,
post_func=post_func,
)
if not resp.ok:
return None
return extract_a_y_q_z(resp.data)
__all__ = [
"DEFAULT_APPKEY",
"DEFAULT_FAP_HOST",
"DEFAULT_SECRETKEY",
"FAP_PATH",
"FapResponse",
"WEAPON_SDK_VERSION",
"build_default_payload",
"build_fap_body",
"build_fap_url",
"build_query",
"build_sign",
"call_fap",
"extract_a_y_q_z",
"fetch_fresh_a_y_q_z",
]

274
core/h5_jsbridge.py Normal file
View File

@ -0,0 +1,274 @@
"""Nebula H5 `$encode` signInput builder and local JS-VM bridge.
前端真实逻辑在 `main-CZ3ZSK5w.js`
1. cookie 只取白名单设备字段
2. 加入 `sigCatVer=1` 和接口 query/body
3. `key=value` 字符串字典序拼接
4. KsGuard/Yoda VM `$encode` 生成 68hex `__NS_sig3`
这里保留稳定的 signInput 组装规则和本地 VM runner fallback正常主流程
已经在 `core.h5_sig3` 里用纯 Python 复现 `$encode` 的摘要字段
"""
from __future__ import annotations
import json
import os
import queue
import subprocess
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any
H5_COOKIE_KEYS = (
"kpn",
"kpf",
"userId",
"did",
"c",
"appver",
"language",
"mod",
"did_tag",
"egid",
"oDid",
"androidApiLevel",
"newOc",
"browseType",
"socName",
"ftt",
"abi",
"userRecoBit",
"device_abi",
"grant_browse_type",
"iuid",
"rdid",
)
DEFAULT_H5_ENCODE_RUNNER = Path("core/h5_vendor_encode.mjs")
DEFAULT_H5_ENCODE_SERVER = Path("core/h5_vendor_encode_server.mjs")
@dataclass(frozen=True)
class H5EncodeResult:
sign_input: str
sig3: str
c_info: Any = None
class H5JsBridgeEncoder:
"""长驻 Node `$encode` 进程,避免每次签名重复加载 VM chunk。"""
def __init__(
self,
server: str | Path = DEFAULT_H5_ENCODE_SERVER,
node_bin: str | None = None,
timeout: int = 30,
) -> None:
self.server = Path(server)
self.node = node_bin or os.environ.get("NODE_BIN") or "node"
self.timeout = timeout
self._proc: subprocess.Popen[str] | None = None
self._stdout_queue: queue.Queue[str] = queue.Queue()
self._lock = threading.Lock()
self._request_id = 0
def _start(self) -> None:
if self._proc is not None and self._proc.poll() is None:
return
if not self.server.exists():
raise FileNotFoundError(f"H5 encode server not found: {self.server}")
self._stdout_queue = queue.Queue()
self._proc = subprocess.Popen(
[self.node, str(self.server)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
encoding="utf-8",
bufsize=1,
)
def read_stdout() -> None:
assert self._proc is not None
assert self._proc.stdout is not None
for line in self._proc.stdout:
self._stdout_queue.put(line)
thread = threading.Thread(target=read_stdout, name="H5JsBridgeEncoderStdout", daemon=True)
thread.start()
def close(self) -> None:
proc = self._proc
self._proc = None
if proc is None:
return
try:
if proc.stdin:
proc.stdin.close()
except Exception:
pass
try:
if proc.stdout:
proc.stdout.close()
except Exception:
pass
try:
proc.wait(timeout=1)
except Exception:
try:
proc.kill()
except Exception:
pass
def encode(self, sign_input: str) -> H5EncodeResult:
with self._lock:
self._start()
assert self._proc is not None
assert self._proc.stdin is not None
self._request_id += 1
request_id = self._request_id
payload = json.dumps(
{"id": request_id, "signInput": sign_input},
ensure_ascii=False,
separators=(",", ":"),
)
try:
self._proc.stdin.write(payload + "\n")
self._proc.stdin.flush()
except Exception:
self.close()
raise
while True:
try:
line = self._stdout_queue.get(timeout=self.timeout)
except queue.Empty as exc:
self.close()
raise TimeoutError("H5 encode server timed out") from exc
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
if data.get("id") not in {request_id, None}:
continue
if not data.get("ok", True):
raise RuntimeError(f"H5 encode server failed: {data.get('error')}")
sig3 = str(data.get("result") or "")
if len(sig3) != 68:
raise RuntimeError(f"H5 encode server returned invalid sig3: {sig3!r}")
return H5EncodeResult(sign_input=sign_input, sig3=sig3, c_info=data.get("cInfo"))
def _js_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def _h5_io(params: dict[str, Any]) -> str:
parts: list[str] = []
for key, value in params.items():
if key.startswith("__NS"):
continue
if isinstance(value, (dict, list)):
value = ""
parts.append(f"{key}={value}")
return "".join(sorted(parts))
def build_h5_sign_input(
cookie: dict[str, str],
query: dict[str, Any] | None = None,
body: str | dict[str, Any] | list[Any] | None = None,
method: str = "GET",
request_type: str = "json",
) -> str:
"""按前端 `Mc()/io()` 规则构造 `$encode` 的 secPlain。"""
selected = {key: cookie[key] for key in H5_COOKIE_KEYS if cookie.get(key)}
params: dict[str, Any] = {"sigCatVer": 1, **selected, **(query or {})}
method_lower = method.lower()
request_type_lower = request_type.lower()
if request_type_lower == "json":
if method_lower in {"get", "options", "head"}:
if isinstance(body, dict):
params.update(body)
return _h5_io(params)
if body is None:
body_text = ""
elif isinstance(body, str):
body_text = body
else:
body_text = _js_json(body)
return _h5_io(params) + body_text
if request_type_lower == "form" and isinstance(body, dict):
params.update(body)
return _h5_io(params)
def encode_h5_sig3_with_runner(
sign_input: str,
runner: str | Path = DEFAULT_H5_ENCODE_RUNNER,
node_bin: str | None = None,
timeout: int = 30,
) -> H5EncodeResult:
"""调用本地 JS-VM runner 生成 H5 68hex `__NS_sig3`。"""
runner_path = Path(runner)
if not runner_path.exists():
raise FileNotFoundError(f"H5 encode runner not found: {runner_path}")
node = node_bin or os.environ.get("NODE_BIN") or "node"
proc = subprocess.run(
[node, str(runner_path), "-"],
input=sign_input,
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
if proc.returncode != 0:
detail = (proc.stderr or proc.stdout or "").strip()
raise RuntimeError(f"H5 encode runner failed: {detail}")
data = json.loads(proc.stdout)
sig3 = str(data.get("result") or "")
if len(sig3) != 68:
raise RuntimeError(f"H5 encode runner returned invalid sig3: {sig3!r}")
return H5EncodeResult(sign_input=sign_input, sig3=sig3, c_info=data.get("cInfo"))
def h5_sig3_for_request(
cookie: dict[str, str],
query: dict[str, Any] | None = None,
body: str | dict[str, Any] | list[Any] | None = None,
method: str = "GET",
request_type: str = "json",
runner: str | Path = DEFAULT_H5_ENCODE_RUNNER,
node_bin: str | None = None,
timeout: int = 30,
encoder: H5JsBridgeEncoder | None = None,
) -> H5EncodeResult:
sign_input = build_h5_sign_input(
cookie,
query=query,
body=body,
method=method,
request_type=request_type,
)
if encoder is not None:
return encoder.encode(sign_input)
return encode_h5_sig3_with_runner(sign_input, runner=runner, node_bin=node_bin, timeout=timeout)
__all__ = [
"DEFAULT_H5_ENCODE_RUNNER",
"DEFAULT_H5_ENCODE_SERVER",
"H5_COOKIE_KEYS",
"H5EncodeResult",
"H5JsBridgeEncoder",
"build_h5_sign_input",
"encode_h5_sig3_with_runner",
"h5_sig3_for_request",
]

356
core/h5_kws.py Normal file
View File

@ -0,0 +1,356 @@
"""Pure-Python KWS/WebWeapon fallback ticket generator.
The Nebula H5 bundle initializes WebWeapon roughly as:
- product cookie: ``kwpsecproductname=kuaishou-growth``;
- fallback fingerprint: AES-CBC(``...|nonce8``, key=iv ``K8wm...``)
wrapped as ``K + b64[:4] + W + b64[4:-2] + F + b64[-2:]``;
- fallback signature token: random 64-char ``kwssectoken`` plus
AES-CBC(``...|kwssectoken[:8]``, key=iv ``H4t...``) wrapped with ``S``.
When the online Web DFP config succeeds, APP may replace these with
server-provided ``secToken`` + sign-script output. This module implements the
local fallback path so a fresh ``ksck`` does not need stale copied KWS cookies.
"""
from __future__ import annotations
import base64
import json
import os
import re
import secrets
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from random import Random
from typing import Any, Mapping, Protocol
from urllib.parse import quote
from core.h5_kww_alg import kwf_aes_cbc_decrypt, kwf_aes_cbc_encrypt
KWS_PRODUCT_NAME = "kuaishou-growth"
KWS_FINGERPRINT_KEY = "K8wm5PvY9nX7qJc2"
KWS_TOKEN_KEY = "H4tL6rNd3vB9xM5k"
KWS_CONFIG_KEY = "webweaponconfigs"
KWS_CONFIG_URL = "https://gdfp.gifshow.com/s/w/c"
KWS_RANDOM_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
KWS_URL_SLICE_LEN = 80
KWS_SCRIPT_CODE_RE = re.compile(r"^[0-9a-z]{64}$")
DEFAULT_KWS_SIGN_RUNNER = Path(__file__).with_name("h5_kws_sign.mjs")
DEFAULT_KWS_SIGN_SCRIPT = Path(__file__).with_name("kws-11-0.0.1-obfuscated.5e0a90af726d8a7e.js")
class _RandLike(Protocol):
def randrange(self, stop: int) -> int: ...
@dataclass(frozen=True)
class H5KwsTicket:
kwpsecproductname: str
kwssectoken: str
kwscode: str
kwfv1: str
kww: str
fingerprint_plain: str
token_plain: str
def cookie_fields(self, *, include_fingerprint: bool = True) -> dict[str, str]:
fields = {
"kwpsecproductname": self.kwpsecproductname,
"kwssectoken": self.kwssectoken,
"kwscode": self.kwscode,
}
if include_fingerprint:
fields["kwfv1"] = self.kwfv1
return fields
@dataclass(frozen=True)
class H5KwsConfigRequest:
product_name: str
ts: int
did: str
plain: str
data: str
url: str = KWS_CONFIG_URL
def body(self) -> dict[str, str]:
return {"data": self.data}
def _now_ms() -> int:
return int(time.time() * 1000)
def _random_text(length: int, rng: _RandLike | None = None) -> str:
alphabet = KWS_RANDOM_ALPHABET
if rng is None:
return "".join(secrets.choice(alphabet) for _ in range(length))
return "".join(alphabet[rng.randrange(len(alphabet))] for _ in range(length))
def _encode_uri(value: str) -> str:
"""Match JavaScript ``encodeURI`` for the URL prefix used by WebWeapon."""
return quote(str(value), safe=";/?:@&=+$,#-_.!~*'()")
def _encrypt_webweapon_b64(plain: str, key_text: str) -> str:
key = key_text.encode("utf-8")
encrypted = kwf_aes_cbc_encrypt(plain.encode("utf-8"), key=key, iv=key)
return base64.b64encode(encrypted).decode("ascii")
def webweapon_aes_encrypt_b64(
plain: str,
key_text: str = KWS_CONFIG_KEY,
) -> str:
"""AES-CBC/PKCS7 + Base64 used by WebWeapon config payloads."""
return _encrypt_webweapon_b64(str(plain), key_text)
def webweapon_aes_decrypt_b64(
encrypted_b64: str,
key_text: str = KWS_CONFIG_KEY,
) -> str:
"""Decrypt WebWeapon AES-CBC/PKCS7 Base64 text."""
key = key_text.encode("utf-8")
try:
encrypted = base64.b64decode(encrypted_b64, validate=True)
plain = kwf_aes_cbc_decrypt(encrypted, key=key, iv=key)
except Exception as exc:
raise ValueError("invalid WebWeapon AES payload") from exc
return plain.decode("utf-8")
def _wrap_webweapon_value(encrypted_b64: str, marker: str) -> str:
return f"K{encrypted_b64[:4]}W{encrypted_b64[4:-2]}{marker}{encrypted_b64[-2:]}"
def is_h5_kws_script_code(value: str) -> bool:
"""Validate the 64-char code emitted by the KWS ``signUrl`` script."""
return bool(KWS_SCRIPT_CODE_RE.fullmatch(str(value or "")))
def _short_runner_error(stdout: str, stderr: str) -> str:
detail = (stderr or stdout or "").strip()
if len(detail) <= 500:
return detail
return detail[:240] + "...(truncated)..." + detail[-240:]
def run_h5_kws_sign_script(
*,
script_path: str | Path | None = None,
runner: str | Path = DEFAULT_KWS_SIGN_RUNNER,
node_bin: str | None = None,
timeout: int = 10,
) -> str:
"""Run the local KWS ``signUrl`` VM script and return ``kwscode``.
This is the script-equivalent path for the online WebWeapon config mode:
``/s/w/c`` gives ``secToken`` + ``signUrl``; loading that sign script calls
``window.kwscb(code)``. The Node runner supplies a deterministic browser
fixture and captures the callback result.
"""
runner_path = Path(runner)
script = Path(script_path) if script_path is not None else DEFAULT_KWS_SIGN_SCRIPT
if not runner_path.exists():
raise FileNotFoundError(f"KWS sign runner not found: {runner_path}")
if not script.exists():
raise FileNotFoundError(f"KWS sign script not found: {script}")
node = node_bin or os.environ.get("NODE_BIN") or "node"
proc = subprocess.run(
[node, str(runner_path), str(script)],
text=True,
encoding="utf-8",
capture_output=True,
timeout=timeout,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(f"KWS sign runner failed: {_short_runner_error(proc.stdout, proc.stderr)}")
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"KWS sign runner returned invalid JSON: {_short_runner_error(proc.stdout, proc.stderr)}") from exc
if not data.get("ok"):
raise RuntimeError(f"KWS sign runner failed: {data.get('error')}")
kwscode = str(data.get("kwscode") or "")
if not is_h5_kws_script_code(kwscode):
raise RuntimeError(f"KWS sign runner returned invalid kwscode length/shape: {kwscode!r}")
return kwscode
def build_h5_kws_script_ticket(
*,
sec_token: str,
kwscode: str | None = None,
product_name: str = KWS_PRODUCT_NAME,
script_path: str | Path | None = None,
runner: str | Path = DEFAULT_KWS_SIGN_RUNNER,
node_bin: str | None = None,
timeout: int = 10,
) -> dict[str, str]:
"""Build KWS cookie fields from server ``secToken`` and sign script code."""
token = str(sec_token or "")
if not token:
raise ValueError("sec_token must be non-empty")
code = kwscode
if not code:
script = Path(script_path) if script_path is not None else DEFAULT_KWS_SIGN_SCRIPT
try:
from core.h5_kws_vm import kwscode_from_known_h5_kws_script
code = kwscode_from_known_h5_kws_script(script)
except Exception:
code = None
if not code:
code = run_h5_kws_sign_script(
script_path=script_path,
runner=runner,
node_bin=node_bin,
timeout=timeout,
)
if not is_h5_kws_script_code(code):
raise ValueError("kwscode must be a 64-char lowercase alnum KWS script code")
return {
"kwpsecproductname": product_name,
"kwssectoken": token,
"kwscode": code,
}
def build_h5_kws_default_ticket(
*,
url: str,
did: str,
product_name: str = KWS_PRODUCT_NAME,
now_ms: int | None = None,
fingerprint_nonce: str | None = None,
sec_token: str | None = None,
rng: Random | None = None,
) -> H5KwsTicket:
"""Build WebWeapon ``getDefaultData(true)`` ticket fields.
``fingerprint_nonce`` corresponds to the random 8-char suffix used for the
fallback ``kwfv1``. ``sec_token`` corresponds to the 64-char
``kwssectoken``; its first 8 chars are folded into ``kwscode``.
"""
ts = _now_ms() if now_ms is None else int(now_ms)
fp_nonce = fingerprint_nonce if fingerprint_nonce is not None else _random_text(8, rng)
if len(fp_nonce) != 8:
raise ValueError("fingerprint_nonce must be 8 characters")
token = sec_token if sec_token is not None else _random_text(64, rng)
if len(token) != 64:
raise ValueError("sec_token must be 64 characters")
url_prefix = _encode_uri(str(url)[:KWS_URL_SLICE_LEN])
common = f"{url_prefix}|{did}|{product_name}|{ts}|"
fingerprint_plain = common + fp_nonce
token_plain = common + token[:8]
kwfv1 = _wrap_webweapon_value(
_encrypt_webweapon_b64(fingerprint_plain, KWS_FINGERPRINT_KEY),
"F",
)
kwscode = _wrap_webweapon_value(
_encrypt_webweapon_b64(token_plain, KWS_TOKEN_KEY),
"S",
)
return H5KwsTicket(
kwpsecproductname=product_name,
kwssectoken=token,
kwscode=kwscode,
kwfv1=kwfv1,
kww=kwfv1,
fingerprint_plain=fingerprint_plain,
token_plain=token_plain,
)
def build_h5_kws_config_request_data(
*,
did: str,
product_name: str = KWS_PRODUCT_NAME,
ts_ms: int | None = None,
) -> H5KwsConfigRequest:
"""Build the encrypted ``/s/w/c`` config POST body.
WebWeapon sends compact JSON in this exact key order:
``productName`` ``ts`` ``did``. The JSON string is encrypted with
AES-CBC where key and IV are both ``webweaponconfigs``.
"""
ts = _now_ms() if ts_ms is None else int(ts_ms)
payload = {
"productName": product_name,
"ts": ts,
"did": str(did),
}
plain = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return H5KwsConfigRequest(
product_name=product_name,
ts=ts,
did=str(did),
plain=plain,
data=webweapon_aes_encrypt_b64(plain, KWS_CONFIG_KEY),
)
def decrypt_h5_kws_config_response_data_rsp(data_rsp: str) -> dict[str, Any]:
"""Decrypt the ``dataRsp`` field returned by ``/s/w/c``."""
plain = webweapon_aes_decrypt_b64(data_rsp, KWS_CONFIG_KEY)
try:
decoded = json.loads(plain)
except json.JSONDecodeError as exc:
raise ValueError("WebWeapon config response is not JSON") from exc
if not isinstance(decoded, dict):
raise ValueError("WebWeapon config response must be a JSON object")
return decoded
def decrypt_h5_kws_config_response(response: Mapping[str, Any]) -> dict[str, Any]:
"""Decrypt a full ``/s/w/c`` JSON response object containing ``dataRsp``."""
data_rsp = response.get("dataRsp")
if not isinstance(data_rsp, str) or not data_rsp:
raise ValueError("WebWeapon config response is missing dataRsp")
return decrypt_h5_kws_config_response_data_rsp(data_rsp)
__all__ = [
"DEFAULT_KWS_SIGN_RUNNER",
"DEFAULT_KWS_SIGN_SCRIPT",
"H5KwsTicket",
"H5KwsConfigRequest",
"KWS_CONFIG_KEY",
"KWS_CONFIG_URL",
"KWS_FINGERPRINT_KEY",
"KWS_PRODUCT_NAME",
"KWS_RANDOM_ALPHABET",
"KWS_SCRIPT_CODE_RE",
"KWS_TOKEN_KEY",
"KWS_URL_SLICE_LEN",
"build_h5_kws_script_ticket",
"build_h5_kws_config_request_data",
"build_h5_kws_default_ticket",
"decrypt_h5_kws_config_response",
"decrypt_h5_kws_config_response_data_rsp",
"is_h5_kws_script_code",
"run_h5_kws_sign_script",
"webweapon_aes_decrypt_b64",
"webweapon_aes_encrypt_b64",
]

307
core/h5_kws_sign.mjs Normal file
View File

@ -0,0 +1,307 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import vm from "node:vm";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_KWS_FILE = path.join(
__dirname,
"kws-11-0.0.1-obfuscated.5e0a90af726d8a7e.js",
);
const MAX_EVENTS = Number(process.env.KS_KWS_MAX_EVENTS || 240);
function sha16(value) {
return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 16);
}
function preview(value) {
try {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 120)}...(len=${value.length})` : value;
}
if (typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "function") return `[function ${value.name || "anonymous"}]`;
if (Array.isArray(value)) return `[array len=${value.length}]`;
return `[object ${Object.keys(value).slice(0, 8).join(",")}]`;
} catch {
return "[unpreviewable]";
}
}
function buildContext() {
const events = [];
const record = (type, detail = {}) => {
if (events.length >= MAX_EVENTS) return;
events.push({ type, ...detail });
};
const mockCache = new Map();
function mock(name = "mock") {
if (mockCache.has(name)) return mockCache.get(name);
const fn = function (...args) {
record("call", { path: name, args: args.map(preview) });
return mock(`${name}()`);
};
const proxy = new Proxy(fn, {
get(_target, prop) {
if (prop === Symbol.toPrimitive) return () => "";
if (prop === "toString") return () => `[mock ${name}]`;
if (prop === "valueOf") return () => 0;
if (prop === "then") return undefined;
return mock(`${name}.${String(prop)}`);
},
set(_target, prop, value) {
record("mock-set", { path: `${name}.${String(prop)}`, value: preview(value) });
return true;
},
apply(_target, _thisArg, args) {
record("apply", { path: name, args: args.map(preview) });
return mock(`${name}()`);
},
construct(_target, args) {
record("new", { path: name, args: args.map(preview) });
return mock(`new ${name}`);
},
has() {
return true;
},
});
mockCache.set(name, proxy);
return proxy;
}
const storage = new Map();
const localStorage = {
getItem(key) {
const text = String(key);
const value = storage.get(text) || null;
record("localStorage.getItem", { key: text, value: preview(value) });
return value;
},
setItem(key, value) {
const text = String(key);
storage.set(text, String(value));
record("localStorage.setItem", { key: text, value: preview(String(value)) });
},
removeItem(key) {
storage.delete(String(key));
record("localStorage.removeItem", { key: String(key) });
},
clear() {
storage.clear();
record("localStorage.clear");
},
};
class FakeXMLHttpRequest {
open(method, url) {
this.method = method;
this.url = url;
record("xhr.open", { method: preview(method), url: preview(url) });
}
setRequestHeader(key, value) {
record("xhr.setRequestHeader", { key: preview(key), value: preview(value) });
}
send(body) {
record("xhr.send", { url: preview(this.url), body: preview(body) });
this.readyState = 4;
this.status = 200;
this.responseText = "{}";
if (typeof this.onreadystatechange === "function") this.onreadystatechange();
if (typeof this.onload === "function") this.onload();
}
addEventListener(name, cb) {
record("xhr.addEventListener", { name: preview(name), cb: preview(cb) });
}
}
let kwscode = "";
const rawWindow = {};
const windowProxy = new Proxy(rawWindow, {
get(target, prop) {
if (prop in target) return target[prop];
const key = String(prop);
const value = mock(`window.${key}`);
target[prop] = value;
record("window.get-unknown", { key });
return value;
},
set(target, prop, value) {
target[prop] = value;
record("window.set", { key: String(prop), value: preview(value) });
return true;
},
has() {
return true;
},
});
Object.assign(rawWindow, {
window: windowProxy,
self: windowProxy,
top: windowProxy,
parent: windowProxy,
globalThis: windowProxy,
Object,
Array,
String,
Function,
Number,
Boolean,
Date,
Math,
RegExp,
Error,
TypeError,
Promise,
JSON,
Infinity,
undefined,
Uint8Array,
parseInt,
parseFloat,
isNaN,
escape,
encodeURI,
encodeURIComponent,
decodeURI,
decodeURIComponent,
Window: function Window() {},
Navigator: function Navigator() {},
Location: function Location() {},
kwscb(value) {
kwscode = String(value || "");
record("kwscb", { length: kwscode.length, sha16: sha16(kwscode) });
},
location: {
href: "https://nebula.kuaishou.com/nebula/task/earning?layoutType=4&source=bottom_guide_first",
origin: "https://nebula.kuaishou.com",
protocol: "https:",
host: "nebula.kuaishou.com",
hostname: "nebula.kuaishou.com",
pathname: "/nebula/task/earning",
search: "?layoutType=4&source=bottom_guide_first",
},
document: {
cookie: "kpn=NEBULA; kpf=ANDROID_PHONE; userId=0; did=ANDROID_FAKE; kuaishou.h5_st=FAKE",
referrer: "",
createElement(tag) {
record("document.createElement", { tag: preview(tag) });
return mock(`element.${tag}`);
},
getElementsByTagName(tag) {
record("document.getElementsByTagName", { tag: preview(tag) });
return [mock(`tag.${tag}`)];
},
addEventListener(name, cb) {
record("document.addEventListener", { name: preview(name), cb: preview(cb) });
},
body: mock("document.body"),
head: mock("document.head"),
documentElement: mock("document.documentElement"),
},
navigator: {
userAgent:
"Mozilla/5.0 (Linux; Android 16; PJZ110 Build/BP2A.250605.015; wv) AppleWebKit/537.36",
language: "zh-CN",
languages: ["zh-CN", "zh"],
platform: "Linux armv8l",
webdriver: false,
},
screen: { width: 1080, height: 2376, colorDepth: 24, pixelDepth: 24 },
localStorage,
sessionStorage: localStorage,
performance: { now: () => 1234.5, timing: {}, getEntriesByType: () => [] },
crypto: {
getRandomValues(arr) {
for (let i = 0; i < arr.length; i += 1) arr[i] = (i * 17 + 3) & 0xff;
return arr;
},
},
Image: function Image() {
return mock("new Image");
},
XMLHttpRequest: FakeXMLHttpRequest,
fetch(url, init) {
record("fetch", { url: preview(url), init: preview(init) });
return Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve("{}"),
json: () => Promise.resolve({}),
});
},
setTimeout(cb) {
record("setTimeout", { cb: preview(cb) });
if (typeof cb === "function") cb();
return 1;
},
clearTimeout() {},
setInterval(cb) {
record("setInterval", { cb: preview(cb) });
return 1;
},
clearInterval() {},
console: {
log: (...args) => record("console.log", { args: args.map(preview) }),
warn: (...args) => record("console.warn", { args: args.map(preview) }),
error: (...args) => record("console.error", { args: args.map(preview) }),
},
});
const context = vm.createContext(windowProxy, {
name: "h5-kws-sign",
codeGeneration: { strings: true, wasm: false },
});
return {
context,
events,
rawWindow,
getKwscode: () => kwscode,
};
}
export function runKwsSignScript(scriptPath = DEFAULT_KWS_FILE, timeoutMs = 5000) {
const { context, events, rawWindow, getKwscode } = buildContext();
const code = fs.readFileSync(scriptPath, "utf8");
const beforeKeys = new Set(Object.keys(rawWindow));
vm.runInContext(code, context, { filename: scriptPath, timeout: timeoutMs });
const kwscode = getKwscode();
if (!kwscode) {
throw new Error("KWS script did not call kwscb()");
}
const eventTypeCounts = {};
for (const event of events) eventTypeCounts[event.type] = (eventTypeCounts[event.type] || 0) + 1;
return {
ok: true,
kwscode,
length: kwscode.length,
sha16: sha16(kwscode),
newWindowKeys: Object.keys(rawWindow).filter((key) => !beforeKeys.has(key)),
eventTypeCounts,
};
}
async function main() {
const scriptPath = process.argv[2] || DEFAULT_KWS_FILE;
const timeoutMs = Number(process.env.KS_KWS_VM_TIMEOUT_MS || 5000);
try {
const result = runKwsSignScript(scriptPath, timeoutMs);
console.log(JSON.stringify(result));
} catch (err) {
const message = String(err && err.stack ? err.stack : err);
console.log(JSON.stringify({ ok: false, error: message.split(/\r?\n/).slice(0, 12).join("\n") }));
process.exitCode = 1;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

507
core/h5_kws_vm.py Normal file
View File

@ -0,0 +1,507 @@
"""Static container parser for the KWS/Jimbei sign script.
`kws-11-*.js` is wrapped as a Jimbei/Sabo VM:
```
Jimbei()(window, {"b": "<base64 bytecode>", "d": [constants...]});
```
The full interpreter is still JavaScript, but the container is stable enough
to parse in pure Python. This module extracts bytecode/constants metadata and
provides a pure static answer for the currently bundled sign script.
"""
from __future__ import annotations
import base64
import hashlib
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
H5_KWS_KNOWN_SCRIPT_CODE = "04dd303d63222chdfec0087d058fb1c4c5f2eb16eefgce98b2a503578e5f8433"
H5_KWS_KNOWN_SCRIPT_SHA256 = "d944b2bc3754bec85c0a238fb052859295a3ecb0756789c47d99417d3f957615"
H5_KWS_KNOWN_BYTECODE_SHA256 = "7668fe4c01dc4721a862b3102cd3c183dbd4721cac6af4129fc2adcdd1d701d8"
H5_KWS_KNOWN_CONSTANTS_SHA256 = "226815ab9e5d21ce285afa698b618be3ff5122cf13880c846600b6e7d1396afe"
_KNOWN_CODE_BY_SCRIPT_SHA256 = {
H5_KWS_KNOWN_SCRIPT_SHA256: H5_KWS_KNOWN_SCRIPT_CODE,
}
_KNOWN_CODE_BY_VM_SHA256 = {
(H5_KWS_KNOWN_BYTECODE_SHA256, H5_KWS_KNOWN_CONSTANTS_SHA256): H5_KWS_KNOWN_SCRIPT_CODE,
}
@dataclass(frozen=True)
class H5KwsFunctionRange:
instruction_index: int
start_const_index: int
end_const_index: int
start: int
end: int
@property
def length(self) -> int:
return self.end - self.start + 1
@dataclass(frozen=True)
class H5KwsVmSummary:
script_sha256: str
bytecode_sha256: str
constants_sha256: str
instruction_count: int
constant_count: int
min_opcode: int
max_opcode: int
opcode_histogram: dict[int, int]
function_ranges: list[H5KwsFunctionRange]
@dataclass(frozen=True)
class H5KwsOpcodeHandler:
index: int
present: bool
label: str
used_count: int
body_sha16: str
body_preview: str
@dataclass(frozen=True)
class H5KwsInstruction:
index: int
opcode: int
label: str
p0: int
p1: int
p2: int
p3: int
operand_a: str
operand_b: str
def to_text(self) -> str:
return (
f"{self.index:04d}: op{self.opcode:02d} {self.label:<22} "
f"{self.operand_a:<22} {self.operand_b}"
)
@dataclass(frozen=True)
class H5KwsFunctionAnalysis:
ordinal: int
name: str
create_index: int
assigned_scope: int | None
start: int
end: int
length: int
call_apply_count: int
branch_targets: list[int]
opcode_histogram: dict[int, int]
_OPCODE_LABELS = {
0: "construct_new",
1: "bit_and",
2: "shift_left",
3: "pre_dec_assign",
4: "throw_stack",
5: "less_equal",
6: "bit_or",
7: "pop_saved_result_to_r4",
8: "add",
9: "return_value",
10: "bit_xor",
11: "noop",
12: "make_function",
13: "in_operator",
14: "bit_not",
15: "hole_unused",
16: "push_result_save",
17: "enter_closure_scope",
18: "jump",
19: "post_inc_assign",
20: "not_strict_equal",
21: "typeof",
22: "not_equal",
23: "shift_right",
24: "call_apply",
25: "make_reference",
26: "leave_scope",
27: "pre_inc_assign",
28: "push_r0",
29: "multiply",
30: "return_undefined",
31: "modulo",
32: "noop",
33: "try_catch_finally",
34: "declare_undefined",
35: "divide",
36: "subtract",
37: "object_literal",
38: "logical_not",
39: "store_global_object",
40: "strict_equal",
41: "greater_equal",
42: "instanceof",
43: "unary_minus",
44: "jump_if_false",
45: "stack_length_to_r3",
46: "load_value",
47: "logical_and",
48: "delete_property",
49: "array_from_stack",
50: "peek_saved_result_to_r4",
51: "shift_unsigned_right",
52: "post_dec_assign",
53: "noop",
54: "pop_stack_to_r1",
55: "peek_stack_to_r0",
56: "jump_sentinel",
57: "logical_or",
58: "unary_plus",
59: "greater_than",
60: "equal",
61: "jump_if_true",
62: "load_global_store",
63: "debugger",
64: "less_than",
65: "assign_reference",
66: "enter_null_scope",
}
def _find_matching_js_bracket(text: str, open_index: int) -> int:
pairs = {"[": "]", "{": "}", "(": ")"}
opener = text[open_index]
closer = pairs[opener]
depth = 1
quote: str | None = None
escaped = False
for index in range(open_index + 1, len(text)):
ch = text[index]
if quote:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == quote:
quote = None
continue
if ch in {"'", '"', "`"}:
quote = ch
elif ch == opener:
depth += 1
elif ch == closer:
depth -= 1
if depth == 0:
return index
raise ValueError("matching JavaScript bracket not found")
def _split_top_level_js_array(array_text: str) -> list[str]:
parts: list[str] = []
start = 0
depth = 0
quote: str | None = None
escaped = False
for index, ch in enumerate(array_text):
if quote:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == quote:
quote = None
continue
if ch in {"'", '"', "`"}:
quote = ch
elif ch in "[{(":
depth += 1
elif ch in "]})":
depth -= 1
elif ch == "," and depth == 0:
parts.append(array_text[start:index].strip())
start = index + 1
parts.append(array_text[start:].strip())
return parts
def _extract_js_array_literal(script_text: str, var_name: str) -> list[str]:
marker = f"var {var_name} = ["
marker_pos = script_text.find(marker)
if marker_pos < 0:
raise ValueError(f"{var_name} array not found")
open_index = script_text.find("[", marker_pos + len(f"var {var_name} = "))
close_index = _find_matching_js_bracket(script_text, open_index)
return _split_top_level_js_array(script_text[open_index + 1 : close_index])
def _extract_jimbei_container(script_text: str) -> dict[str, Any]:
marker = "Jimbei()(window,"
marker_pos = script_text.find(marker)
if marker_pos < 0:
raise ValueError("KWS Jimbei invocation not found")
object_start = script_text.find("{", marker_pos + len(marker))
if object_start < 0:
raise ValueError("KWS Jimbei container object not found")
container, _end = json.JSONDecoder().raw_decode(script_text[object_start:])
if not isinstance(container, dict):
raise ValueError("KWS Jimbei container must be a JSON object")
if not isinstance(container.get("b"), str) or not isinstance(container.get("d"), list):
raise ValueError("KWS Jimbei container must contain b:string and d:list")
return container
def _decode_bytecode_values(encoded: str) -> list[int]:
# Jimbei's loader applies Base64 -> UTF-8 string -> charCodeAt(char) - 1.
decoded_text = base64.b64decode(encoded, validate=True).decode("utf-8")
values = [ord(ch) - 1 for ch in decoded_text]
if len(values) % 5 != 0:
raise ValueError("KWS bytecode value count must be divisible by 5")
return values
def _instruction_rows(values: list[int]) -> list[list[int]]:
return [values[i : i + 5] for i in range(0, len(values), 5)]
def _bytecode_hash(values: list[int]) -> str:
# Keep the historical corpus hash stable: the VM bytes are stored as
# integer cells; cells above 255 are represented by their low byte here.
return hashlib.sha256(bytes((value & 0xFF) for value in values)).hexdigest()
def _constants_hash(constants: list[Any]) -> str:
text = json.dumps(constants, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _constant_int(constants: list[Any], source_type: int, const_index: int) -> int | None:
if source_type != 6 or const_index < 0 or const_index >= len(constants):
return None
value = constants[const_index]
return value if isinstance(value, int) else None
def _format_operand(constants: list[Any], source_type: int, operand_index: int) -> str:
if source_type == 0:
return f"unused({operand_index})"
if source_type == 1:
return f"reg[{operand_index}]"
if source_type == 2:
return f"arg[{operand_index}]"
if source_type == 3:
return f"scope[{operand_index}]"
if source_type == 4:
name = constants[operand_index] if 0 <= operand_index < len(constants) else None
return f"window_const[{operand_index}]={name!r}"
if source_type == 5:
return f"this[{operand_index}]"
if source_type == 6:
value = constants[operand_index] if 0 <= operand_index < len(constants) else None
return f"const[{operand_index}]={value!r}"
if source_type == 7:
return f"callctx[{operand_index}]"
if source_type == 8:
return f"global_store[{operand_index}]"
return f"src{source_type}[{operand_index}]"
def _extract_function_ranges(rows: list[list[int]], constants: list[Any]) -> list[H5KwsFunctionRange]:
ranges: list[H5KwsFunctionRange] = []
for instruction_index, row in enumerate(rows):
opcode, p0, p1, p2, p3 = row
if opcode != 12:
continue
start = _constant_int(constants, p0, p1)
end = _constant_int(constants, p2, p3)
if start is None or end is None:
continue
if 0 <= start <= end < len(rows):
ranges.append(
H5KwsFunctionRange(
instruction_index=instruction_index,
start_const_index=p1,
end_const_index=p3,
start=start,
end=end,
)
)
return ranges
def _assigned_scope_after_create(rows: list[list[int]], create_index: int) -> int | None:
next_index = create_index + 1
if next_index >= len(rows):
return None
opcode, p0, p1, p2, _p3 = rows[next_index]
# make_function leaves the new function in VM reg0. A following
# assign_reference(scope[x], reg0) means the function is named by scope[x].
if opcode == 65 and p0 == 3 and p2 == 1:
return p1
return None
def _function_name(start: int, end: int, assigned_scope: int | None) -> str:
if start == 3063 and end == 4407 and assigned_scope == 80:
return "scope80_main_orchestrator"
if start == 4662 and end == 4663:
return "inline_return_undefined_stub"
if start == 4664 and end == 4675:
return "inline_call_scope107_with_arg0"
if assigned_scope is not None:
return f"scope{assigned_scope}_fn_{start}_{end}"
return f"inline_fn_{start}_{end}"
def _branch_target(constants: list[Any], row: list[int]) -> int | None:
opcode, p0, p1, _p2, _p3 = row
if opcode not in {18, 44, 61}:
return None
target = constants[p1] if p0 == 6 and 0 <= p1 < len(constants) else p1
return target if isinstance(target, int) else None
def parse_h5_kws_vm_script(script_path: str | Path) -> H5KwsVmSummary:
script = Path(script_path).read_text(encoding="utf-8")
container = _extract_jimbei_container(script)
constants = container["d"]
values = _decode_bytecode_values(container["b"])
rows = _instruction_rows(values)
opcodes = [row[0] for row in rows]
histogram = dict(sorted(Counter(opcodes).items()))
return H5KwsVmSummary(
script_sha256=hashlib.sha256(script.encode("utf-8")).hexdigest(),
bytecode_sha256=_bytecode_hash(values),
constants_sha256=_constants_hash(constants),
instruction_count=len(rows),
constant_count=len(constants),
min_opcode=min(opcodes),
max_opcode=max(opcodes),
opcode_histogram=histogram,
function_ranges=_extract_function_ranges(rows, constants),
)
def analyze_h5_kws_function_ranges(script_path: str | Path) -> list[H5KwsFunctionAnalysis]:
"""Summarize Jimbei function ranges with names, branches and call counts."""
script = Path(script_path).read_text(encoding="utf-8")
container = _extract_jimbei_container(script)
constants = container["d"]
rows = _instruction_rows(_decode_bytecode_values(container["b"]))
ranges = _extract_function_ranges(rows, constants)
analyses: list[H5KwsFunctionAnalysis] = []
for ordinal, function_range in enumerate(ranges):
body_rows = rows[function_range.start : function_range.end + 1]
histogram = dict(sorted(Counter(row[0] for row in body_rows).items()))
branch_targets = [
target
for row in body_rows
if (target := _branch_target(constants, row)) is not None
]
assigned_scope = _assigned_scope_after_create(rows, function_range.instruction_index)
analyses.append(
H5KwsFunctionAnalysis(
ordinal=ordinal,
name=_function_name(function_range.start, function_range.end, assigned_scope),
create_index=function_range.instruction_index,
assigned_scope=assigned_scope,
start=function_range.start,
end=function_range.end,
length=function_range.length,
call_apply_count=histogram.get(24, 0),
branch_targets=branch_targets,
opcode_histogram=histogram,
)
)
return analyses
def extract_h5_kws_opcode_handlers(script_path: str | Path) -> list[H5KwsOpcodeHandler]:
"""Extract and label the Jimbei opcode handler array from the script."""
script = Path(script_path).read_text(encoding="utf-8")
summary = parse_h5_kws_vm_script(script_path)
parts = _extract_js_array_literal(script, "_sabo_57b82")
handlers: list[H5KwsOpcodeHandler] = []
for index, body in enumerate(parts):
present = bool(body)
body_sha16 = hashlib.sha256(body.encode("utf-8")).hexdigest()[:16] if present else ""
preview = " ".join(body.split())[:240] if present else ""
handlers.append(
H5KwsOpcodeHandler(
index=index,
present=present,
label=_OPCODE_LABELS.get(index, "unknown"),
used_count=summary.opcode_histogram.get(index, 0),
body_sha16=body_sha16,
body_preview=preview,
)
)
return handlers
def disassemble_h5_kws_range(
script_path: str | Path,
start: int,
end: int,
) -> list[H5KwsInstruction]:
"""Disassemble a bytecode row range with opcode labels and operands."""
if start < 0 or end < start:
raise ValueError("invalid KWS bytecode range")
script = Path(script_path).read_text(encoding="utf-8")
container = _extract_jimbei_container(script)
constants = container["d"]
rows = _instruction_rows(_decode_bytecode_values(container["b"]))
if end >= len(rows):
raise ValueError("KWS bytecode range exceeds instruction count")
instructions: list[H5KwsInstruction] = []
for index in range(start, end + 1):
opcode, p0, p1, p2, p3 = rows[index]
instructions.append(
H5KwsInstruction(
index=index,
opcode=opcode,
label=_OPCODE_LABELS.get(opcode, "unknown"),
p0=p0,
p1=p1,
p2=p2,
p3=p3,
operand_a=_format_operand(constants, p0, p1),
operand_b=_format_operand(constants, p2, p3),
)
)
return instructions
def kwscode_from_known_h5_kws_script(script_path: str | Path) -> str | None:
summary = parse_h5_kws_vm_script(script_path)
return _KNOWN_CODE_BY_SCRIPT_SHA256.get(summary.script_sha256) or _KNOWN_CODE_BY_VM_SHA256.get(
(summary.bytecode_sha256, summary.constants_sha256)
)
__all__ = [
"H5_KWS_KNOWN_BYTECODE_SHA256",
"H5_KWS_KNOWN_CONSTANTS_SHA256",
"H5_KWS_KNOWN_SCRIPT_CODE",
"H5_KWS_KNOWN_SCRIPT_SHA256",
"H5KwsFunctionRange",
"H5KwsFunctionAnalysis",
"H5KwsInstruction",
"H5KwsOpcodeHandler",
"H5KwsVmSummary",
"analyze_h5_kws_function_ranges",
"disassemble_h5_kws_range",
"extract_h5_kws_opcode_handlers",
"kwscode_from_known_h5_kws_script",
"parse_h5_kws_vm_script",
]

212
core/h5_kww.py Normal file
View File

@ -0,0 +1,212 @@
"""Nebula H5 `kww` header generator backed by the KWF WebView VM.
KWF runtime behavior confirmed from APP WebView:
- `kwf-0.0.2` installs `window.kwpsec.getData`;
- `getData()` returns the 174-char `PnGU...` value used as H5 request header
`kww`;
- each call updates `localStorage.kwfcv1` and `localStorage.kwfv1`.
The active `PnGU...` branch is implemented in pure Python. The Node VM bridge
is kept as a cross-check/fallback for future KWF branch changes.
"""
from __future__ import annotations
import json
import os
import queue
import subprocess
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from core.h5_kww_alg import kwf_generate_kww
DEFAULT_H5_KWW_SERVER = Path("core/h5_kww_server.mjs")
@dataclass(frozen=True)
class H5KwwResult:
kww: str
kwfcv1: str = ""
kwfv1: str = ""
class H5KwwGenerator:
"""长驻 Node/KWF 进程,保持 localStorage 计数状态。"""
def __init__(
self,
server: str | Path = DEFAULT_H5_KWW_SERVER,
node_bin: str | None = None,
timeout: int = 30,
) -> None:
self.server = Path(server)
self.node = node_bin or os.environ.get("NODE_BIN") or "node"
self.timeout = timeout
self._proc: subprocess.Popen[str] | None = None
self._stdout_queue: queue.Queue[str] = queue.Queue()
self._lock = threading.Lock()
self._request_id = 0
def _start(self) -> None:
if self._proc is not None and self._proc.poll() is None:
return
if not self.server.exists():
raise FileNotFoundError(f"H5 kww server not found: {self.server}")
self._stdout_queue = queue.Queue()
self._proc = subprocess.Popen(
[self.node, str(self.server)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
encoding="utf-8",
bufsize=1,
)
def read_stdout() -> None:
assert self._proc is not None
assert self._proc.stdout is not None
for line in self._proc.stdout:
self._stdout_queue.put(line)
thread = threading.Thread(target=read_stdout, name="H5KwwGeneratorStdout", daemon=True)
thread.start()
def close(self) -> None:
proc = self._proc
self._proc = None
if proc is None:
return
try:
if proc.stdin:
proc.stdin.close()
except Exception:
pass
try:
if proc.stdout:
proc.stdout.close()
except Exception:
pass
try:
proc.wait(timeout=1)
except Exception:
try:
proc.kill()
except Exception:
pass
def get(
self,
*,
url: str = "",
method: str = "GET",
headers: dict[str, str] | None = None,
body: str = "",
cookie: str = "",
) -> H5KwwResult:
with self._lock:
self._start()
assert self._proc is not None
assert self._proc.stdin is not None
self._request_id += 1
request_id = self._request_id
payload = json.dumps(
{
"id": request_id,
"url": url,
"method": method,
"headers": headers or {},
"body": body,
"cookie": cookie,
},
ensure_ascii=False,
separators=(",", ":"),
)
try:
self._proc.stdin.write(payload + "\n")
self._proc.stdin.flush()
except Exception:
self.close()
raise
while True:
try:
line = self._stdout_queue.get(timeout=self.timeout)
except queue.Empty as exc:
self.close()
raise TimeoutError("H5 kww server timed out") from exc
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
if data.get("id") not in {request_id, None}:
continue
if not data.get("ok", True):
raise RuntimeError(f"H5 kww server failed: {data.get('error')}")
kww = str(data.get("kww") or "")
if not _looks_like_kww(kww):
raise RuntimeError(f"H5 kww server returned invalid kww: {kww!r}")
return H5KwwResult(
kww=kww,
kwfcv1=str(data.get("kwfcv1") or ""),
kwfv1=str(data.get("kwfv1") or ""),
)
class PureH5KwwGenerator:
"""Pure-Python KWF `PnGU...` generator for the current Nebula branch."""
def __init__(
self,
*,
start_collect_count: int = 1,
now_ms: int | None = None,
language: str = "zh-CN",
) -> None:
self.collect_count = start_collect_count
self.now_ms = now_ms
self.language = language
self._lock = threading.Lock()
def close(self) -> None:
return
def get(
self,
*,
url: str = "",
method: str = "GET",
headers: dict[str, str] | None = None,
body: str = "",
cookie: str = "",
) -> H5KwwResult:
del url, method, headers, body, cookie
with self._lock:
current = self.collect_count
now_ms = self.now_ms if self.now_ms is not None else int(time.time() * 1000)
kww = kwf_generate_kww(
collect_count=current,
now_ms=now_ms,
language=self.language,
)
self.collect_count += 1
return H5KwwResult(kww=kww, kwfcv1=str(self.collect_count), kwfv1=kww)
def _looks_like_kww(value: str) -> bool:
if len(value) < 120:
return False
return all(ch.isalnum() or ch in "+/=" for ch in value)
__all__ = [
"DEFAULT_H5_KWW_SERVER",
"H5KwwGenerator",
"H5KwwResult",
"PureH5KwwGenerator",
]

608
core/h5_kww_alg.py Normal file
View File

@ -0,0 +1,608 @@
"""Pure-Python pieces of the KWF `kwpsec.getData` algorithm.
This module is intentionally incremental. The first closed slice is the VM
function at 9756..9942, which converts a JS string into a byte-string using
UTF-16 code units and a classic UTF-8-like encoder.
"""
from __future__ import annotations
import math
from collections.abc import Mapping
from typing import Any
KWF_FINGERPRINT_KEYS = tuple(f"k{idx}" for idx in range(1, 15))
KWF_BASE64_ALPHABET = (
"ZmserbBoHQtNP+wOcza/LpngG8yJq42KWYj0DSfdikx3VT16IlUAFM97hECvuRX5"
)
KWF_AES_IV = b"mhaqhnjmr0rsoo3o"
KWF_OBFUSCATED_KEY_GRID = (
(-2002111551, 1077744408, -672376612, 419994569),
(528185349, -425267957, 826979799, -102918898),
(-1176467946, -1009525813, -1747891260, -5343550),
(1903666895, 811678054, 1999858998, 1902537836),
(845440362, 1915501381, 85939827, 1954101791),
(1176330101, -723278305, -771952532, -1517958541),
(-476539642, -1765160690, 1194670434, -491719919),
(19424791, -1426441870, -305201136, 260043521),
(240620822, -506429735, 203390665, 56659400),
(221714654, 1534944014, 1466047943, 1409454095),
(1496680657, -1917231675, -623165438, -1898328051),
)
KWF_AES_SEED = 14
_AES_SBOX = (
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
)
_AES_RCON = (0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36)
_AES_INV_SBOX = (
0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38, 0xBF, 0x40, 0xA3, 0x9E, 0x81, 0xF3, 0xD7, 0xFB,
0x7C, 0xE3, 0x39, 0x82, 0x9B, 0x2F, 0xFF, 0x87, 0x34, 0x8E, 0x43, 0x44, 0xC4, 0xDE, 0xE9, 0xCB,
0x54, 0x7B, 0x94, 0x32, 0xA6, 0xC2, 0x23, 0x3D, 0xEE, 0x4C, 0x95, 0x0B, 0x42, 0xFA, 0xC3, 0x4E,
0x08, 0x2E, 0xA1, 0x66, 0x28, 0xD9, 0x24, 0xB2, 0x76, 0x5B, 0xA2, 0x49, 0x6D, 0x8B, 0xD1, 0x25,
0x72, 0xF8, 0xF6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xD4, 0xA4, 0x5C, 0xCC, 0x5D, 0x65, 0xB6, 0x92,
0x6C, 0x70, 0x48, 0x50, 0xFD, 0xED, 0xB9, 0xDA, 0x5E, 0x15, 0x46, 0x57, 0xA7, 0x8D, 0x9D, 0x84,
0x90, 0xD8, 0xAB, 0x00, 0x8C, 0xBC, 0xD3, 0x0A, 0xF7, 0xE4, 0x58, 0x05, 0xB8, 0xB3, 0x45, 0x06,
0xD0, 0x2C, 0x1E, 0x8F, 0xCA, 0x3F, 0x0F, 0x02, 0xC1, 0xAF, 0xBD, 0x03, 0x01, 0x13, 0x8A, 0x6B,
0x3A, 0x91, 0x11, 0x41, 0x4F, 0x67, 0xDC, 0xEA, 0x97, 0xF2, 0xCF, 0xCE, 0xF0, 0xB4, 0xE6, 0x73,
0x96, 0xAC, 0x74, 0x22, 0xE7, 0xAD, 0x35, 0x85, 0xE2, 0xF9, 0x37, 0xE8, 0x1C, 0x75, 0xDF, 0x6E,
0x47, 0xF1, 0x1A, 0x71, 0x1D, 0x29, 0xC5, 0x89, 0x6F, 0xB7, 0x62, 0x0E, 0xAA, 0x18, 0xBE, 0x1B,
0xFC, 0x56, 0x3E, 0x4B, 0xC6, 0xD2, 0x79, 0x20, 0x9A, 0xDB, 0xC0, 0xFE, 0x78, 0xCD, 0x5A, 0xF4,
0x1F, 0xDD, 0xA8, 0x33, 0x88, 0x07, 0xC7, 0x31, 0xB1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xEC, 0x5F,
0x60, 0x51, 0x7F, 0xA9, 0x19, 0xB5, 0x4A, 0x0D, 0x2D, 0xE5, 0x7A, 0x9F, 0x93, 0xC9, 0x9C, 0xEF,
0xA0, 0xE0, 0x3B, 0x4D, 0xAE, 0x2A, 0xF5, 0xB0, 0xC8, 0xEB, 0xBB, 0x3C, 0x83, 0x53, 0x99, 0x61,
0x17, 0x2B, 0x04, 0x7E, 0xBA, 0x77, 0xD6, 0x26, 0xE1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0C, 0x7D,
)
def js_utf16_code_units(text: str) -> list[int]:
"""Return JavaScript `charCodeAt`-style UTF-16 code units."""
raw = text.encode("utf-16-le", errors="surrogatepass")
return [
raw[idx] | (raw[idx + 1] << 8)
for idx in range(0, len(raw), 2)
]
def kwf_string_encoder_bytes(text: str) -> bytes:
"""Port of KWF VM slice 9756..9942.
The original VM iterates JavaScript `charCodeAt` units. It emits:
- ASCII code units 1..127 as-is;
- code units 128..2047 as two bytes;
- code units above 2047 as three bytes.
Code unit 0 intentionally falls through to the two-byte branch, matching
the VM's `code >= 1 && code <= 127` ASCII condition.
"""
out = bytearray()
for code in js_utf16_code_units(text):
if 1 <= code <= 0x7F:
out.append(code)
elif code > 0x7FF:
out.append(0xE0 | ((code >> 12) & 0x0F))
out.append(0x80 | ((code >> 6) & 0x3F))
out.append(0x80 | (code & 0x3F))
else:
out.append(0xC0 | ((code >> 6) & 0x1F))
out.append(0x80 | (code & 0x3F))
return bytes(out)
def kwf_string_encoder_binary(text: str) -> str:
"""Return the VM's JavaScript byte-string representation."""
return "".join(chr(item) for item in kwf_string_encoder_bytes(text))
def kwf_mixer_permutation(total: int, seed: float) -> list[int]:
"""Port the index shuffle from KWF VM slice 8889..9050.
The VM builds ``Array(total).fill().map((_, index) => index)`` and then
swaps from the end using ``Math.floor((i * this._seed) % (i + 1))``.
"""
if total < 0:
raise ValueError("total must be non-negative")
items = list(range(total))
for idx in range(total - 1, 0, -1):
swap_idx = math.floor((idx * seed) % (idx + 1))
if swap_idx < 0 or swap_idx > idx:
raise ValueError("seed produced an invalid swap index")
items[idx], items[swap_idx] = items[swap_idx], items[idx]
return items
def kwf_mixer_char(key_grid: list[str] | tuple[str, ...], seed: float, row: int, col: int) -> str:
"""Return ``this._Ke[floor(pos / width)][pos % width]`` after shuffle.
``row`` and ``col`` are the original coordinates. The shuffled array maps
original flat indexes to output positions, then the output position selects
the final character from ``_Ke``.
"""
if not key_grid:
raise ValueError("key_grid must not be empty")
width = len(key_grid[0])
if width == 0:
raise ValueError("key_grid rows must not be empty")
if any(len(item) != width for item in key_grid):
raise ValueError("key_grid rows must have the same length")
height = len(key_grid)
if row < 0 or row >= height or col < 0 or col >= width:
raise IndexError("row or col out of range")
target = row * width + col
permutation = kwf_mixer_permutation(height * width, seed)
position = permutation.index(target)
return key_grid[position // width][position % width]
def _kwf_mixer_value(
grid: tuple[tuple[int, ...], ...],
seed: float,
row: int,
col: int,
) -> int:
width = len(grid[0])
target = row * width + col
permutation = kwf_mixer_permutation(len(grid) * width, seed)
position = permutation.index(target)
return grid[position // width][position % width] & 0xFFFFFFFF
def kwf_aes_key_bytes() -> bytes:
"""Recover the fixed AES key hidden behind the `_Ke` mixer."""
words = [
_kwf_mixer_value(KWF_OBFUSCATED_KEY_GRID, KWF_AES_SEED, 0, col)
for col in range(4)
]
return b"".join(word.to_bytes(4, "big") for word in words)
def _aes_xtime(value: int) -> int:
return (((value << 1) ^ 0x1B) & 0xFF) if value & 0x80 else (value << 1) & 0xFF
def _aes_mix_column(col: list[int]) -> None:
total = col[0] ^ col[1] ^ col[2] ^ col[3]
first = col[0]
col[0] ^= total ^ _aes_xtime(col[0] ^ col[1])
col[1] ^= total ^ _aes_xtime(col[1] ^ col[2])
col[2] ^= total ^ _aes_xtime(col[2] ^ col[3])
col[3] ^= total ^ _aes_xtime(col[3] ^ first)
def _aes_expand_key(key: bytes) -> list[list[int]]:
if len(key) != 16:
raise ValueError("AES-128 key must be 16 bytes")
words = [list(key[idx: idx + 4]) for idx in range(0, 16, 4)]
for idx in range(4, 44):
temp = words[idx - 1].copy()
if idx % 4 == 0:
temp = temp[1:] + temp[:1]
temp = [_AES_SBOX[item] for item in temp]
temp[0] ^= _AES_RCON[idx // 4]
words.append([left ^ right for left, right in zip(words[idx - 4], temp)])
return [sum(words[4 * round_idx: 4 * round_idx + 4], []) for round_idx in range(11)]
def _aes_encrypt_block(block: bytes, key: bytes) -> bytes:
if len(block) != 16:
raise ValueError("AES block must be 16 bytes")
state = list(block)
round_keys = _aes_expand_key(key)
def add_round_key(round_idx: int) -> None:
for idx, item in enumerate(round_keys[round_idx]):
state[idx] ^= item
def sub_bytes() -> None:
for idx, item in enumerate(state):
state[idx] = _AES_SBOX[item]
def shift_rows() -> None:
state[1], state[5], state[9], state[13] = state[5], state[9], state[13], state[1]
state[2], state[6], state[10], state[14] = state[10], state[14], state[2], state[6]
state[3], state[7], state[11], state[15] = state[15], state[3], state[7], state[11]
def mix_columns() -> None:
for col_idx in range(4):
start = col_idx * 4
col = state[start: start + 4]
_aes_mix_column(col)
state[start: start + 4] = col
add_round_key(0)
for round_idx in range(1, 10):
sub_bytes()
shift_rows()
mix_columns()
add_round_key(round_idx)
sub_bytes()
shift_rows()
add_round_key(10)
return bytes(state)
def _aes_gmul(left: int, right: int) -> int:
"""Multiply two bytes in AES' GF(2^8)."""
result = 0
for _ in range(8):
if right & 1:
result ^= left
high_bit = left & 0x80
left = (left << 1) & 0xFF
if high_bit:
left ^= 0x1B
right >>= 1
return result
def _aes_inv_mix_column(col: list[int]) -> None:
first, second, third, fourth = col
col[0] = (
_aes_gmul(first, 0x0E)
^ _aes_gmul(second, 0x0B)
^ _aes_gmul(third, 0x0D)
^ _aes_gmul(fourth, 0x09)
)
col[1] = (
_aes_gmul(first, 0x09)
^ _aes_gmul(second, 0x0E)
^ _aes_gmul(third, 0x0B)
^ _aes_gmul(fourth, 0x0D)
)
col[2] = (
_aes_gmul(first, 0x0D)
^ _aes_gmul(second, 0x09)
^ _aes_gmul(third, 0x0E)
^ _aes_gmul(fourth, 0x0B)
)
col[3] = (
_aes_gmul(first, 0x0B)
^ _aes_gmul(second, 0x0D)
^ _aes_gmul(third, 0x09)
^ _aes_gmul(fourth, 0x0E)
)
def _aes_decrypt_block(block: bytes, key: bytes) -> bytes:
if len(block) != 16:
raise ValueError("AES block must be 16 bytes")
state = list(block)
round_keys = _aes_expand_key(key)
def add_round_key(round_idx: int) -> None:
for idx, item in enumerate(round_keys[round_idx]):
state[idx] ^= item
def inv_sub_bytes() -> None:
for idx, item in enumerate(state):
state[idx] = _AES_INV_SBOX[item]
def inv_shift_rows() -> None:
state[1], state[5], state[9], state[13] = state[13], state[1], state[5], state[9]
state[2], state[6], state[10], state[14] = state[10], state[14], state[2], state[6]
state[3], state[7], state[11], state[15] = state[7], state[11], state[15], state[3]
def inv_mix_columns() -> None:
for col_idx in range(4):
start = col_idx * 4
col = state[start: start + 4]
_aes_inv_mix_column(col)
state[start: start + 4] = col
add_round_key(10)
for round_idx in range(9, 0, -1):
inv_shift_rows()
inv_sub_bytes()
add_round_key(round_idx)
inv_mix_columns()
inv_shift_rows()
inv_sub_bytes()
add_round_key(0)
return bytes(state)
def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
pad = block_size - (len(data) % block_size)
return data + bytes([pad]) * pad
def _pkcs7_unpad(data: bytes, block_size: int = 16) -> bytes:
if not data or len(data) % block_size:
raise ValueError("PKCS7 data length must be a positive block multiple")
pad = data[-1]
if pad < 1 or pad > block_size:
raise ValueError("invalid PKCS7 padding length")
if data[-pad:] != bytes([pad]) * pad:
raise ValueError("invalid PKCS7 padding bytes")
return data[:-pad]
def kwf_aes_cbc_encrypt(
data: bytes,
key: bytes | None = None,
iv: bytes = KWF_AES_IV,
) -> bytes:
"""AES-CBC used by the KWF fingerprint tail."""
key = kwf_aes_key_bytes() if key is None else key
if len(iv) != 16:
raise ValueError("AES-CBC IV must be 16 bytes")
padded = _pkcs7_pad(data)
out = bytearray()
previous = iv
for idx in range(0, len(padded), 16):
block = bytes(left ^ right for left, right in zip(padded[idx: idx + 16], previous))
encrypted = _aes_encrypt_block(block, key)
out.extend(encrypted)
previous = encrypted
return bytes(out)
def kwf_aes_cbc_decrypt(
data: bytes,
key: bytes | None = None,
iv: bytes = KWF_AES_IV,
) -> bytes:
"""AES-CBC/PKCS7 decrypt used by WebWeapon/KWF-compatible payloads."""
key = kwf_aes_key_bytes() if key is None else key
if len(key) != 16:
raise ValueError("AES-128 key must be 16 bytes")
if len(iv) != 16:
raise ValueError("AES-CBC IV must be 16 bytes")
if len(data) == 0 or len(data) % 16:
raise ValueError("AES-CBC ciphertext length must be a positive block multiple")
out = bytearray()
previous = iv
for idx in range(0, len(data), 16):
block = bytes(data[idx: idx + 16])
decrypted = _aes_decrypt_block(block, key)
out.extend(left ^ right for left, right in zip(decrypted, previous))
previous = block
return _pkcs7_unpad(bytes(out))
def kwf_encrypt_fingerprint_hex(plain: str) -> str:
"""Encrypt the local100 fingerprint string and return KWF lowercase hex."""
return kwf_aes_cbc_encrypt(plain.encode("utf-8")).hex()
def kwf_pack_fingerprint_plain(
plain: str,
alg_version: str = "0",
key_version: str = "0",
) -> str:
encrypted_hex = kwf_encrypt_fingerprint_hex(plain)
encoded = kwf_base64_encode_bytes(encrypted_hex.encode("ascii"))
return kwf_insert_version_fields(encoded, alg_version, key_version)
def kwf_pack_fingerprint_fields(
fields: Mapping[str, Any],
alg_version: str = "0",
key_version: str = "0",
) -> str:
return kwf_pack_fingerprint_plain(
kwf_fingerprint_plain(fields),
alg_version=alg_version,
key_version=key_version,
)
def kwf_default_fingerprint_fields(
collect_count: int | str,
now_ms: int,
language: str = "zh-CN",
k10: int = 60,
k11: int = 508,
) -> dict[str, Any]:
"""Build the current KWF default environment fingerprint fields.
These values match the local WebView stubs used by `core/h5_kww_server.mjs`
and the active Nebula `PnGU...` branch. `collect_count` is the value read
from `localStorage.kwfcv1` before it is incremented.
"""
return {
"k1": 1,
"k2": "0.0.2",
"k3": language,
"k4": "0",
"k5": False,
"k6": True,
"k7": True,
"k8": "1",
"k9": "0",
"k10": k10,
"k11": k11,
"k12": int(now_ms),
"k13": str(collect_count),
"k14": "",
}
def kwf_generate_kww(
collect_count: int | str,
now_ms: int,
language: str = "zh-CN",
) -> str:
return kwf_pack_fingerprint_fields(
kwf_default_fingerprint_fields(
collect_count=collect_count,
now_ms=now_ms,
language=language,
)
)
def _js_fingerprint_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "1" if value else "0"
return str(value)
def kwf_fingerprint_plain(
fields: Mapping[str, Any],
keys: tuple[str, ...] = KWF_FINGERPRINT_KEYS,
) -> str:
"""Port the KWF local100/local91 plain fingerprint join.
The VM builds ``["k1", ..., "k14"]``, maps values from an Object.assign
result, coerces booleans to ``"1"``/``"0"``, and joins with ``"|"``.
Missing/``undefined`` values become empty fields under JavaScript join
semantics.
"""
return "|".join(_js_fingerprint_value(fields.get(key)) for key in keys)
def kwf_insert_version_fields(
encoded_ciphertext: str,
alg_version: str = "0",
key_version: str = "0",
) -> str:
"""Port the visible packaging part of KWF VM slice 9959..10210.
After encryption/base64, the VM inserts ``ALG_VERSION`` after ten
characters and ``KEY_VERSION`` after the next five characters.
"""
return (
encoded_ciphertext[:10]
+ alg_version
+ encoded_ciphertext[10:15]
+ key_version
+ encoded_ciphertext[15:]
)
def kwf_remove_version_fields(packed: str) -> tuple[str, str, str]:
"""Reverse ``kwf_insert_version_fields`` for analysis/parity checks."""
if len(packed) < 17:
raise ValueError("packed text is too short to contain version fields")
return packed[:10] + packed[11:16] + packed[17:], packed[10], packed[16]
def kwf_base64_encode_bytes(
data: bytes | bytearray | memoryview,
alphabet: str = KWF_BASE64_ALPHABET,
) -> str:
"""Port KWF VM slice 9509..9755.
This is standard 3-byte to 4-character base64 packing, but with KWF's
custom alphabet from local[44].
"""
raw = bytes(data)
if len(alphabet) != 64:
raise ValueError("alphabet must contain exactly 64 characters")
out: list[str] = []
for idx in range(0, len(raw), 3):
chunk = raw[idx : idx + 3]
b0 = chunk[0]
if len(chunk) == 1:
out.append(alphabet[b0 >> 2])
out.append(alphabet[(b0 & 0x03) << 4])
out.append("=")
out.append("=")
elif len(chunk) == 2:
b1 = chunk[1]
out.append(alphabet[b0 >> 2])
out.append(alphabet[((b0 & 0x03) << 4) | ((b1 & 0xF0) >> 4)])
out.append(alphabet[(b1 & 0x0F) << 2])
out.append("=")
else:
b1 = chunk[1]
b2 = chunk[2]
out.append(alphabet[b0 >> 2])
out.append(alphabet[((b0 & 0x03) << 4) | ((b1 & 0xF0) >> 4)])
out.append(alphabet[((b1 & 0x0F) << 2) | ((b2 & 0xC0) >> 6)])
out.append(alphabet[b2 & 0x3F])
return "".join(out)
def kwf_base64_decode_bytes(
text: str,
alphabet: str = KWF_BASE64_ALPHABET,
) -> bytes:
"""Decode the KWF custom-alphabet base64 form."""
if len(alphabet) != 64:
raise ValueError("alphabet must contain exactly 64 characters")
if len(text) % 4 != 0:
raise ValueError("base64 text length must be a multiple of 4")
lookup = {ch: idx for idx, ch in enumerate(alphabet)}
out = bytearray()
for idx in range(0, len(text), 4):
block = text[idx : idx + 4]
pad = block.count("=")
if pad and block[-pad:] != "=" * pad:
raise ValueError("padding is only valid at the end of a block")
values = [lookup[ch] if ch != "=" else 0 for ch in block]
out.append((values[0] << 2) | (values[1] >> 4))
if pad < 2:
out.append(((values[1] & 0x0F) << 4) | (values[2] >> 2))
if pad < 1:
out.append(((values[2] & 0x03) << 6) | values[3])
return bytes(out)
__all__ = [
"KWF_BASE64_ALPHABET",
"KWF_AES_IV",
"KWF_AES_SEED",
"KWF_FINGERPRINT_KEYS",
"KWF_OBFUSCATED_KEY_GRID",
"js_utf16_code_units",
"kwf_aes_cbc_encrypt",
"kwf_aes_cbc_decrypt",
"kwf_aes_key_bytes",
"kwf_base64_decode_bytes",
"kwf_base64_encode_bytes",
"kwf_default_fingerprint_fields",
"kwf_encrypt_fingerprint_hex",
"kwf_fingerprint_plain",
"kwf_generate_kww",
"kwf_insert_version_fields",
"kwf_mixer_char",
"kwf_mixer_permutation",
"kwf_pack_fingerprint_fields",
"kwf_pack_fingerprint_plain",
"kwf_remove_version_fields",
"kwf_string_encoder_binary",
"kwf_string_encoder_bytes",
]

387
core/h5_kww_server.mjs Normal file
View File

@ -0,0 +1,387 @@
import fs from "fs";
import readline from "readline";
import vm from "vm";
const KWF_PATH = new URL("./kwf-0.0.2.2cee19b4b7dec496.js", import.meta.url);
let fixedNow = process.env.KS_H5_KWW_NOW ? Number(process.env.KS_H5_KWW_NOW) : null;
let rngState = (process.env.KS_H5_KWW_SEED ? Number(process.env.KS_H5_KWW_SEED) : Date.now()) >>> 0;
let traceEvents = null;
let traceCounts = null;
const TRACE_TYPE_LIMITS = {
"Math.floor": 256,
"String.fromCharCode": 512,
"Object.assign": 64,
"Array.join": 64,
};
function trace(type, detail = {}) {
if (!traceEvents) return;
if (traceCounts) traceCounts[type] = (traceCounts[type] || 0) + 1;
const limit = TRACE_TYPE_LIMITS[type] || 2000;
if (traceCounts && traceCounts[type] > limit) return;
if (traceEvents.length >= 4000) return;
traceEvents.push({ type, ...detail });
}
function previewArgs(values, limit = 32) {
const arr = Array.from(values || []);
return {
count: arr.length,
first: arr.slice(0, limit),
truncated: arr.length > limit,
};
}
function seededRandom() {
rngState = (Math.imul(rngState, 1664525) + 1013904223) >>> 0;
const value = rngState / 0x100000000;
trace("random", { value });
return value;
}
function currentNow() {
const value = fixedNow === null || Number.isNaN(fixedNow) ? Date.now() : fixedNow;
trace("now", { value });
return value;
}
function FakeDate(...args) {
if (new.target) {
return args.length > 0 ? new Date(...args) : new Date(currentNow());
}
return new Date(args.length > 0 ? args[0] : currentNow()).toString();
}
Object.setPrototypeOf(FakeDate, Date);
FakeDate.prototype = Date.prototype;
FakeDate.now = () => currentNow();
FakeDate.parse = Date.parse;
FakeDate.UTC = Date.UTC;
const fakeMath = Object.create(Math);
fakeMath.random = () => seededRandom();
fakeMath.floor = (value) => {
const result = Math.floor(value);
trace("Math.floor", { value, result });
return result;
};
const NativeString = String;
function TracedString(...args) {
return NativeString(...args);
}
Object.setPrototypeOf(TracedString, NativeString);
TracedString.prototype = NativeString.prototype;
TracedString.fromCharCode = (...codes) => {
const result = NativeString.fromCharCode(...codes);
trace("String.fromCharCode", {
args: previewArgs(codes),
result: preview(result),
});
return result;
};
const NativeObject = Object;
function previewObject(value) {
if (!value || typeof value !== "object") return preview(value);
const keys = NativeObject.keys(value);
const sample = {};
for (const key of keys.slice(0, 16)) sample[key] = preview(value[key]);
return { keys: keys.slice(0, 32), sample, keyCount: keys.length };
}
function TracedObject(value) {
return NativeObject(value);
}
NativeObject.setPrototypeOf(TracedObject, NativeObject);
TracedObject.prototype = NativeObject.prototype;
for (const name of NativeObject.getOwnPropertyNames(NativeObject)) {
if (name === "length" || name === "name" || name === "prototype") continue;
NativeObject.defineProperty(
TracedObject,
name,
NativeObject.getOwnPropertyDescriptor(NativeObject, name),
);
}
TracedObject.assign = (target, ...sources) => {
const result = NativeObject.assign(target, ...sources);
trace("Object.assign", {
sourceCount: sources.length,
sources: sources.map((item) => previewObject(item)),
result: previewObject(result),
});
return result;
};
const nativeArrayJoin = Array.prototype.join;
Array.prototype.join = function tracedJoin(separator) {
const result = nativeArrayJoin.call(this, separator);
if (separator === "|" || (this.length === 14 && result.includes("|"))) {
trace("Array.join", {
separator: separator === undefined ? "," : String(separator),
length: this.length,
result: preview(result),
});
}
return result;
};
const mockCache = new Map();
function preview(value) {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "string") return value.length > 120 ? `${value.slice(0, 120)}...(len=${value.length})` : value;
if (typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "function") {
const text = NativeString(value);
return text.length > 160 ? `${text.slice(0, 160)}...(len=${text.length})` : text;
}
if (Array.isArray(value)) return `[array len=${value.length}]`;
try {
return `[object ${Object.keys(value).slice(0, 8).join(",")}]`;
} catch {
return "[unpreviewable]";
}
}
function mock(path = "mock") {
if (mockCache.has(path)) return mockCache.get(path);
const fn = function () {
return mock(`${path}()`);
};
const proxy = new Proxy(fn, {
get(_target, prop) {
if (prop === Symbol.toPrimitive) return () => "";
if (prop === "toString") return () => `[mock ${path}]`;
if (prop === "valueOf") return () => 0;
if (prop === "then") return undefined;
return mock(`${path}.${String(prop)}`);
},
set() {
return true;
},
apply() {
return mock(`${path}()`);
},
construct() {
return mock(`new ${path}`);
},
has() {
return true;
},
});
mockCache.set(path, proxy);
return proxy;
}
const storage = new Map();
const localStorage = {
getItem(key) {
const value = storage.get(String(key)) || null;
trace("localStorage.getItem", { key: String(key), value });
return value;
},
setItem(key, value) {
trace("localStorage.setItem", { key: String(key), value: String(value) });
storage.set(String(key), String(value));
},
removeItem(key) {
trace("localStorage.removeItem", { key: String(key) });
storage.delete(String(key));
},
};
class FakeXMLHttpRequest {
open(method, url) {
this.method = method;
this.url = url;
}
setRequestHeader() {}
send() {
this.readyState = 4;
this.status = 200;
this.responseText = "{}";
if (typeof this.onreadystatechange === "function") this.onreadystatechange();
if (typeof this.onload === "function") this.onload();
}
addEventListener() {}
}
const rawWindow = {};
const windowProxy = new Proxy(rawWindow, {
get(target, prop) {
if (prop in target) return target[prop];
const value = mock(`window.${String(prop)}`);
target[prop] = value;
return value;
},
set(target, prop, value) {
target[prop] = value;
return true;
},
has() {
return true;
},
});
Object.assign(rawWindow, {
window: windowProxy,
self: windowProxy,
top: windowProxy,
parent: windowProxy,
Object: TracedObject,
Array,
String: TracedString,
Function,
Number,
Boolean,
Date: FakeDate,
Math: fakeMath,
RegExp,
Error,
TypeError,
Promise,
JSON,
Infinity,
undefined,
Uint8Array,
parseInt,
parseFloat,
isNaN,
escape,
encodeURI,
encodeURIComponent,
decodeURI,
decodeURIComponent,
Window: function Window() {},
Navigator: function Navigator() {},
Location: function Location() {},
location: {
href: "https://nebula.kuaishou.com/nebula/task/earning?layoutType=4&source=bottom_guide_first",
origin: "https://nebula.kuaishou.com",
protocol: "https:",
host: "nebula.kuaishou.com",
hostname: "nebula.kuaishou.com",
pathname: "/nebula/task/earning",
search: "?layoutType=4&source=bottom_guide_first",
},
document: {
cookie: "",
referrer: "",
createElement(tag) {
return mock(`element.${tag}`);
},
getElementsByTagName(tag) {
return [mock(`tag.${tag}`)];
},
addEventListener() {},
body: mock("document.body"),
head: mock("document.head"),
documentElement: mock("document.documentElement"),
},
navigator: {
userAgent: "Mozilla/5.0 (Linux; Android 16; PJZ110 Build/BP2A.250605.015; wv) AppleWebKit/537.36",
language: "zh-CN",
languages: ["zh-CN", "zh"],
platform: "Linux armv8l",
webdriver: false,
},
screen: { width: 1080, height: 2376, colorDepth: 24, pixelDepth: 24 },
localStorage,
sessionStorage: localStorage,
performance: { now: () => currentNow() % 100000, timing: {}, getEntriesByType: () => [] },
crypto: {
getRandomValues(arr) {
for (let i = 0; i < arr.length; i += 1) arr[i] = Math.floor(seededRandom() * 256);
return arr;
},
},
XMLHttpRequest: FakeXMLHttpRequest,
fetch() {
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve("{}"), json: () => Promise.resolve({}) });
},
setTimeout(cb) {
if (typeof cb === "function") cb();
return 1;
},
clearTimeout() {},
setInterval() {
return 1;
},
clearInterval() {},
console: {
log() {},
warn() {},
error() {},
},
});
rawWindow.globalThis = windowProxy;
const context = vm.createContext(windowProxy, {
name: "h5-kww",
codeGeneration: { strings: true, wasm: false },
});
function loadKwf() {
const code = fs.readFileSync(KWF_PATH, "utf8");
vm.runInContext(code, context, { filename: KWF_PATH.pathname, timeout: 5000 });
if (!rawWindow.kwpsec || typeof rawWindow.kwpsec.getData !== "function") {
throw new Error(`kwpsec.getData not installed, kwpsec=${preview(rawWindow.kwpsec)}`);
}
}
function setCookie(cookie) {
rawWindow.document.cookie = String(cookie || "");
}
function buildArg(req) {
if (req && req.arg !== undefined) return req.arg;
if (req && req.url) {
return {
url: String(req.url),
method: String(req.method || "GET"),
headers: req.headers || {},
body: req.body || "",
data: req.body || "",
};
}
return undefined;
}
function makeKww(req) {
traceEvents = req.trace ? [] : null;
traceCounts = req.trace ? {} : null;
setCookie(req.cookie || "");
const arg = buildArg(req);
const getData = rawWindow.kwpsec.getData;
const value = arg === undefined ? getData.call(rawWindow.kwpsec) : getData.call(rawWindow.kwpsec, arg);
const kww = String(value || "");
if (!kww) {
throw new Error("kwpsec.getData returned empty value");
}
return {
kww,
kwfcv1: storage.get("kwfcv1") || "",
kwfv1: storage.get("kwfv1") || "",
trace: traceEvents || undefined,
traceSummary: traceCounts || undefined,
};
}
loadKwf();
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on("line", (line) => {
let id = null;
try {
const req = JSON.parse(line);
id = req.id ?? null;
const result = makeKww(req);
process.stdout.write(JSON.stringify({ id, ok: true, ...result }) + "\n");
} catch (err) {
process.stdout.write(JSON.stringify({ id, ok: false, error: String(err && err.stack ? err.stack : err) }) + "\n");
}
});

379
core/h5_sig3.py Normal file
View File

@ -0,0 +1,379 @@
"""H5 `__NS_sig3` 34-byte envelope helpers.
The Nebula H5 bridge returns a 68-hex digest shape that is distinct from the
regular API 10418 48-hex `__NS_sig3`. The final byte is a checksum mask; the
first 33 bytes are XOR-mixed with `(mask ^ index)`.
"""
from __future__ import annotations
import zlib
from dataclasses import dataclass
from .sig3 import KWSG_10418_HMAC_KEY, kwsg_10418_binary48, load_kwsg_10418_tables
H5_SIG3_LENGTH = 34
H5_SIG3_HEX_LENGTH = H5_SIG3_LENGTH * 2
H5_SIG3_MAGIC = bytes.fromhex("54450130")
H5_SIG3_BLOCK_TAG = bytes.fromhex("9f01")
H5_SIG3_DEFAULT_SESSION_SEED = 0x4B4B4BD1
H5_SIG3_DEFAULT_TAIL = bytes.fromhex("0100000000")
H5_ENCODE_SHA_IV1 = (
0xE066DF2C,
0xA9888E01,
0xB18EE43D,
0x501AFA25,
0x53A550E5,
0xA4BCE311,
0x70E554F5,
0x6AA046EE,
)
H5_ENCODE_SHA_IV2 = (
0x3C66483A,
0x0096038E,
0xD62FEC37,
0x346D7B3D,
0xDE7FB319,
0xF56D5DD5,
0xE614E937,
0x6DD5E338,
)
H5_ENCODE_SHA_K = (
0x428A2F98,
0x71374491,
0xB5C0FBCF,
0xE9B5DBA5,
0x3956C25B,
0x59F111F1,
0x923F82A4,
0xAB1C5ED5,
0xD807AA98,
0x12835B01,
0x243185BE,
0x550C7DC3,
0x72BE5D74,
0x80DEB1FE,
0x9BDC06A7,
0xC19BF174,
0xE49B69C1,
0xEFBE4786,
0x0FC19DC6,
0x240CA1CC,
0x2DE92C6F,
0x4A7484AA,
0x5CB0A9DC,
0x76F988DA,
0x983E5152,
0xA831C66D,
0xB00327C8,
0xBF597FC7,
0xC6E00BF3,
0xD5A79147,
0x06CA6351,
0x14292967,
0x27B70A85,
0x2E1B2138,
0x4D2C6DFC,
0x53380D13,
0x650A7354,
0x766A0ABB,
0x81C2C92E,
0x92722C85,
0xA2BFE8A1,
0xA81A664B,
0xC24B8B70,
0xC76C51A3,
0xD192E819,
0xD6990624,
0xF40E3585,
0x106AA070,
0x19A4C116,
0x1E376C08,
0x2748774C,
0x34B0BCB5,
0x391C0CB3,
0x4ED8AA4A,
0x5B9CCA4F,
0x682E6FF3,
0x748F82EE,
0x78A5636F,
0x84C87814,
0x8CC70208,
0x90BEFFFA,
0xA4506CEB,
0xBEF9A3F7,
0xC67178F2,
)
@dataclass(frozen=True)
class H5Sig3Fields:
"""扰动前的 H5 sig3 字段视图。"""
raw_hex: str
preimage: bytes
mask: int
mask_ok: bool
magic_ok: bool
first_tag_ok: bool
second_tag_ok: bool
tail_ok: bool
session_seed: int
counter: int
crc32: int
elapsed_ms: int
state_value: int
tail: bytes
@property
def ok(self) -> bool:
return (
self.mask_ok
and self.magic_ok
and self.first_tag_ok
and self.second_tag_ok
and self.tail_ok
)
def _coerce_digest(value: str | bytes | bytearray) -> bytes:
if isinstance(value, str):
if len(value) != H5_SIG3_HEX_LENGTH:
raise ValueError("H5 sig3 must be exactly 68 hex chars")
return bytes.fromhex(value)
digest = bytes(value)
if len(digest) != H5_SIG3_LENGTH:
raise ValueError("H5 sig3 must be exactly 34 bytes")
return digest
def h5_sig3_expected_mask(preimage33: bytes | bytearray) -> int:
"""按已观测到的 H5 envelope 规则计算最后 1 字节 mask。"""
if len(preimage33) != H5_SIG3_LENGTH - 1:
raise ValueError("H5 sig3 preimage prefix must be exactly 33 bytes")
return (-sum(preimage33)) & 0xFF
def h5_sig3_unmix(value: str | bytes | bytearray) -> tuple[bytes, int, bool]:
"""恢复扰动前 34 字节,其中最后 1 字节固定置 0。"""
digest = _coerce_digest(value)
mask = digest[-1]
preimage = bytearray(H5_SIG3_LENGTH)
for i in range(H5_SIG3_LENGTH - 1):
preimage[i] = digest[i] ^ ((mask ^ i) & 0xFF)
expected = h5_sig3_expected_mask(preimage[: H5_SIG3_LENGTH - 1])
return bytes(preimage), mask, mask == expected
def h5_sig3_mix(preimage: bytes | bytearray, mask: int | None = None) -> bytes:
"""由扰动前字段重建 34-byte H5 sig3 digest。"""
pre = bytearray(preimage)
if len(pre) == H5_SIG3_LENGTH - 1:
pre.append(0)
if len(pre) != H5_SIG3_LENGTH:
raise ValueError("H5 sig3 preimage must be exactly 33 or 34 bytes")
if mask is None:
mask = h5_sig3_expected_mask(pre[: H5_SIG3_LENGTH - 1])
mask &= 0xFF
out = bytearray(H5_SIG3_LENGTH)
for i in range(H5_SIG3_LENGTH - 1):
out[i] = pre[i] ^ ((mask ^ i) & 0xFF)
out[-1] = mask
return bytes(out)
def parse_h5_sig3(value: str | bytes | bytearray) -> H5Sig3Fields:
"""解析 H5 68hex `__NS_sig3` 的 envelope 字段。"""
digest = _coerce_digest(value)
pre, mask, mask_ok = h5_sig3_unmix(digest)
return H5Sig3Fields(
raw_hex=digest.hex(),
preimage=pre,
mask=mask,
mask_ok=mask_ok,
magic_ok=pre[:4] == H5_SIG3_MAGIC,
first_tag_ok=pre[8:10] == H5_SIG3_BLOCK_TAG,
second_tag_ok=pre[22:24] == H5_SIG3_BLOCK_TAG,
tail_ok=pre[28:33] == H5_SIG3_DEFAULT_TAIL,
session_seed=int.from_bytes(pre[4:8], "little"),
counter=int.from_bytes(pre[10:14], "little"),
crc32=int.from_bytes(pre[14:18], "little"),
elapsed_ms=int.from_bytes(pre[18:22], "little"),
state_value=int.from_bytes(pre[24:28], "little"),
tail=pre[28:33],
)
def build_h5_sig3_preimage(
*,
crc32_value: int,
counter: int,
elapsed_ms: int,
state_value: int,
session_seed: int = H5_SIG3_DEFAULT_SESSION_SEED,
tail: bytes = H5_SIG3_DEFAULT_TAIL,
) -> bytes:
"""构造 H5 sig3 扰动前字段,便于回放样本和后续接入真实 CRC。"""
if len(tail) != 5:
raise ValueError("H5 sig3 tail must be exactly 5 bytes")
pre = bytearray(H5_SIG3_LENGTH)
pre[:4] = H5_SIG3_MAGIC
pre[4:8] = (int(session_seed) & 0xFFFFFFFF).to_bytes(4, "little")
pre[8:10] = H5_SIG3_BLOCK_TAG
pre[10:14] = (int(counter) & 0xFFFFFFFF).to_bytes(4, "little")
pre[14:18] = (int(crc32_value) & 0xFFFFFFFF).to_bytes(4, "little")
pre[18:22] = (int(elapsed_ms) & 0xFFFFFFFF).to_bytes(4, "little")
pre[22:24] = H5_SIG3_BLOCK_TAG
pre[24:28] = (int(state_value) & 0xFFFFFFFF).to_bytes(4, "little")
pre[28:33] = tail
return bytes(pre)
def h5_sig3_from_fields(
*,
crc32_value: int,
counter: int,
elapsed_ms: int,
state_value: int,
session_seed: int = H5_SIG3_DEFAULT_SESSION_SEED,
tail: bytes = H5_SIG3_DEFAULT_TAIL,
) -> str:
"""按字段直接生成 H5 68hex sig3。"""
return h5_sig3_mix(
build_h5_sig3_preimage(
crc32_value=crc32_value,
counter=counter,
elapsed_ms=elapsed_ms,
state_value=state_value,
session_seed=session_seed,
tail=tail,
)
).hex()
def _rotr32(value: int, bits: int) -> int:
value &= 0xFFFFFFFF
return ((value >> bits) | (value << (32 - bits))) & 0xFFFFFFFF
def _sha256_compress_block(state: tuple[int, ...], block64: bytes) -> tuple[int, ...]:
if len(block64) != 64:
raise ValueError("SHA-256 compression block must be exactly 64 bytes")
w = [int.from_bytes(block64[i : i + 4], "big") for i in range(0, 64, 4)]
for i in range(16, 64):
s0 = _rotr32(w[i - 15], 7) ^ _rotr32(w[i - 15], 18) ^ (w[i - 15] >> 3)
s1 = _rotr32(w[i - 2], 17) ^ _rotr32(w[i - 2], 19) ^ (w[i - 2] >> 10)
w.append((w[i - 16] + s0 + w[i - 7] + s1) & 0xFFFFFFFF)
a, b, c, d, e, f, g, h = (item & 0xFFFFFFFF for item in state)
for i in range(64):
s1 = _rotr32(e, 6) ^ _rotr32(e, 11) ^ _rotr32(e, 25)
ch = (e & f) ^ ((~e) & g)
temp1 = (h + s1 + ch + H5_ENCODE_SHA_K[i] + w[i]) & 0xFFFFFFFF
s0 = _rotr32(a, 2) ^ _rotr32(a, 13) ^ _rotr32(a, 22)
maj = (a & b) ^ (a & c) ^ (b & c)
temp2 = (s0 + maj) & 0xFFFFFFFF
h = g
g = f
f = e
e = (d + temp1) & 0xFFFFFFFF
d = c
c = b
b = a
a = (temp1 + temp2) & 0xFFFFFFFF
return (
(state[0] + a) & 0xFFFFFFFF,
(state[1] + b) & 0xFFFFFFFF,
(state[2] + c) & 0xFFFFFFFF,
(state[3] + d) & 0xFFFFFFFF,
(state[4] + e) & 0xFFFFFFFF,
(state[5] + f) & 0xFFFFFFFF,
(state[6] + g) & 0xFFFFFFFF,
(state[7] + h) & 0xFFFFFFFF,
)
def _sha256_continue_from_virtual_prefix(
data: bytes,
state: tuple[int, ...],
prefix_len: int = 64,
) -> bytes:
"""从已压缩过 `prefix_len` 字节的自定义 SHA-256 state 继续计算。"""
if len(state) != 8:
raise ValueError("SHA-256 state must contain exactly 8 words")
total_len = prefix_len + len(data)
padded = bytearray(data)
padded.append(0x80)
while (prefix_len + len(padded)) % 64 != 56:
padded.append(0)
padded += (total_len * 8).to_bytes(8, "big")
current = tuple(word & 0xFFFFFFFF for word in state)
for offset in range(0, len(padded), 64):
current = _sha256_compress_block(current, bytes(padded[offset : offset + 64]))
return b"".join(word.to_bytes(4, "big") for word in current)
def h5_encode_sha_digest(sign_input: str | bytes | bytearray) -> bytes:
"""复现 H5 `$encode` 中用于 envelope CRC 字段的双层 SHA digest。"""
if isinstance(sign_input, str):
data = sign_input.encode("utf-8")
else:
data = bytes(sign_input)
first = _sha256_continue_from_virtual_prefix(data, H5_ENCODE_SHA_IV1, 64)
return _sha256_continue_from_virtual_prefix(first, H5_ENCODE_SHA_IV2, 64)
def h5_sig3_crc32_from_sign_input(sign_input: str | bytes | bytearray) -> int:
"""返回 H5 envelope 14..17 字节对应的 little-endian 字段值。
名字保留 `crc32` 是为了兼容既有字段命名实际已确认不是标准 CRC32
而是 `$encode` 自定义双层 SHA digest 的首 32-bit word envelope
小端字段视图解释
"""
digest = h5_encode_sha_digest(sign_input)
return int.from_bytes(digest[:4], "little")
def h5_sig3_crc32_from_10418_input(
input_value: str | bytes | bytearray,
t1: bytes | None = None,
t2: bytes | None = None,
hmac_key: bytes = KWSG_10418_HMAC_KEY,
) -> int:
"""候选 CRC 生成器CRC32(10418 binary48(input))。
这个 helper 用于离线枚举 H5 `secPlain` 形态当前只表示一个已知
强候选路径不代表 H5 bridge 的输入已经完全闭合
"""
if isinstance(input_value, str):
input_bytes = input_value.encode("utf-8")
else:
input_bytes = bytes(input_value)
if t1 is None and t2 is None:
t1, t2 = load_kwsg_10418_tables()
elif t1 is None or t2 is None:
raise ValueError("t1 and t2 must be provided together")
return zlib.crc32(kwsg_10418_binary48(input_bytes, t1, t2, hmac_key)) & 0xFFFFFFFF
__all__ = [
"H5_SIG3_BLOCK_TAG",
"H5_SIG3_DEFAULT_SESSION_SEED",
"H5_SIG3_DEFAULT_TAIL",
"H5_SIG3_HEX_LENGTH",
"H5_SIG3_LENGTH",
"H5_SIG3_MAGIC",
"H5Sig3Fields",
"build_h5_sig3_preimage",
"h5_sig3_crc32_from_10418_input",
"h5_sig3_crc32_from_sign_input",
"h5_sig3_expected_mask",
"h5_encode_sha_digest",
"h5_sig3_from_fields",
"h5_sig3_mix",
"h5_sig3_unmix",
"parse_h5_sig3",
]

243
core/h5_vendor_encode.mjs Normal file
View File

@ -0,0 +1,243 @@
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
export function installBrowserStubs(cookie = "") {
const g = globalThis;
g.window = g;
g.self = g;
g.top = g;
g.__assetsPath = "./";
Object.defineProperty(g, "navigator", {
value: {
userAgent:
"Mozilla/5.0 (Linux; Android 16; PJZ110 Build/BP2A.250605.015; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.7827.164 Mobile Safari/537.36 ksNebula/14.5.50.11631",
appVersion: "Android 16",
language: "zh-CN",
},
configurable: true,
});
g.screen = { width: 1080, height: 2376, availWidth: 1080, availHeight: 2376 };
g.devicePixelRatio = 3;
Object.defineProperty(g, "location", {
value: {
href: "https://nebula.kuaishou.com/nebula/task/earning?&layoutType=4&source=bottom_guide_first",
search: "?layoutType=4&source=bottom_guide_first",
origin: "https://nebula.kuaishou.com",
pathname: "/nebula/task/earning",
hash: "",
hostname: "nebula.kuaishou.com",
assign(v) {
this.href = v;
},
replace(v) {
this.href = v;
},
},
configurable: true,
});
g.history = { state: null, length: 1, pushState() {}, replaceState() {}, back() {}, go() {} };
g.Event = class {
constructor(type, opts = {}) {
this.type = type;
this.cancelable = !!opts.cancelable;
this.defaultPrevented = false;
}
};
g.CustomEvent = g.Event;
g.HTMLElement = class {};
g.Element = class {};
g.Node = class {};
const elem = (tag = "div") => {
const e = {
tagName: String(tag).toUpperCase(),
nodeType: 1,
style: {},
children: [],
childNodes: [],
parentNode: { removeChild() {} },
setAttribute() {},
getAttribute() {
return null;
},
appendChild(c) {
this.children.push(c);
this.childNodes.push(c);
return c;
},
insertBefore(c) {
this.children.push(c);
this.childNodes.push(c);
return c;
},
removeChild() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
href: "",
rel: "",
as: "",
crossOrigin: "",
cloneNode() {
return elem(tag);
},
getBoundingClientRect() {
return { top: 0, left: 0, width: 1, height: 1, right: 1, bottom: 1 };
},
clientWidth: 1080,
clientHeight: 2376,
};
Object.setPrototypeOf(e, Element.prototype);
return e;
};
Object.defineProperty(g, "document", {
value: {
cookie,
hidden: false,
visibilityState: "visible",
nodeType: 9,
createElement: elem,
createTextNode(text) {
return { nodeType: 3, textContent: text, nodeValue: text, childNodes: [], children: [] };
},
createEvent(type) {
return {
type,
initEvent(name, bubbles, cancelable) {
this.type = name;
this.bubbles = bubbles;
this.cancelable = cancelable;
},
preventDefault() {
this.defaultPrevented = true;
},
};
},
getElementsByTagName() {
return [];
},
querySelector() {
return null;
},
querySelectorAll() {
return [];
},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return true;
},
},
configurable: true,
});
document.documentElement = elem("html");
document.head = elem("head");
document.body = elem("body");
g.getComputedStyle = () => ({
display: "block",
visibility: "visible",
opacity: "1",
width: "1px",
height: "1px",
getPropertyValue() {
return "";
},
});
g.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {}, clear() {} };
g.sessionStorage = g.localStorage;
g.performance = { now: () => Date.now(), mark() {}, measure() {} };
g.requestAnimationFrame = (fn) => setTimeout(fn, 0);
g.cancelAnimationFrame = (id) => clearTimeout(id);
g.addEventListener = () => {};
g.removeEventListener = () => {};
g.dispatchEvent = () => true;
g.MutationObserver = class {
constructor() {}
observe() {}
disconnect() {}
takeRecords() {
return [];
}
};
g.XMLHttpRequest = class {
open() {}
setRequestHeader() {}
send() {
this.readyState = 4;
this.status = 0;
this.onreadystatechange && this.onreadystatechange();
}
addEventListener() {}
};
g.fetch = async () => ({ ok: false, status: 0, json: async () => ({}), text: async () => "" });
g.atob = (s) => Buffer.from(s, "base64").toString("binary");
g.btoa = (s) => Buffer.from(s, "binary").toString("base64");
Object.defineProperty(g, "crypto", {
value: {
getRandomValues(arr) {
for (let i = 0; i < arr.length; i++) arr[i] = Math.floor(Math.random() * 256);
return arr;
},
},
configurable: true,
});
}
export async function loadVendor() {
let src = readFileSync(new URL("./main-vendor-ox-x1_g5.mjs", import.meta.url), "utf8");
src = src.replace(
'import{_ as __vitePreload}from"./main-CZ3ZSK5w.js";',
"const __vitePreload=(base,deps,importerUrl)=>base();",
);
return import("data:text/javascript;base64," + Buffer.from(src).toString("base64"));
}
export function encodeWith(instance, signInput) {
return new Promise((resolve, reject) => {
instance.call("$encode", [
signInput,
{
suc(result, cInfo) {
resolve({ result, cInfo });
},
err(error) {
reject(error);
},
},
]);
});
}
async function main() {
const signInput = process.argv[2] === "-" ? readFileSync(0, "utf8") : process.argv[2] || "sigCatVer=1";
const cookie = process.env.KS_COOKIE || "";
const exportName = process.env.KS_VENDOR_EXPORT || "f";
installBrowserStubs(cookie);
const realConsole = { log: console.log, warn: console.warn, error: console.error };
if (!process.env.KS_VENDOR_DEBUG) {
console.log = () => {};
console.warn = () => {};
console.error = () => {};
}
const vendor = await loadVendor();
console.log = realConsole.log;
console.warn = realConsole.warn;
console.error = realConsole.error;
const instance = vendor[exportName];
if (!instance || typeof instance.call !== "function") {
throw new Error(`vendor export ${exportName} has no call()`);
}
const encoded = await encodeWith(instance, signInput);
console.log(JSON.stringify({ exportName, signInput, ...encoded }, null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@ -0,0 +1,57 @@
import readline from "node:readline";
import { encodeWith, installBrowserStubs, loadVendor } from "./h5_vendor_encode.mjs";
const cookie = process.env.KS_COOKIE || "";
const exportName = process.env.KS_VENDOR_EXPORT || "f";
installBrowserStubs(cookie);
const realConsole = { log: console.log, warn: console.warn, error: console.error };
if (!process.env.KS_VENDOR_DEBUG) {
console.log = () => {};
console.warn = () => {};
console.error = () => {};
}
const vendor = await loadVendor();
console.log = realConsole.log;
console.warn = realConsole.warn;
console.error = realConsole.error;
const instance = vendor[exportName];
if (!instance || typeof instance.call !== "function") {
throw new Error(`vendor export ${exportName} has no call()`);
}
const rl = readline.createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
function writeJson(value) {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
rl.on("line", async (line) => {
if (!line.trim()) {
return;
}
let req;
try {
req = JSON.parse(line);
const signInput = String(req.signInput ?? "");
const encoded = await encodeWith(instance, signInput);
writeJson({ id: req.id ?? null, ok: true, exportName, ...encoded });
} catch (error) {
writeJson({
id: req && "id" in req ? req.id : null,
ok: false,
error: error && error.stack ? error.stack : String(error),
});
}
});
rl.on("close", () => {
process.exit(0);
});

142
core/http_transport.py Normal file
View File

@ -0,0 +1,142 @@
"""HTTP transport sessions used by the login protocol client."""
from __future__ import annotations
from typing import Any, Callable
OKHTTP4_ANDROID10_JA3 = ",".join(
[
"771",
"4865-4866-4867-49195-49196-52393-49199-49200-52392-49171-49172-156-157-47-53",
"0-23-65281-10-11-35-16-5-13-51-45-43-21",
"29-23-24",
"0",
]
)
OKHTTP4_ANDROID10_AKAMAI = "4:16777216|16711681|0|m,p,a,s"
OKHTTP4_ANDROID10_EXTRA_FP = {
"tls_signature_algorithms": [
"ecdsa_secp256r1_sha256",
"rsa_pss_rsae_sha256",
"rsa_pkcs1_sha256",
"ecdsa_secp384r1_sha384",
"rsa_pss_rsae_sha384",
"rsa_pkcs1_sha384",
"rsa_pss_rsae_sha512",
"rsa_pkcs1_sha512",
"rsa_pkcs1_sha1",
]
}
class _CurlCookieAdapter:
"""Accept Playwright cookie attributes not represented by curl_cffi."""
def __init__(self, cookies: Any) -> None:
self._cookies = cookies
def set(
self,
name: str,
value: str,
*,
domain: str = "",
path: str = "/",
secure: bool = False,
expires: int | None = None,
**_: Any,
) -> None:
del expires
self._cookies.set(
name,
value,
domain=domain,
path=path,
secure=secure,
)
def __iter__(self):
return iter(self._cookies)
def __getattr__(self, name: str) -> Any:
return getattr(self._cookies, name)
class OkHttp4Android10Session:
"""Requests-like session with the documented OkHttp 4 Android 10 profile."""
def __init__(self, session: Any) -> None:
self._session = session
self.cookies = _CurlCookieAdapter(session.cookies)
def request(self, method: str, url: str, **kwargs: Any) -> Any:
kwargs.setdefault("http_version", "v2")
kwargs.setdefault("default_headers", False)
# The protocol client supplies its own Accept-Encoding header.
kwargs.setdefault("accept_encoding", None)
headers = kwargs.get("headers")
if headers is not None:
# HTTP/2 represents connection state at the framing layer.
kwargs["headers"] = {
str(name): value
for name, value in headers.items()
if str(name).lower() != "connection"
}
return self._session.request(method, url, **kwargs)
def get(self, url: str, **kwargs: Any) -> Any:
return self.request("GET", url, **kwargs)
def post(self, url: str, **kwargs: Any) -> Any:
return self.request("POST", url, **kwargs)
def close(self) -> None:
self._session.close()
def create_http_session(
transport: str,
*,
curl_session: Any | None = None,
curl_session_factory: Callable[..., Any] | None = None,
) -> Any:
"""Create a shared requests-compatible session for one transport profile."""
if transport == "requests":
import requests
session = requests.Session()
# OkHttp does not add requests' default ``Accept: */*`` header.
headers = getattr(session, "headers", None)
if headers is not None:
headers.pop("Accept", None)
return session
if transport != "okhttp4-android10":
raise ValueError(f"unsupported HTTP transport: {transport}")
if curl_session is None:
if curl_session_factory is None:
try:
from curl_cffi.requests import Session as curl_session_factory
except ImportError as exc:
raise RuntimeError(
"okhttp4-android10 transport requires the curl-cffi dependency"
) from exc
curl_session = curl_session_factory(
ja3=OKHTTP4_ANDROID10_JA3,
akamai=OKHTTP4_ANDROID10_AKAMAI,
extra_fp=OKHTTP4_ANDROID10_EXTRA_FP,
default_headers=False,
)
return OkHttp4Android10Session(curl_session)
__all__ = [
"OKHTTP4_ANDROID10_AKAMAI",
"OKHTTP4_ANDROID10_EXTRA_FP",
"OKHTTP4_ANDROID10_JA3",
"OkHttp4Android10Session",
"create_http_session",
]

13
core/ksse_crc.py Normal file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Small libksse checksum primitives recovered from native code."""
from __future__ import annotations
import zlib
def ksse_crc32(data: bytes, seed: int = 0) -> int:
"""Match libksse FUN_001567b8(seed, data, len(data))."""
return zlib.crc32(data, seed) & 0xFFFFFFFF

120
core/ksse_deobf.py Normal file
View File

@ -0,0 +1,120 @@
"""Pure Python port of libksse static string deobfuscation helpers.
Recovered native path:
* FUN_00150ba0 wraps the decoded bytes into a C++ string.
* FUN_001502f0 reads a little-endian uint16 length followed by encrypted bytes.
* FUN_0014fe6c seeds three LFSR-like registers from bytes 4..7 of the seed.
* FUN_00150128 emits one keystream byte and returns ``(ks + 3) ^ cipher``.
The default seed literal is visible in libksse: ``Vuz4fCHxn1CO``.
"""
from __future__ import annotations
from dataclasses import dataclass
DEFAULT_KSSE_STRING_SEED = b"Vuz4fCHxn1CO"
def _u32(value: int) -> int:
return value & 0xFFFFFFFF
@dataclass
class KsseStringState:
ac48: int
ac4c: int
ac50: int
ac54: int = 0x80000062
ac58: int = 0x40000020
ac5c: int = 0x10000002
ac60: int = 0x7FFFFFFF
ac64: int = 0x3FFFFFFF
ac68: int = 0x0FFFFFFF
ac6c: int = 0x80000000
ac70: int = 0xC0000000
ac74: int = 0xF0000000
def init_ksse_string_state(seed: bytes | str = DEFAULT_KSSE_STRING_SEED) -> KsseStringState:
if isinstance(seed, str):
seed = seed.encode("utf-8")
if len(seed) > 20 or not seed:
seed = b"quajdsfjasodfue"
if len(seed) < 12:
seed = seed + seed[: 12 - len(seed)]
# Ghidra expression is a 32-bit byte swap of *(uint32_t *)(seed + 4).
word = int.from_bytes(seed[4:8], "big")
if word == 0:
return KsseStringState(ac48=0x13579BDF, ac4c=0x2468ACE0, ac50=0xFDB97531)
return KsseStringState(ac48=word, ac4c=word, ac50=word)
def decode_ksse_string_byte(cipher_byte: int, state: KsseStringState) -> int:
"""Port sub_00150128 for a single byte."""
out = 0
bit6 = state.ac50 & 1
bit7 = state.ac4c & 1
reg50 = state.ac50
reg4c = state.ac4c
for _ in range(8):
if (state.ac48 & 1) == 0:
state.ac48 = _u32(state.ac60 & (state.ac48 >> 1))
if (reg50 & 1) == 0:
bit6 = 0
reg50 = _u32(state.ac68 & (reg50 >> 1))
state.ac50 = reg50
else:
reg50 = _u32(((state.ac5c >> 1) ^ reg50) | state.ac74)
bit6 = 1
state.ac50 = reg50
else:
state.ac48 = _u32(((state.ac54 >> 1) ^ state.ac48) | state.ac6c)
if (reg4c & 1) == 0:
reg4c = _u32(state.ac64 & (reg4c >> 1))
bit7 = 0
state.ac4c = reg4c
else:
reg4c = _u32(((state.ac58 >> 1) ^ reg4c) | state.ac70)
bit7 = 1
state.ac4c = reg4c
out = ((bit6 ^ bit7) | ((out & 0x7F) << 1)) & 0xFF
return ((out + 3) & 0xFF) ^ (cipher_byte & 0xFF)
def decode_ksse_string_payload(payload: bytes, seed: bytes | str = DEFAULT_KSSE_STRING_SEED) -> bytes:
state = init_ksse_string_state(seed)
return bytes(decode_ksse_string_byte(item, state) for item in payload)
def decode_ksse_string_blob(blob: bytes, seed: bytes | str = DEFAULT_KSSE_STRING_SEED) -> bytes:
if len(blob) < 2:
raise ValueError("encoded blob must contain a 16-bit length prefix")
length = int.from_bytes(blob[:2], "little")
payload = blob[2 : 2 + length]
if len(payload) != length:
raise ValueError(f"encoded blob length mismatch: need {length}, got {len(payload)}")
return decode_ksse_string_payload(payload, seed)
def pack_le_values(*items: tuple[int, int]) -> bytes:
"""Pack decompiler stack constants as little-endian byte fragments."""
return b"".join(value.to_bytes(size, "little") for value, size in items)
__all__ = [
"DEFAULT_KSSE_STRING_SEED",
"KsseStringState",
"decode_ksse_string_blob",
"decode_ksse_string_byte",
"decode_ksse_string_payload",
"init_ksse_string_state",
"pack_le_values",
]

779
core/ksse_sted.py Normal file
View File

@ -0,0 +1,779 @@
"""Semantic model for libksse command 1114139 / EngineProxy.sted.
This is not a full native allocator port. It captures the recovered behavior
that matters for the EGID/cache_m path:
* the decoded string table used by FUN_00142bf4;
* sentinel file names written by FUN_00140ea8 and read by FUN_00142bf4;
* suffix recovery polarity: existing path selects the candidate character;
* product-keyed JSON returned by FUN_00143d98.
"""
from __future__ import annotations
import json
import hashlib
import time
from dataclasses import dataclass
from typing import Callable, Iterable
from .ksse_crc import ksse_crc32
KSSE_DFP_TABLE_RAW = (
"0123456789ABCDEF@/.Android_@KUAISHOU@a21c40ada1eb475b@"
"DFP@/sdcard/Android@NEBULA"
)
KSSE_DOCUMENTS_DIR = "/sdcard/Documents"
STED_CACHE_FILE_TOKEN = "LnNrdmVj"
STED_CACHE_FILE_NAME = ".skvec"
STED_SHARED_PREF_KEY = "kwtk_n"
DFP_SUFFIX_LEN = 0x3D
class KsseSentinelMissing(ValueError):
"""Raised when the sentinel set cannot recover a full DFP suffix."""
@dataclass(frozen=True)
class KsseDfpStringTable:
alphabet: str
android_hidden_dir_suffix: str
default_product: str
static_salt: str
dfp_prefix: str
sdcard_android_dir: str
active_product: str
@classmethod
def parse(cls, raw: str = KSSE_DFP_TABLE_RAW) -> "KsseDfpStringTable":
parts = raw.split("@")
if len(parts) != 7:
raise ValueError(f"unexpected libksse DFP table entry count: {len(parts)}")
return cls(
alphabet=parts[0],
android_hidden_dir_suffix=parts[1],
default_product=parts[2],
static_salt=parts[3],
dfp_prefix=parts[4],
sdcard_android_dir=parts[5],
active_product=parts[6],
)
@dataclass(frozen=True)
class StedJsonInsertionPlan:
"""One product-keyed JSON insertion attempt in ``FUN_00143d98``."""
output_key: str
stage: str
candidate_paths: list[str]
guard: str
@dataclass(frozen=True)
class StedPersistenceArtifacts:
"""Java/native EGID persistence artifacts after ``rq0.d.e(cache_e, cache_m)``."""
cache_json: str
in_memory_cache: dict[str, str]
shared_preferences: dict[str, str]
app_private_files: dict[str, str]
product_marker: str
native_base_path: str
native_sentinel_paths: list[str]
native_readback_json: str
def normalize_product_marker(marker: str | None, fallback: str = "NEBULA") -> str:
"""Convert native marker like ``0NEBULA`` / ``1KWE_N`` to product key."""
if not marker:
return fallback
marker = str(marker)
if marker[:1] in {"0", "1"}:
marker = marker[1:]
return marker or fallback
def ksse_md5_hex16(data: str | bytes) -> str:
"""Return native ``FUN_001575d0`` + ``FUN_00151204(..., 8)`` output.
``FUN_00151204`` uses the lowercase nibble table at ``DAT_0015cd1f``
(`0123456789abcdef...`), and top-level callers pass only the first
8 digest bytes, so the Python equivalent is the first 16 lowercase
hexadecimal characters of MD5.
"""
if isinstance(data, str):
data = data.encode("utf-8")
return hashlib.md5(data).hexdigest()[:16]
def engine_sted_product_marker(product: str = "NEBULA", writable_external_storage: bool = False) -> str:
"""Return EngineProxy.sted's native marker argument.
Java builds this as ``("1" if z else "0") + productName`` before calling
``Watermelon.jniCommand(1114139, "", str, marker)``. On targetSdk >= 30
the rq0.d.j() caller passes ``z=false``; legacy external-storage paths can
pass ``z=true`` when READ/WRITE external storage permission is available.
"""
product = normalize_product_marker(product)
return ("1" if writable_external_storage else "0") + product
def build_sted_cache_json(cache_e: str, cache_m: str, c_time_ms: int) -> str:
"""Build the compact JSONObject written by ``rq0.d.e``.
Native Java insertion order is ``c_time`` -> ``cache_e`` -> ``cache_m``.
The same text is written to SharedPreferences ``kwtk_n`` and app-private
files ``.skvec``.
"""
return json.dumps(
{
"c_time": int(c_time_ms),
"cache_e": str(cache_e),
"cache_m": str(cache_m),
},
ensure_ascii=False,
separators=(",", ":"),
)
def hidden_cache_path(base_path: str, suffix: str, table: KsseDfpStringTable | None = None) -> str:
"""Return ``base + '/.Android_' + suffix`` as constructed by native code."""
table = table or KsseDfpStringTable.parse()
return f"{str(base_path).rstrip('/')}{table.android_hidden_dir_suffix}{suffix}"
def sted_external_candidate_paths(
product_marker: str | None,
*,
table: KsseDfpStringTable | None = None,
documents_dir: str = KSSE_DOCUMENTS_DIR,
) -> list[str]:
"""First ``FUN_00143d98`` candidate pair: Documents then Android.
Native initializes the primary base from the decoded ``/sdcard/Documents``
constant and the fallback from table slot ``0x78`` (``/sdcard/Android``).
For ``KUAISHOU`` it appends the static salt; every other product key uses
``md5(product_key)[:16]``.
"""
table = table or KsseDfpStringTable.parse()
product = normalize_product_marker(product_marker, table.active_product)
suffix = table.static_salt if product == table.default_product else ksse_md5_hex16(product)
return [
hidden_cache_path(documents_dir, suffix, table),
hidden_cache_path(table.sdcard_android_dir, suffix, table),
]
def sted_product_salt_candidate_paths(
*,
table: KsseDfpStringTable | None = None,
documents_dir: str = KSSE_DOCUMENTS_DIR,
) -> list[str]:
"""Candidate pair using the static table salt ``a21c40ada1eb475b``."""
table = table or KsseDfpStringTable.parse()
return [
hidden_cache_path(documents_dir, table.static_salt, table),
hidden_cache_path(table.sdcard_android_dir, table.static_salt, table),
]
def sted_hidden_md5_candidate_paths(
*,
table: KsseDfpStringTable | None = None,
documents_dir: str = KSSE_DOCUMENTS_DIR,
active_product: str | None = None,
) -> list[str]:
"""Candidate pair using ``md5(table[0x90])[:16]``.
Static flow hashes the active table product (currently ``NEBULA``), not
the dynamic marker passed in ``param_5``.
"""
table = table or KsseDfpStringTable.parse()
product = active_product or table.active_product
suffix = ksse_md5_hex16(product)
return [
hidden_cache_path(documents_dir, suffix, table),
hidden_cache_path(table.sdcard_android_dir, suffix, table),
]
def sted_candidate_path_families(
product_marker: str | None,
*,
table: KsseDfpStringTable | None = None,
documents_dir: str = KSSE_DOCUMENTS_DIR,
) -> dict[str, list[str]]:
"""Return the ordered path families currently recovered from ``sted``."""
table = table or KsseDfpStringTable.parse()
return {
"external": sted_external_candidate_paths(
product_marker,
table=table,
documents_dir=documents_dir,
),
"product_salt": sted_product_salt_candidate_paths(
table=table,
documents_dir=documents_dir,
),
"hidden_md5": sted_hidden_md5_candidate_paths(
table=table,
documents_dir=documents_dir,
),
}
def sted_json_insertion_plan(
product_marker: str | None,
*,
table: KsseDfpStringTable | None = None,
documents_dir: str = KSSE_DOCUMENTS_DIR,
) -> list[StedJsonInsertionPlan]:
"""Return native JSON insertion attempts for ``FUN_00143d98``.
Each attempt still depends on successful suffix recovery. The native code
checks the output string length before prepending ``DFP`` and inserting the
member into the JSON object.
"""
table = table or KsseDfpStringTable.parse()
product = normalize_product_marker(product_marker, table.active_product)
plans = [
StedJsonInsertionPlan(
output_key=product,
stage="external_current",
candidate_paths=sted_external_candidate_paths(
product,
table=table,
documents_dir=documents_dir,
),
guard="always attempted first; insert only if recovered suffix is non-empty",
)
]
if product == table.active_product:
plans.append(
StedJsonInsertionPlan(
output_key=table.default_product,
stage="product_salt_default",
candidate_paths=sted_product_salt_candidate_paths(
table=table,
documents_dir=documents_dir,
),
guard="bVar6 == true; product matches table[0x90] active product",
)
)
elif product == table.default_product:
plans.append(
StedJsonInsertionPlan(
output_key=table.active_product,
stage="hidden_md5_active",
candidate_paths=sted_hidden_md5_candidate_paths(
table=table,
documents_dir=documents_dir,
),
guard="bVar1 == true; product matches table[0x30] default product",
)
)
else:
plans.extend(
[
StedJsonInsertionPlan(
output_key=table.default_product,
stage="product_salt_default",
candidate_paths=sted_product_salt_candidate_paths(
table=table,
documents_dir=documents_dir,
),
guard="!bVar1 && !bVar6; product is neither default nor active",
),
StedJsonInsertionPlan(
output_key=table.active_product,
stage="hidden_md5_active",
candidate_paths=sted_hidden_md5_candidate_paths(
table=table,
documents_dir=documents_dir,
),
guard="!bVar1 && !bVar6; product is neither default nor active",
),
]
)
return plans
def join_sentinel_base(base_path: str) -> str:
"""Return the native sentinel prefix equivalent to ``base + '/.'``."""
base = str(base_path).rstrip("/")
return f"{base}/."
def sentinel_half(index: int, char: str) -> str:
"""Return the half marker value used by FUN_00140ea8.
Native compares the byte with ASCII ``'8'``:
``0`` covers 0..7 and ``1`` covers 8..F for the uppercase hex alphabet.
"""
if len(char) != 1:
raise ValueError("char must be one byte/character")
return "0" if ord(char) < ord("8") else "1"
def sentinel_half_path(base_path: str, index: int, half: str | int) -> str:
"""Path for ``.<index>@<half>``."""
if index < 1:
raise ValueError("native sentinel indices are one-based")
half_text = str(half)
if half_text not in {"0", "1"}:
raise ValueError("half must be 0 or 1")
return f"{join_sentinel_base(base_path)}{index}@{half_text}"
def sentinel_char_path(base_path: str, index: int, char: str) -> str:
"""Path for ``.<index>_<actual_hex_char>``."""
if index < 1:
raise ValueError("native sentinel indices are one-based")
if len(char) != 1:
raise ValueError("char must be one byte/character")
return f"{join_sentinel_base(base_path)}{index}_{char}"
def sentinel_paths_for_suffix(base_path: str, suffix: str) -> list[str]:
"""Return all sentinel paths FUN_00140ea8 would materialize."""
paths: list[str] = []
for index, char in enumerate(suffix, start=1):
paths.append(sentinel_half_path(base_path, index, sentinel_half(index, char)))
paths.append(sentinel_char_path(base_path, index, char))
return paths
def recover_suffix_from_sentinels(
base_path: str,
exists: Callable[[str], bool],
*,
alphabet: str | None = None,
length: int = DFP_SUFFIX_LEN,
) -> str:
"""Recover the 61-byte DFP suffix using FUN_00142bf4's probe order."""
table = KsseDfpStringTable.parse()
alphabet = alphabet or table.alphabet
if len(alphabet) < 16:
raise ValueError("alphabet must contain at least 16 characters")
recovered: list[str] = []
for index in range(1, length + 1):
if exists(sentinel_half_path(base_path, index, "0")):
candidates = alphabet[:8]
elif exists(sentinel_half_path(base_path, index, "1")):
candidates = alphabet[8:16]
else:
candidates = alphabet[:16]
for char in candidates:
if exists(sentinel_char_path(base_path, index, char)):
recovered.append(char)
break
else:
raise KsseSentinelMissing(
f"missing sentinel for suffix index {index} under {base_path!r}"
)
return "".join(recovered)
def ksse_suffix_crc_material(
suffix: str,
*,
model: str = "",
table_raw: str = KSSE_DFP_TABLE_RAW,
) -> bytes:
"""Material that FUN_00142bf4 feeds to FUN_001567b8 / CRC32.
Static evidence shows the native code appends the raw table, then the
recovered suffix after in-place reversal, then ``ro.product.model``.
"""
return (table_raw + suffix[::-1] + model).encode("utf-8")
def ksse_suffix_crc32(
suffix: str,
*,
model: str = "",
table_raw: str = KSSE_DFP_TABLE_RAW,
) -> int:
return ksse_crc32(ksse_suffix_crc_material(suffix, model=model, table_raw=table_raw))
def dfp_suffix_from_value(
value: str,
*,
table: KsseDfpStringTable | None = None,
strict: bool = True,
) -> str:
"""Return the 61-byte suffix from ``DFP...`` or a raw suffix string."""
table = table or KsseDfpStringTable.parse()
text = str(value)
if text.startswith(table.dfp_prefix):
text = text[len(table.dfp_prefix) :]
if strict and len(text) != DFP_SUFFIX_LEN:
raise ValueError(f"DFP suffix must be {DFP_SUFFIX_LEN} characters, got {len(text)}")
return text
def ksse_dfp_suffix_crc_material(
suffix: str,
*,
model: str = "",
table_raw: str = KSSE_DFP_TABLE_RAW,
) -> bytes:
"""CRC material after native reverses recovered marker-order suffix back."""
if len(suffix) != DFP_SUFFIX_LEN:
raise ValueError(f"DFP suffix must be {DFP_SUFFIX_LEN} characters, got {len(suffix)}")
return (table_raw + suffix + model).encode("utf-8")
def ksse_dfp_suffix_crc32(
suffix: str,
*,
model: str = "",
table_raw: str = KSSE_DFP_TABLE_RAW,
) -> int:
return ksse_crc32(ksse_dfp_suffix_crc_material(suffix, model=model, table_raw=table_raw))
def sted_writer_marker_suffix(value: str, *, table: KsseDfpStringTable | None = None) -> str:
"""Suffix order written by ``FUN_00140ea8`` sentinel files.
The writer receives a Java-visible ``DFP`` value, strips the ``DFP`` prefix,
reverses the 61-byte suffix, then materializes sentinels for that reversed
byte order.
"""
return dfp_suffix_from_value(value, table=table)[::-1]
def sted_crc_marker_path(
base_path: str,
value: str,
*,
model: str = "",
table: KsseDfpStringTable | None = None,
table_raw: str = KSSE_DFP_TABLE_RAW,
) -> str:
"""Path for the CRC guard marker written after all byte sentinels."""
suffix = dfp_suffix_from_value(value, table=table)
crc = ksse_dfp_suffix_crc32(suffix, model=model, table_raw=table_raw)
return f"{join_sentinel_base(base_path)}{crc}"
def sted_writer_sentinel_paths(base_path: str, value: str) -> list[str]:
"""Per-byte sentinel files created by ``FUN_00140ea8`` for a DFP value."""
return sentinel_paths_for_suffix(base_path, sted_writer_marker_suffix(value))
def sted_writer_paths(
base_path: str,
value: str,
*,
model: str = "",
include_crc: bool = True,
) -> list[str]:
"""All deterministic marker paths created by ``FUN_00140ea8``.
File contents are just a small truth marker; path names carry the data.
"""
paths = sted_writer_sentinel_paths(base_path, value)
if include_crc:
paths.append(sted_crc_marker_path(base_path, value, model=model))
return paths
def build_sted_persistence_artifacts(
cache_e: str,
cache_m: str,
*,
c_time_ms: int | None = None,
product: str = "NEBULA",
writable_external_storage: bool = False,
model: str = "",
include_crc: bool = True,
table: KsseDfpStringTable | None = None,
documents_dir: str = KSSE_DOCUMENTS_DIR,
) -> StedPersistenceArtifacts:
"""Build deterministic Java/native persistence artifacts for an EGID.
This models the confirmed ``rq0.d.e(cache_e, cache_m)`` chain:
1. put ``c_time/cache_e/cache_m`` into the in-memory map;
2. write the same JSON to SharedPreferences key ``kwtk_n``;
3. write the same JSON to app-private file ``.skvec``;
4. call ``EngineProxy.sted(cache_e, z)`` to materialize native sentinel
files for command ``1114139``.
It deliberately does not try to reproduce the optional external
Java-serialized ``LinkedHashMap`` file from ``rq0.d.j`` because that path is
encrypted through ``uq0.o`` and is not needed for the native STED readback.
"""
table = table or KsseDfpStringTable.parse()
dfp_suffix_from_value(cache_e, table=table)
if c_time_ms is None:
c_time_ms = int(time.time() * 1000)
cache_json = build_sted_cache_json(cache_e, cache_m, c_time_ms)
product_marker = engine_sted_product_marker(
product,
writable_external_storage=writable_external_storage,
)
native_base_path = sted_external_candidate_paths(
product_marker,
table=table,
documents_dir=documents_dir,
)[0]
native_sentinel_paths = sted_writer_paths(
native_base_path,
cache_e,
model=model,
include_crc=include_crc,
)
existing = set(native_sentinel_paths)
native_readback_json = recover_sted_result_json_from_sentinels(
product_marker,
existing.__contains__,
model=model,
require_crc=include_crc,
table=table,
documents_dir=documents_dir,
)
return StedPersistenceArtifacts(
cache_json=cache_json,
in_memory_cache={
"c_time": str(int(c_time_ms)),
"cache_e": str(cache_e),
"cache_m": str(cache_m),
},
shared_preferences={STED_SHARED_PREF_KEY: cache_json},
app_private_files={STED_CACHE_FILE_NAME: cache_json},
product_marker=product_marker,
native_base_path=native_base_path,
native_sentinel_paths=native_sentinel_paths,
native_readback_json=native_readback_json,
)
def recover_dfp_suffix_from_sted_sentinels(
base_path: str,
exists: Callable[[str], bool],
*,
model: str = "",
require_crc: bool = False,
) -> str:
"""Recover the Java-visible DFP suffix from writer-created sentinels."""
marker_order = recover_suffix_from_sentinels(base_path, exists)
suffix = marker_order[::-1]
if require_crc and not exists(sted_crc_marker_path(base_path, suffix, model=model)):
raise KsseSentinelMissing(f"missing CRC marker for recovered suffix under {base_path!r}")
return suffix
def recover_dfp_value_from_sted_sentinels(
base_path: str,
exists: Callable[[str], bool],
*,
model: str = "",
require_crc: bool = False,
table: KsseDfpStringTable | None = None,
) -> str:
"""Recover ``DFP`` + suffix from writer-created sentinels."""
table = table or KsseDfpStringTable.parse()
suffix = recover_dfp_suffix_from_sted_sentinels(
base_path,
exists,
model=model,
require_crc=require_crc,
)
return table.dfp_prefix + suffix
def recover_first_dfp_value_from_candidate_paths(
candidate_paths: Iterable[str],
exists: Callable[[str], bool],
*,
model: str = "",
require_crc: bool = False,
table: KsseDfpStringTable | None = None,
) -> str | None:
"""Try native primary/fallback path order and return the first DFP value."""
table = table or KsseDfpStringTable.parse()
for base_path in candidate_paths:
try:
return recover_dfp_value_from_sted_sentinels(
base_path,
exists,
model=model,
require_crc=require_crc,
table=table,
)
except KsseSentinelMissing:
continue
return None
def recover_sted_values_from_sentinels(
product_marker: str | None,
exists: Callable[[str], bool],
*,
model: str = "",
require_crc: bool = False,
table: KsseDfpStringTable | None = None,
documents_dir: str = KSSE_DOCUMENTS_DIR,
) -> dict[str, str]:
"""Recover ``FUN_00143d98`` product-keyed values from sentinel files.
This models the Java-visible ``EngineProxy.sted(null,z)`` read path:
every planned JSON member is attempted in native insertion order, each
member uses its own primary/fallback path pair, and missing sentinel sets
simply skip that JSON member.
"""
table = table or KsseDfpStringTable.parse()
values: dict[str, str] = {}
for plan in sted_json_insertion_plan(
product_marker,
table=table,
documents_dir=documents_dir,
):
value = recover_first_dfp_value_from_candidate_paths(
plan.candidate_paths,
exists,
model=model,
require_crc=require_crc,
table=table,
)
if value:
values[plan.output_key] = value
return values
def recover_sted_result_json_from_sentinels(
product_marker: str | None,
exists: Callable[[str], bool],
*,
model: str = "",
require_crc: bool = False,
table: KsseDfpStringTable | None = None,
documents_dir: str = KSSE_DOCUMENTS_DIR,
) -> str:
"""Return compact product-keyed JSON recovered from native sentinels."""
return build_sted_result_json(
recover_sted_values_from_sentinels(
product_marker,
exists,
model=model,
require_crc=require_crc,
table=table,
documents_dir=documents_dir,
)
)
def build_sted_result_json(values: dict[str, str]) -> str:
"""Serialize product-keyed DFP values like FUN_00154a0c compact mode."""
return json.dumps(values, ensure_ascii=False, separators=(",", ":"))
def parse_sted_result_json(data: str) -> dict[str, str]:
parsed = json.loads(data)
if not isinstance(parsed, dict):
raise ValueError("sted result must be a JSON object")
return {str(key): str(value) for key, value in parsed.items()}
def select_sted_product_value(data: str | dict[str, str], marker: str | None) -> str:
"""Select current product's DFP value from FUN_00143d98-style JSON."""
values = parse_sted_result_json(data) if isinstance(data, str) else data
product = normalize_product_marker(marker)
if product in values:
return values[product]
table = KsseDfpStringTable.parse()
if table.default_product in values:
return values[table.default_product]
if values:
return next(iter(values.values()))
raise KeyError("empty sted result")
__all__ = [
"DFP_SUFFIX_LEN",
"KSSE_DOCUMENTS_DIR",
"KSSE_DFP_TABLE_RAW",
"STED_CACHE_FILE_NAME",
"STED_CACHE_FILE_TOKEN",
"STED_SHARED_PREF_KEY",
"KsseDfpStringTable",
"KsseSentinelMissing",
"StedJsonInsertionPlan",
"StedPersistenceArtifacts",
"build_sted_cache_json",
"build_sted_persistence_artifacts",
"build_sted_result_json",
"dfp_suffix_from_value",
"engine_sted_product_marker",
"hidden_cache_path",
"join_sentinel_base",
"ksse_dfp_suffix_crc32",
"ksse_dfp_suffix_crc_material",
"ksse_md5_hex16",
"ksse_suffix_crc32",
"ksse_suffix_crc_material",
"normalize_product_marker",
"parse_sted_result_json",
"recover_first_dfp_value_from_candidate_paths",
"recover_dfp_value_from_sted_sentinels",
"recover_dfp_suffix_from_sted_sentinels",
"recover_suffix_from_sentinels",
"recover_sted_result_json_from_sentinels",
"recover_sted_values_from_sentinels",
"select_sted_product_value",
"sentinel_char_path",
"sentinel_half",
"sentinel_half_path",
"sentinel_paths_for_suffix",
"sted_candidate_path_families",
"sted_external_candidate_paths",
"sted_hidden_md5_candidate_paths",
"sted_json_insertion_plan",
"sted_product_salt_candidate_paths",
"sted_crc_marker_path",
"sted_writer_marker_suffix",
"sted_writer_paths",
"sted_writer_sentinel_paths",
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

210
core/kwsg.py Normal file
View File

@ -0,0 +1,210 @@
"""Compatibility aggregate for recovered KWSG-related algorithms.
Implementations live in focused modules:
- `core.sig`
- `core.tokensig`
- `core.sig3`
- `core.enc_data`
- `core.atlas_sign`
- `core.dfp_sign`
- `core.reward_sign`
- `core.xfalcon`
This module exists so old `from ks_sign import ...` call sites keep working.
Do not add new algorithm implementation here.
"""
from __future__ import annotations
from .captured_profile import API_ST, CLIENT_KEY, CLIENT_SALT, DID, EGID, ODID, RDID
from .atlas_sign import (
ATLAS_SIGN_HMAC_KEY,
atlas_sign,
atlas_sign_from_digest_hex,
atlas_sign_to_digest_hex,
)
from .dfp_sign import (
DFP_PAYLOAD_KEYS,
DFP_SDK_ID,
DFP_SIGN_KEYS,
DfpSignMaterial,
build_dfp_sign_material,
dfp_atlas_sign,
id_mapping_data_only,
legacy_product_ts_sv_payload,
parse_dfp_atlas_sign,
sign_dfp_form,
tree_values_sorted_non_empty_except_sign,
)
from .enc_data import (
KWSG_10400_DEFAULT_CFG9,
KWSG_10400_NONCE_XOR,
KWSG_10400_T1_LEN,
KWSG_10400_T2_LEN,
KWSG_266FC_PERM,
KWSG_STATIC_AES_IV,
KWSG_STATIC_AES_KEY,
ZT_OUTER_CONFIGS,
build_inner_zt_header,
derive_outer_xor_key,
kwsg_10400_ecb_encrypt,
kwsg_10400_nonce9,
kwsg_10400_raw,
kwsg_10400_raw_with_inner_fields,
kwsg_266fc_block,
load_kwsg_10400_tables,
parse_inner_zt_header,
zt_outer_unwrap,
zt_outer_wrap,
)
from .reward_sign import (
KWSG_10418_SIGN_HMAC_KEY,
kwsg_10418_reward_sign,
kwsg_10418_reward_sign_from_digest_hex,
kwsg_10418_reward_sign_to_digest_hex,
)
from .sig import SIG_SALT, body_md5, build_sig_plaintext, sig
from .sig3 import (
KWSG_10418_DEFAULT_STATE_SOURCE,
KWSG_10418_HMAC_KEY,
KWSG_10418_PREFIX8,
KWSG_10418_PREFIX_HEAD2,
KWSG_10418_SAMPLE_SESSION_SEED,
KWSG_10418_SIG3_HMAC_KEY,
KWSG_10418_SIG3_PREFIX8,
KWSG_10418_SIG3_PREFIX_CODE,
KWSG_10418_SIGN_PREFIX8,
KWSG_10418_SIGN_PREFIX_CODE,
Kwsg10418State,
kwsg_10418_binary48,
kwsg_10418_binary48_from_prehash32,
kwsg_10418_digest24,
kwsg_10418_digest24_from_binary48,
kwsg_10418_digest24_unmix,
kwsg_10418_prefix8,
kwsg_10418_prehash32,
kwsg_10418_sig3_hex,
kwsg_10418_state_low24_from_source,
load_kwsg_10418_sign_tables,
load_kwsg_10418_tables,
)
from .tokensig import tokensig
from .xfalcon import (
xfalcon_digest_hex_from_input_bytes,
xfalcon_digest_hex_from_input_hex,
xfalcon_digest_words_from_input_bytes,
xfalcon_digest_words_from_input_hex,
xfalcon_folded_block_from_input_bytes,
xfalcon_folded_block_from_raw_bytes,
xfalcon_message_words_from_folded_block,
xfalcon_message_words_from_input_hex,
xfalcon_packed_block_from_input_hex,
xfalcon_te_hex_from_input_bytes,
xfalcon_te_hex_from_input_hex,
xfalcon_value_from_input_bytes,
xfalcon_value_from_input_hex,
)
__all__ = [
"API_ST",
"ATLAS_SIGN_HMAC_KEY",
"CLIENT_KEY",
"CLIENT_SALT",
"DID",
"DFP_PAYLOAD_KEYS",
"DFP_SDK_ID",
"DFP_SIGN_KEYS",
"DfpSignMaterial",
"EGID",
"KWSG_10400_DEFAULT_CFG9",
"KWSG_10400_NONCE_XOR",
"KWSG_10400_T1_LEN",
"KWSG_10400_T2_LEN",
"KWSG_10418_DEFAULT_STATE_SOURCE",
"KWSG_10418_HMAC_KEY",
"KWSG_10418_PREFIX8",
"KWSG_10418_PREFIX_HEAD2",
"KWSG_10418_SAMPLE_SESSION_SEED",
"KWSG_10418_SIG3_HMAC_KEY",
"KWSG_10418_SIG3_PREFIX8",
"KWSG_10418_SIG3_PREFIX_CODE",
"KWSG_10418_SIGN_HMAC_KEY",
"KWSG_10418_SIGN_PREFIX8",
"KWSG_10418_SIGN_PREFIX_CODE",
"KWSG_266FC_PERM",
"KWSG_STATIC_AES_IV",
"KWSG_STATIC_AES_KEY",
"Kwsg10418State",
"ODID",
"RDID",
"SIG_SALT",
"ZT_OUTER_CONFIGS",
"atlas_sign",
"atlas_sign_from_digest_hex",
"atlas_sign_to_digest_hex",
"body_md5",
"build_inner_zt_header",
"build_dfp_sign_material",
"build_sig_plaintext",
"dfp_atlas_sign",
"derive_outer_xor_key",
"id_mapping_data_only",
"kwsg_10400_ecb_encrypt",
"kwsg_10400_nonce9",
"kwsg_10400_raw",
"kwsg_10400_raw_with_inner_fields",
"kwsg_10418_binary48",
"kwsg_10418_binary48_from_prehash32",
"kwsg_10418_digest24",
"kwsg_10418_digest24_from_binary48",
"kwsg_10418_digest24_unmix",
"kwsg_10418_prefix8",
"kwsg_10418_prehash32",
"kwsg_10418_reward_sign",
"kwsg_10418_reward_sign_from_digest_hex",
"kwsg_10418_reward_sign_to_digest_hex",
"kwsg_10418_sig3_hex",
"kwsg_10418_state_low24_from_source",
"kwsg_266fc_block",
"legacy_product_ts_sv_payload",
"load_kwsg_10400_tables",
"load_kwsg_10418_sign_tables",
"load_kwsg_10418_tables",
"parse_inner_zt_header",
"parse_dfp_atlas_sign",
"sig",
"sign_dfp_form",
"tokensig",
"tree_values_sorted_non_empty_except_sign",
"xfalcon_digest_hex_from_input_bytes",
"xfalcon_digest_hex_from_input_hex",
"xfalcon_digest_words_from_input_bytes",
"xfalcon_digest_words_from_input_hex",
"xfalcon_folded_block_from_input_bytes",
"xfalcon_folded_block_from_raw_bytes",
"xfalcon_message_words_from_folded_block",
"xfalcon_message_words_from_input_hex",
"xfalcon_packed_block_from_input_hex",
"xfalcon_te_hex_from_input_bytes",
"xfalcon_te_hex_from_input_hex",
"xfalcon_value_from_input_bytes",
"xfalcon_value_from_input_hex",
"zt_outer_unwrap",
"zt_outer_wrap",
]
def _self_test() -> None:
assert tokensig("6000aa38bfa87d4bacd329574529f9e2", CLIENT_SALT) == (
"13a8a7fc2860f9d6da3d878c22b54c010edbefc9aae4aa138ab9293aa342e41b"
)
assert xfalcon_digest_hex_from_input_hex(
"c29904a53f6a7138ab1d2d4584d24b32a9b8cdeb8ab04ece59e0e2e3f56979b286b5b591fcf0fee8"
) == "a8651e4eff5a688f80b628fd2d9a255ad7655c772cdc835728dccaf5ee5ef8ab"
print("[OK] core.kwsg compatibility exports verified")
if __name__ == "__main__":
_self_test()

File diff suppressed because one or more lines are too long

134
core/mobile_encrypt.py Normal file
View File

@ -0,0 +1,134 @@
"""APP `LoginHelper.b(phone)` 手机号字段加密。
静态链路
``LoginHelper.b``
-> ``KSecurity.atlasEncrypt(phone.getBytes())``
-> ``doCommandNative(10400, ...)``
-> ``xm0.b.b().b(raw)`` 标准 Base64
该分支输出的是 **inner ZT**
``dec0adde + header + B 10400 payload``不是 DFP/广告
`deviceInfo/encData` 那种带 ``5a54...`` outer wrapper 的形态
"""
from __future__ import annotations
import base64
from pathlib import Path
from .enc_data import (
build_inner_zt_header,
kwsg_10400_ecb_encrypt,
kwsg_10400_nonce9,
load_kwsg_10400_tables,
parse_inner_zt_header,
)
# 从 APP `LoginHelper.b` 动态样本解析出的 10400 inner cfg9。
LOGINHELPER_MOBILE_CFG9 = bytes.fromhex("00cf0700eec9b64f27")
# `LoginHelper.b` 使用 10418 B 表分支A 表会得到另一组 payload。
LOGINHELPER_MOBILE_T1_PATH = Path("out/kwsg_10418_B_T1.bin")
LOGINHELPER_MOBILE_T2_PATH = Path("out/kwsg_10418_B_T2.bin")
def _tables_or_load(t1: bytes | None, t2: bytes | None) -> tuple[bytes, bytes]:
if t1 is None and t2 is None:
return load_kwsg_10400_tables(LOGINHELPER_MOBILE_T1_PATH, LOGINHELPER_MOBILE_T2_PATH)
if t1 is None or t2 is None:
raise ValueError("t1 and t2 must be provided together")
return t1, t2
def loginhelper_encrypt_mobile_raw(
mobile: str,
*,
epoch_seconds: int | None = None,
cfg9: bytes = LOGINHELPER_MOBILE_CFG9,
t1: bytes | None = None,
t2: bytes | None = None,
) -> bytes:
"""生成 `LoginHelper.b` Base64 之前的 raw bytes。"""
if not str(mobile):
raise ValueError("mobile is empty")
if len(cfg9) != 9:
raise ValueError("cfg9 must be exactly 9 bytes")
table1, table2 = _tables_or_load(t1, t2)
encrypted_payload = kwsg_10400_ecb_encrypt(str(mobile).encode("utf-8"), table1, table2)
return (
build_inner_zt_header(
kwsg_10400_nonce9(epoch_seconds),
cfg9,
encrypted_payload,
)
+ encrypted_payload
)
def loginhelper_encrypt_mobile(
mobile: str,
*,
epoch_seconds: int | None = None,
cfg9: bytes = LOGINHELPER_MOBILE_CFG9,
t1: bytes | None = None,
t2: bytes | None = None,
) -> str:
"""复现 APP `LoginHelper.b(phone)` 返回值。"""
raw = loginhelper_encrypt_mobile_raw(
mobile,
epoch_seconds=epoch_seconds,
cfg9=cfg9,
t1=t1,
t2=t2,
)
return base64.b64encode(raw).decode("ascii")
def parse_loginhelper_encrypted_mobile(value: str) -> dict:
"""解析 `LoginHelper.b` 输出,便于对照日志样本。"""
raw = base64.b64decode(value)
parsed = parse_inner_zt_header(raw)
parsed["raw"] = raw
return parsed
def loginhelper_encrypted_mobile_matches(value: str, mobile: str) -> bool:
"""判断捕获到的 `LoginHelper.b` 密文是否属于指定手机号。
APP 每次调用时 header 里的 9 字节 nonce 会随时间变化完整 Base64
字符串不能直接比较手机号本身落在 10400 加密后的 payload
因此同号判断只比较 cfg9payload_lenpayload payload CRC
"""
if not value or not str(mobile):
return False
try:
captured = parse_loginhelper_encrypted_mobile(value)
expected = parse_loginhelper_encrypted_mobile(loginhelper_encrypt_mobile(mobile, epoch_seconds=0))
except Exception:
return False
return (
captured.get("magic") == expected.get("magic") == bytes.fromhex("dec0adde")
and captured.get("header_size") == expected.get("header_size") == 0x20
and captured.get("cfg9") == expected.get("cfg9")
and captured.get("crc32") == expected.get("crc32")
and captured.get("payload_len") == expected.get("payload_len")
and captured.get("payload") == expected.get("payload")
)
__all__ = [
"LOGINHELPER_MOBILE_CFG9",
"LOGINHELPER_MOBILE_T1_PATH",
"LOGINHELPER_MOBILE_T2_PATH",
"loginhelper_encrypted_mobile_matches",
"loginhelper_encrypt_mobile",
"loginhelper_encrypt_mobile_raw",
"parse_loginhelper_encrypted_mobile",
]

391
core/privacykit_encrypt.py Normal file
View File

@ -0,0 +1,391 @@
"""PrivacyKit / WeaponHI 风控票据相关工具。
已确认静态链路
``WeaponHI.dd(21)``
-> 读取内存缓存或 SharedPreferences ``wcfg["a_y_q_z"]``
``WeaponHI.b(str)``
-> GZIP(str.getBytes())
-> ``MXSec.atlasEncrypt("privacykit", UUID, 0, gzipBytes)``
-> native ``10400``
-> Base64.NO_WRAP
注意``weaponhi_vimg_upload`` 复现的是 ``WeaponHI.b`` 的另一条上传链
不等同于登录票据登录请求里的 ``VIMG_<base64>$AI_<32hex>``
``Engine.pr(99999, 0, ...)`` 在本地生成 Python 实现在 ``weapon_vimg``
"""
from __future__ import annotations
import base64
import gzip
import hashlib
from dataclasses import asdict, dataclass
from io import BytesIO
from pathlib import Path
from typing import Any, Mapping
from .enc_data import (
KWSG_10400_DEFAULT_CFG9,
ZT_OUTER_CONFIGS,
kwsg_10400_raw,
load_kwsg_10400_tables,
parse_inner_zt_header,
zt_outer_unwrap,
)
from .weapon_vimg import (
decode_passport_account_image_payload,
generate_passport_account_image,
)
PRIVACYKIT_PRODUCT = "privacykit"
PRIVACYKIT_SDK_ID = "7e46b28a-8c93-4940-8238-4c60e64e3c81"
PASSPORT_ACCOUNT_IMAGE_PREFIX = "VIMG_"
PASSPORT_ACCOUNT_IMAGE_AI_MARKER = "$AI_"
PASSPORT_WCFG_KEY = "a_y_q_z"
WEAPONHI_IMG_INITIAL = "R_I_N_I"
@dataclass(frozen=True)
class PassportImageInfo:
"""``passport_account_image`` 的可诊断摘要。"""
ok: bool
has_vimg_prefix: bool
has_ai_hash: bool
base64_len: int
raw_len: int
raw_head_hex: str
format_kind: str
sdk_id: str = ""
ai_hash: str = ""
ai_matches_md5_raw: bool = False
ai_matches_md5_base64: bool = False
ai_matches_local: bool = False
inner_magic_hex: str = ""
inner_header_size: int = 0
inner_payload_len: int = 0
error: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class WeaponHiDd21Result:
"""``WeaponHI.dd(21)`` 的纯 Python 状态机结果。
静态 smali 对齐
- ``WeaponHI.img`` 初值是 ``R_I_N_I``
- ``img.startsWith("VIMG_")````dd`` 直接返回内存缓存
- 否则读取 ``wcfg["a_y_q_z"]``默认值为旧 ``img``并写回 ``img``
``wcfg`` 只承担本地持久化票据内容可由 ``weapon_mf``
``weapon_vimg`` 现场纯算不依赖服务端回写
"""
value: str
source: str
mtype: int
cache_before: str
cache_after: str
wcfg_key: str
has_wcfg_value: bool
is_final_ticket: bool
is_upload_vimg: bool
diagnosis: dict[str, Any]
@property
def ok(self) -> bool:
return bool(self.value and self.value.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX))
def to_dict(self) -> dict[str, Any]:
out = asdict(self)
out["ok"] = self.ok
return out
def java_gzip(data: bytes) -> bytes:
"""生成接近 Java ``GZIPOutputStream`` 的 gzip bytes。
Java 样本头固定为 ``1f8b08000000000000ff``
- mtime = 0
- XFL = 0默认压缩级别
- OS = 255
"""
buf = BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb", compresslevel=6, mtime=0) as gz:
gz.write(bytes(data))
out = buf.getvalue()
if len(out) >= 10 and out[:3] == b"\x1f\x8b\x08":
out = out[:8] + b"\x00\xff" + out[10:]
return out
def _tables_or_load(
t1: bytes | None,
t2: bytes | None,
*,
t1_path: str | Path = "bin/kwsg_10400_T1.bin",
t2_path: str | Path = "bin/kwsg_10400_T2.bin",
) -> tuple[bytes, bytes]:
if t1 is None and t2 is None:
return load_kwsg_10400_tables(t1_path, t2_path)
if t1 is None or t2 is None:
raise ValueError("t1 and t2 must be provided together")
return t1, t2
def privacykit_atlas_encrypt_raw(
payload: bytes,
*,
sdk_id: str = PRIVACYKIT_SDK_ID,
epoch_seconds: int | None = None,
cfg9: bytes = KWSG_10400_DEFAULT_CFG9,
t1: bytes | None = None,
t2: bytes | None = None,
) -> bytes:
"""复现 ``atlasEncrypt("privacykit", sdk_id, 0, payload)`` 的 10400 raw。"""
if not isinstance(payload, (bytes, bytearray)):
raise TypeError("payload must be bytes")
if sdk_id not in ZT_OUTER_CONFIGS:
raise ValueError(f"unknown privacykit sdk_id: {sdk_id}")
if len(cfg9) != 9:
raise ValueError("cfg9 must be exactly 9 bytes")
table1, table2 = _tables_or_load(t1, t2)
return kwsg_10400_raw(
bytes(payload),
sdk_id,
table1,
table2,
epoch_seconds=epoch_seconds,
cfg9=cfg9,
)
def weaponhi_b(
value: str,
*,
sdk_id: str = PRIVACYKIT_SDK_ID,
epoch_seconds: int | None = None,
cfg9: bytes = KWSG_10400_DEFAULT_CFG9,
t1: bytes | None = None,
t2: bytes | None = None,
) -> str:
"""复现 ``WeaponHI.b(str)``gzip -> privacykit 10400 -> Base64.NO_WRAP。"""
payload = java_gzip(str(value).encode("utf-8"))
raw = privacykit_atlas_encrypt_raw(
payload,
sdk_id=sdk_id,
epoch_seconds=epoch_seconds,
cfg9=cfg9,
t1=t1,
t2=t2,
)
return base64.b64encode(raw).decode("ascii")
def weaponhi_vimg_upload(value: str, **kwargs: Any) -> str:
"""构造上传态 ``VIMG_`` 值。
该值用于对齐 ``WeaponHI.b`` 上游加密积木不等同于登录请求中的
``Engine.pr`` 票据
"""
return PASSPORT_ACCOUNT_IMAGE_PREFIX + weaponhi_b(value, **kwargs)
def _mapping_get(mapping: Mapping[str, Any] | dict[str, Any] | None, key: str, default: str) -> str:
if mapping is None:
return default
try:
value = mapping.get(key, default) # type: ignore[attr-defined]
except AttributeError:
return default
if value is None:
return default
return str(value)
def weaponhi_dd21_from_wcfg(
wcfg: Mapping[str, Any] | dict[str, Any] | None,
*,
img_cache: str = WEAPONHI_IMG_INITIAL,
mtype: int = 21,
) -> WeaponHiDd21Result:
"""按 smali 复现 ``WeaponHI.dd(21)`` 的读取/缓存语义。
参数 ``wcfg`` 是已解析的 SharedPreferences/wcfg 字典只读取
``a_y_q_z``如果当前 ``img_cache`` 已经是 ``VIMG_``则完全复用缓存
不再读取 wcfg
"""
cache_before = str(img_cache or "")
if cache_before.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX):
value = cache_before
source = "img_cache"
has_wcfg_value = bool(_mapping_get(wcfg, PASSPORT_WCFG_KEY, ""))
else:
wcfg_value = _mapping_get(wcfg, PASSPORT_WCFG_KEY, cache_before)
value = wcfg_value
source = f"wcfg.{PASSPORT_WCFG_KEY}" if wcfg_value != cache_before else "default_img_cache"
has_wcfg_value = wcfg_value != cache_before
diagnosis = diagnose_passport_account_image(value) if value else {}
return WeaponHiDd21Result(
value=value,
source=source,
mtype=int(mtype),
cache_before=cache_before,
cache_after=value,
wcfg_key=PASSPORT_WCFG_KEY,
has_wcfg_value=has_wcfg_value,
is_final_ticket=bool(
value.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX)
and PASSPORT_ACCOUNT_IMAGE_AI_MARKER in value
and diagnosis.get("ok")
),
is_upload_vimg=bool(
value.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX)
and PASSPORT_ACCOUNT_IMAGE_AI_MARKER not in value
and diagnosis.get("format_kind") == "zt_outer"
),
diagnosis=diagnosis,
)
def _split_passport_value(value: str) -> tuple[str, str]:
text = str(value or "").strip()
if text.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX):
text = text[len(PASSPORT_ACCOUNT_IMAGE_PREFIX):]
if PASSPORT_ACCOUNT_IMAGE_AI_MARKER in text:
body, ai_hash = text.split(PASSPORT_ACCOUNT_IMAGE_AI_MARKER, 1)
return body, ai_hash
return text, ""
def _known_outer_sdk_id(raw: bytes) -> str:
for sdk_id, cfg in ZT_OUTER_CONFIGS.items():
if raw.startswith(cfg["head8"]):
return sdk_id
return ""
def parse_passport_account_image(value: str) -> PassportImageInfo:
"""解析 ``passport_account_image``,返回形态和哈希诊断。
已知两类形态
- ``zt_outer``本地 ``WeaponHI.b`` 生成的 ``5a54...`` 外层 ZT
- ``weapon_pr``本地 ``Engine.pr`` 生成的 ``VIMG_...$AI_...`` 票据
"""
original = str(value or "").strip()
if not original:
raise ValueError("passport_account_image is empty")
has_vimg = original.startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX)
body_b64, ai_hash = _split_passport_value(original)
raw = base64.b64decode(body_b64, validate=True)
raw_head_hex = raw[:16].hex()
md5_raw = hashlib.md5(raw).hexdigest()
md5_b64 = hashlib.md5(body_b64.encode("ascii")).hexdigest()
sdk_id = _known_outer_sdk_id(raw)
format_kind = "unknown"
inner_magic_hex = ""
inner_header_size = 0
inner_payload_len = 0
ai_matches_local = False
if has_vimg and ai_hash:
format_kind = "weapon_pr"
try:
payload = decode_passport_account_image_payload(original)
inner_magic_hex = "2d3d00007d01"
inner_header_size = 8
inner_payload_len = len(payload.encode("utf-8"))
ai_matches_local = generate_passport_account_image(payload) == original
except Exception:
pass
elif sdk_id:
format_kind = "zt_outer"
try:
inner = zt_outer_unwrap(raw, ZT_OUTER_CONFIGS[sdk_id]["xor_key"])
parsed = parse_inner_zt_header(inner)
inner_magic_hex = parsed["magic"].hex()
inner_header_size = int(parsed["header_size"])
inner_payload_len = int(parsed["payload_len"])
except Exception:
# 保留 zt_outer 识别结果inner 解析失败时诊断字段留空。
pass
elif raw.startswith(bytes.fromhex("dec0adde")):
format_kind = "inner_zt"
try:
parsed = parse_inner_zt_header(raw)
inner_magic_hex = parsed["magic"].hex()
inner_header_size = int(parsed["header_size"])
inner_payload_len = int(parsed["payload_len"])
except Exception:
pass
return PassportImageInfo(
ok=True,
has_vimg_prefix=has_vimg,
has_ai_hash=bool(ai_hash),
base64_len=len(body_b64),
raw_len=len(raw),
raw_head_hex=raw_head_hex,
format_kind=format_kind,
sdk_id=sdk_id,
ai_hash=ai_hash,
ai_matches_md5_raw=bool(ai_hash) and ai_hash.lower() == md5_raw,
ai_matches_md5_base64=bool(ai_hash) and ai_hash.lower() == md5_b64,
ai_matches_local=ai_matches_local,
inner_magic_hex=inner_magic_hex,
inner_header_size=inner_header_size,
inner_payload_len=inner_payload_len,
)
def diagnose_passport_account_image(value: str) -> dict[str, Any]:
"""JSON-friendly 诊断;解析失败也返回结构化错误。"""
try:
return parse_passport_account_image(value).to_dict()
except Exception as exc: # noqa: BLE001
return PassportImageInfo(
ok=False,
has_vimg_prefix=str(value or "").startswith(PASSPORT_ACCOUNT_IMAGE_PREFIX),
has_ai_hash=PASSPORT_ACCOUNT_IMAGE_AI_MARKER in str(value or ""),
base64_len=0,
raw_len=0,
raw_head_hex="",
format_kind="invalid",
error=f"{exc.__class__.__name__}: {exc}",
).to_dict()
__all__ = [
"PASSPORT_ACCOUNT_IMAGE_AI_MARKER",
"PASSPORT_ACCOUNT_IMAGE_PREFIX",
"PASSPORT_WCFG_KEY",
"PRIVACYKIT_PRODUCT",
"PRIVACYKIT_SDK_ID",
"PassportImageInfo",
"WEAPONHI_IMG_INITIAL",
"WeaponHiDd21Result",
"diagnose_passport_account_image",
"java_gzip",
"parse_passport_account_image",
"privacykit_atlas_encrypt_raw",
"weaponhi_dd21_from_wcfg",
"weaponhi_b",
"weaponhi_vimg_upload",
]

172
core/reward_request.py Normal file
View File

@ -0,0 +1,172 @@
from __future__ import annotations
import base64
import json
from dataclasses import dataclass
from typing import Any
from .enc_data import kwsg_10400_raw, load_kwsg_10400_tables
from .sig3 import Kwsg10418State
REWARD_SDK = "95147564-9763-4413-a937-6f0e3c12caf1"
APP_NAME = "\u5feb\u624b\u6781\u901f\u7248"
@dataclass(frozen=True)
class RewardBody:
enc_data: str
sign: str
sdk_id: str
sign_counter: int
def _safe_int(value: Any, default: int = 0) -> int:
try:
if value in (None, ""):
return default
return int(str(value))
except Exception:
return default
def _android_release(api_params: dict[str, str]) -> str:
sys_value = str(api_params.get("sys") or "")
if sys_value.startswith("ANDROID_"):
return sys_value.split("_", 1)[1]
return str(api_params.get("androidApiLevel") or "16")
def _screen_size(api_params: dict[str, str]) -> dict[str, int]:
width = _safe_int(api_params.get("sw"), 1080)
height = _safe_int(api_params.get("sh"), 2376)
status_bar = _safe_int(api_params.get("sbh"), 120)
content_height = max(1, height - status_bar - 48)
return {"width": width, "height": content_height}
def _connection_type(api_params: dict[str, str]) -> int:
net = str(api_params.get("net") or "").upper()
if net in {"5G", "NR"}:
return 5
if net in {"WIFI", "WI-FI"}:
return 100
return 100
def _json_bytes(value: dict[str, Any]) -> bytes:
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
def build_reward_str_e(
*,
kind: str,
cookie_dict: dict[str, str],
api_params: dict[str, str],
oaid: str,
scene: tuple[int, int, int],
business_id: int,
neo_params: str = "",
now_ms: int,
network_ip: str = "172.31.230.147",
ipdx_ip: str = "106.178.190.155",
) -> bytes:
user_id = cookie_dict.get("userId") or cookie_dict.get("ud") or api_params.get("ud") or ""
page_id, sub_page_id, _pos_id = scene
imp_ext: dict[str, Any] = {
"openH5AdCount": 0,
"sessionLookedCompletedCount": "0",
"sessionType": "1",
}
if neo_params:
imp_ext["neoParams"] = neo_params
session_id = f"adNeo-{user_id}-{sub_page_id}-{now_ms}"
request_scene_type = 1 if business_id != 606 else 7
data = {
"appInfo": {
"appId": "kuaishou_nebula",
"name": APP_NAME,
"packageName": "com.kuaishou.nebula",
"version": api_params.get("appver") or cookie_dict.get("appver") or "14.5.50.11631",
"versionCode": -1,
},
"deviceInfo": {
"oaid": oaid,
"osType": 1,
"osVersion": _android_release(api_params),
"language": "zh",
"deviceId": cookie_dict.get("did") or api_params.get("did") or "",
"screenSize": _screen_size(api_params),
"ftt": api_params.get("ftt", ""),
"supportGyroscope": True,
},
"networkInfo": {
"ip": network_ip,
"connectionType": _connection_type(api_params),
},
"geoInfo": {"latitude": 0, "longitude": 0},
"userInfo": {"userId": user_id, "age": 0, "gender": ""},
"impInfo": [
{
"pageId": page_id,
"subPageId": sub_page_id,
"action": 0,
"width": 0,
"height": 0,
"browseType": _safe_int(api_params.get("browseType"), 3),
"requestSceneType": request_scene_type,
"lastReceiveAmount": 0,
"impExtData": json.dumps(imp_ext, ensure_ascii=False, separators=(",", ":")),
"mediaExtData": "{}",
"session": json.dumps({"id": session_id}, ensure_ascii=False, separators=(",", ":")),
}
],
"adClientInfo": json.dumps(
{"ipdxIP": ipdx_ip},
ensure_ascii=False,
separators=(",", ":"),
),
"recoReportContext": json.dumps(
{"adClientInfo": {"shouldShowAdProfileSectionBanner": None, "profileAuthorId": 0}},
ensure_ascii=False,
separators=(",", ":"),
),
}
return _json_bytes(data)
def build_reward_body(
str_e: bytes,
state: Kwsg10418State,
*,
t1: bytes | None = None,
t2: bytes | None = None,
sdk_id: str = REWARD_SDK,
unix_time: int | None = None,
enc_epoch_seconds: int | None = None,
) -> RewardBody:
if t1 is None and t2 is None:
t1, t2 = load_kwsg_10400_tables()
if t1 is None or t2 is None:
raise ValueError("t1 and t2 must be provided together")
enc_raw = kwsg_10400_raw(str_e, sdk_id, t1, t2, epoch_seconds=enc_epoch_seconds)
enc_data = base64.b64encode(enc_raw).decode("ascii")
sign = state.reward_sign(str_e, sdk_id, unix_time=unix_time, t1=t1, t2=t2)
return RewardBody(
enc_data=enc_data,
sign=sign,
sdk_id=sdk_id,
sign_counter=state.counter,
)
__all__ = [
"APP_NAME",
"REWARD_SDK",
"RewardBody",
"build_reward_body",
"build_reward_str_e",
]

101
core/reward_sign.py Normal file
View File

@ -0,0 +1,101 @@
"""KWSG 10418 reward body `sign` helpers."""
from __future__ import annotations
import time
from .enc_data import ZT_OUTER_CONFIGS
from .sig3 import (
KWSG_10418_DEFAULT_STATE_SOURCE,
KWSG_10418_SAMPLE_SESSION_SEED,
KWSG_10418_SIGN_PREFIX8,
KWSG_10418_SIGN_PREFIX_CODE,
kwsg_10418_digest24,
kwsg_10418_prefix8,
load_kwsg_10418_sign_tables,
)
KWSG_10418_SIGN_HMAC_KEY = (
b"ndjrNsJq6F6Qk8uavcZT56G2RSsbbeTQX6ROeIdpXAUUm5Xh3BlJtIWZv2ibwyVD"
)
def _input_to_bytes(value: str | bytes | bytearray) -> bytes:
if isinstance(value, str):
return value.encode("utf-8")
return bytes(value)
def _kwsg_10418_sign_tables_or_load(
t1: bytes | None,
t2: bytes | None,
) -> tuple[bytes, bytes]:
if t1 is None and t2 is None:
return load_kwsg_10418_sign_tables()
if t1 is None or t2 is None:
raise ValueError("t1 and t2 must be provided together")
return t1, t2
def kwsg_10418_reward_sign_from_digest_hex(digest_hex48: str, sdk_id: str) -> str:
"""由 `10418` 内部 24-byte digest 生成 reward body `sign`。"""
digest = bytes.fromhex(digest_hex48)
if len(digest) != 24:
raise ValueError("10418 digest must be 24 bytes / 48 hex")
cfg = ZT_OUTER_CONFIGS[sdk_id]
body = bytes(b ^ cfg["xor_key"][i & 0x0F] for i, b in enumerate(digest))
return (cfg["head8"] + body).hex()
def kwsg_10418_reward_sign(
input_value: str | bytes | bytearray,
sdk_id: str,
counter: int,
unix_time: int | None = None,
t1: bytes | None = None,
t2: bytes | None = None,
state_source: int = KWSG_10418_DEFAULT_STATE_SOURCE,
prefix8: bytes | None = None,
session_seed: int = KWSG_10418_SAMPLE_SESSION_SEED,
) -> str:
"""生成 reward body `sign` 的 64hex。"""
if unix_time is None:
unix_time = int(time.time())
t1, t2 = _kwsg_10418_sign_tables_or_load(t1, t2)
if prefix8 is None:
prefix8 = kwsg_10418_prefix8(KWSG_10418_SIGN_PREFIX_CODE, session_seed)
digest_hex = kwsg_10418_digest24(
_input_to_bytes(input_value),
counter,
unix_time,
t1,
t2,
state_source,
prefix8,
KWSG_10418_SIGN_HMAC_KEY,
).hex()
return kwsg_10418_reward_sign_from_digest_hex(digest_hex, sdk_id)
def kwsg_10418_reward_sign_to_digest_hex(sign_hex64: str, sdk_id: str) -> str:
"""反解 reward body `sign`,返回内部 24-byte digest 的 48hex。"""
raw = bytes.fromhex(sign_hex64)
if len(raw) != 32:
raise ValueError("10418 reward sign must be 32 bytes / 64 hex")
cfg = ZT_OUTER_CONFIGS[sdk_id]
if raw[:8] != cfg["head8"]:
raise ValueError("unexpected 10418 reward sign head8")
body = raw[8:]
digest = bytes(b ^ cfg["xor_key"][i & 0x0F] for i, b in enumerate(body))
return digest.hex()
__all__ = [
"KWSG_10418_SIGN_HMAC_KEY",
"KWSG_10418_SIGN_PREFIX8",
"KWSG_10418_SIGN_PREFIX_CODE",
"kwsg_10418_reward_sign",
"kwsg_10418_reward_sign_from_digest_hex",
"kwsg_10418_reward_sign_to_digest_hex",
]

37
core/sig.py Normal file
View File

@ -0,0 +1,37 @@
"""`sig` and related MD5 helpers."""
from __future__ import annotations
import hashlib
# libcore.so 解码出的静态 app salt同 app 版本固定。
SIG_SALT = "772867c19925"
def build_sig_plaintext(params: dict) -> bytes:
"""构造 sig 明文。
参数集是请求的全部非签名参数FormBody 请求会把 body 参数一起纳入
URL `sig`业务字段 `sign` 不属于 URL 签名参数必须保留参与拼接
"""
skip = {"sig", "sig2"}
parts = []
for key in sorted(params.keys()):
if key in skip or key.startswith("__NS"):
continue
parts.append(f"{key}={params[key]}")
return "".join(parts).encode("utf-8")
def sig(params: dict, salt: str = SIG_SALT) -> str:
"""`sig = MD5(build_sig_plaintext(params) + salt)`."""
return hashlib.md5(build_sig_plaintext(params) + salt.encode()).hexdigest()
def body_md5(body: bytes) -> str:
"""`bodyMd5 = MD5(body)`,用于 `sig2` 输入。"""
return hashlib.md5(body).hexdigest()
__all__ = ["SIG_SALT", "body_md5", "build_sig_plaintext", "sig"]

357
core/sig3.py Normal file
View File

@ -0,0 +1,357 @@
"""KWSG 10418 `__NS_sig3` helpers."""
from __future__ import annotations
import hashlib
import hmac
import time
import zlib
from .enc_data import kwsg_10400_ecb_encrypt, load_kwsg_10400_tables
KWSG_10418_PREFIX_HEAD2 = bytes.fromhex("4151")
KWSG_10418_SIG3_PREFIX_CODE = 0x27
KWSG_10418_SIGN_PREFIX_CODE = 0x02
KWSG_10418_SAMPLE_SESSION_SEED = 0x0610FDA7
KWSG_10418_SIG3_PREFIX8 = bytes.fromhex("41512700a7fd1006")
KWSG_10418_SIGN_PREFIX8 = bytes.fromhex("41510200a7fd1006")
KWSG_10418_PREFIX8 = KWSG_10418_SIG3_PREFIX8
KWSG_10418_DEFAULT_STATE_SOURCE = 0xC001000000000000
KWSG_10418_SIG3_HMAC_KEY = (
b"hnna6SanFd1n43zLjCRrdyvLqhyJBw4Ao8NpRcEfixVHSueTmOJDao4KsUDS2nkP"
)
KWSG_10418_HMAC_KEY = KWSG_10418_SIG3_HMAC_KEY
def _input_to_bytes(value: str | bytes | bytearray) -> bytes:
if isinstance(value, str):
return value.encode("utf-8")
return bytes(value)
def kwsg_10418_session_seed_from_time(unix_time: int) -> int:
"""复现 native ``srand(time); rand() + 1`` 的进程 seed。"""
# Android bionic 沿用 BSD random 的 128-byte TYPE_3 状态和 310 次预热。
seed = int(unix_time) & 0xFFFFFFFF
state = [seed or 1]
for index in range(1, 31):
state.append((16807 * state[index - 1]) % 0x7FFFFFFF)
state.extend((state[0], state[1], state[2]))
for index in range(34, 345):
state.append((state[index - 31] + state[index - 3]) & 0xFFFFFFFF)
return ((state[344] >> 1) & 0x7FFFFFFF) + 1
def kwsg_10418_prefix8(
prefix_code: int = KWSG_10418_SIG3_PREFIX_CODE,
session_seed: int = KWSG_10418_SAMPLE_SESSION_SEED,
) -> bytes:
"""构造 `10418` digest pre-buffer 的前 8 字节。"""
code = int(prefix_code) & 0x0FFF
seed = int(session_seed) & 0xFFFFFFFF
return KWSG_10418_PREFIX_HEAD2 + code.to_bytes(2, "little") + seed.to_bytes(4, "little")
def load_kwsg_10418_tables(
t1_path: str = "out/kwsg_10418_B_T1.bin",
t2_path: str = "out/kwsg_10418_B_T2.bin",
) -> tuple[bytes, bytes]:
"""加载 `10418 innerFlag=false` / `0x26e9c` 所需 B 表。"""
return load_kwsg_10400_tables(t1_path, t2_path)
def load_kwsg_10418_sign_tables(
t1_path: str = "bin/kwsg_10400_T1.bin",
t2_path: str = "bin/kwsg_10400_T2.bin",
) -> tuple[bytes, bytes]:
"""加载 `10418 innerFlag=true` / `0x266fc` 所需 A 表。"""
return load_kwsg_10400_tables(t1_path, t2_path)
def _kwsg_10418_tables_or_load(
t1: bytes | None,
t2: bytes | None,
) -> tuple[bytes, bytes]:
if t1 is None and t2 is None:
return load_kwsg_10418_tables()
if t1 is None or t2 is None:
raise ValueError("t1 and t2 must be provided together")
return t1, t2
def kwsg_10418_prehash32(
input_bytes: bytes,
hmac_key: bytes = KWSG_10418_HMAC_KEY,
) -> bytes:
"""移植 `0x1ddbc -> 0x1de14 -> 0x241f4 -> 0x21604`。"""
return hmac.new(hmac_key, input_bytes, hashlib.sha256).digest()
def kwsg_10418_binary48_from_prehash32(prehash32: bytes, t1: bytes, t2: bytes) -> bytes:
"""移植 `0x1df8c -> 0x1dfc4 -> 0x274fc -> 0x27534`。"""
if len(prehash32) != 32:
raise ValueError("10418 prehash must be exactly 32 bytes")
return kwsg_10400_ecb_encrypt(prehash32, t1, t2)
def kwsg_10418_binary48(
input_bytes: bytes,
t1: bytes,
t2: bytes,
hmac_key: bytes = KWSG_10418_HMAC_KEY,
) -> bytes:
"""由原始 `10418` input string 生成 48-byte binary stage。"""
return kwsg_10418_binary48_from_prehash32(
kwsg_10418_prehash32(input_bytes, hmac_key),
t1,
t2,
)
def kwsg_10418_state_low24_from_source(state_source: int) -> int:
"""移植 `0x462a0` 从运行期状态源抽取的 24-bit 状态字段。"""
x = int(state_source) & 0xFFFFFFFFFFFFFFFF
v = 0
v |= (x >> 57) & 0x02
v |= (x >> 61) & 0x01
v |= (x >> 58) & 0x04
v |= (x >> 53) & 0x10
v |= (x >> 54) & 0x20
v |= (x >> 44) & 0x40
v |= 0x0D00
return v & 0x00FFFFFF
def kwsg_10418_digest24_from_binary48(
binary48: bytes,
counter: int,
unix_time: int,
state_source: int = KWSG_10418_DEFAULT_STATE_SOURCE,
prefix8: bytes = KWSG_10418_PREFIX8,
) -> bytes:
"""由 `10418` 的 48-byte binary stage 重建最终 24-byte digest。"""
if len(binary48) != 48:
raise ValueError("10418 binary stage must be exactly 48 bytes")
if len(prefix8) != 8:
raise ValueError("10418 prefix8 must be exactly 8 bytes")
buf = bytearray()
buf += prefix8
buf += (int(counter) & 0xFFFFFFFF).to_bytes(4, "little")
buf += (zlib.crc32(binary48) & 0xFFFFFFFF).to_bytes(4, "little")
buf += (int(unix_time) & 0xFFFFFFFF).to_bytes(4, "little")
buf += kwsg_10418_state_low24_from_source(state_source).to_bytes(4, "little")
s = sum(buf[:23])
mask = (s if s <= 0xFF else -s) & 0xFF
buf[23] = mask
for i in range(23):
buf[i] ^= (mask ^ i) & 0xFF
return bytes(buf)
def kwsg_10418_digest24(
input_bytes: bytes,
counter: int,
unix_time: int,
t1: bytes,
t2: bytes,
state_source: int = KWSG_10418_DEFAULT_STATE_SOURCE,
prefix8: bytes = KWSG_10418_PREFIX8,
hmac_key: bytes = KWSG_10418_HMAC_KEY,
) -> bytes:
"""由原始 `10418` input string 生成最终 24-byte digest。"""
return kwsg_10418_digest24_from_binary48(
kwsg_10418_binary48(input_bytes, t1, t2, hmac_key),
counter,
unix_time,
state_source,
prefix8,
)
def kwsg_10418_sig3_hex(
input_value: str | bytes | bytearray,
counter: int,
unix_time: int | None = None,
t1: bytes | None = None,
t2: bytes | None = None,
state_source: int = KWSG_10418_DEFAULT_STATE_SOURCE,
prefix8: bytes | None = None,
session_seed: int = KWSG_10418_SAMPLE_SESSION_SEED,
hmac_key: bytes = KWSG_10418_SIG3_HMAC_KEY,
) -> str:
"""生成 `innerFlag=false` 时 Java 返回的 48hex。"""
if unix_time is None:
unix_time = int(time.time())
t1, t2 = _kwsg_10418_tables_or_load(t1, t2)
if prefix8 is None:
prefix8 = kwsg_10418_prefix8(KWSG_10418_SIG3_PREFIX_CODE, session_seed)
return kwsg_10418_digest24(
_input_to_bytes(input_value),
counter,
unix_time,
t1,
t2,
state_source,
prefix8,
hmac_key,
).hex()
def kwsg_10418_digest24_unmix(digest24: bytes | bytearray | str) -> dict:
"""反解析 `10418` 最终 24-byte digest恢复扰动前字段。"""
if isinstance(digest24, str):
digest = bytes.fromhex(digest24)
else:
digest = bytes(digest24)
if len(digest) != 24:
raise ValueError("10418 digest must be exactly 24 bytes / 48 hex")
mask = digest[23]
pre = bytearray(24)
for i in range(23):
pre[i] = digest[i] ^ ((mask ^ i) & 0xFF)
pre[23] = 0
s = sum(pre[:23])
expected_mask = (s if s <= 0xFF else -s) & 0xFF
prefix8 = bytes(pre[:8])
return {
"prefix8": prefix8,
"prefix8_ok": prefix8[:2] == KWSG_10418_PREFIX_HEAD2 and prefix8[3] == 0,
"prefix_code": int.from_bytes(prefix8[2:4], "little") & 0x0FFF,
"session_seed": int.from_bytes(prefix8[4:8], "little"),
"counter": int.from_bytes(pre[8:12], "little"),
"crc32": int.from_bytes(pre[12:16], "little"),
"unix_time": int.from_bytes(pre[16:20], "little"),
"state_low24": int.from_bytes(pre[20:23], "little"),
"mask": mask,
"mask_ok": mask == expected_mask,
"pre": bytes(pre),
}
class Kwsg10418State:
"""管理 `10418` 的 session_seed 与全局 counter。"""
def __init__(
self,
session_seed: int = KWSG_10418_SAMPLE_SESSION_SEED,
counter: int = 0,
state_source: int = KWSG_10418_DEFAULT_STATE_SOURCE,
) -> None:
self.session_seed = int(session_seed) & 0xFFFFFFFF
self.counter = int(counter) & 0xFFFFFFFF
self.state_source = int(state_source) & 0xFFFFFFFFFFFFFFFF
@classmethod
def from_digest(
cls,
digest24: bytes | bytearray | str,
state_source: int = KWSG_10418_DEFAULT_STATE_SOURCE,
) -> "Kwsg10418State":
parsed = kwsg_10418_digest24_unmix(digest24)
if not parsed["mask_ok"] or not parsed["prefix8_ok"]:
raise ValueError("invalid 10418 digest")
return cls(parsed["session_seed"], parsed["counter"], state_source)
@classmethod
def from_reward_sign(
cls,
sign_hex64: str,
sdk_id: str,
state_source: int = KWSG_10418_DEFAULT_STATE_SOURCE,
) -> "Kwsg10418State":
from .reward_sign import kwsg_10418_reward_sign_to_digest_hex
return cls.from_digest(
kwsg_10418_reward_sign_to_digest_hex(sign_hex64, sdk_id),
state_source,
)
def observe_digest(self, digest24: bytes | bytearray | str) -> dict:
parsed = kwsg_10418_digest24_unmix(digest24)
if not parsed["mask_ok"] or not parsed["prefix8_ok"]:
raise ValueError("invalid 10418 digest")
self.session_seed = parsed["session_seed"]
self.counter = parsed["counter"]
return parsed
def observe_reward_sign(self, sign_hex64: str, sdk_id: str) -> dict:
from .reward_sign import kwsg_10418_reward_sign_to_digest_hex
return self.observe_digest(
kwsg_10418_reward_sign_to_digest_hex(sign_hex64, sdk_id)
)
def next_counter(self) -> int:
self.counter = (self.counter + 1) & 0xFFFFFFFF
return self.counter
def sig3_hex(
self,
input_value: str | bytes | bytearray,
unix_time: int | None = None,
t1: bytes | None = None,
t2: bytes | None = None,
) -> str:
return kwsg_10418_sig3_hex(
input_value,
self.next_counter(),
unix_time,
t1,
t2,
self.state_source,
session_seed=self.session_seed,
)
def reward_sign(
self,
input_value: str | bytes | bytearray,
sdk_id: str,
unix_time: int | None = None,
t1: bytes | None = None,
t2: bytes | None = None,
) -> str:
from .reward_sign import kwsg_10418_reward_sign
return kwsg_10418_reward_sign(
input_value,
sdk_id,
self.next_counter(),
unix_time,
t1,
t2,
self.state_source,
session_seed=self.session_seed,
)
__all__ = [
"KWSG_10418_DEFAULT_STATE_SOURCE",
"KWSG_10418_HMAC_KEY",
"KWSG_10418_PREFIX8",
"KWSG_10418_PREFIX_HEAD2",
"KWSG_10418_SAMPLE_SESSION_SEED",
"KWSG_10418_SIG3_HMAC_KEY",
"KWSG_10418_SIG3_PREFIX8",
"KWSG_10418_SIG3_PREFIX_CODE",
"KWSG_10418_SIGN_PREFIX8",
"KWSG_10418_SIGN_PREFIX_CODE",
"Kwsg10418State",
"kwsg_10418_binary48",
"kwsg_10418_binary48_from_prehash32",
"kwsg_10418_digest24",
"kwsg_10418_digest24_from_binary48",
"kwsg_10418_digest24_unmix",
"kwsg_10418_prefix8",
"kwsg_10418_prehash32",
"kwsg_10418_session_seed_from_time",
"kwsg_10418_sig3_hex",
"kwsg_10418_state_low24_from_source",
"load_kwsg_10418_sign_tables",
"load_kwsg_10418_tables",
]

59
core/sig3_shape.py Normal file
View File

@ -0,0 +1,59 @@
"""Shape classifier for the two observed `__NS_sig3` families."""
from __future__ import annotations
from enum import Enum
API_10418_SIG3_HEX_LENGTH = 48
H5_SIG3_HEX_LENGTH = 68
class Sig3Shape(str, Enum):
API_10418 = "api_10418"
H5_ENVELOPE = "h5_envelope"
UNKNOWN = "unknown"
def _is_hex(value: str) -> bool:
try:
int(value, 16)
except ValueError:
return False
return bool(value)
def classify_sig3(value: str | bytes | bytearray | None) -> Sig3Shape:
"""Classify `__NS_sig3` by confirmed wire length."""
if value is None:
return Sig3Shape.UNKNOWN
if isinstance(value, (bytes, bytearray)):
text = bytes(value).hex()
else:
text = str(value).strip()
if not _is_hex(text):
return Sig3Shape.UNKNOWN
if len(text) == API_10418_SIG3_HEX_LENGTH:
return Sig3Shape.API_10418
if len(text) == H5_SIG3_HEX_LENGTH:
return Sig3Shape.H5_ENVELOPE
return Sig3Shape.UNKNOWN
def is_api_10418_sig3(value: str | bytes | bytearray | None) -> bool:
return classify_sig3(value) == Sig3Shape.API_10418
def is_h5_sig3(value: str | bytes | bytearray | None) -> bool:
return classify_sig3(value) == Sig3Shape.H5_ENVELOPE
__all__ = [
"API_10418_SIG3_HEX_LENGTH",
"H5_SIG3_HEX_LENGTH",
"Sig3Shape",
"classify_sig3",
"is_api_10418_sig3",
"is_h5_sig3",
]

1581
core/sms_login.py Normal file

File diff suppressed because it is too large Load Diff

16
core/tokensig.py Normal file
View File

@ -0,0 +1,16 @@
"""`__NStokensig` helper."""
from __future__ import annotations
import hashlib
def tokensig(sig_value: str, client_salt: str) -> str:
"""`__NStokensig = SHA256(sig + tokenClientSalt)`.
`client_salt` 来自账号登录态不是静态算法常量
"""
return hashlib.sha256((sig_value + client_salt).encode()).hexdigest()
__all__ = ["tokensig"]

99
core/weapon_d0.py Normal file
View File

@ -0,0 +1,99 @@
"""d0 weapon SDK crypto — pure-computation port of ``com.kuaishou.weapon.ks.d0``.
Reverse-engineered from the APK Java fallback path (``d0.java``,
``l.java``, ``j1.java``, ``i0.java``, ``q.java`` under
``out/jadx/sources/com/kuaishou/weapon/ks/``). The native JNI path
(``W.dc/dr/ar/ac``) is the accelerated equivalent; the Java fallback produces
byte-identical output and is what we reproduce here.
Layer map (verified against the jadx sources):
q = ``android.util.Base64`` with flag NO_WRAP (standard alphabet)
i0 = gzip (``a``=compress, ``b``=decompress)
j1 = RC4 (KSA ``b(str)`` + PRGA ``a(data,key)``; symmetric)
l = AES/CBC/PKCS5Padding (``a``=decrypt(key,iv,data), ``c``=encrypt(key,iv,data))
Key derivation (``d0.java:22`` / ``41`` / ``192``)::
raw = base64_decode("a3NyaXNrY3RsYnVzaW5zc3Z4cHprd3NwYWlvcXBrc3M=")
= b"ksriskctlbusinssvxpzkwspaioqpkss"
key16 = first 16 chars -> "ksriskctlbusinss" (pad with '0' / truncate to 16)
The same ``key16`` is used as BOTH the AES key and the AES IV ``l.a``/``l.c``
are always called with ``(key16, key16, data)``.
Transforms::
d0.encrypt(str) = base64( AES-CBC-enc(key16,key16, RC4(key16, gzip(plaintext))) )
d0.decrypt(str) = gunzip( RC4(key16, AES-CBC-dec(key16,key16, base64_decode(ct))) )
AES-CBC primitives are reused from :mod:`core.h5_kww_alg` (vendored pure-Python
AES-128), so no new third-party crypto dependency is introduced.
"""
from __future__ import annotations
import base64
import gzip
from .h5_kww_alg import kwf_aes_cbc_decrypt, kwf_aes_cbc_encrypt
_KEY_B64 = "a3NyaXNrY3RsYnVzaW5zc3Z4cHprd3NwYWlvcXBrc3M="
def _derive_key16() -> bytes:
"""Reproduce ``d0.java`` key normalization: pad-with-'0' or truncate to 16."""
raw = base64.b64decode(_KEY_B64) # q.a(bytes, 2)
s = raw.decode("latin-1") # new String(bytes); bytes are ASCII
if len(s) < 16:
s = s + "0" * (16 - len(s))
elif len(s) > 16:
s = s[:16]
return s[:16].encode("latin-1")
KEY16 = _derive_key16() # b"ksriskctlbusinss"
def _rc4(key: bytes, data: bytes) -> bytes:
"""Textbook RC4 — port of ``j1.b`` (KSA) + ``j1.a`` (PRGA). Symmetric."""
s = list(range(256))
j = 0
klen = len(key)
for i in range(256):
j = (key[i % klen] + s[i] + j) & 0xFF
s[i], s[j] = s[j], s[i]
out = bytearray(len(data))
i = 0
j = 0
for n in range(len(data)):
i = (i + 1) & 0xFF
j = (s[i] + j) & 0xFF
s[i], s[j] = s[j], s[i]
out[n] = s[(s[i] + s[j]) & 0xFF] ^ data[n]
return bytes(out)
def decrypt(ciphertext_b64: str) -> str:
"""``d0.a(str)`` / ``d0.a(str,key)`` / static ``d0.b(str)`` — decrypt a d0 blob.
Used by ``p1.java`` to decrypt the config-pull ``antispamSdkRsp`` field, and
by ``d0.b(str)`` to decode hardcoded host/path strings.
"""
raw = base64.b64decode(ciphertext_b64) # q.a(bytes, 2)
aes_out = kwf_aes_cbc_decrypt(raw, KEY16, KEY16) # l.a(key16,key16,_)
rc4_out = _rc4(KEY16, aes_out) # j1.b(_,key16)
plain = gzip.decompress(rc4_out) # i0.b(_)
return plain.decode("utf-8") # new String(bytes)
def encrypt(plaintext: str) -> str:
"""``d0.c(str)`` / ``d0.b(str,key)`` — encrypt a string into a d0 blob.
Used to encrypt the config-pull request body.
"""
data = plaintext.encode("utf-8") # str.getBytes()
gz = gzip.compress(data) # i0.a(_)
rc4_out = _rc4(KEY16, gz) # j1.c(_,key16)
aes_out = kwf_aes_cbc_encrypt(rc4_out, KEY16, KEY16) # l.c(key16,key16,_)
return base64.b64encode(aes_out).decode("ascii") # q.c(_,2)

47
core/weapon_kas.py Normal file
View File

@ -0,0 +1,47 @@
"""Weapon ``W.pr(99999, 2, ...)`` 的纯 Python KAS 生成器。"""
from __future__ import annotations
import base64
import urllib.parse
from dataclasses import dataclass
from .weapon_vimg import _generate_ai_hex, _java_modified_utf8
# ``WeaponHI.g(context)`` 在 ``z_y_x_a`` 尚未下发时使用的 APK 内置值。
APK_DEFAULT_WEAPON_KAW = (
"MDAgOGjoRLeHCT6xRdsOAngNH4RQOdDTY7WVEbnxKJqLVJUDnvV26jVwacDZJjYLO8Uz"
"N8PYNaiJslgA"
)
def build_weapon_signature_input(url: str, kaw: str) -> str:
"""还原 ``WeaponSigInterceptor`` 传给 ``W.pr`` 的明文。"""
parsed = urllib.parse.urlsplit(url)
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
sig3 = query.get("__NS_sig3", [""])[0]
return f"{parsed.path}{sig3}{kaw}"
def generate_weapon_kas(payload: str) -> str:
"""复现 ``W.pr(99999, 2, len(payload) * 2, payload)``。"""
payload_base64 = base64.b64encode(_java_modified_utf8(payload)).decode("ascii")
return "00" + _generate_ai_hex(payload_base64)
@dataclass(frozen=True, slots=True)
class WeaponProofProvider:
"""根据最终签名 URL 生成对应的 ``kaw`` / ``kas`` 请求头。"""
kaw: str = APK_DEFAULT_WEAPON_KAW
def __post_init__(self) -> None:
if not self.kaw or self.kaw.strip() != self.kaw:
raise ValueError("weapon kaw must be a non-empty value without outer whitespace")
def __call__(self, url: str) -> dict[str, str]:
payload = build_weapon_signature_input(url, self.kaw)
return {"kaw": self.kaw, "kas": generate_weapon_kas(payload)}

182
core/weapon_mf.py Normal file
View File

@ -0,0 +1,182 @@
"""纯 Python 复现 ``new mf(context, event).a()`` 的设备状态 JSON。"""
from __future__ import annotations
import hashlib
import json
import time
from dataclasses import dataclass
from typing import Any, Mapping
from .device_profile import DeviceProfile
from .weapon_vimg import generate_passport_account_image
@dataclass(frozen=True)
class WeaponRuntimeSnapshot:
"""一次 WeaponHI 上报使用的易变 Android 运行时状态。"""
now_ms: int
elapsed_realtime_ms: int
uptime_ms: int
boot_count: int
report_counter: int = 1
def __post_init__(self) -> None:
if self.now_ms < 0:
raise ValueError("now_ms must not be negative")
if self.elapsed_realtime_ms < 0:
raise ValueError("elapsed_realtime_ms must not be negative")
if not 0 <= self.uptime_ms <= self.elapsed_realtime_ms:
raise ValueError("uptime_ms must be between 0 and elapsed_realtime_ms")
if self.boot_count < 0 or self.report_counter < 1:
raise ValueError("boot_count/report_counter out of range")
@classmethod
def fresh(
cls,
profile: DeviceProfile,
*,
now_ms: int | None = None,
report_counter: int = 1,
) -> "WeaponRuntimeSnapshot":
current_ms = int(time.time() * 1000) if now_ms is None else int(now_ms)
seed = _profile_seed(profile)
boot_age_ms = 6 * 60 * 60 * 1000 + seed % (5 * 24 * 60 * 60 * 1000)
sleep_ms = (seed >> 17) % max(1, boot_age_ms // 3)
return cls(
now_ms=current_ms,
elapsed_realtime_ms=boot_age_ms,
uptime_ms=boot_age_ms - sleep_ms,
boot_count=1 + ((seed >> 29) % 180),
report_counter=report_counter,
)
def _profile_seed(profile: DeviceProfile) -> int:
material = "|".join(
(
profile.android_id,
profile.local_did,
profile.sid,
str(profile.install_time_ms),
)
)
return int.from_bytes(hashlib.sha256(material.encode("utf-8")).digest()[:8], "big")
def _installation_id(profile: DeviceProfile) -> str:
source = profile.sid.replace("-", "")
if len(source) < 16:
source = hashlib.sha256(profile.android_id.encode("ascii")).hexdigest()
random_half = "".join(source[index * 2] for index in range(8))
created_ms = profile.install_time_ms or profile.cold_launch_time_ms
time_half = f"{created_ms:x}"[-8:].zfill(8)
return f"a_{random_half}{time_half}"
def build_mf_payload(
profile: DeviceProfile,
*,
event: int = 1,
snapshot: WeaponRuntimeSnapshot | None = None,
field_overrides: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""按 ``mf.a()`` 的插入顺序构建设备状态对象。"""
runtime = snapshot or WeaponRuntimeSnapshot.fresh(profile)
seed = _profile_seed(profile)
battery_level = 40 + seed % 61
storage_bytes = profile.runtime_hints.storage_available_bytes or 491_765_592_064
boot_epoch_seconds = (runtime.now_ms - runtime.elapsed_realtime_ms) // 1000
payload: dict[str, Any] = {
"0": 1,
"3": 1,
"8": 0,
"24": 0,
"35": 0,
"30": 0,
"31": 0,
"65": 0,
"66": 0,
"68": 0,
"101": 0,
"102": 0,
"1021": _installation_id(profile),
"6": "0",
"47": "0",
"48": "0",
"37": "0",
"38": "0",
"45": "0",
"91": "0",
"54": "0",
"55": "0",
"79": "0",
"80": "1",
"83": '["{\\"1\\":1}","{\\"2\\":1}"]',
"87": "0",
"89": "0",
"75": "0",
"88": "0",
"92": "0",
"98": "0",
"100": "0",
"02001": profile.manufacturer,
"02002": profile.manufacturer,
"02003": profile.model,
"02008": profile.build_display,
"02016": profile.android_release,
"03014": True,
"03113": "1970-01-01",
"07025": "0",
"03020": "USB charger",
"03033": False,
"03043": runtime.elapsed_realtime_ms,
"03044": runtime.uptime_ms,
"03045": boot_epoch_seconds,
"03085": str(runtime.boot_count),
"03086": str(runtime.boot_count),
"02029": f"{280 + seed % 40}.{(seed >> 8) % 100:02d}",
"03128": str(storage_bytes),
"03030": 20 + ((seed >> 16) % 100),
"03006": (seed >> 24) % 8,
"03007": f"{battery_level}%",
"03015": "0",
"03115": (seed >> 7) % 1_000_000_000,
# 主 APP 在云 DID 更新后调用 WeaponHI.setGmf 的 ne.k() 读取当前 DID。
"03000": profile.did,
"20000": int(event),
"11113": 0,
"11111": runtime.now_ms // 1000,
"11112": runtime.report_counter,
"07069": 0,
"07070": 0,
}
if field_overrides:
for key, value in field_overrides.items():
payload[str(key)] = value
return payload
def serialize_mf_payload(payload: Mapping[str, Any]) -> str:
"""复现 Android ``JSONObject.toString()`` 的紧凑 JSON。"""
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def generate_profile_passport_account_image(
profile: DeviceProfile,
*,
event: int = 1,
snapshot: WeaponRuntimeSnapshot | None = None,
field_overrides: Mapping[str, Any] | None = None,
) -> str:
payload = build_mf_payload(
profile,
event=event,
snapshot=snapshot,
field_overrides=field_overrides,
)
return generate_passport_account_image(serialize_mf_payload(payload))

View File

@ -0,0 +1,217 @@
"""Weapon SDK plugin-loader client - pure-computation port of ``com.kuaishou.weapon.ks.u1``.
Fetches the dynamic weapon plugin manifest from the GDFP "plugin manager"
endpoint, decrypts ``antispamPluginManageRsp`` via :mod:`core.weapon_d0`, and
exposes the ``plugin`` map (each entry's ``wm`` is the dex download URL).
Reverse-engineered from ``u1.java`` / ``x0.java`` / ``h1.java`` / ``t.java`` /
``i.java`` / ``g.java`` under ``out/jadx/sources/com/kuaishou/weapon/ks/``.
Endpoint (``x0.f50698a`` + ``x0.f50700c``)::
https://gdfp.gifshow.com/rest/infra/gdfp/a/q
Request (``u1.a`` / ``u1.b`` / ``h1.b``)::
query = appkey=16&secretkey=<sk>&timestamp=<ts>&sign=md5(16+sk+ts)
body = {"data": d0.c(h1.b(ctx).toString())}
resp.result==1 -> d0.a(antispamPluginManageRsp) -> {status, policyId, plugin:{name:{wk,wan,wm,...}}}
The ``plugin`` map's p0 entry has ``wm`` = apk download URL (``b1.f50366i``) and
``apkMD5`` (``b1.f50367j``). The downloaded blob is then AES-decrypted (``l.b``)
before being loaded by ``a0`` (InMemoryDexClassLoader) - see
:func:`decrypt_plugin_blob`.
"""
from __future__ import annotations
import hashlib
import json
import time
from typing import Any, Callable, Dict, Optional, Tuple
from urllib.parse import quote
import requests
from .weapon_d0 import decrypt, encrypt
HOST = "https://gdfp.gifshow.com"
POLICY_PATH = "/rest/infra/gdfp/m/k" # x0.f50699b (p1: antispamSdkRsp)
PLUGINLOADER_PATH = "/rest/infra/gdfp/a/q" # x0.f50700c (u1: antispamPluginManageRsp)
POLICY_URL = HOST + POLICY_PATH
PLUGINLOADER_URL = HOST + PLUGINLOADER_PATH
APPKEY = "20001"
SECRETKEY = "117d05716732fb8835c5b32cdc6c5e9e" # hardcoded in WeaponSdkInitModule.smali:360,364
SDKVER = "7.2.1"
PIV = "v1" # ConsumeInfoUtils.f73701b (= h1.c "iv" and h1.b "piv")
PACKAGE_NAME = "com.kuaishou.nebula"
def _md5_hex(s: str) -> str:
return hashlib.md5(s.encode("utf-8")).hexdigest()
def build_plugin_query(ts: Optional[int] = None) -> str:
"""``u1.b`` / ``h1.d`` -> ``appkey=..&secretkey=..&timestamp=..&sign=md5(16+sk+ts)``."""
if ts is None:
ts = int(time.time())
sign = _md5_hex(APPKEY + SECRETKEY + str(ts))
return f"appkey={APPKEY}&secretkey={SECRETKEY}&timestamp={ts}&sign={sign}"
def build_h1b_json(profile) -> dict:
"""``h1.b(ctx)`` plaintext - u1 plugin-manager body descriptor."""
return {
"k": "",
"hp": PACKAGE_NAME, # t.e(ctx)
"hv": profile.app_version, # t.d(ctx)
"pver": "0.0.0", # z0 plc001_v_s default
"platform": 1,
"device_id": profile.did, # t.f(ctx)
"sdkver": SDKVER,
"piv": PIV, # ConsumeInfoUtils.f73701b
"sysver": f"ANDROID_{profile.android_release}", # t.f()
"mod": f"{profile.manufacturer}({profile.model})", # t.d()
}
def build_h1c_json(profile) -> dict:
"""``h1.c(ctx)`` plaintext - p1 policy body descriptor (``iv`` not ``piv``; no sysver/mod)."""
return {
"k": "",
"hp": PACKAGE_NAME,
"hv": profile.app_version,
"pver": "0.0.0",
"platform": 1,
"device_id": profile.did,
"sdkver": SDKVER,
"iv": PIV,
}
def build_plugin_cookie(profile) -> str:
"""``t.b()`` -> ``;``-joined device params (the ``Cookie`` header)."""
parts = [
("userId", ""),
("platform", ""),
("channel", ""),
("mod", quote(f"{profile.manufacturer}({profile.model})", safe="")),
("globalId", ""),
("sysver", quote(f"ANDROID_{profile.android_release}", safe="")),
("rdid", profile.rdid),
("did_tag", ""),
("cdid_tag", ""),
]
return ";".join(f"{k}={v}" for k, v in parts)
def _encrypt_body(h1_json: dict) -> str:
data = encrypt(json.dumps(h1_json, separators=(",", ":"), ensure_ascii=False))
return json.dumps({"data": data}, separators=(",", ":"))
def _gdfp_post(url, body, profile, *, post_func, timeout, include_cookie):
headers = {"Content-Type": "application/json"}
if include_cookie:
headers["Cookie"] = build_plugin_cookie(profile)
resp = post_func(url, data=body, headers=headers, timeout=timeout)
return resp
def _parse_gdfp_response(resp, rsp_field: str) -> Tuple[str, Optional[dict], Any]:
"""Common p1/u1 response parse: result==1 -> d0.a(<rsp_field>) -> JSON dict."""
text = getattr(resp, "text", str(resp))
try:
outer = json.loads(text)
except Exception:
return text, None, resp
if outer.get("result") != 1:
return text, None, resp
enc = outer.get(rsp_field, "")
if not enc:
return text, None, resp
try:
dec = decrypt(enc)
except Exception as exc: # noqa: BLE001
return f"<decrypt-failed: {exc.__class__.__name__}: {exc}>\nraw={enc[:200]}", None, resp
try:
inner = json.loads(dec)
except Exception:
return dec, None, resp
return dec, inner, resp
def fetch_policy(
profile,
*,
post_func: Optional[Callable] = None,
timeout: int = 30,
include_cookie: bool = True,
device_id: Optional[str] = None,
) -> Tuple[str, Optional[dict], Any]:
"""POST ``/rest/infra/gdfp/m/k`` (p1) -> d0.a(antispamSdkRsp) -> policy dict."""
if post_func is None:
post_func = requests.post
prof = _profile_with_device_id(profile, device_id) if device_id is not None else profile
ts = int(time.time())
url = f"{POLICY_URL}?{build_plugin_query(ts)}"
body = _encrypt_body(build_h1c_json(prof))
resp = _gdfp_post(url, body, prof, post_func=post_func, timeout=timeout, include_cookie=include_cookie)
return _parse_gdfp_response(resp, "antispamSdkRsp")
def fetch_plugin_manifest(
profile,
*,
post_func: Optional[Callable] = None,
timeout: int = 30,
include_cookie: bool = True,
device_id: Optional[str] = None,
) -> Tuple[str, Optional[dict], Any]:
"""POST ``/rest/infra/gdfp/a/q`` (u1) -> d0.a(antispamPluginManageRsp) -> plugin map.
Returns ``(decrypted_inner_str, plugin_map_or_inner_dict, response)``.
``plugin_map`` is ``None`` if result!=1 / no antispamPluginManageRsp / decrypt failed.
"""
if post_func is None:
post_func = requests.post
prof = _profile_with_device_id(profile, device_id) if device_id is not None else profile
ts = int(time.time())
url = f"{PLUGINLOADER_URL}?{build_plugin_query(ts)}"
body = _encrypt_body(build_h1b_json(prof))
resp = _gdfp_post(url, body, prof, post_func=post_func, timeout=timeout, include_cookie=include_cookie)
dec, inner, resp = _parse_gdfp_response(resp, "antispamPluginManageRsp")
plugin_map = inner.get("plugin") if isinstance(inner, dict) else None
return dec, plugin_map, resp
class _ShimProfile:
"""Lightweight profile override so we don't mutate the caller's object."""
def __init__(self, base, device_id):
self.__dict__.update(base.__dict__)
self.did = device_id
def _profile_with_device_id(profile, device_id):
return _ShimProfile(profile, device_id)
def find_p0_plugin(plugin_map: Optional[dict]) -> Optional[Tuple[str, dict]]:
"""Find the p0 plugin descriptor. Returns ``(plugin_name, descriptor_dict)``.
u1 selects p0 via ``b1.f50360c.contains("p0")`` (apkPackageName). Without
reversing the ``k`` getter mapping, we heuristically pick the entry whose
serialized descriptor (or key) mentions ``p0``.
"""
if not plugin_map:
return None
for name, desc in plugin_map.items():
if not isinstance(desc, dict):
continue
blob = json.dumps(desc, ensure_ascii=False) + name
if "p0" in blob:
return name, desc
return None

275
core/weapon_vimg.py Normal file
View File

@ -0,0 +1,275 @@
"""Weapon p0 ``Engine.pr(99999, 0, ...)`` 的纯 Python VIMG base 生成器。"""
from __future__ import annotations
import base64
import struct
VIMG_PREFIX = "VIMG_"
_MASK32 = 0xFFFFFFFF
_XOR_BYTE = 0x55
_BLAKE2S_SIGMA = (
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
(14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3),
(11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4),
(7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8),
(9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13),
(2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9),
(12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11),
(13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10),
(6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5),
(10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0),
)
_H1_INITIAL_STATE = (
0xAA98186E,
0xF3CCD768,
0x99531AAE,
0x669781D3,
0x125FD5B4,
0x9883595E,
0x74F4CBCD,
0x98C26A78,
)
_H1_IV = (0xAB99184E, *_H1_INITIAL_STATE[1:])
_H2_ADD_BASE = bytes.fromhex("0c35ff3dcbfefb3f3efd6539fd39efcd")
_AI_XOR_MASK = bytes.fromhex("2dd345c0")
# 0x1a6b28 初始化、0x1a79f8 执行 20-round ChaCha block。这里保留 native
# 实际状态,不替换为标准的 "expand 32-byte k" 常量。
_CHACHA_STATE = (
0x1783595E,
0x8DC26A78,
0x2599184E,
0x729781D3,
0x2ADEF3F4,
0x9876EF16,
0x9ABED34F,
0x9103DE12,
0xA92157F6,
0xA9A24FF4,
0x9138D3FD,
0x2A2193F3,
1,
0x74F4CBCD,
0x98C26A78,
0xAB99184E,
)
def _rotate_left32(value: int, shift: int) -> int:
return ((value << shift) | (value >> (32 - shift))) & _MASK32
def _rotate_right32(value: int, shift: int) -> int:
return ((value >> shift) | (value << (32 - shift))) & _MASK32
def _quarter_round(words: list[int], a: int, b: int, c: int, d: int) -> None:
words[a] = (words[a] + words[b]) & _MASK32
words[d] = _rotate_left32(words[d] ^ words[a], 16)
words[c] = (words[c] + words[d]) & _MASK32
words[b] = _rotate_left32(words[b] ^ words[c], 12)
words[a] = (words[a] + words[b]) & _MASK32
words[d] = _rotate_left32(words[d] ^ words[a], 8)
words[c] = (words[c] + words[d]) & _MASK32
words[b] = _rotate_left32(words[b] ^ words[c], 7)
def _chacha_block(counter: int) -> bytes:
if not 0 <= counter <= _MASK32:
raise ValueError("VIMG ChaCha counter 超出 uint32")
initial = list(_CHACHA_STATE)
initial[12] = counter
working = initial.copy()
for _ in range(10):
_quarter_round(working, 0, 4, 8, 12)
_quarter_round(working, 1, 5, 9, 13)
_quarter_round(working, 2, 6, 10, 14)
_quarter_round(working, 3, 7, 11, 15)
_quarter_round(working, 0, 5, 10, 15)
_quarter_round(working, 1, 6, 11, 12)
_quarter_round(working, 2, 7, 8, 13)
_quarter_round(working, 3, 4, 9, 14)
return struct.pack(
"<16I",
*((value + original) & _MASK32 for value, original in zip(working, initial)),
)
def _java_modified_utf8(value: str) -> bytes:
"""复现 JNI ``GetStringUTFChars`` 对 Java String 的 modified UTF-8。"""
utf16 = value.encode("utf-16-be", errors="surrogatepass")
output = bytearray()
for offset in range(0, len(utf16), 2):
code_unit = int.from_bytes(utf16[offset : offset + 2], "big")
if 0x01 <= code_unit <= 0x7F:
output.append(code_unit)
elif code_unit <= 0x7FF:
output.extend((0xC0 | (code_unit >> 6), 0x80 | (code_unit & 0x3F)))
else:
output.extend(
(
0xE0 | (code_unit >> 12),
0x80 | ((code_unit >> 6) & 0x3F),
0x80 | (code_unit & 0x3F),
)
)
return bytes(output)
def _decode_java_modified_utf8(value: bytes) -> str:
code_units: list[int] = []
offset = 0
while offset < len(value):
first = value[offset]
if 0x01 <= first <= 0x7F:
code_units.append(first)
offset += 1
elif first & 0xE0 == 0xC0 and offset + 1 < len(value):
code_units.append(((first & 0x1F) << 6) | (value[offset + 1] & 0x3F))
offset += 2
elif first & 0xF0 == 0xE0 and offset + 2 < len(value):
code_units.append(
((first & 0x0F) << 12)
| ((value[offset + 1] & 0x3F) << 6)
| (value[offset + 2] & 0x3F)
)
offset += 3
else:
raise ValueError("VIMG payload 含非法 modified UTF-8")
utf16 = b"".join(code_unit.to_bytes(2, "big") for code_unit in code_units)
return utf16.decode("utf-16-be", errors="surrogatepass")
def _xor_chacha(data: bytes) -> bytes:
output = bytearray(len(data))
for block_index, offset in enumerate(range(0, len(data), 64), start=1):
key_stream = _chacha_block(block_index)
chunk = data[offset : offset + 64]
output[offset : offset + len(chunk)] = (
value ^ key_stream[index] for index, value in enumerate(chunk)
)
return bytes(output)
def generate_vimg_base(payload: str) -> str:
"""生成 ``Engine.pr(..., mode=0, payload)`` 的 ``VIMG_<base64>`` 部分。"""
payload_bytes = _java_modified_utf8(payload)
if len(payload_bytes) > 0xFFFF:
raise ValueError("VIMG payload 超过 native uint16 长度上限")
plain = b"\x2d\x3d\x00\x00\x7d\x01" + struct.pack("<H", len(payload_bytes)) + payload_bytes
native_buffer = bytes(value ^ _XOR_BYTE for value in plain)
encoded = base64.b64encode(_xor_chacha(native_buffer)).decode("ascii")
return VIMG_PREFIX + encoded
def _compress_h1(
state: list[int],
message: list[int],
counter: int,
is_last: bool,
) -> list[int]:
"""复现 0x1ab3d0 的自定义 IV BLAKE2s 压缩。"""
if len(message) != 16:
raise ValueError("H1 压缩块必须包含 16 个 uint32")
working = state.copy() + list(_H1_IV)
working[12] ^= counter & _MASK32
working[13] ^= (counter >> 32) & _MASK32
if is_last:
working[14] ^= _MASK32
def mix(a: int, b: int, c: int, d: int, x: int, y: int) -> None:
working[a] = (working[a] + working[b] + x) & _MASK32
working[d] = _rotate_right32(working[d] ^ working[a], 16)
working[c] = (working[c] + working[d]) & _MASK32
working[b] = _rotate_right32(working[b] ^ working[c], 12)
working[a] = (working[a] + working[b] + y) & _MASK32
working[d] = _rotate_right32(working[d] ^ working[a], 8)
working[c] = (working[c] + working[d]) & _MASK32
working[b] = _rotate_right32(working[b] ^ working[c], 7)
for schedule in _BLAKE2S_SIGMA:
mix(0, 4, 8, 12, message[schedule[0]], message[schedule[1]])
mix(1, 5, 9, 13, message[schedule[2]], message[schedule[3]])
mix(2, 6, 10, 14, message[schedule[4]], message[schedule[5]])
mix(3, 7, 11, 15, message[schedule[6]], message[schedule[7]])
mix(0, 5, 10, 15, message[schedule[8]], message[schedule[9]])
mix(1, 6, 11, 12, message[schedule[10]], message[schedule[11]])
mix(2, 7, 8, 13, message[schedule[12]], message[schedule[13]])
mix(3, 4, 9, 14, message[schedule[14]], message[schedule[15]])
return [
(state[index] ^ working[index] ^ working[index + 8]) & _MASK32
for index in range(8)
]
def _generate_h1_words(vimg_base: str) -> list[int]:
base_bytes = vimg_base.encode("ascii")
word_count = (len(base_bytes) + 3) // 4
padded = base_bytes.ljust(word_count * 4, b"\0")
words = list(struct.unpack(f"<{word_count}I", padded))
state = list(_H1_INITIAL_STATE)
# Native 每批读取最多 64 个字,再按索引模 16 折叠成 BLAKE2s 块。
for offset in range(0, word_count, 64):
source = words[offset : offset + 64]
folded = [0] * 16
for index, value in enumerate(source):
folded[index % 16] ^= value
counter = offset + len(source)
state = _compress_h1(
state,
folded,
counter,
is_last=counter == word_count,
)
return state
def _generate_ai_hex(vimg_base: str) -> str:
h1_text = "".join(f"{value:08x}" for value in _generate_h1_words(vimg_base)) + " "
h2 = bytes(
(((_H2_ADD_BASE[index] + 3) & 0xFF) ^ ord(h1_text[index]))
for index in range(16)
)
return bytes(
value ^ _AI_XOR_MASK[index % len(_AI_XOR_MASK)]
for index, value in enumerate(h2)
).hex()
def generate_passport_account_image(payload: str) -> str:
"""纯 Python 生成完整 ``passport_account_image``。"""
vimg_base = generate_vimg_base(payload)
return f"{vimg_base}$AI_{_generate_ai_hex(vimg_base)}"
def decode_passport_account_image_payload(value: str) -> str:
"""反解本地 ``Engine.pr(..., mode=0)`` 票据并返回原始 Java 字符串。"""
vimg_base = str(value).split("$AI_", 1)[0]
if not vimg_base.startswith(VIMG_PREFIX):
raise ValueError("passport_account_image 缺少 VIMG_ 前缀")
try:
cipher = base64.b64decode(vimg_base[len(VIMG_PREFIX) :], validate=True)
except (ValueError, base64.binascii.Error) as exc:
raise ValueError("passport_account_image Base64 非法") from exc
native_buffer = _xor_chacha(cipher)
plain = bytes(value ^ _XOR_BYTE for value in native_buffer)
if len(plain) < 8 or plain[:6] != b"\x2d\x3d\x00\x00\x7d\x01":
raise ValueError("passport_account_image VIMG 头非法")
payload_length = struct.unpack_from("<H", plain, 6)[0]
if len(plain) != payload_length + 8:
raise ValueError("passport_account_image payload 长度不匹配")
return _decode_java_modified_utf8(plain[8:])

216
core/xfalcon.py Normal file
View File

@ -0,0 +1,216 @@
from __future__ import annotations
import argparse
import struct
from pathlib import Path
from .xfalcon_blake_core import compress, digest_hex, words_hex
from .xfalcon_te import (
XFALCON_PREFIX,
xfalcon_te_hex_from_digest_hex,
xfalcon_value_from_digest_hex,
)
MASK32 = 0xFFFFFFFF
INPUT_HEX_LEN = 80
PACKED_LEN = 64
M0_XOR_CONST = 0x3D
RAW_CHUNK_LEN = 256
FOLDED_BLOCK_LEN = 64
PARAM_BLOCK_BYTES = 0x01010020
CUSTOM_IV = [
0xA92157F6, 0xA9A24FF4, 0x9138D3FD, 0x2A2193F3,
0x2ADEF3F4, 0x9876EF16, 0x9ABED34F, 0x9103DE12,
]
def _validate_input_hex(input_hex: str) -> str:
input_hex = input_hex.strip()
if len(input_hex) != INPUT_HEX_LEN:
raise ValueError(f"input_hex must be {INPUT_HEX_LEN} chars")
if any(c not in "0123456789abcdefABCDEF" for c in input_hex):
raise ValueError("input_hex must contain only hex chars")
return input_hex
def _input_to_bytes(value: bytes | bytearray | str) -> bytes:
if isinstance(value, str):
return value.encode("utf-8")
return bytes(value)
def _initial_h() -> list[int]:
h = CUSTOM_IV.copy()
h[0] ^= PARAM_BLOCK_BYTES
return h
def _compress_v0(h: list[int], counter: int, final: bool) -> list[int]:
v0 = h.copy() + CUSTOM_IV.copy()
v0[12] ^= counter & MASK32
if final:
v0[14] ^= MASK32
return v0
def _state_after_compress(h: list[int], block: bytes, counter: int, final: bool) -> list[int]:
if len(block) != FOLDED_BLOCK_LEN:
raise ValueError(f"folded block must be {FOLDED_BLOCK_LEN} bytes")
m_words = [struct.unpack_from("<I", block, i * 4)[0] for i in range(16)]
v_final = compress(_compress_v0(h, counter, final), m_words)
return [(h[i] ^ v_final[i] ^ v_final[i + 8]) & MASK32 for i in range(8)]
def _chunk_logical_len(raw_len: int, offset: int) -> int:
if offset >= raw_len:
return 0
return min(FOLDED_BLOCK_LEN, (raw_len - offset + 3) // 4)
def xfalcon_folded_block_from_raw_bytes(raw: bytes | bytearray, offset: int) -> bytes:
"""Fold one 256-byte VM raw chunk into the 64-byte BLAKE message block.
The VM appends the fixed HUDR prefix to the caller input, then compresses
each 256-byte raw chunk into 64 bytes by XORing four 64-byte lanes.
"""
raw_bytes = bytes(raw)
if offset < 0:
raise ValueError("offset must be non-negative")
block = bytearray(FOLDED_BLOCK_LEN)
for j in range(FOLDED_BLOCK_LEN):
value = 0
for lane in range(4):
pos = offset + lane * FOLDED_BLOCK_LEN + j
if pos < len(raw_bytes):
value ^= raw_bytes[pos]
block[j] = value
return bytes(block)
def xfalcon_folded_block_from_input_bytes(input_bytes: bytes | bytearray | str, offset: int) -> bytes:
raw = _input_to_bytes(input_bytes) + XFALCON_PREFIX.encode("ascii")
return xfalcon_folded_block_from_raw_bytes(raw, offset)
def xfalcon_message_words_from_folded_block(block: bytes | bytearray) -> list[int]:
block_bytes = bytes(block)
if len(block_bytes) != FOLDED_BLOCK_LEN:
raise ValueError(f"folded block must be {FOLDED_BLOCK_LEN} bytes")
return [struct.unpack_from("<I", block_bytes, i * 4)[0] for i in range(16)]
def xfalcon_digest_words_from_raw_bytes(raw: bytes | bytearray) -> list[int]:
raw_bytes = bytes(raw)
if not raw_bytes:
raise ValueError("raw bytes must not be empty")
h = _initial_h()
counter = 0
for offset in range(0, len(raw_bytes), RAW_CHUNK_LEN):
block = xfalcon_folded_block_from_raw_bytes(raw_bytes, offset)
counter += _chunk_logical_len(len(raw_bytes), offset)
final = offset + RAW_CHUNK_LEN >= len(raw_bytes)
h = _state_after_compress(h, block, counter, final)
return h
def xfalcon_digest_words_from_input_bytes(input_bytes: bytes | bytearray | str) -> list[int]:
raw = _input_to_bytes(input_bytes) + XFALCON_PREFIX.encode("ascii")
return xfalcon_digest_words_from_raw_bytes(raw)
def xfalcon_digest_hex_from_input_bytes(input_bytes: bytes | bytearray | str) -> str:
return digest_hex(xfalcon_digest_words_from_input_bytes(input_bytes))
def xfalcon_te_hex_from_input_bytes(input_bytes: bytes | bytearray | str) -> str:
return xfalcon_te_hex_from_digest_hex(xfalcon_digest_hex_from_input_bytes(input_bytes))
def xfalcon_value_from_input_bytes(input_bytes: bytes | bytearray | str) -> str:
return xfalcon_value_from_digest_hex(xfalcon_digest_hex_from_input_bytes(input_bytes))
def xfalcon_packed_block_from_input_hex(input_hex: str) -> bytes:
"""Rebuild the legacy pre-XOR packed block from the 80-char ASCII input."""
input_hex = _validate_input_hex(input_hex)
block = bytearray(xfalcon_folded_block_from_input_bytes(input_hex, 0))
block[0] ^= M0_XOR_CONST
return bytes(block)
def xfalcon_message_words_from_input_hex(input_hex: str) -> list[int]:
"""Rebuild BLAKE message words at x23+0x2fa8."""
input_hex = _validate_input_hex(input_hex)
return xfalcon_message_words_from_folded_block(
xfalcon_folded_block_from_input_bytes(input_hex, 0)
)
def xfalcon_digest_words_from_input_hex(input_hex: str) -> list[int]:
input_hex = _validate_input_hex(input_hex)
return xfalcon_digest_words_from_input_bytes(input_hex)
def xfalcon_digest_hex_from_input_hex(input_hex: str) -> str:
return digest_hex(xfalcon_digest_words_from_input_hex(input_hex))
def xfalcon_te_hex_from_input_hex(input_hex: str) -> str:
return xfalcon_te_hex_from_digest_hex(xfalcon_digest_hex_from_input_hex(input_hex))
def xfalcon_value_from_input_hex(input_hex: str) -> str:
return xfalcon_value_from_digest_hex(xfalcon_digest_hex_from_input_hex(input_hex))
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("input_hex", nargs="?", help="80-char ASCII hex input passed to __NS_xfalcon")
source_group = parser.add_mutually_exclusive_group()
source_group.add_argument("--file", help="read arbitrary xfalcon input bytes from file")
source_group.add_argument("--text", help="use arbitrary UTF-8 xfalcon input text")
group = parser.add_mutually_exclusive_group()
group.add_argument("--digest", action="store_true", help="print digest hex only")
group.add_argument("--te", action="store_true", help="print $TE_ raw hex only")
group.add_argument("--value", action="store_true", help="print full __NS_xfalcon value only")
group.add_argument("--packed", action="store_true", help="print direct legacy packed block hex only")
group.add_argument("--m", action="store_true", help="print first folded BLAKE message words only")
args = parser.parse_args()
if args.file:
input_bytes = Path(args.file).read_bytes()
digest = xfalcon_digest_hex_from_input_bytes(input_bytes)
first_block = xfalcon_folded_block_from_input_bytes(input_bytes, 0)
elif args.text is not None:
digest = xfalcon_digest_hex_from_input_bytes(args.text)
first_block = xfalcon_folded_block_from_input_bytes(args.text, 0)
else:
if not args.input_hex:
parser.error("input_hex is required unless --file or --text is used")
digest = xfalcon_digest_hex_from_input_hex(args.input_hex)
first_block = xfalcon_folded_block_from_input_bytes(args.input_hex, 0)
if args.digest:
print(digest)
elif args.te:
print(xfalcon_te_hex_from_digest_hex(digest))
elif args.value:
print(xfalcon_value_from_digest_hex(digest))
elif args.packed:
if args.file or args.text is not None:
parser.error("--packed is only defined for direct 80-char ASCII hex input")
print(xfalcon_packed_block_from_input_hex(args.input_hex).hex())
elif args.m:
print(words_hex(xfalcon_message_words_from_folded_block(first_block)))
else:
print(f"digest={digest}")
print(f"te={xfalcon_te_hex_from_digest_hex(digest)}")
print(f"value={xfalcon_value_from_digest_hex(digest)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

193
core/xfalcon_blake_core.py Normal file
View File

@ -0,0 +1,193 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Replay the VM's BLAKE2s-style compression core for xfalcon."""
from __future__ import annotations
import glob
import re
import struct
import sys
from pathlib import Path
MASK32 = 0xFFFFFFFF
SIGMA = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
[7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
[2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
[13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
[10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
]
SAMPLE_V0 = [
0xA82057D6, 0xA9A24FF4, 0x9138D3FD, 0x2A2193F3,
0x2ADEF3F4, 0x9876EF16, 0x9ABED34F, 0x9103DE12,
0xA92157F6, 0xA9A24FF4, 0x9138D3FD, 0x2A2193F3,
0x2ADEF3D5, 0x9876EF16, 0x65412CB0, 0x9103DE12,
]
SAMPLE_M = [
0x0C5B0466, 0x04580152, 0x51500555, 0x00565451,
0x36753729, 0x5B72176D, 0x070A1F60, 0x7C662341,
0x682F4A37, 0x26562F2D, 0x47573177, 0x540A115A,
0x715D5A79, 0x061C581D, 0x6D0E0D1C, 0x0F230801,
]
SAMPLE_V_FINAL = [
0x296C7D05, 0xA2E09447, 0x62AB2823, 0xB3FD4E92,
0xF96DE833, 0xA5517BB8, 0xBEBB2C06, 0xFAD21B26,
0x2929349D, 0xF418B33C, 0x7325D323, 0xB446F83B,
0x04D647B0, 0x11FB17F9, 0x0CD935BC, 0x858F3D9F,
]
SAMPLE_DIGEST = [
0xA8651E4E, 0xFF5A688F, 0x80B628FD, 0x2D9A255A,
0xD7655C77, 0x2CDC8357, 0x28DCCAF5, 0xEE5EF8AB,
]
def ror32(value: int, count: int) -> int:
value &= MASK32
count &= 31
return ((value >> count) | ((value << (32 - count)) & MASK32)) & MASK32
def compress(v0: list[int], m: list[int]) -> list[int]:
v = [x & MASK32 for x in v0]
def g(a: int, b: int, c: int, d: int, x: int, y: int) -> None:
v[a] = (v[a] + v[b] + x) & MASK32
v[d] = ror32(v[d] ^ v[a], 16)
v[c] = (v[c] + v[d]) & MASK32
v[b] = ror32(v[b] ^ v[c], 12)
v[a] = (v[a] + v[b] + y) & MASK32
v[d] = ror32(v[d] ^ v[a], 8)
v[c] = (v[c] + v[d]) & MASK32
v[b] = ror32(v[b] ^ v[c], 7)
for s in SIGMA:
g(0, 4, 8, 12, m[s[0]], m[s[1]])
g(1, 5, 9, 13, m[s[2]], m[s[3]])
g(2, 6, 10, 14, m[s[4]], m[s[5]])
g(3, 7, 11, 15, m[s[6]], m[s[7]])
g(0, 5, 10, 15, m[s[8]], m[s[9]])
g(1, 6, 11, 12, m[s[10]], m[s[11]])
g(2, 7, 8, 13, m[s[12]], m[s[13]])
g(3, 4, 9, 14, m[s[14]], m[s[15]])
return v
def finalize_digest(v0: list[int], v_final: list[int]) -> list[int]:
return [(v0[i] ^ v_final[i] ^ v_final[i + 8]) & MASK32 for i in range(8)]
def words_hex(words: list[int]) -> str:
return " ".join(f"{x & MASK32:08x}" for x in words)
def digest_hex(words: list[int]) -> str:
return "".join((x & MASK32).to_bytes(4, "big").hex() for x in words)
def newest_log() -> Path:
logs = sorted(glob.glob("out/xfalcon_blake_params_*.log"))
if not logs:
raise SystemExit("no xfalcon_blake_params log found")
return Path(logs[-1])
def collect_dump(text: str, name: str, base_rel: int) -> bytearray:
buf = bytearray()
pat = rf"\[BLK\]\[dump\.{re.escape(name)}\] rel=0x([0-9a-f]+).*? hex=([0-9a-f]+)"
for m in re.finditer(pat, text):
rel = int(m.group(1), 16)
off = rel - base_rel
data = bytes.fromhex(m.group(2))
if off < 0:
continue
if len(buf) < off + len(data):
buf.extend(b"\x00" * (off + len(data) - len(buf)))
buf[off : off + len(data)] = data
return buf
def qwords_low32(buf: bytes, off: int, count: int) -> list[int]:
return [struct.unpack_from("<Q", buf, off + i * 8)[0] & MASK32 for i in range(count)]
def parse_log(path: Path) -> tuple[list[int], list[int], list[int], list[int]]:
text = path.read_text(encoding="utf-8", errors="replace")
work = collect_dump(text, "work_2e00_at_compress", 0x2E00)
if len(work) < 0x228:
raise SystemExit("log missing work_2e00_at_compress dump")
v0 = qwords_low32(work, 0x2E28 - 0x2E00, 16)
m_words = qwords_low32(work, 0x2FA8 - 0x2E00, 16)
table: dict[int, int] = {}
for sm in re.finditer(r"\[BLK\]\[store\].*?rel=0x([0-9a-f]+).*?val=0x([0-9a-f]+)", text):
rel = int(sm.group(1), 16)
if 0x2E28 <= rel < 0x2EA8 and (rel - 0x2E28) % 8 == 0:
table[(rel - 0x2E28) // 8] = int(sm.group(2), 16) & MASK32
if len(table) < 16:
raise SystemExit(f"log missing final table stores: got {len(table)}")
v_final = [table[i] for i in range(16)]
digest: dict[int, int] = {}
for sm in re.finditer(r"\[BLK\]\[store\].*?rel=0x([0-9a-f]+).*?val=0x([0-9a-f]+)", text):
rel = int(sm.group(1), 16)
if 0x103A68 <= rel < 0x103AA8 and (rel - 0x103A68) % 8 == 0:
digest[(rel - 0x103A68) // 8] = int(sm.group(2), 16) & MASK32
digest_words = [digest[i] for i in range(8)] if len(digest) >= 8 else []
return v0, m_words, v_final, digest_words
def main() -> int:
if len(sys.argv) > 1:
path = Path(sys.argv[1])
else:
path = newest_log()
if path.exists():
v0, m_words, expected_v, expected_digest = parse_log(path)
print(f"log={path}")
else:
v0, m_words, expected_v, expected_digest = (
SAMPLE_V0,
SAMPLE_M,
SAMPLE_V_FINAL,
SAMPLE_DIGEST,
)
print("using built-in sample")
got_v = compress(v0, m_words)
got_digest = finalize_digest(v0, got_v)
print("v0: " + words_hex(v0))
print("m: " + words_hex(m_words))
print("v_calc: " + words_hex(got_v))
print("v_expect:" + words_hex(expected_v))
print("digest: " + words_hex(got_digest))
print("hex: " + digest_hex(got_digest))
ok_v = got_v == expected_v
ok_d = not expected_digest or got_digest == expected_digest
if ok_v and ok_d:
print("[OK] xfalcon blake core replay verified")
return 0
print(f"[FAIL] v_ok={ok_v} digest_ok={ok_d}")
return 1
if __name__ == "__main__":
raise SystemExit(main())

49
core/xfalcon_te.py Normal file
View File

@ -0,0 +1,49 @@
from __future__ import annotations
import argparse
XFALCON_PREFIX = "HUDR_sFnX+n5uAUNVsMPNK3DOP5wnti1Lc8Axjy5z88T61A=="
TEMP_VAR_XOR = bytes.fromhex("22eb4780")
TEMP_TE_TEMPLATE = bytes.fromhex(
"4b54cdabab77585a3a250077585a3a25000107020037df98acba"
"00000000"
"1eae285989015a563eda7b563efb00"
)
def xfalcon_te_raw_from_digest_hex(digest_hex: str) -> bytes:
digest_hex = digest_hex.strip().lower()
if len(digest_hex) != 64 or any(c not in "0123456789abcdef" for c in digest_hex):
raise ValueError("digest_hex must be 64 lowercase/uppercase hex chars")
temp = bytearray(TEMP_TE_TEMPLATE)
digest_ascii4 = digest_hex[:4].encode("ascii")
temp[26:30] = bytes(a ^ b for a, b in zip(digest_ascii4, TEMP_VAR_XOR))
checksum = (-(0x9F + sum(temp[2:44]))) & 0xFF
return bytes(b ^ checksum for b in temp)
def xfalcon_te_hex_from_digest_hex(digest_hex: str) -> str:
return xfalcon_te_raw_from_digest_hex(digest_hex).hex()
def xfalcon_value_from_digest_hex(digest_hex: str) -> str:
return f"{XFALCON_PREFIX}$TE_{xfalcon_te_hex_from_digest_hex(digest_hex)}"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("digest_hex")
parser.add_argument("--value", action="store_true", help="print full __NS_xfalcon value")
args = parser.parse_args()
if args.value:
print(xfalcon_value_from_digest_hex(args.digest_hex))
else:
print(xfalcon_te_hex_from_digest_hex(args.digest_hex))
return 0
if __name__ == "__main__":
raise SystemExit(main())

144
docs/capture_login_chain.md Normal file
View File

@ -0,0 +1,144 @@
# capture/ 登录链路 + APP 初始化 实测分析
> 来源:`capture/` Reqable body-only 抓包导出1738 文件)
> 时间2026-07-23 06:32 起session B全新装机 + 登录)
> 目标设备:`did=ANDROID_f05497e9cef09a7f`(与 `.env` 账号设备 `e8dfd2f16b618053` 不同 = 全新装)
## 0. 抓包格式与固有限制
- 文件命名:`{ts_ms}-{flowId}-{part}-{type}.reqable``flowId`/`ts` 决定时序。
- 类型:`req_raw-body`(请求体,常为加密 `{"data":"..."}` 或明文 form、`res-raw-body`原始响应chunked/gzip 二进制)、`res-extract-body`**已解码响应 JSON主要可读源**)、`req-extract-body`(解码请求体)。
- **无 URL/header/method 文件**。端点只能从 body 内容 + 响应推断。
- 混入少量酷狗(`com.kugou.android`flow 466/467噪声主体是快手 nebula。
- 请求体多为 kwsg 加密;关键字 grep`mobile`/`smsCode`命中的多是日志上报flow 577 几百段=批量 log非登录。
## 1. APP 初始化序列session B06:32 起,已登录前 uid=0
| flow | 时间 | 端点(推断) | 关键字段 |
|------|------|------|------|
| 13 | 06:32 | 冷启动首批请求 | — |
| 171 | 06:48 | 配置拉取 | `public_param{uid:0,did,kpn:NEBULA,kpf,app_version}` + `request_info[{config_name:config}]` |
| 189 | ~06:xx | **DFP 设备注册** | `productName=NEBULA&ts=...&deviceInfo=<urlenc>`= `gdfp_report`/`unified_fetch`,已还原于 `core/dfp_forms.py` |
| 272/274 | ~08:44 | 域名-IP 路由表 | `domains[]{domain,iplist{ipv4,ipv6}}`(含 `api.e.kuaishou.com`、`id.kuaishou.com` 等,**非登录** |
> DFP bootstrap设备 did+egid 在线注册)在登录前完成,与 `tools/new_device.py` 已还原链路一致。✓
## 2. 登录链路(核心发现)
### 登录方式 = 运营商一键登录provider-token非手机号短信
**flow 22108:18登录提交请求体明文 + 已还原签名):**
```
provider=11
&provider_token=CgZORUJVTEESGEFORFJPSURfZjA1NDk3ZTljZWYwOWE3ZhjdzY/JgMgdIKy0l/v4Mw==
&session_id=eab9a270-1d55-45a2-97dd-f4f32f9c3eb8
&cs=false&os=android&client_key=2ac2a76d
&sig=6d37a2436f910601ef23cd2b6fd212f2
&__NS_sig3=544530168b10f35b1f1c1f1ecddd17765822656c010d0315
&__NS_xfalcon=HUDR_sFnX+...==$TE_253aa3c5c5193634544b6e193634544b...
```
`provider_token` base64 解码为 protobuf
```
field1(string) = "NEBULA" # kpn
field2(string) = "ANDROID_f05497e9cef09a7f" # did
field3(varint) = 0xddcd8fc980c81d # 时间戳类
field4(varint) = 0xacb497fbf833 # nonce/凭证
```
`{kpn, did, ts, nonce}`——由**运营商一键登录 SDK**(如移动认证/极光一键登录)签发,绑定设备 did。
- `provider=11` = 认证 provider 编码(一键登录)。
- 请求签名 `sig/__NS_sig3/__NS_xfalcon` 全部用**已还原算法**可复算。✓
### 登录响应
- **flow 221 响应**`{"result":1,"bind_interval_ms":604800000}` → 登录成功,设备绑定有效期 7 天604800000 ms
- **flow 22208:19疑似 getLoginUser/refreshToken 后续)响应**
```json
{"dataRsp":"<576B base64>","result":1,"error_msg":""}
```
`result=1` = 成功。会话票据(`api_st`/`h5_st`/`user_id`/`client_salt`)封装在 `dataRsp` 加密块内。
## 3. 会话票据加密层(关键)
- `dataRsp`576B`head8 = 7a1c41a6 6f7ee464`。
- **不属于**已还原的 kwsg 10400 ZT envelope`head8``5a54eecd...`,见 `core/enc_data.py:ZT_OUTER_CONFIGS`)。
- 判定:登录会话走 **`libpfl_crypto`** 层(`Pfl.decryptBinaryNative`FINDINGS 历史"解密方向"用的是 native 桥,**纯 Python 解密未还原**)。
- 对比reward/ad 的 `encData` = kwsg 10400已还原纯 Python 加密);登录 `dataRsp` = libpfl未还原纯 Python
- 另:`api_st`/`h5_st` 也可能下发在**响应 header**body-only 抓包看不到)——子代理此前同样怀疑此点。
## 4. 与既有结论的交叉验证
- `out/FINDINGS.md:2476-2504` 已实测:直接换 H5 did/egid → `signIn/report`、`treasureBox/report` 返回 `result=50 签名验证失败`。H5 真正缺口是 `kwssectoken/kwscode/kwfv1/kww` 票据组Yoda/KsGuard/WebView 安全层)。
- 本次 capture 未见 `kwssectoken/kwscode/kwfv1/kww` 在 body 中签发 → 这组票据是 **WebView/JS 运行时产物或 header**HTTP body 抓包不包含其生成。`kww` 在真实 App 中确为请求 header`main.py:H5_HEADERS.kww`),本抓包无 header → 不可见。
## 5. "新设备纯 Python 登录"的阻塞点(结论)
| 阻塞点 | 性质 | 现状 |
|--------|------|------|
| ① 运营商 `provider_token` | 外部黑盒(运营商一键登录 SDK需 SIM+设备) | 无法纯 Python 生成;本抓包只看到它被消费 |
| ② `dataRsp` 会话解密 | libpfl_crypto envelopehead8 `7a1c41a6` | 未纯 Python 还原(历史仅 native 桥) |
| ③ `kwssectoken/kwscode/kwfv1/kww` | Yoda/KsGuard/WebView 运行时票据 | 不在 HTTP body 抓包H5 写请求换设备必 `result=50` |
| KS 侧请求签名 `sig/__NS_sig3/__NS_xfalcon` | 已还原 | ✓ 可复算登录请求 |
| DFP 设备注册 | 已还原 | ✓ 新设备可在线注册 |
### 含义
- "新设备 + 复用已有账号 token 跑任务"A 路线API 侧可行(已验证 200**H5 侧被 ③ 卡死**result=50除非补 kws* 票据(需 WebView JS 逆向,不在 HTTP 抓包内)。
- "新设备 + 全新登录拿设备绑定 token"B 路线):被 ①② 卡死。① 要么复刻运营商一键登录 SDK极难、需 SIM要么改走手机号+短信登录(**本抓包未捕获该路径**,需另抓);② 需还原 libpfl_crypto 纯 Python 解密。
## 6. 关键 flow 索引
| flow | 文件前缀 | 用途 |
|------|------|------|
| 13 | `1784817239557610-13-*` | 冷启动 |
| 171 | `1784817240305029-171-1-*` | 配置拉取 |
| 189 | `1784817240121*-189-1-*` | DFP 设备注册deviceInfo |
| 213 | `1784817244968189-213-1-*` | 登录前加密请求3798B待解 |
| 221 | `1784817247*-221-1-*` | **登录提交provider_token明文+签名)** |
| 222 | `1784817247*-222-1-*` | 登录后续响应dataRsp 会话,加密) |
| 272/274 | `1784817247*-27[24]-1-*` | 域名-IP 路由表 |
## 7. 下一步可选
1. **解密 flow 222 `dataRsp`**:还原 libpfl_crypto 纯 Python 解密(参照 `EmbeddedAesKeyHex` + `Pfl.decryptBinaryNative` 的 native 逻辑),取出 `api_st/h5_st/user_id/client_salt` 明文,确认是否设备绑定。
2. **抓手机号+短信登录路径**:作为 ① 的替代(更可纯 Python 复刻),需真机抓一次短信登录 HAR。
3. **WebView JS 逆向 kws* 票据**:解 ③,才能真正让新设备跑 H5 写请求。
## 8. pfl_crypto 静态逆向进展dataRsp 解密2026-07-23
### 已拿到(新硬证据)
- **内嵌 AES key 已静态还原**(此前 FINDINGS 认为需 runtime frida
- `EmbeddedAesKeyHex` @ `libpfl_crypto.so:0x6249c`lazy singletonguard @ `0x111f78`storage @ `0x111fa0`)。
- 构造器 `0x62550`:分配 64 字节,`key[i] = blobA[i] ^ blobB[i % 32]`i=0..63)。
- `blobA` @ `0x182c8`64B`blobB` @ `0x18308`32B循环
- 还原结果:`027e5393fd36a4ba1b6cf52094edb4aeffcc55d175492d87ed7df271eb691ef0`64 hex = AES-256 key
- 诱饵 `WrongEmbeddedAesKeyHex` @ `0x625e4`blobs `0x18328`/`0x18368`= 真 key 首字节 XOR `0x10` -> `127e...`
- **`AesDecrypt` @ `0x62fbc`1728B**hex-decode key 串(校验 len 0x40=AES-256 / 0x20=AES-128随后对密文做**自定义字节重排**memcpy @ 偏移 `0xc``end-0x10`),再调内部 cipher。
- **排除 kwsg-10400**dataRsp `head8=7a1c41a6...`,用 4 个已知 kwsg xor_key 解包后 inner magic 均 ≠ `dec0adde`;且 `7a1c41a6` 非 kwsg head8`5a54ee..`-> **dataRsp 不是 kwsg 10400 envelope**,是 pfl 自有格式。
- **设备绑定已硬证据(无需解密 dataRsp**
- 登录请求 flow 221 `provider_token` = protobuf `{kpn=NEBULA, did=ANDROID_f05497e9cef09a7f, ts, nonce}` -> 登录凭证**构造上就绑设备 did**。
- `probe3.log` 常规任务请求携带 `klinkToken`(同结构 protobuf含 .env 设备 did-> 设备绑定 token 随请求流转。
- `out/FINDINGS.md` 实测:换 H5 did -> `result=50 签名验证失败`
### 仍阻塞(需动态捕获)
- **cipher 是 standard AES硬件实现**`libpfl_crypto.so` 用 ARM 硬件
`aese/aesd/aesmc/aesimc`(全 .so 共 1769 条),故无软件 S-box/rcon/T-table。
- **用还原 keyreal/decoy对 dataRsp 试遍 ECB/CBC/CTR/CFB/OFBAES-128/256
IV@首16/末16/0/12/16/32ct 全/[16:]/[16:-16]/[32:] 等)均乱码** ->
dataRsp **不是**简单 `AesDecrypt(还原key, dataRsp)`
- 要么 dataRsp 用的 key 非 EmbeddedAesKeyHex会话派生 / RSA 包裹 / 另一静态 key
- 要么缺 `Pfl.decryptBinaryNative` 的**第二个参数 envelope**body-only 抓包看不到,
IV/envelope 可能来自该 arg 或响应 header
- 要么 dataRsp 走的不是 pfl网络层另解
- `AesDecrypt@0x62fbc` 字节重排:把密文拆 `[0:12]` / `[12:N-16]` / `[N-16:N]` 三段
std::string再调硬件 AES`0x9492c`等 + 循环)。
- 要拿 dataRsp 明文,需动态捕获真正用的 (key, envelope, IV, plaintext)。
已写探针 `out/probe_pfl_login.js`:登录时 hook `Pfl.decryptBinaryNative`
+ `pfl::crypto::AesDecrypt` + `EmbeddedAesKeyHex`,命中 `api_st/h5_st/user_id`
即打印明文。
### 结论
- 设备绑定问题**已有硬答案 = 是**(请求侧 provider_token 内嵌 did + H5 换设备 result=50
- dataRsp 明文api_st/h5_st/user_id 具体值)需动态跑 `out/probe_pfl_login.js`
抓取key 已静态还原但 dataRsp 非该 key 直解,缺 envelope/session key。属
"确认响应侧 token 是否也带 did 校验"的锦上添花项,不改变绑定结论。

View File

@ -0,0 +1,438 @@
# Android eCapture 抓包 runbook
本文记录本项目中已验证可复现的 Android eCapture 抓包流程。核心结论:
- 不需要代理、不需要安装 CA、不需要 Hook APP。
- 依赖设备 root、eBPF/uprobe 和 eCapture hook BoringSSL/Conscrypt。
- 当前设备普通 `adb shell` 看不到 `su`,但可通过 `bin.mt.termex` 的 root 授权间接执行。
## 1. 已验证环境
- 目标包名:`com.ct.client`
- 目标 UID`10409`
- root 方案SukiSU Ultra
- 已授权 root 的 APP`bin.mt.termex`
- eCapture 版本:`v2.5.2`
- 设备 eCapture 路径:`/data/local/tmp/ecapture`
- 设备 text 启动脚本:`/data/local/tmp/run_ecapture_dx.sh`
- 本地 text 启动脚本:`tools/ecapture/run_ecapture_dx.sh`
- 设备 pcap 启动脚本:`/data/local/tmp/run_ecapture_dx_pcap.sh`
- 本地 pcap 启动脚本:`tools/ecapture/run_ecapture_dx_pcap.sh`
- eCapture 目标 TLS 库:
- `/apex/com.android.conscrypt/lib64/libssl.so`
## 2. root 权限获取方式
普通 `adb shell``su` 不可见,这是本设备的正常现象:
```sh
adb shell id
adb shell su -c id
```
预期现象:
```text
uid=2000(shell)
su: inaccessible or not found
```
正确方式是通过已授权 root 的 `bin.mt.termex` 进入同一 root 授权上下文:
```sh
adb shell 'run-as bin.mt.termex id'
adb shell 'run-as bin.mt.termex /system/bin/su -c id'
```
成功时第二条应返回类似:
```text
uid=0(root) gid=0(root) ... context=u:r:ksu:s0
```
如果这里失败,优先检查:
1. SukiSU Ultra 是否仍启用。
2. `bin.mt.termex` 是否仍有 root 授权。
3. 命令必须走 `/system/bin/su`,不要依赖普通 shell PATH。
## 3. 一次性部署 eCapture
确认设备在线:
```sh
adb devices
```
确认目标 UID
```sh
adb shell 'cmd package list packages -U | grep com.ct.client'
```
部署 eCapture 二进制和启动脚本:
```sh
adb push tools/ecapture/ecapture-v2.5.2-android-arm64/ecapture /data/local/tmp/ecapture
adb shell 'chmod 755 /data/local/tmp/ecapture'
adb push tools/ecapture/run_ecapture_dx.sh /data/local/tmp/run_ecapture_dx.sh
adb shell 'chmod 755 /data/local/tmp/run_ecapture_dx.sh'
adb push tools/ecapture/run_ecapture_dx_pcap.sh /data/local/tmp/run_ecapture_dx_pcap.sh
adb shell 'chmod 755 /data/local/tmp/run_ecapture_dx_pcap.sh'
```
可选:检查设备是否支持 eBPF/uprobe
```sh
adb shell 'run-as bin.mt.termex /system/bin/su -c "mount | grep -E \"tracefs|bpf\"; zcat /proc/config.gz | grep -E \"CONFIG_BPF=|CONFIG_UPROBES=|CONFIG_DEBUG_INFO_BTF=\" 2>/dev/null"'
```
## 4. 启动抓包
### 4.1 text 模式:日常抽明文 body
直接运行项目脚本:
```sh
adb shell 'run-as bin.mt.termex /system/bin/su -c /data/local/tmp/run_ecapture_dx.sh'
```
成功时输出:
```text
ecapture_pid=<pid>
```
脚本实际执行内容:
```sh
nohup /data/local/tmp/ecapture tls \
-m text \
--uid=10409 \
--libssl=/apex/com.android.conscrypt/lib64/libssl.so \
--ssl_version="boringssl 1.1.1" \
> /data/local/tmp/ecapture_dx.log \
2> /data/local/tmp/ecapture_dx.err &
```
检查进程:
```sh
adb shell 'ps -A | grep ecapture || true'
```
### 4.2 pcapng 模式:还原 HTTP/2 path/header
`text` 模式足够抽 JSON body但 HTTP/2 的 `:authority/:path`
依赖 HPACK 状态,文本日志不适合可靠还原。
需要闭合真实业务 URL 时,用 pcapng 模式:
```sh
adb shell 'run-as bin.mt.termex /system/bin/su -c /data/local/tmp/run_ecapture_dx_pcap.sh'
```
默认网卡是 `wlan0`。如果需要指定接口:
```sh
adb shell 'run-as bin.mt.termex /system/bin/su -c "/data/local/tmp/run_ecapture_dx_pcap.sh wlan0"'
```
可先用 root 查看设备接口:
```sh
adb shell 'run-as bin.mt.termex /system/bin/su -c "ip -o link show"'
```
pcap 脚本实际执行:
```sh
nohup /data/local/tmp/ecapture tls \
-m pcap \
-i wlan0 \
-w /data/local/tmp/ecapture_dx.pcapng \
--uid=10409 \
--libssl=/apex/com.android.conscrypt/lib64/libssl.so \
--ssl_version="boringssl 1.1.1" \
tcp port 443 \
> /data/local/tmp/ecapture_dx_pcap.log \
2> /data/local/tmp/ecapture_dx_pcap.err &
```
注意pcap 脚本启动时会覆盖
`/data/local/tmp/ecapture_dx.pcapng`。如果要保留上一轮,先拉回本地。
## 5. 停止并拉取日志
PowerShell 模板:
```powershell
$stamp=Get-Date -Format 'yyyyMMdd_HHmmss'
$out="out\ecapture_case_$stamp"
New-Item -ItemType Directory -Force -Path $out | Out-Null
adb shell 'run-as bin.mt.termex /system/bin/su -c "pkill -f /data/local/tmp/ecapture || true; chmod 644 /data/local/tmp/ecapture_dx.log /data/local/tmp/ecapture_dx.err 2>/dev/null || true"'
adb pull /data/local/tmp/ecapture_dx.log "$out\ecapture_dx.log"
adb pull /data/local/tmp/ecapture_dx.err "$out\ecapture_dx.err"
adb pull /data/local/tmp/ecapture_dx.pcapng "$out\ecapture_dx.pcapng" 2>$null
adb pull /data/local/tmp/ecapture_dx_pcap.log "$out\ecapture_dx_pcap.log" 2>$null
adb pull /data/local/tmp/ecapture_dx_pcap.err "$out\ecapture_dx_pcap.err" 2>$null
adb shell 'uiautomator dump /sdcard/window_after_case.xml >/dev/null 2>&1'
adb pull /sdcard/window_after_case.xml "$out\window.xml"
adb shell screencap -p /sdcard/screen_after_case.png
adb pull /sdcard/screen_after_case.png "$out\screen.png"
adb shell 'dumpsys activity activities | grep -E "topResumedActivity|mResumedActivity" | head -n 20' |
Set-Content -LiteralPath "$out\activity.txt"
adb shell 'logcat -d -v time' | Set-Content -LiteralPath "$out\logcat.txt"
```
说明:
- `pkill` 只停止本次 eCapture 进程。
- 启动脚本会清空 `/data/local/tmp/ecapture_dx.log``.err`,所以每次启动前先确认上轮日志已拉回。
- 不要在用户点击流程中途停止 eCapture确认登录等动作必须在 eCapture 正在运行时执行。
## 6. 解析日志
本项目提供文本解析脚本:
```powershell
powershell -ExecutionPolicy Bypass -File tools\ecapture\parse_ecapture_text.ps1 `
-Log out\ecapture_case_xxx\ecapture_dx.log `
-OutJson out\ecapture_case_xxx\summary.json |
Set-Content -LiteralPath out\ecapture_case_xxx\summary.txt
```
解析输出会汇总:
- HTTP 请求method、host、path
- 302 跳转链:`Location`
- Cookie`Set-Cookie`
- 表单体:如 `appId/pk/ps/sign`
- JSON 业务 code`oneKeyLogin`
注意APP 大量接口走 HTTP/2。`-m text` 模式下HTTP/2 头压缩帧和 JSON body 可能混在一行,看到二进制乱码是正常现象;直接按 JSON 关键字抽取即可。
如果本轮采集了 pcapng用 Wireshark/tshark 抽 HTTP/2 头:
```powershell
powershell -ExecutionPolicy Bypass -File tools\ecapture\extract_http2_from_pcap.ps1 `
-Pcap out\ecapture_case_xxx\ecapture_dx.pcapng `
-OutTsv out\ecapture_case_xxx\http2.tsv
```
重点看:
- `http2.headers.authority`
- `http2.headers.path`
- `http2.headers.method`
- `http2.data.data`
业务登录已通过 pcapng 闭合以下 endpoint
```text
POST https://appgologinsz.189.cn/login/client/userLoginNormal
POST https://appgologinsz.189.cn/login/client/getAccessCodeDaily
POST https://appgologinsz.189.cn/login/client/oneKeyLogin
POST https://appgologinsz.189.cn/login/custIdInfo
```
## 7. 已验证抓包流程
### 7.1 无 Hook 启动 APP
```sh
adb shell 'run-as bin.mt.termex /system/bin/su -c /data/local/tmp/run_ecapture_dx.sh'
adb shell 'monkey -p com.ct.client -c android.intent.category.LAUNCHER 1'
```
已验证 APP 能进入:
```text
com.ct.client/.activity.MainActivity
```
代表性产物:
- `out/ecapture_nohook_20260720_081836`
### 7.2 进入“我”页
底部“我”tab 坐标约:
```text
x=972 y=2253
```
命令:
```sh
adb shell 'input tap 972 2253'
```
代表性产物:
- `out/ecapture_mytab_20260720_082333`
### 7.3 点击“一键登录”
“一键登录”按钮坐标约:
```text
x=174 y=411
```
命令:
```sh
adb shell 'input tap 174 411'
```
已抓到运营商预认证链:
```text
POST id6.me /auth/presdk.do
302 -> yw.wosms.cn /unicomAuth/openapi/qc
302 -> nisportal.10010.com:9001 /api
302 -> enrichgw.10010.com /d93222629f52ec79/api
302 -> yw.wosms.cn /unicomAuth/openapi/callback
302 -> ne189.21cn.com /openapi/networkauth/nm/spcallback/...
Set-Cookie: gw_auth=<COOKIE>
```
APP 侧业务上报:
```text
getAccessCodeDaily
```
代表性产物:
- `out/ecapture_login_tap_20260720_082514`
### 7.4 点击“确认登录”
确认登录前要保持 eCapture 正在运行。登录页有 `FLAG_SECURE`,截图可能是黑屏,但 `uiautomator` 仍能看到控件。
登录页控件状态示例:
```text
Activity: com.ct.client/.login.activity.SwitchUserActivity
tab: 本机登录
button: 确认登录
checkbox: 我已阅读并同意...
```
已抓到最终确认登录链:
```text
userLoginNormal
getAccessCodeDaily
oneKeyLogin
custIdInfo
loginNetworkReport
```
其中 `oneKeyLogin` 请求的字段结构为:
```json
{
"headerInfos": {
"code": "oneKeyLogin",
"clientType": "#13.3.0#channel45#OnePlus PJZ110#",
"source": "110003",
"sourcePassword": "Sid98s",
"timestamp": "yyyyMMddHHmmss",
"token": "",
"userLoginName": ""
},
"content": {
"attach": "test",
"fieldData": {
"pswType": "04",
"accessCode": "nm...",
"gwAuth": "<COOKIE_VALUE>",
"accountType": "c2000004",
"operatorType": "CU",
"loginAuthCipherAsymmertric": "<LONG_SECRET>",
"deviceUid": "",
"shopId": "20002",
"source": "110003",
"systemVersion": "16",
"androidId": "<ANDROID_ID>"
}
}
}
```
登录成功后,后续业务接口会带登录态字段:
```text
provinceCode=600101
token=<TOKEN>
userLoginName=<USER>
account=<ACCOUNT>
userId=<USERID>
```
代表性产物:
- `out/ecapture_confirm_login_20260720_082947`
## 8. 常见问题
### 普通 adb shell 里找不到 su
本设备就是这种状态。不要卡在 `adb shell su`,直接使用:
```sh
adb shell 'run-as bin.mt.termex /system/bin/su -c id'
```
### eCapture 没有输出
按顺序检查:
1. `bin.mt.termex` 是否仍有 root 授权。
2. `run-as bin.mt.termex /system/bin/su -c id` 是否返回 root。
3. 目标 UID 是否仍是 `10409`
4. 目标是否使用 `/apex/com.android.conscrypt/lib64/libssl.so`
5. `/data/local/tmp/ecapture_dx.err` 是否有错误。
### 截图黑屏
登录页设置了安全窗口。黑屏不代表 APP 崩溃:
```sh
adb shell 'uiautomator dump /sdcard/window.xml'
```
`window.xml` 判断真实 UI。
### 为什么不用代理
这个 APP 在 Hook/代理场景容易卡启动或被壳逻辑干扰。eCapture 是内核侧 uprobes 抓 BoringSSL 明文绕过代理、CA、证书锁定和 APP 内部网络栈差异。
### HTTP/2 日志有乱码
正常。`-m text` 能拿到明文,但 HTTP/2 帧、HPACK 头和 JSON body 会混杂。现阶段优先用:
```powershell
Select-String -LiteralPath ecapture_dx.log -Pattern 'oneKeyLogin|userLoginNormal|ticket|token'
```
如果后续需要 Wireshark 级别解析,切换到 pcapng 模式:
```sh
adb shell 'run-as bin.mt.termex /system/bin/su -c /data/local/tmp/run_ecapture_dx_pcap.sh'
```
再拉取 `/data/local/tmp/ecapture_dx.pcapng`,用
`tools/ecapture/extract_http2_from_pcap.ps1``:authority/:path`

View File

@ -0,0 +1,156 @@
# libweapon VIMG 纯算实现记录
> 状态:已完成
>
> 约束:运行时不依赖 Frida、抓包、APK、ELF、Unicorn 或 app-fields。
## 1. 最终结论
`passport_account_image` 是客户端本地确定性生成值:
```text
mf(context, event).a().toString()
-> Engine.pr(99999, 0, javaLength * 2, json)
-> VIMG_<base64>$AI_<32hex>
-> WeaponHI.img / wcfg["a_y_q_z"]
-> mobile/checker、requestMobileCode、mobileVerifyCode
```
`wcfg` 只负责本地缓存。`$AI_` 不是服务端签发值,`/f/a/p` 也不是
登录票据的生成前置条件。
目标样本:
```text
out/p0_64_extract/lib/arm64-v8a/libweapon.2.2174a68a..so
SHA256 5d7681c583c66b35e0ee3ce558e5a1ff7fa5be44f19a1e217acc2224455837d7
```
## 2. Java 与 JNI 链
- `wc.k()` 调用 `Engine.pr(ze.v0, 0, s2.a(str) * 2, str)`
- `ze.v0 = 99999`
- `str = new mf(context, event).a().toString()`
- mode 0 对应 native `0x19ea90`
- `0x19ea90` 依次调用 base 生成器 `0x1a1850` 和 AI 生成器 `0x1a2d94`
## 3. VIMG Base
```text
payload_bytes = JNI modified UTF-8(payload)
plain = 2d 3d 00 00 7d 01 || LE16(len(payload_bytes)) || payload_bytes
native_buffer = plain XOR 0x55
cipher = ChaCha20-IETF(native_buffer, fixed_state, counter=1)
base = "VIMG_" + Base64(cipher)
```
ChaCha 初始状态:
```text
1783595e 8dc26a78 2599184e 729781d3
2adef3f4 9876ef16 9abed34f 9103de12
a92157f6 a9a24ff4 9138d3fd 2a2193f3
00000001 74f4cbcd 98c26a78 ab99184e
```
轮函数是标准 20 轮 ChaCha旋转量为 `16/12/8/7`
## 4. AI H1
将完整 `VIMG_...` ASCII 文本按小端 uint32 分组。调用方每批处理最多
64 个字native 压缩函数先折叠为 16 个字:
```python
folded = [0] * 16
for index, word in enumerate(source_words):
folded[index % 16] ^= word
```
随后执行自定义 IV 的 10 轮 BLAKE2s 压缩:
```text
H0:
aa98186e f3ccd768 99531aae 669781d3
125fd5b4 9883595e 74f4cbcd 98c26a78
IV:
ab99184e f3ccd768 99531aae 669781d3
125fd5b4 9883595e 74f4cbcd 98c26a78
```
- counter 是累计处理的 uint32 字数。
- 最后一批令 `v[14] ^= 0xffffffff`
- sigma 是标准 BLAKE2s 10x16 消息置换表。
- 输出为 8 个低 32 位链值。
- H1 文本为 8 个 `%08x` 直接拼接,末尾附一个空格。
## 5. AI H2
H2 只使用 H1 文本前 16 个 ASCII 字节:
```text
ADD_BASE = 0c35ff3dcbfefb3f3efd6539fd39efcd
H2[i] = ((ADD_BASE[i] + 3) & 0xff) XOR H1_TEXT[i]
AI[i] = H2[i] XOR (2dd345c0)[i % 4]
```
最终:
```text
passport_account_image = base + "$AI_" + hex(AI)
```
## 6. mf Payload
`core/weapon_mf.py``mf.a()` 的 JSONObject 插入顺序构造字段,包括:
- 设备型号、厂商、Android 版本和本地 DID。
- elapsedRealtime、uptime、启动时间和 boot count。
- Weapon 上报时间与本地报告计数器。
- 样本中的反调试/环境状态字段。
同一轮登录只生成一次票据,并复用于预检、发码和验码。
## 7. 实现与验证
生产实现:
- `core/weapon_vimg.py`VIMG base、H1/H2、完整票据与反解。
- `core/weapon_mf.py`:设备 JSON 和 profile 票据生成。
- `tools/sms_login_cli.py`:纯算票据接入登录链。
分析 oracle
- `tools/libweapon_vimg_oracle.py`:仅用于离线差分,不进入生产路径。
验证结果:
- 固定 native 向量覆盖空输入、UTF-8、ChaCha 跨块、H1 跨 16/64 字。
- 21 组不同长度随机输入与 Unicorn native oracle 逐字节一致。
- 捕获票据反解出的 757 字节真实 `mf` JSON 可由 oracle 重新生成完全相同
的 base 和 `$AI_`
## 8. CLI
短信登录每次生成全新画像、在线注册 DFP并在注册完成后用当前云 DID 现场生成
`passport_account_image`
```powershell
uv run python -m tools.sms_login_cli --mobile MOBILE
```
已有验证码时跳过发码:
```powershell
uv run python -m tools.sms_login_cli `
--mobile MOBILE `
--code CODE
```
CLI 不提供设备画像、app-fields、WCFG 或复现种子的注入入口,避免把历史设备
状态带入下一次登录。
DFP 在线注册可能把本地初始 DID 替换为云 DID。主 APP 随后调用
`WeaponHI.setG(currentDid)`,所以 `mf` 字段 `03000` 必须使用注册后的
`DeviceProfile.did`,并与登录 query 的 `did` 保持一致,不能继续使用
`local_did`

View File

@ -0,0 +1,221 @@
# 新设备注册到指定账号并完成任务 — 设计与缺口文档
> 状态:草案 v12026-07-23
> 范围:把"每次运行生成全新设备 → 注册到指定账号 → 用该新设备跑完整任务链"这件事从**现状**推进到**可闭环**。
> 关联:`task_plan.md`(阶段 112/ `findings.md` / `progress.md` / `core/README.md`
---
## 1. 目标与范围
### 1.1 目标拆解
"新设备注册到指定账号,并用这个新设备完成后续任务"实际包含三件事:
1. **新设备身份生成**每次运行产出一套全新、自洽的设备画像did/oDid/rdid/egid + 硬件 + runtime hints
2. **新设备注册到服务端**:通过 DFP bootstrap 让服务端签发并承认这套设备身份cloud did + egid
3. **新设备绑定到指定账号**:让服务端把"这套新设备"与"指定账号"关联起来,后续任务请求同时带账号会话 + 新设备身份并被接受。
### 1.2 关键解释分叉(需明确,影响工作量量级)
| 解释 | 含义 | 是否需要登录流 |
|------|------|----------------|
| **A. 账号已登录态绑定** | 账号已通过 `.env` 持有有效 `api_st/h5_st/client_salt`"注册到账号"= 让新设备成为该已登录账号的活跃设备 | 否(若 token 不绑设备)/ 待验证 |
| **B. 全新登录绑定** | 仅有账号凭据(手机号+密码/短信),需在新设备上走完整登录流程,拿设备绑定的 token | 是passport SDK 逆向,量大) |
**推荐路线**:先按 A 推进并用真机/在线测试验证 token 是否跨设备存活(成本低);仅当 A 不可行token 强绑设备)才投入 B。本文档以 A 为主线B 作为兜底章节。
---
## 2. 账号–设备绑定模型(基于证据)
证据来源:`out/FINDINGS.md`、`out/h5_ticket_direct_20260712_142457.log`H5 Cookie 完整抓取)、`core/captured_profile.py`、`core/device_cookie.py`。
### 2.1 三类身份材料
| 类别 | 字段 | 绑定对象 | 获取方式 | 当前来源 |
|------|------|----------|----------|----------|
| 账号会话 | `kuaishou.api_st`native~405B、`kuaishou.h5_st`web~402B、`userId`/`ud` | 账号 + 登录态 | 登录接口签发HttpOnly | `.env` 抓包固化 |
| 账号密码材料 | `client_salt`32-hex | 账号 | `QCurrentUser.getTokenClientSalt()` | `.env` / `captured_profile.py` |
| 设备身份 | `did`/`oDid`/`rdid`/`egid` + 硬件字段 | 设备 | DFP bootstrap 在线签发 / 本地派生 | 内存设备生成 |
| 应用常量 | `client_key=2ac2a76d` | 应用 | 静态 | 硬编码 |
### 2.2 绑定机制 = 隐式共载
- **未发现**独立的"bind device to account"接口HAR 内 grep `passport/login/account/grant` 无命中,仅 `refresh=false` 噪声)。
- 账号 token 与设备 did/egid **同处一个 Cookie** 共载上报;服务端在"看到有效账号 token 携带某设备 did/egid"时建立关联。
- `__NStokensig = SHA256(sig + client_salt)``client_salt` 账号绑定、**非设备绑定**,理论上可在任意设备复用(`out/FINDINGS.md` 已验证)。
### 2.3 两套会话 token关键
- `kuaishou.api_st`native API 链路(广告拉取/上报、API 签到/宝箱)。
- `kuaishou.h5_st`H5 WebView 链路余额、task_list、签到、宝箱信息
- 两者**各自独立**、均账号绑定、均 HttpOnly。新设备能否跑通需分别验证。
---
## 3. 现状评估
| 环节 | 状态 | 证据 / 位置 |
|------|------|-------------|
| 新设备画像生成(全套随机) | ✅ 完成 | `core/device_profile.py:289-489` `DeviceProfileGenerator.new_profile` |
| 进程内即用即弃(不落盘) | ✅ 完成 | `main.py:727-747` `register_memory_device` |
| DFP 在线注册设备cloud did+egid | ✅ 完成 | `tools/new_device.py:54-113`、`core/dfp_client.py` |
| API 链路使用新设备 | ✅ 已验证 200 OK | `progress.md`(换设备后广告拉取 result=1 |
| 账号 token 复用于新设备API | 🟡 实测可用 | 同上:换设备后 API 仍 200 |
| **H5 链路使用新设备** | 🟡 opt-in`--rotate-h5-device`,默认关) | D1 已实现G2 待在线验证 |
| 账号 token 复用于新设备H5 | ❓ 未测试 | 因 §4.1 阻塞h5_st 从未在新设备上跑过 |
| 登录流(获取设备绑定 token | 🟡 已抓到+定性capture/ | 见 `docs/capture_login_chain.md`;运营商一键登录,会话走 libpfl 加密未还原 |
| 新设备登录风控(短信/通知/审核) | ❌ 未处理 | — |
| 出口 IP 轮换 | ❌ 缺失 | `core/dfp_client.py:74-113`、`main._request` 裸连 |
| bootstrap 失败硬断言 | 🟡 opt-in`--strict-device-online`,默认关) | D1 已实现(`main.py` `register_memory_device` |
---
## 4. 缺口清单(按优先级)
### G1【P0·已实现 opt-in】H5 cookie 未随设备轮换
- **位置**`main.py:662-663``h5_cookie_dict/h5_full_cookie` 仅 `__init__` 拷贝一次);`main.py:723-725``_apply_device_profile` 只更 `cookie_dict/full_cookie`)。
- **后果**`query_balance/final_balance/task_list/sign_in_resource/sign_in/treasure_box_info/open_treasure_box` 的 Cookie 头与 `__NS_sig3`/`kww``main.py:1036,1062,1075`)仍用旧账号设备 → **一次运行双设备**H5 旧 / API 新)→ "新设备注册到账号"在 H5 侧根本没发生。
- **修法**
```python
def _apply_device_profile(self, profile: DeviceProfile) -> None:
self.cookie_dict = apply_device_profile_to_cookie(self.cookie_dict, profile)
self.full_cookie = cookie_to_string(self.cookie_dict)
self.h5_cookie_dict = apply_device_profile_to_cookie(self.h5_cookie_dict, profile)
self.h5_full_cookie = cookie_to_string(self.h5_cookie_dict)
```
- **验证**`--memory-device` 在线跑,对比两次 `out/request_replay_*.jsonl` 中 H5 接口 Cookie 的 `did/egid` 是否换新且互异。
### G2【P0·验证未知】h5_st / api_st 是否跨设备存活
- **问题**:账号 token 是在旧设备上签发的新设备上携带是否被服务端接受API 侧已观测 200**H5 侧从未测过**(被 G1 阻塞)。
- **动作**:先修 G1再在线用 `--memory-device` 跑 H5 链路,观察 `sign_in_resource`/`treasure_box_info` 是否 `result=1`
- **分支**
- 若 H5 也 200 → 解释 A 成立,无需登录流,跳到 G4+。
- 若 H5 返回会话失效/设备校验类错误 → token 强绑设备,需走 G3登录流
### G3【P1·大块逆向】登录流仅 G2 失败时启用)
- **现状**HAR 未捕获登录;无 passport/login SDK 逆向产物。
- **需补**
1. 真机抓一次**全新设备首次登录**的完整 HARpassport 域、登录接口、短信验证、token 签发)。
2. 逆向登录接口签名(可能复用已还原的 sig/sig3/xfalcon但登录体有额外字段
3. 还原 `api_st/h5_st/client_salt` 的签发与下发路径(`getTokenClientSalt()` 来源)。
4. 处理短信验证码 / 滑块 / 设备校验等服务端挑战。
- **依赖**:账号凭据(手机号+密码 或 接码通道)。
### G4【P1·风控前置】出口 IP 轮换
- **问题**:同 IP 反复注册新设备 + 跑任务 = 经典风控信号。`dfp_client.post_request`、`main._request` 均无 `proxies` 接入。
- **修法**:给 `KsNebulaClient` / DFP client 加 `proxies` 透传(`KS_HTTP_PROXY` / per-run 代理池),每运行换出口 IP。
### G5【P1·已实现 opt-in】bootstrap 失败应硬失败
- **位置**`main.py:731-742`。
- **问题**bootstrap 异常或无 egid 时 fallback 到本地假 egid`device_id.py:98-110` sha512服务端未签发后照跑 → 必触发风控。
- **修法**`device_online` 模式下 bootstrap 失败 → `raise SystemExit`,不 fallback或加 `--allow-fallback-egid` 显式开关。
### G6【P2·用法】设备 seed 不可固定
- **位置**`main.py:1368``--device-seed`)、`device_profile.py:287`。
- **问题**:固定 seed → 每次生成**完全相同**设备。文档/示例里 `--device-seed 20260711` 仅供 dry-run 复现。
- **修法**:默认不传 seed如需可复现用运行时间派生唯一 seed 并记录到日志。
### G7【P2·质量】设备字段合理性 / 去聚类
- **位置**`device_profile.py:291``install_time_ms = now - randint(10s,600s)` → 每个新设备都"10 分钟内刚装",弱聚类特征)。
- **修法**install_time 分布到几小时~几天3 个硬件模板可扩充;`keeper_seed/du/manus/ipv6_map` 已随机(确认其确实进入 DFP deviceInfo 且语义合法)。
### G8【P2·一致性】STED / native 持久化态随 egid 同步
- **位置**`core/device_profile.py:154-169` `sync_egid_cache` / `refresh_persisted_cache_m`、`core/ksse_sted.py`。
- **现状**:换 egid 后 `sted_cache_json`/`persisted_cache_m` 已重算并进 DFP deviceInfo。
- **需确认**:这些 native 态在"纯 Python 无真机"下是否仅作为 deviceInfo 字段上报(服务端不读回本地文件)——若是则已足够;若服务端有读回校验则需补持久化模拟。**初判:仅上报字段,已足够。**
---
## 5. 关键未知与决策点
| 编号 | 未知 / 决策 | 解决方式 | 阻塞 |
|------|------------|----------|------|
| Q1 | h5_st/api_st 是否跨设备存活? | 修 G1 后在线测 H5G2 | 决定是否需 G3 |
| Q2 | 是否有账号凭据可供全新登录? | 用户确认 | 决定 G3 可行性 |
| Q3 | 新设备登录是否触发短信/通知风控? | 真机抓登录 HAR 观察 | G3 子问题 |
| Q4 | DFP bootstrap 在高频注册下是否被限流? | 多次注册实测 | G4、G5 |
| Q5 | 单账号挂多新设备的服务端上限? | 实测 + 观察风控响应 | 影响换设备策略 |
---
## 6. 实施计划A 路线优先)
### 阶段 D1打通 H5 新设备P012 项改动)
- [ ] 修 G1`_apply_device_profile` 同步 h5 cookie
- [ ] 修 G5bootstrap 失败硬失败,或加显式 fallback 开关)
- [ ] 单测:`tests/test_main_device_profile.py` 增"H5 cookie 随 memory device 更新"用例
- [ ] dry-run 验证 H5 与 API 一致用新设备
### 阶段 D2在线验证 token 跨设备P0解 Q1
- [ ] `--memory-device` 在线跑完整链,记录 H5 各接口 result
- [ ] 重复跑 2 次,确认设备每次不同且任务成功
- [ ] 结论A 可行 → 进 D3不可行 → 转 B 路线(阶段 D5+
### 阶段 D3风控前置P1
- [ ] G4 接入代理池 / `KS_HTTP_PROXY`
- [ ] G6 默认随机 seed
- [ ] G7 install_time 去聚类、扩硬件模板
### 阶段 D4闭环与回归P1
- [ ] 全链在线跑 N 次,统计成功率与风控响应
- [ ] 回归既有单测 + DFP parity 测试
- [ ] 更新 `task_plan.md` 新增阶段
### 阶段 D5仅 B 路线):登录流逆向
- [ ] 真机抓首次登录 HAR
- [ ] 定位 passport/login 接口与签名
- [ ] 还原 api_st/h5_st/client_salt 签发
- [ ] 处理短信/滑块挑战
---
## 7. 验证计划
每阶段"完成"的判定标准(可复现命令):
```bash
# D1 单测
uv run python -m unittest tests.test_main_device_profile tests.test_device_cookie -v
# D1 dry-runH5+API 一致用新设备)
uv run python main.py --dry-run --memory-device --no-device-online --out-dir out\d1_dryrun
# D2 在线验证(真·每次新设备)
uv run python main.py --memory-device --out-dir out\d2_online_run1
uv run python main.py --memory-device --out-dir out\d2_online_run2
# 比对 run1/run2 的 request_replay_*.jsonldid/egid 互异且 H5 接口 result=1
```
验收清单:
- [ ] 两次运行 did/egid 互不相同(无固定 seed 泄漏)
- [ ] H5 与 API 的 Cookie 中 did/egid **同次运行一致**
- [ ] H5 接口在线返回 `result=1`(解 Q1
- [ ] bootstrap 失败时进程退出而非 fallback 假 egid
- [ ] 出口 IP 每运行可切换
---
## 8. 风险
| 风险 | 等级 | 缓解 |
|------|------|------|
| h5_st 强绑设备 → A 路线失败 | 高 | D2 早验证;失败转 B |
| 单账号多设备触发风控/封号 | 高 | 限频、IP 轮换、控制换设备次数(已有 `--device-max-switches` |
| DFP bootstrap 高频被限流 | 中 | 实测限流阈值;代理 + 退避 |
| 登录流逆向工作量超预期 | 中 | 仅在 A 失败时启动;先抓 HAR 评估 |
| 假 egid 静默 fallback 致封号 | 中 | G5 硬失败 |
| 设备字段聚类特征 | 低 | G7 去聚类 |
---
## 9. 附录:关键文件索引
| 关注点 | 文件 |
|--------|------|
| 任务编排 / 设备轮换 | `main.py``KsNebulaClient`、`register_memory_device:727`、`maybe_rotate_device_for_record:749` |
| 设备画像生成 | `core/device_profile.py` |
| 设备字段→Cookie | `core/device_cookie.py` |
| 设备 id 派生 / 假 egid | `core/device_id.py` |
| DFP 在线注册 | `tools/new_device.py`、`core/dfp_client.py`、`core/dfp_forms.py` |
| STED 持久化 | `core/ksse_sted.py` |
| 账号材料样本 | `core/captured_profile.py`、`.env` |
| H5 会话/ticket 分析 | `out/analyze_h5_ticket_flow.py`、`out/h5_ticket_direct_*.log` |
| 算法总览 | `core/README.md`、`out/FINDINGS.md` |

View File

@ -0,0 +1,161 @@
# region_ticket 静态逆向结论
## 结论
`region_ticket` 不是 `sig`、`__NS_sig3` 或 DFP 一类的客户端计算结果。
APK 将它建模为服务端响应顶层 `region.ticket` 中的不透明票据,按用户保存到
`DefaultPreferenceHelper``<uid>_Region`,后续请求再把它放入 Cookie。
因此,纯 Python 的正确实现是“接收、持久化、复用”,不是本地生成:
1. 解析每个普通 API JSON 响应的顶层 `region`
2. 有 `ticket` 时按 `region.uid` 保存;`uid` 为空时使用当前用户 ID。
3. 后续请求注入 `Cookie: region_ticket=<ticket>; __NSWJ=<value>`
4. 账号切换时按 UID 隔离,不能把一个账号的票据当作设备级常量。
## 静态调用链
```text
任意普通 API JSON 响应
-> ResponseDeserializer.deserialize()
-> 读取顶层 region.uid / region.name / region.ticket
-> ylm.d.mRegion
-> u0a.f.buildObservableBeforeRetry()
-> doOnNext(com.kwai.framework.network.regions.c)
-> regions.c.accept()
-> o2a.c.c(region, "New region received")
-> DefaultPreferenceHelper[<uid>_Region] = Region JSON
下一次请求
-> v0a.c.c(sceneName)
-> q01.g.l0()
-> NetworkAccessParams.e.l0()
-> o2a.c.b(Region.class).ticket
-> Cookie: region_ticket=<ticket>; __NSWJ=<value>
```
## 关键证据
### 1. IOC 实现绑定
- `pnm.b.b(-1479227965)` 在生成的 `IOCProviderImpl` 中绑定到
`com.kwai.framework.network.access.params.e`
- `q01.g.l0()` 的实际实现读取 `o2a.c.b(Region.class)`,返回 `Region.b()`
`mTicket`;不存在加密、哈希或 native 调用。
- Region scheduler 的 IOC ID `1013182224` 绑定到 `o2a.e`
证据文件:
- `out/region_ticket_jadx/IOCProviderImpl.java`
- `out/region_ticket_jadx/NetworkAccessParamsE.java`
- `out/region_ticket_jadx/RegionSchedulerProviderE.java`
### 2. 响应下发与统一持久化
`ResponseDeserializer` 只读取响应 JSON 的顶层字段:
```json
{
"region": {
"uid": "...",
"name": "...",
"ticket": "RT_..."
}
}
```
`u0a.f``regions.c` 注册为普通 API 的全局 `doOnNext` 处理器。
`regions.c` 检查 `ylm.d.k()`,非空时直接调用:
```java
o2a.c.c(response.k(), "New region received");
```
这说明没有唯一的 `region_ticket` 申请接口。任何使用统一响应包的普通 API
都可以顺带下发或轮换 Region。
### 3. 本地存储
`o2a.c` 使用 `DefaultPreferenceHelper`
```text
读取键: <QCurrentUser.id>_Region
写入键: <region.uid>_Region
回退键: <QCurrentUser.id>_Region
值格式: {"uid":"...","name":"...","ticket":"RT_..."}
```
未登录时当前 UID 回退为 `0`。如果响应明确带 `region.uid`,写入时优先使用
该 UID所以账号票据不会天然复制到另一个 UID。
### 4. Cookie 注入
`v0a.c` 先组合 `region_ticket`、`kuaishou.api_st`、`__NSWJ`,再序列化为
`Cookie` 请求头。`region_ticket` 为空时该项被省略,不会生成占位值。
### 5. RegionInfo 不是票据来源
`o2a.e` 会从 SharedPreferences 的 `RegionInfo` 或 raw 资源加载 API 区域路由。
APK 资源 `raw/0x7f100082` 只有 `api_group_host_list``api_mapping`,没有
`ticket`。它负责 follow/nearby 的区域 host 选择,不负责签发 Region 票据。
## HAR 审计
新增 `tools/analyze_region_ticket.py`,结构化审计以下三类事件:
- 响应 JSON 出现顶层或嵌套 `region.ticket`
- 响应头出现 `Set-Cookie: region_ticket=...`
- 请求 Cookie 携带 `region_ticket`
对仓库全部 7 个 HAR、8334 个 entry 的结果:
```text
响应 region.ticket: 0
响应 Set-Cookie: 0
请求 region_ticket: 362
唯一票据: 12
票据长度: 全部 76
票据形态: RT_ + 73 个十六进制字符
```
12 个样本的 73 个十六进制位置均有变化,没有固定版本位或可见字段边界。
这与“服务端不透明票据”一致,但仅凭格式不能反推出生成算法。
现有 HAR 的抓包窗口开始时票据已经存在:例如历史窗口的第一批 startup、spot、
reddot 请求已经携带同一票据。因此“首个携带票据的请求”不是签发接口,这批 HAR
不能确定首次下发发生在哪个 endpoint。
脱敏审计产物位于:
- `out/region_ticket_har_small.json`
- `out/region_ticket_har_20260708_1416.json`
- `out/region_ticket_har_20260710.json`
- `out/region_ticket_har_20260726.json`
- `out/region_ticket_har_ip_20260726.json`
- `out/region_ticket_har_cdn_20260724.json`
## 对短信登录 CLI 的影响
当前 CLI 已支持 `--region-ticket`、`KS_REGION_TICKET` 和 app-fields 注入,
但当前 `sms_device_latest.json` 不包含该状态。静态链给出的实现边界是:
- DFP 注册不会本地生成 `region_ticket`
- `passport_account_image`、account_security 和请求签名也不会生成它。
- CLI 应在 checker、发码、登录及后续普通 API 响应中检查顶层 `region`,收到后
更新当前 Session Cookie 并按 UID 保存。
- 在首次收到服务端 Region 之前,只能不带该 Cookie不能构造一个等价票据。
缺少 `region_ticket` 会造成 APP/CLI 请求上下文差异,可能增加风控评分,但静态证据
不足以把 `705` 单独归因于它。要验证因果,应在同一设备画像、同一手机号、同一网络
下只改变该 Cookie比较 checker 和 mobileVerifyCode 的结果。
## 复现命令
```powershell
uv run python -m tools.analyze_region_ticket `
ks.har `
nebula.kuaishou.com_2026_07_10_17_15_37.har `
--out out/region_ticket_audit.json
uv run python -m unittest tests.test_analyze_region_ticket
```

188
docs/sms_login_flow.md Normal file
View File

@ -0,0 +1,188 @@
# 快手极速版 短信登录链路(纯 Python 可复刻路径)
> 来源:`login_gateway_plugin-master.apk`jadx+ 主 app jadx`zvl.a` Retrofit 接口)
> 日期2026-07-24
> 关联:`docs/capture_login_chain.md`(运营商一键登录 = 加密 dataRsp难复刻
## 核心结论
**短信登录是纯 Python 可复刻的登录路径**:请求为明文 form响应为明文 JSON直接返回
`api_st/h5_st/api_client_salt/userInfo`。**无需 pfl/kwsg 解密**(与运营商一键登录的
加密 `dataRsp` 不同)。
唯一非 Python 环节:**接收短信验证码**(需一台能收码的手机/接码服务)--但比运营商
一键登录(需 carrier SDK + SIM + 设备)门槛低得多。
## 登录 APIRetrofit 接口 `zvl.a`,全部 POST form
### 1. 发送短信验证码
```
POST n/user/requestMobileCode # 方法 M@FormUrlEncoded
form:
mobileCountryCode "+86"
mobile "<手机号>"
type 27 # 已存在手机号短信登录
useVoice false
needCheck true
prefetchPhoneNumber ""
requestSource "<来源>"
query:
+ sig / __NS_sig3 / __NS_xfalcon / client_key / os / did / egid / oDid / rdid / ...
-> RequestVerifyCodeResponse { result, isCheck, phone[] }
```
### 2. 验证码登录(拿会话)
```
POST /rest/n/user/login/mobileVerifyCode # 方法 r0@FormUrlEncoded@y0n.d Map
query:
did / egid / oDid / rdid / client_key / os / ...
form (动态 map / FieldMap):
code / mobile / mobileCountryCode / type=27 / isDegraded=false
deviceName / deviceMode / publicKey / raw / secret
+ sig / __NS_sig3 / __NS_xfalcon
-> LoginUserResponse (明文 JSON见下
```
签名细节2026-07-24 Frida + jadx 确认):
- `sig = CPU.getClock(sorted(query + body 非签名字段))`
- `__NS_sig3 = KSecurity.atlasSign(encodedPath + sig)`
- `__NS_xfalcon = KXGS((sig + __NS_sig3).getBytes(), 2096)`
- `encodedPath` 只用于 sig3 与跳过列表判断;`xfalcon` 原始 byte[] **不包含 path**
相关变体(同接口):
- `n/user/login/mobileVerifyCode`r0、`n/user/login/token`s0
`n/user/login/mobile`148、`n/user/login/mobileQuick`197一键
`n/user/login/quickLogin`140、`n/user/login/preCheck`205
`/rest/n/loginRegister/unified/verify`258
## 响应LoginUserResponse明文含完整会话
直接 JSON 字段(`@c` 注解 = JSON key
| 字段 | JSON key | 用途 |
|------|----------|------|
| mApiServiceToken | `kuaishou.api_st` | **API 会话 token** |
| mH5ServiceToken | `kuaishou.h5_st` | **H5 会话 token** |
| mNewTokenClientSalt | `kuaishou.api_client_salt` | **client_salt**__NStokensig 用) |
| mobile / mobileCountryCode | `mobile` / `mobileCountryCode` | 登录手机号 |
| mPassToken | `passToken` | pass token |
| quickloginToken | `quickloginToken` | 一键登录 token |
| userInfo / multiUserInfo | `userInfo` / `multiUserInfo` | 用户信息(含 user_id |
| codeKey / codeUri | `codeKey` / `codeUri` | 二维码登录用 |
| bindPhoneRequired / canLogin / canQuickLogin / isNewRegisterUser / loginType | 同名 | 登录态标志 |
> 响应包装:`Observable<ylm.d<LoginUserResponse>>` -> 标准 KS 信封
> `{result:1, error_msg:"", data:{ kuaishou.api_st, kuaishou.h5_st, kuaishou.api_client_salt, ... }}`
> **明文,非 dataRsp 加密**
## 与运营商一键登录对比
| 维度 | 短信登录 `mobileVerifyCode` | 运营商一键 `quickLogin` |
|------|----------------------|----------------------|
| 请求 | 明文 form + sig/sig3/xfalcon | 明文 form `provider=N&provider_token=<carrier proto>` |
| 凭证来源 | 手机号 + 收到的短信码 | carrier SDK token需 SIM+设备+SDK |
| 响应 | **明文** LoginUserResponse | **加密** `dataRsp`libpfl未纯 Python 还原) |
| 纯 Python 可行 | **是**(仅缺收码) | 否carrier token + dataRsp 解密两道黑盒) |
## 复刻流程(纯 Python
```
新设备画像DFP bootstrap 在线注册 did/egid/oDid/rdid已还原
POST /rest/n/user/requestMobileCode (mobile=手机号, did=新设备, sig/sig3/xfalcon)
↓ 服务端发短信
[人工/接码] 收到验证码
POST /rest/n/user/login/mobileVerifyCode
(query=设备, body=code/type=27/publicKey/raw/secret/sig/sig3/xfalcon)
↓ 明文响应
LoginUserResponse -> 取 kuaishou.api_st / kuaishou.h5_st / api_client_salt / userInfo
写入 .env / cookie_dict后续任务链即可用新设备+新会话跑
```
## 待确认 / 风险
1. **host**`/rest/n/user/login/...` 走 aegon 网关,确切 host 需实测确认
(候选:`api2.kuaishou.com` / `apissl.ksapisrv.com` / `api.ksapisrv.com`)。
静态未见明文 base URL`zvl.a` 经 `pnm.b.b(1559932927)` DI 创建base URL 在网络模块)。
2. **签名**:高概率走已还原的 `sig/__NS_sig3/__NS_xfalcon`(运营商登录 flow 221 实测带这些,
同一网络层),验码 FieldMap 中已补 `publicKey/raw/secret` 账号保护字段。
3. **请求是否带 encData**`@FormUrlEncoded` + flow 221 明文先例 -> 判定明文 form无 encData。
待实测确认。
4. **风控**:新设备 + 短信登录可能触发设备校验/短信频控/异地登录提示,需小流量验证。
5. **收码**:必须能接收短信(自有手机或接码平台),这是 SMS 登录固有限制。
## 关键文件索引
- Retrofit 接口:`out/jadx/sources/zvl/a.java``M` 发码,`r0` mobileVerifyCode
- `LoginUserResponse`:含 `kuaishou.api_st`/`h5_st`/`api_client_salt`/`userInfo`
- `RequestVerifyCodeResponse``{result, isCheck, phone[]}`
- `login_gateway_plugin-master.apk`:仅 carrier 一键登录(联通/电信/移动 SDK + AuthModel
**无短信路径** -> 短信登录在主 app。
- 主 app 调用点:`com.yxcorp.login.bind.*`ChangePhoneFragment 等)
## 实现已完成2026-07-24
- `core/sms_login.py`
- `login_api_params(profile)` 设备+app 参数(静态协议字段 + `device_profile_cookie_fields` 覆盖)。
- `signed_login_url(path, params, body_pairs, state, base_url, t1, t2)` 签名 URL
`sig` / `__NS_sig3` / `__NS_xfalcon`**无 `__NStokensig`**,登录态未建立)。
- `request_mobile_code(...)` -> `RequestVerifyCodeResponse`(发码)。
- `login_by_code(...)` -> `LoginSession{api_st, h5_st, client_salt, user_id, mobile, pass_token}`
`login/*` 为 Retrofit `@FieldMap`,签名字段放在 form bodyURL query 只放设备参数。
- `parse_login_user_response(data)` 解析明文 `LoginUserResponse`
- sig3 使用进程级 `Kwsg10418State`seed 按 native
`srand(time) -> rand()+1` 现场生成,验证码重放、发码和登录共享同一递增
counter。
- `tools/sms_login_cli.py`CLI 闭环(全新设备画像 -> 在线 DFP 注册 -> 发码 ->
输入码 -> 会话)。每次执行都不读取或保存历史设备、抓包字段及区域票据。
- CLI 只提供 `--mobile`、`--code`、`--base-url`、`--transport`;短信类型、
超时、sig3、region 和验证码重试策略均由内部固定。
## 待实测确认(用 CLI 跑一次真机即可)
```bash
# 真实流程(在线注册全新设备 -> 发码 -> 输入码 -> 会话)
uv run python -m tools.sms_login_cli --mobile <手机号>
# 如果发码请求超时但手机已收到短信,可跳过发码继续验码:
uv run python -m tools.sms_login_cli --mobile <手机号> --code <收到的验证码>
# 可选覆盖登录 host
uv run python -m tools.sms_login_cli --mobile <手机号> --base-url https://api.ksapisrv.com
# 可选切换 HTTP 传输:
uv run python -m tools.sms_login_cli --mobile <手机号> --transport okhttp4-android10
```
`requestMobileCode``mobileVerifyCode` 同时返回 `result=705` 和 HTTPS
`error_url`CLI 自动尝试验证码求解,并在拿到 `captchaToken` 后重放原请求。
每个阶段内部最多重试 2 次。HTTP 求解未拿到 `captchaToken` 时直接停止,不启动
Playwright、Edge/Chrome 或系统浏览器。
当前 `14.5.50.11631` 匿名登录 query 已按 APP 样本对齐 `kcv=1630`,并补齐
`language/ud/bottom_navigation/is_background/icaver/darkMode/ftt`。这些字段参与签名和
服务端设备上下文判断,不能继续使用旧版本默认值。
登录阶段的 `raw/publicKey/secret`、设备画像、手机号密文和
`passport_account_image` 在当前 CLI 流程内固定,验证后重试时保持原请求正文一致。
当请求表现为 `status=0 body={}`CLI 会额外打印底层 `error` 字段。该状态属于
传输失败,不会误判为 705也不会进入验证重试。
实测需确认:
1. **host**:默认 `apissl.ksapisrv.com`;若 `result!=1`/网络错误,再换
`api2.kuaishou.com` / `api.ksapisrv.com` / `api.e.kuaishou.com`
2. **session_seed/counter**seed 是每进程动态值不是固定常量CLI 已纯 Python
复现 native PRNG并从启动后基线 `counter=0x5f` 开始让所有请求连续递增。
CLI 不提供固定 seed/counter 的复现入口。
3. **响应明文**`mobileVerifyCode` 响应应直接含 `kuaishou.api_st`/`h5_st`/`api_client_salt`。
4. **userInfo.user_id 字段名**:解析已兼容 `user_id/userId/eid/uid`
5. **验证码位数 / type 归属**:复核 `PhoneVerifyParams``zvl.a.B(map)` 后确认,
APP 6 位码对应“手机验证页”路径:`requestMobileCode(type=6)` ->
`n/user/verify/mobile`,返回 `ActionResponse`,不是直接登录拿会话。
用户实测 `type=1` 文案为“仅用于注册”不用于登录CLI 直接登录固定使用
`type=27`
登录成功后 CLI 会自动输出可直接写入 `.env`
`ksck="账号#完整 Cookie#client_salt"`,无需额外参数。

View File

@ -0,0 +1,75 @@
# Core Signing Algorithms Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move the recovered signing/encryption algorithms into a reusable `core` package.
**Architecture:** Keep proven algorithm code intact first, then expose narrow modules by responsibility: `sig`, `tokensig`, `sig3`, `xfalcon`, `enc_data`, and `reward_sign`. Existing `out/*` scripts remain usable through compatibility imports.
**Tech Stack:** Python 3.13, stdlib only.
---
### Task 1: Create core package skeleton
**Files:**
- Create: `core/__init__.py`
- Create: `core/constants.py`
- Create: `core/sig.py`
- Create: `core/tokensig.py`
- Create: `core/sig3.py`
- Create: `core/xfalcon.py`
- Create: `core/enc_data.py`
- Create: `core/reward_sign.py`
- [ ] Create modules with focused exports.
- [ ] Keep table file paths pointing at existing `out/*.bin` artifacts.
- [ ] Validate imports with `python -m py_compile core\*.py`.
### Task 2: Move xfalcon implementation
**Files:**
- Create: `core/xfalcon_blake_core.py`
- Create: `core/xfalcon_te.py`
- Modify: `core/xfalcon.py`
- [ ] Copy the verified BLAKE2s-style compression and `$TE_` formatter.
- [ ] Adjust relative imports to stay inside `core`.
- [ ] Verify with existing xfalcon tests.
### Task 3: Move KWSG 10400/10418 implementation
**Files:**
- Create: `core/kwsg.py`
- Modify: `core/sig3.py`
- Modify: `core/enc_data.py`
- Modify: `core/reward_sign.py`
- [ ] Copy the verified `ks_sign.py` implementation.
- [ ] Adjust xfalcon imports to `core.xfalcon`.
- [ ] Expose category modules as stable public entrypoints.
### Task 4: Compatibility and call sites
**Files:**
- Modify: `out/analyze_live_reward_log.py`
- Modify: `out/build_followup_request.py`
- Modify: `out/ks_sign.py` only if needed
- [ ] Prefer importing from `core` in request builders.
- [ ] Preserve old command behavior for existing scripts.
- [ ] Avoid touching dynamic probe scripts.
### Task 5: Verification
**Commands:**
```powershell
python -m py_compile core\*.py out\analyze_live_reward_log.py out\build_followup_request.py out\build_reward_request.py out\run_reward_cycle.py
python out\test_rebuild_xfalcon_digest.py
python out\test_rebuild_xfalcon_reward.py
python out\test_build_reward_request.py
python out\test_live_reward_log.py
```
Expected: all commands exit `0`.

View File

@ -0,0 +1,488 @@
# Device Profile Generation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a local device profile generator that can create, persist, reload, and export stable synthetic Android device identities.
**Architecture:** Add a focused `core/device_profile.py` module for pure identity generation and validation. Add a small `tools/new_device.py` CLI that uses the core module without depending on APP runtime, Frida, HAR, or `out/`.
**Tech Stack:** Python 3.13, standard library only, pytest for tests.
---
## File Structure
- Create `tests/test_device_profile.py`
- Unit tests for profile generation, validation, persistence, cloud override, and env export.
- Create `core/device_profile.py`
- Dataclass model, generator, validation helpers, JSON persistence, env export.
- Create `tools/new_device.py`
- CLI for generating one or more profiles.
- Modify `core/__init__.py`
- Export `DeviceProfile`, `DeviceProfileGenerator`, `load_device_profile`, and `save_device_profile`.
---
### Task 1: Add failing tests for device profile core
**Files:**
- Create: `tests/test_device_profile.py`
- Create later: `core/device_profile.py`
- [ ] **Step 1: Write the failing tests**
```python
import hashlib
import json
import pytest
from core.device_profile import (
DeviceProfile,
DeviceProfileGenerator,
load_device_profile,
save_device_profile,
)
def test_generator_creates_consistent_local_identity():
profile = DeviceProfileGenerator(seed=1234).new_profile()
assert len(profile.android_id) == 16
assert profile.android_id == profile.android_id.lower()
int(profile.android_id, 16)
assert profile.o_did == f"ANDROID_{profile.android_id}"
assert profile.local_did.startswith("ANDROID_")
assert profile.did == profile.local_did
expected_rdid = hashlib.md5(profile.g_rdi2.encode("utf-8")).hexdigest()[16:32]
assert profile.rdid == f"ANDROID_{expected_rdid}"
def test_profile_persistence_roundtrip(tmp_path):
path = tmp_path / "device.json"
profile = DeviceProfileGenerator(seed=5678).new_profile()
save_device_profile(profile, path)
loaded = load_device_profile(path)
assert loaded == profile
assert json.loads(path.read_text(encoding="utf-8"))["android_id"] == profile.android_id
def test_apply_cloud_identity_updates_only_server_fields():
profile = DeviceProfileGenerator(seed=9012).new_profile()
old_android_id = profile.android_id
old_o_did = profile.o_did
old_rdid = profile.rdid
profile.apply_cloud_identity(
did="ANDROID_e8dfd2f16b618053",
cdid_tag=2,
egid="DFP68CA12B5D3C714E4439D5E255B197DA809D63CB77139F1420A763F53FE718",
)
assert profile.did == "ANDROID_e8dfd2f16b618053"
assert profile.cdid_tag == 2
assert profile.egid == "DFP68CA12B5D3C714E4439D5E255B197DA809D63CB77139F1420A763F53FE718"
assert profile.android_id == old_android_id
assert profile.o_did == old_o_did
assert profile.rdid == old_rdid
def test_env_export_contains_expected_identity_keys():
profile = DeviceProfileGenerator(seed=3456).new_profile()
env_text = profile.to_env()
assert f"KS_ANDROID_ID={profile.android_id}" in env_text
assert f"KS_DID={profile.did}" in env_text
assert f"KS_ODID={profile.o_did}" in env_text
assert f"KS_RDID={profile.rdid}" in env_text
assert f"KS_LOCAL_DID={profile.local_did}" in env_text
@pytest.mark.parametrize(
"field,value",
[
("android_id", "XYZ"),
("local_did", "BAD"),
("did", "BAD"),
("o_did", "BAD"),
("rdid", "BAD"),
],
)
def test_profile_validation_rejects_invalid_identity_fields(field, value):
data = DeviceProfileGenerator(seed=7890).new_profile().to_dict()
data[field] = value
with pytest.raises(ValueError):
DeviceProfile.from_dict(data)
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```powershell
uv run pytest tests/test_device_profile.py -v
```
Expected:
```text
ModuleNotFoundError: No module named 'core.device_profile'
```
---
### Task 2: Implement core device profile module
**Files:**
- Create: `core/device_profile.py`
- Modify: `core/__init__.py`
- Test: `tests/test_device_profile.py`
- [ ] **Step 1: Create `core/device_profile.py`**
```python
from __future__ import annotations
import hashlib
import json
import random
import re
import time
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
ANDROID_ID_RE = re.compile(r"^[0-9a-f]{16}$")
ANDROID_PREFIX_RE = re.compile(r"^ANDROID_[0-9a-f]{16}$")
EGID_RE = re.compile(r"^$|^DFP[0-9A-F]{61}$")
@dataclass
class DeviceProfile:
android_id: str
local_did: str
did: str
o_did: str
rdid: str
g_rdi2: str
cdid_tag: int = 0
egid: str = ""
install_time_ms: int = 0
cold_launch_time_ms: int = 0
sid: str = ""
def __post_init__(self) -> None:
self.validate()
def validate(self) -> None:
if not ANDROID_ID_RE.fullmatch(self.android_id):
raise ValueError(f"invalid android_id: {self.android_id!r}")
for name in ("local_did", "did", "o_did", "rdid"):
value = getattr(self, name)
if not ANDROID_PREFIX_RE.fullmatch(value):
raise ValueError(f"invalid {name}: {value!r}")
if self.o_did != f"ANDROID_{self.android_id}":
raise ValueError("o_did must equal ANDROID_<android_id>")
expected_rdid = hashlib.md5(self.g_rdi2.encode("utf-8")).hexdigest()[16:32]
if self.rdid != f"ANDROID_{expected_rdid}":
raise ValueError("rdid must equal ANDROID_<md5(g_rdi2)[16:32]>")
if not isinstance(self.cdid_tag, int) or self.cdid_tag < 0:
raise ValueError(f"invalid cdid_tag: {self.cdid_tag!r}")
if not EGID_RE.fullmatch(self.egid):
raise ValueError(f"invalid egid: {self.egid!r}")
def apply_cloud_identity(self, did: str, cdid_tag: int, egid: str = "") -> None:
self.did = did
self.cdid_tag = cdid_tag
if egid:
self.egid = egid
self.validate()
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "DeviceProfile":
return cls(
android_id=str(data["android_id"]),
local_did=str(data["local_did"]),
did=str(data["did"]),
o_did=str(data["o_did"]),
rdid=str(data["rdid"]),
g_rdi2=str(data["g_rdi2"]),
cdid_tag=int(data.get("cdid_tag", 0)),
egid=str(data.get("egid", "")),
install_time_ms=int(data.get("install_time_ms", 0)),
cold_launch_time_ms=int(data.get("cold_launch_time_ms", 0)),
sid=str(data.get("sid", "")),
)
def to_env(self) -> str:
lines = [
f"KS_ANDROID_ID={self.android_id}",
f"KS_DID={self.did}",
f"KS_LOCAL_DID={self.local_did}",
f"KS_ODID={self.o_did}",
f"KS_RDID={self.rdid}",
f"KS_GRDI2={self.g_rdi2}",
f"KS_CDID_TAG={self.cdid_tag}",
f"KS_EGID={self.egid}",
f"KS_INSTALL_TIME_MS={self.install_time_ms}",
f"KS_COLD_LAUNCH_TIME_MS={self.cold_launch_time_ms}",
f"KS_SID={self.sid}",
]
return "\n".join(lines) + "\n"
class DeviceProfileGenerator:
def __init__(self, seed: int | None = None) -> None:
self._random = random.Random(seed)
def new_profile(self) -> DeviceProfile:
now_ms = int(time.time() * 1000)
android_id = self._hex16()
local_did = f"ANDROID_{self._hex16()}"
g_rdi2 = self._g_rdi2()
rdid_suffix = hashlib.md5(g_rdi2.encode("utf-8")).hexdigest()[16:32]
return DeviceProfile(
android_id=android_id,
local_did=local_did,
did=local_did,
o_did=f"ANDROID_{android_id}",
rdid=f"ANDROID_{rdid_suffix}",
g_rdi2=g_rdi2,
install_time_ms=now_ms - self._random.randint(10_000, 600_000),
cold_launch_time_ms=now_ms,
sid=str(uuid.UUID(int=self._random.getrandbits(128))),
)
def _hex16(self) -> str:
return f"{self._random.getrandbits(64):016x}"
def _g_rdi2(self) -> str:
parts = []
for _ in range(5):
left = self._random.choice((7, 8, 9)) * 100_000_000 + self._random.randint(0, 999_999)
right = self._random.choice((4741, 8641))
parts.append(f"{left}::{right}")
return "|".join(parts)
def save_device_profile(profile: DeviceProfile, path: str | Path) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
json.dumps(profile.to_dict(), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def load_device_profile(path: str | Path) -> DeviceProfile:
source = Path(path)
try:
data = json.loads(source.read_text(encoding="utf-8"))
except Exception as exc:
raise ValueError(f"failed to load device profile: {source}") from exc
if not isinstance(data, dict):
raise ValueError(f"device profile must be a JSON object: {source}")
return DeviceProfile.from_dict(data)
```
- [ ] **Step 2: Export from `core/__init__.py`**
```python
from .device_profile import (
DeviceProfile,
DeviceProfileGenerator,
load_device_profile,
save_device_profile,
)
__all__ = [
"DeviceProfile",
"DeviceProfileGenerator",
"load_device_profile",
"save_device_profile",
]
```
- [ ] **Step 3: Run tests**
Run:
```powershell
uv run pytest tests/test_device_profile.py -v
```
Expected:
```text
8 passed
```
---
### Task 3: Add CLI for generating profiles
**Files:**
- Create: `tools/new_device.py`
- Test with smoke commands.
- [ ] **Step 1: Create `tools/new_device.py`**
```python
from __future__ import annotations
import argparse
from pathlib import Path
from core.device_profile import DeviceProfileGenerator, save_device_profile
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Generate local Kuaishou device profiles")
parser.add_argument("--count", type=int, default=1)
parser.add_argument("--out-dir", default="out/devices")
parser.add_argument("--prefix", default="device")
parser.add_argument("--seed", type=int, default=None)
parser.add_argument("--env", action="store_true", help="also write .env files")
parser.add_argument("--force", action="store_true", help="overwrite existing files")
return parser
def main() -> int:
args = build_parser().parse_args()
if args.count < 1:
raise SystemExit("--count must be >= 1")
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
generator = DeviceProfileGenerator(seed=args.seed)
for index in range(1, args.count + 1):
profile = generator.new_profile()
stem = f"{args.prefix}_{index:03d}"
json_path = out_dir / f"{stem}.json"
env_path = out_dir / f"{stem}.env"
if not args.force and json_path.exists():
raise SystemExit(f"refusing to overwrite existing file: {json_path}")
if args.env and not args.force and env_path.exists():
raise SystemExit(f"refusing to overwrite existing file: {env_path}")
save_device_profile(profile, json_path)
if args.env:
env_path.write_text(profile.to_env(), encoding="utf-8")
print(f"[OK] {json_path} did={profile.did} odid={profile.o_did} rdid={profile.rdid}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
```
- [ ] **Step 2: Run CLI smoke test**
Run:
```powershell
uv run python tools/new_device.py --count 2 --out-dir out/devices_test --seed 1 --env --force
```
Expected:
```text
[OK] out\devices_test\device_001.json ...
[OK] out\devices_test\device_002.json ...
```
- [ ] **Step 3: Compile check**
Run:
```powershell
uv run python -m compileall core tools tests
```
Expected:
```text
Listing 'core'...
Listing 'tools'...
Listing 'tests'...
```
---
### Task 4: Final verification
**Files:**
- Verify: `core/device_profile.py`
- Verify: `tools/new_device.py`
- Verify: `tests/test_device_profile.py`
- [ ] **Step 1: Run focused tests**
Run:
```powershell
uv run pytest tests/test_device_profile.py -v
```
Expected:
```text
8 passed
```
- [ ] **Step 2: Run existing test suite**
Run:
```powershell
uv run pytest tests -v
```
Expected:
```text
all tests passed
```
- [ ] **Step 3: Generate sample profile**
Run:
```powershell
uv run python tools/new_device.py --count 1 --out-dir out/devices_sample --seed 20260711 --env --force
```
Expected:
```text
[OK] out\devices_sample\device_001.json did=ANDROID_...
```
Generated files:
```text
out/devices_sample/device_001.json
out/devices_sample/device_001.env
```
---
## Self-Review
- Spec coverage: local generation, persistence, env export, cloud override, and validation are covered.
- Placeholder scan: no `TBD`, `TODO`, or unspecified code steps remain.
- Type consistency: `DeviceProfile`, `DeviceProfileGenerator`, `save_device_profile`, and `load_device_profile` names match across all tasks.
- Scope check: online DFP bootstrap remains out of phase 1 by design.

View File

@ -0,0 +1,600 @@
# DFP Bootstrap Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a tested dry-run DFP bootstrap layer that consumes `DeviceProfile` and emits signed DFP/unifiedId request specs without importing from `out/`.
**Architecture:** Migrate reusable protocol builders into focused `core` modules: `dfp_sq0` for protobuf wire bytes, `dfp_knn` for lite/full kNN maps, and `dfp_forms` for 10400 `deviceInfo` plus signed request forms. Extend `tools/new_device.py` only after core builders pass focused tests.
**Tech Stack:** Python 3.13, standard library, existing `core.enc_data`, existing `core.dfp_sign`, existing `unittest`.
---
## File Structure
- Create `core/dfp_sq0.py`
- Encodes and decodes DFP sq0 string fields.
- Create `core/dfp_knn.py`
- Builds lite/full kNN maps from `DeviceProfile`.
- Create `core/dfp_forms.py`
- Builds signed request specs for unifiedId and gdfp report.
- Modify `core/__init__.py`
- Adds module names without removing existing exports.
- Modify `tools/new_device.py`
- Adds `--dfp-dry-run` and `--profile`.
- Create `tests/test_dfp_sq0.py`
- Tests protobuf encoding and decoding.
- Create `tests/test_dfp_knn.py`
- Tests kNN identity field mapping and CRC.
- Create `tests/test_dfp_forms.py`
- Tests form order and request spec construction.
---
### Task 1: Migrate sq0 protobuf encoder
**Files:**
- Create: `tests/test_dfp_sq0.py`
- Create: `core/dfp_sq0.py`
- [ ] **Step 1: Write failing sq0 tests**
```python
import unittest
from core.dfp_sq0 import decode_sq0_string_fields, encode_sq0_device_info
class DfpSq0Tests(unittest.TestCase):
def test_lite_encoding_preserves_known_tag_order(self):
raw = encode_sq0_device_info({"k5": "a", "k14": "bc", "k113": "z"}, mode="lite")
self.assertEqual(raw.hex(), "2a0161720262638a07017a")
fields = decode_sq0_string_fields(raw)
self.assertEqual(
[(field["proto_tag"], field["value"]) for field in fields],
[(5, "a"), (14, "bc"), (113, "z")],
)
def test_empty_values_are_not_encoded(self):
raw = encode_sq0_device_info({"k5": "a", "k14": "", "k113": "z"}, mode="lite")
fields = decode_sq0_string_fields(raw)
self.assertEqual(
[(field["proto_tag"], field["value"]) for field in fields],
[(5, "a"), (113, "z")],
)
def test_unknown_key_is_rejected(self):
with self.assertRaises(KeyError):
encode_sq0_device_info({"k999": "x"}, mode="lite")
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run RED**
Run:
```powershell
uv run python -m unittest tests.test_dfp_sq0 -v
```
Expected:
```text
ModuleNotFoundError: No module named 'core.dfp_sq0'
```
- [ ] **Step 3: Implement `core/dfp_sq0.py`**
Implementation requirements:
```python
LITE_TAGS = {
"k5": 5,
"k14": 14,
"k22": 22,
"k23": 23,
"k27": 27,
"k29": 29,
"k31": 31,
"k34": 34,
"k35": 35,
"k36": 36,
"k39": 39,
"k40": 40,
"k46": 46,
"k57": 57,
"k61": 61,
"k64": 64,
"k66": 66,
"k68": 68,
"k83": 83,
"k86": 86,
"k93": 93,
"k97": 97,
"k101": 101,
"k102": 102,
"k105": 105,
"k106": 106,
"k107": 107,
"k108": 108,
"k109": 109,
"k110": 110,
"k111": 111,
"k112": 112,
"k113": 113,
}
```
The module must expose:
```python
def encode_sq0_device_info(values: dict[str, str], mode: str) -> bytes: ...
def decode_sq0_string_fields(raw: bytes) -> list[dict[str, object]]: ...
```
Full mode can initially use tags `k1..k119` mapped to matching numeric tags.
- [ ] **Step 4: Run GREEN**
Run:
```powershell
uv run python -m unittest tests.test_dfp_sq0 -v
```
Expected:
```text
Ran 3 tests
OK
```
---
### Task 2: Build lite kNN from DeviceProfile
**Files:**
- Create: `tests/test_dfp_knn.py`
- Create: `core/dfp_knn.py`
- [ ] **Step 1: Write failing kNN tests**
```python
import json
import unittest
from core.device_profile import DeviceProfileGenerator
from core.dfp_knn import LITE_KEYS, build_lite_knn, recompute_k14_crc
class DfpKnnTests(unittest.TestCase):
def test_lite_knn_uses_device_profile_identity_fields(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
knn = build_lite_knn(profile)
self.assertEqual(list(knn), LITE_KEYS)
self.assertEqual(knn["k31"], profile.android_id)
self.assertEqual(knn["k66"], profile.o_did.removeprefix("ANDROID_"))
self.assertEqual(knn["k107"], str(profile.cdid_tag))
self.assertIn(profile.g_rdi2, json.loads(knn["k93"])["28"])
def test_k14_crc_changes_when_identity_changes(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
knn = build_lite_knn(profile)
original = knn["k14"]
changed = dict(knn)
changed["k31"] = "0000000000000000"
changed["k14"] = recompute_k14_crc(changed, LITE_KEYS)
self.assertNotEqual(changed["k14"], original)
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run RED**
Run:
```powershell
uv run python -m unittest tests.test_dfp_knn -v
```
Expected:
```text
ModuleNotFoundError: No module named 'core.dfp_knn'
```
- [ ] **Step 3: Implement `core/dfp_knn.py`**
Implementation requirements:
```python
LITE_KEYS = [
"k5", "k14", "k22", "k23", "k27", "k29", "k31", "k34",
"k35", "k36", "k39", "k40", "k46", "k57", "k61", "k64",
"k66", "k68", "k83", "k86", "k93", "k97", "k101", "k102",
"k105", "k106", "k107", "k108", "k109", "k110", "k111",
"k112", "k113",
]
```
The module must expose:
```python
def build_lite_knn(profile: DeviceProfile, overrides: dict[str, str] | None = None) -> dict[str, str]: ...
def build_full_knn(profile: DeviceProfile, overrides: dict[str, str] | None = None) -> dict[str, str]: ...
def recompute_k14_crc(values: dict[str, str], ordered_keys: list[str]) -> str: ...
```
Minimum identity mapping:
```text
k31 = profile.android_id
k66 = profile.o_did without ANDROID_
k83 = profile.egid
k107 = profile.cdid_tag
k93["28"] = profile.g_rdi2
k14 = AND:<crc32>
```
- [ ] **Step 4: Run GREEN**
Run:
```powershell
uv run python -m unittest tests.test_dfp_knn -v
```
Expected:
```text
Ran 2 tests
OK
```
---
### Task 3: Build signed DFP form specs
**Files:**
- Create: `tests/test_dfp_forms.py`
- Create: `core/dfp_forms.py`
- [ ] **Step 1: Write failing form tests**
```python
import unittest
from urllib.parse import parse_qs
from core.device_profile import DeviceProfileGenerator
from core.dfp_forms import (
GDFP_REPORT_FORM_ORDER,
UNIFIED_FETCH_FORM_ORDER,
build_gdfp_report_request,
build_unified_fetch_request,
)
class DfpFormsTests(unittest.TestCase):
def test_unified_fetch_preserves_form_order(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
request = build_unified_fetch_request(
profile,
counter=1,
unix_time=1783749817,
session_seed=0x5D7E742B,
ts_millis="1783749817000",
epoch_seconds=1783749817,
)
self.assertEqual(request.form_order, UNIFIED_FETCH_FORM_ORDER)
self.assertEqual(request.form["did"], profile.did)
self.assertEqual(request.form["rdid"], profile.rdid)
self.assertIn("sign", request.form)
def test_gdfp_report_request_body_order(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
request = build_gdfp_report_request(
profile,
counter=2,
unix_time=1783749817,
session_seed=0x5D7E742B,
ts_millis="1783749817000",
epoch_seconds=1783749817,
)
self.assertEqual(request.form_order, GDFP_REPORT_FORM_ORDER)
self.assertTrue(request.body.startswith("productName=NEBULA&ts=1783749817000&deviceInfo="))
parsed = parse_qs(request.body)
self.assertEqual(parsed["rdid"], [profile.rdid])
self.assertEqual(parsed["didtag"], [str(profile.cdid_tag)])
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run RED**
Run:
```powershell
uv run python -m unittest tests.test_dfp_forms -v
```
Expected:
```text
ModuleNotFoundError: No module named 'core.dfp_forms'
```
- [ ] **Step 3: Implement `core/dfp_forms.py`**
Implementation requirements:
```python
@dataclass(frozen=True)
class DfpRequestSpec:
method: str
url: str
headers: dict[str, str]
form_order: list[str]
form: dict[str, str]
body: str
```
Expose:
```python
def build_unified_fetch_request(profile: DeviceProfile, *, counter: int, unix_time: int, session_seed: int, ts_millis: str | None = None, epoch_seconds: int | None = None) -> DfpRequestSpec: ...
def build_gdfp_report_request(profile: DeviceProfile, *, counter: int, unix_time: int, session_seed: int, ts_millis: str | None = None, epoch_seconds: int | None = None) -> DfpRequestSpec: ...
```
Required constants:
```python
UNIFIED_FETCH_FORM_ORDER = [
"aegon", "appVersion", "deviceInfo", "did", "didTag",
"hgidReportId", "platform", "productName", "rdid",
"requestId", "sdkVersion", "sv", "ts", "sign",
]
GDFP_REPORT_FORM_ORDER = [
"productName", "ts", "deviceInfo", "sign", "sv", "rdid", "didtag",
]
```
Use:
- `core.dfp_knn.build_lite_knn()`
- `core.dfp_knn.build_full_knn()`
- `core.dfp_sq0.encode_sq0_device_info()`
- `core.enc_data.kwsg_10400_raw()`
- `core.dfp_sign.sign_dfp_form()`
- [ ] **Step 4: Run GREEN**
Run:
```powershell
uv run python -m unittest tests.test_dfp_forms -v
```
Expected:
```text
Ran 2 tests
OK
```
---
### Task 4: Export modules and add CLI dry-run
**Files:**
- Modify: `core/__init__.py`
- Modify: `tools/new_device.py`
- Create: `tests/test_new_device_dfp_cli.py`
- [ ] **Step 1: Write failing CLI dry-run test**
```python
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
class NewDeviceDfpCliTests(unittest.TestCase):
def test_cli_writes_dfp_dry_run_requests(self):
with tempfile.TemporaryDirectory() as tmp:
out_dir = Path(tmp) / "devices"
result = subprocess.run(
[
sys.executable,
"tools/new_device.py",
"--count",
"1",
"--out-dir",
str(out_dir),
"--seed",
"1",
"--env",
"--dfp-dry-run",
"--force",
],
check=False,
cwd=Path(__file__).resolve().parents[1],
text=True,
capture_output=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
request_path = out_dir / "device_001_dfp_requests.json"
self.assertTrue(request_path.exists())
data = json.loads(request_path.read_text(encoding="utf-8"))
self.assertIn("unified_fetch", data)
self.assertIn("gdfp_report", data)
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run RED**
Run:
```powershell
uv run python -m unittest tests.test_new_device_dfp_cli -v
```
Expected:
```text
error: unrecognized arguments: --dfp-dry-run
```
- [ ] **Step 3: Update `tools/new_device.py`**
Add parser option:
```python
parser.add_argument("--dfp-dry-run", action="store_true", help="write DFP request material")
```
After saving each profile, when `args.dfp_dry_run` is true:
```python
from core.dfp_forms import build_gdfp_report_request, build_unified_fetch_request
dfp_requests = {
"unified_fetch": build_unified_fetch_request(
profile,
counter=1,
unix_time=int(profile.cold_launch_time_ms // 1000),
session_seed=0x5D7E742B,
ts_millis=str(profile.cold_launch_time_ms),
epoch_seconds=int(profile.cold_launch_time_ms // 1000),
).to_dict(),
"gdfp_report": build_gdfp_report_request(
profile,
counter=2,
unix_time=int(profile.cold_launch_time_ms // 1000),
session_seed=0x5D7E742B,
ts_millis=str(profile.cold_launch_time_ms),
epoch_seconds=int(profile.cold_launch_time_ms // 1000),
).to_dict(),
}
(out_dir / f"{stem}_dfp_requests.json").write_text(
json.dumps(dfp_requests, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
```
- [ ] **Step 4: Run GREEN**
Run:
```powershell
uv run python -m unittest tests.test_new_device_dfp_cli -v
```
Expected:
```text
Ran 1 test
OK
```
---
### Task 5: Verification
**Files:**
- Verify: `core/dfp_sq0.py`
- Verify: `core/dfp_knn.py`
- Verify: `core/dfp_forms.py`
- Verify: `tools/new_device.py`
- [ ] **Step 1: Run focused DFP tests**
Run:
```powershell
uv run python -m unittest tests.test_dfp_sq0 tests.test_dfp_knn tests.test_dfp_forms tests.test_new_device_dfp_cli -v
```
Expected:
```text
Ran 8 tests
OK
```
- [ ] **Step 2: Run phase1 tests**
Run:
```powershell
uv run python -m unittest tests.test_device_profile tests.test_new_device_cli -v
```
Expected:
```text
Ran 6 tests
OK
```
- [ ] **Step 3: Compile core/tools/tests**
Run:
```powershell
uv run python -m compileall core tools tests
```
Expected:
```text
Listing 'core'...
Listing 'tools'...
Listing 'tests'...
```
- [ ] **Step 4: Generate dry-run sample**
Run:
```powershell
uv run python tools/new_device.py --count 1 --out-dir out/devices_dfp_sample --seed 20260711 --env --dfp-dry-run --force
```
Expected files:
```text
out/devices_dfp_sample/device_001.json
out/devices_dfp_sample/device_001.env
out/devices_dfp_sample/device_001_dfp_requests.json
```
---
## Self-Review
- Spec coverage: dry-run sq0/kNN/forms and CLI integration are covered.
- Online POST is intentionally not included in this first implementation plan.
- Placeholder scan: no placeholder tasks remain.
- Type consistency: module and function names match across tests and implementation tasks.
- Known global blocker: `tests/test_main.py` currently imports old `main.BuiltRequest`; final verification must report that separately instead of claiming full suite success.

View File

@ -0,0 +1,41 @@
# DFP Runtime Hints Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add persistent native/runtime hint fields to `DeviceProfile` and map them into full DFP kNN fields.
**Architecture:** Store native/runtime-like values in a `DfpRuntimeHints` dataclass nested inside `DeviceProfile`. `core.dfp_knn` reads those hints for full kNN fields such as `k4/k20/k51/k84/k101/k102/k105/k108/k109/k110/k111/k112/k113/k119`.
**Tech Stack:** Python 3.13, standard library, `unittest`.
---
## Tasks
### Task 1: Runtime hints model
**Files:**
- Modify: `core/device_profile.py`
- Modify: `tests/test_device_profile.py`
- [ ] Write failing tests that generated profiles include runtime hints and roundtrip them.
- [ ] Implement `DfpRuntimeHints`.
- [ ] Add deterministic generation and `.env` export.
- [ ] Verify `tests.test_device_profile`.
### Task 2: full kNN mapping
**Files:**
- Modify: `core/dfp_knn.py`
- Modify: `tests/test_dfp_knn.py`
- [ ] Write failing tests mapping runtime hints to full kNN.
- [ ] Implement mapping.
- [ ] Verify `tests.test_dfp_knn`.
### Task 3: Verification
- [ ] Run focused phase tests.
- [ ] Compile `core tools tests`.
- [ ] Regenerate `out/devices_dfp_sample`.
- [ ] Record progress.

View File

@ -0,0 +1,54 @@
# Captcha Session Handoff Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Retry a 705-blocked request only after the official browser verification endpoint reports success.
**Architecture:** Add a small Playwright adapter with pure response-parsing and cookie-transfer helpers. Keep request construction in `core.sms_login` unchanged and integrate the adapter at the two existing CLI retry points.
**Tech Stack:** Python 3.13, Playwright Python, requests, unittest.
---
### Task 1: Verification state and cookie transfer
**Files:**
- Create: `core/captcha_assist.py`
- Modify: `tests/test_sms_login.py`
- [ ] Write failing tests for token extraction, verify-result recognition, and cookie transfer.
- [ ] Run the focused tests and confirm failures are caused by the missing module.
- [ ] Implement the minimal dataclasses and pure helpers.
- [ ] Run the focused tests and confirm they pass.
### Task 2: Visible browser adapter
**Files:**
- Modify: `core/captcha_assist.py`
- Modify: `pyproject.toml`
- Modify: `uv.lock`
- Modify: `tests/test_sms_login.py`
- [ ] Write a failing test for browser response observation using an injected fake Playwright factory.
- [ ] Implement visible Edge launch, response observation, timeout handling, and structured errors.
- [ ] Add Playwright as a project dependency and refresh the lock file.
- [ ] Run adapter tests and confirm they pass.
### Task 3: CLI integration
**Files:**
- Modify: `tools/sms_login_cli.py`
- Modify: `tests/test_sms_login.py`
- Modify: `docs/sms_login_flow.md`
- [ ] Write failing CLI tests for verified and failed browser handoffs.
- [ ] Replace blind Enter-based retries with the adapter and preserve the manual fallback.
- [ ] Add browser channel and timeout CLI options.
- [ ] Update user-facing documentation.
- [ ] Run `uv run python -m unittest tests.test_sms_login`.
### Task 4: Verification
- [ ] Run `uv run python -m unittest discover -s tests -p 'test_*.py'` with a 60-second limit.
- [ ] Run `uv run python -m compileall -q core tools tests`.
- [ ] Record any unrelated pre-existing failures separately.

View File

@ -0,0 +1,131 @@
# Device Profile Generation Design
## Goal
Build a self-contained device identity generation pipeline for producing many stable,
self-consistent Android device profiles without depending on live APP runtime state.
The first deliverable is local identity generation and persistence. Online DFP
bootstrap is intentionally separated into a later step because `egid` and cloud
`did` are server-issued values and require a coherent `deviceInfo` payload.
## Current Findings
Known runtime relationships:
- `oDid = "ANDROID_" + android_id`
- `rdid = "ANDROID_" + md5(gRdi2)[16:32]`
- local fallback `did` can be generated independently
- cloud `did` and `cdid_tag` override local fallback values after unifiedId refresh
- `egid` is returned by DFP report, not derived by a local hash
Observed sample:
```text
android_id=46a032e0a2af8184
oDid=ANDROID_46a032e0a2af8184
gRdi2=799999139::8641|899999556::8641|999999345::4741|899999995::8641|999999515::4741
md5(gRdi2)=be49e5841412c571741de4351c44850d
rdid=ANDROID_741de4351c44850d
did=ANDROID_e8dfd2f16b618053
cdid_tag=2
```
## Scope
### Included in phase 1
- Generate local Android identity fields.
- Generate a stable `gRdi2` string and matching `rdid`.
- Generate a local fallback `did`.
- Persist and reload profiles without changing identities.
- Apply server identity values later through an explicit update method.
- Export profiles as JSON and `.env` snippets for other scripts.
### Excluded from phase 1
- Calling DFP/unifiedId services.
- Producing a guaranteed valid `egid`.
- Replacing `main.py` task runner behavior.
- Reusing HAR files as runtime templates.
## Architecture
### `core/device_profile.py`
Owns device identity data and local generation rules.
Main objects:
- `DeviceProfile`: serializable profile model.
- `DeviceProfileGenerator`: creates new profiles from a random source.
- `load_device_profile(path)`: loads a persisted profile.
- `save_device_profile(profile, path)`: writes profile JSON.
### `tools/new_device.py`
Small CLI wrapper around the core generator.
Responsibilities:
- create one or more profiles
- save JSON files
- optionally print `.env` format
- avoid depending on APP, Frida, HAR, or `out/`
### Tests
`tests/test_device_profile.py` verifies:
- android_id is 16 lowercase hex characters
- `oDid` matches android_id
- `rdid` matches `md5(gRdi2)[16:32]`
- persisted profile reloads identically
- cloud identity update changes `did`, `cdid_tag`, and `egid` only when explicit
## Data Flow
```text
new_device.py
-> DeviceProfileGenerator.new_profile()
-> DeviceProfile.to_dict()
-> save JSON / print env
existing JSON
-> load_device_profile()
-> use stable identity in request builders
server response
-> profile.apply_cloud_identity(did, cdid_tag, egid)
-> save JSON
```
## Error Handling
- Invalid `android_id`, `did`, `oDid`, `rdid`, or `egid` values raise `ValueError`.
- Loading malformed JSON raises `ValueError` with the file path.
- Existing output files are not overwritten unless the CLI receives `--force`.
- Batch generation creates separate files and fails fast on duplicate filenames.
## Testing Strategy
Use TDD:
1. Write failing tests for local generation and persistence.
2. Implement minimal core code to pass tests.
3. Add CLI tests or smoke checks.
4. Run focused tests and compile checks before claiming completion.
## Future Phase
After phase 1, add an online bootstrap layer:
```text
DeviceProfile
-> build DFP fetch/check/repair/report forms
-> call unifiedId / gdfp report
-> apply cloud did / cdid_tag / egid
```
That layer should migrate useful code out of `out/build_dfp_*.py` into `core/`
without making `main.py` depend on HAR templates.

View File

@ -0,0 +1,170 @@
# DFP Bootstrap Design
## Goal
Add a pure Python DFP bootstrap layer that consumes `DeviceProfile` and builds
DFP/unifiedId request material without depending on APP runtime, HAR templates,
Frida logs, or scripts under `out/`.
The first deliverable is deterministic dry-run request construction. Online
POST and profile update are added only after dry-run form material is isolated,
tested, and stable.
## Current Evidence
Recovered relationships and algorithms already available in `core/`:
- `core.enc_data.kwsg_10400_raw()` builds the 10400 `deviceInfo` envelope.
- `core.dfp_sign.sign_dfp_form()` builds 10405 `sign`.
- `core.device_profile.DeviceProfile` holds local `did/oDid/rdid/egid` state.
- `egid` is returned by `/rest/infra/gdfp/report/kuaishou/android`.
- cloud `did/cdid_tag` comes from unifiedId fetch/repair responses.
Useful prototype code currently lives under `out/`:
- `out/dfp_sq0_proto.py`
- `out/build_dfp_lite_knn.py`
- `out/build_dfp_full_knn.py`
- `out/build_dfp_fetch_form.py`
- `out/build_dfp_repair_form.py`
- `out/build_dfp_check_repair_form.py`
- `out/build_dfp_report_form.py`
- `out/dfp_protocol_client.py`
Phase 2 migrates the reusable pieces into `core/` and leaves analysis scripts in
`out/` as historical evidence only.
## Scope
### Included
- Encode DFP sq0 protobuf bytes for lite and full kNN maps.
- Build lite and full kNN maps from `DeviceProfile` plus optional static
environment/profile fields.
- Build signed forms for:
- `unifiedId/fetch/android`
- `unifiedId/checkRepair`
- `unifiedId/repair/android`
- `gdfp/report/kuaishou/android`
- Export request specs with URL, headers, ordered form, body, and debug hashes.
- Add optional online POST client after dry-run builders pass tests.
- Parse cloud DID / did tag / egid responses and write them back through
`DeviceProfile.apply_cloud_identity()`.
### Excluded
- Live APP instrumentation.
- HAR runtime templates.
- Mutating `main.py` task flow.
- Claiming `egid` is locally generated.
## Architecture
```text
DeviceProfile
-> core.dfp_knn.build_lite_knn()
-> core.dfp_sq0.encode_sq0_device_info()
-> core.enc_data.kwsg_10400_raw()
-> core.dfp_forms.build_unified_fetch_form()
-> core.dfp_forms.build_unified_check_repair_form()
-> core.dfp_forms.build_gdfp_report_form()
-> core.dfp_client.post_request() # online only
-> DeviceProfile.apply_cloud_identity()
```
### `core/dfp_sq0.py`
Pure sq0 protobuf encoder/decoder.
Responsibilities:
- load embedded lite/full key-to-proto-tag schema
- encode ordered kNN string maps
- decode fields for tests and diagnostics
### `core/dfp_knn.py`
Build DFP plaintext kNN maps.
Responsibilities:
- build lite 33-key map
- build full 119-key map
- compute `k14` CRC
- use `DeviceProfile` for identity fields:
- `k31` android id
- `k66` oDid suffix
- `k83` current cached egid or empty first-run value
- `k107` did tag
- `k112` cache marker if supplied
- `k93.28` gRdi2
### `core/dfp_forms.py`
Build signed form objects.
Responsibilities:
- wrap sq0 bytes in 10400 `deviceInfo`
- Java-style base64 and form encoding
- preserve request form order
- use `core.dfp_sign.sign_dfp_form()`
- expose `DfpRequestSpec`
### `core/dfp_client.py`
Optional online client.
Responsibilities:
- POST request specs using `requests`
- parse JSON safely
- return structured `DfpResponse`
- update `DeviceProfile` only when response fields pass validation
### `tools/new_device.py`
Extends current CLI:
- `--dfp-dry-run`: write DFP request material without sending.
- `--online`: send DFP bootstrap requests and update saved profile.
- `--profile PATH`: load an existing profile instead of creating a new one.
## Error Handling
- Missing required identity fields raise `ValueError`.
- Invalid DFP response fields are recorded and not applied.
- Online mode records HTTP status, JSON parse failures, and business result.
- Request builders never silently omit known empty fields; empty strings remain
present when the Java form includes the key.
## Test Strategy
Use TDD and `unittest` because this project does not currently depend on
`pytest`.
Required checks:
- sq0 encoder keeps key order and skips empty values in the same way as the
prototype.
- k14 CRC changes when any mapped value changes.
- lite kNN uses `DeviceProfile` identity fields.
- form builders preserve known form order.
- DFP sign input material matches `core.dfp_sign` rules.
- dry-run request specs produce deterministic body ordering.
- online response parser only applies valid `cloud_did/did_tag/egid`.
## Milestones
1. Dry-run migration:
- `core/dfp_sq0.py`
- `core/dfp_knn.py`
- `core/dfp_forms.py`
2. CLI integration:
- `tools/new_device.py --dfp-dry-run`
3. Online bootstrap:
- `core/dfp_client.py`
- `tools/new_device.py --online`
4. Request/profile persistence:
- save request material under `out/devices/<profile>/dfp_*.json`
- save updated profile JSON after accepted server identity response

View File

@ -0,0 +1,64 @@
# Captcha Session Handoff Design
## Goal
Replace the blind `webbrowser.open() + Enter` retry with an observable browser
handoff. A user completes the official challenge in a visible browser; the CLI
waits for the official verification request to succeed, synchronizes cookies,
and only then retries the original API request.
## Scope
Included:
- Open the HTTPS `error_url` in the normal system browser by default.
- Observe `kSecretApiVerify` and extract a masked `captchaToken` for diagnostics.
- Observe `/rest/wd/captcha/verify` and require a successful JSON result.
- Copy browser cookies into the current `requests.Session`.
- Preserve the current device profile, signed request body, and retry limits.
- Fall back to the existing manual browser flow when Playwright is unavailable.
Excluded:
- Image recognition, slider movement, trajectory generation, or fingerprint
fabrication.
- Replaying captured captcha tokens.
- Treating a page close or terminal Enter as successful verification.
## Architecture
`core/captcha_assist.py` owns the optional Playwright observation details and
exposes one public operation returning a structured result. The CLI defaults to
the regular system browser because Playwright-launched Edge exposes
`navigator.webdriver=true`; `msedge/chrome` remain explicit diagnostic modes.
Pure helpers parse response payloads and synchronize cookies so they can be
tested without launching a browser.
## Data Flow
```text
705 error_url
-> visible Edge page
-> user completes official challenge
-> observe kSecretApiVerify / captchaToken
-> observe /rest/wd/captcha/verify result=1
-> copy browser cookies to requests.Session
-> retry the unchanged checker or mobileVerifyCode request
```
## Error Handling
- Reject non-HTTPS challenge URLs before browser launch.
- Report missing Playwright separately from browser launch errors.
- Stop on timeout, page close, malformed verification JSON, or non-success
verification response.
- Never print the full captcha token unless the existing secret-display option
is explicitly enabled.
## Testing
- Unit-test recursive token extraction and verification-result recognition.
- Unit-test browser-cookie transfer into a requests-compatible jar.
- Unit-test that CLI retries after a verified handoff and does not retry after
a failed handoff.
- Run the focused SMS login tests and Python compilation checks.

71
docs/weapon_kas.md Normal file
View File

@ -0,0 +1,71 @@
# Weapon KAW/KAS 纯 Python 还原
## 静态调用链
APP 的 Weapon 请求拦截器按以下顺序构造请求头:
1. `kaw = WeaponHI.g(context)`,对应配置项 `z_y_x_a`
2. `payload = encodedPath + (__NS_sig3 or "") + kaw`
3. `kas = WeaponHI.a(payload, "")`
4. `WeaponHI.a` 调用 `W.pr(99999, 2, payload.length() * 2, payload)`
KAW 是版本/远端配置材料不按账号计算。KAS 绑定最终 URL必须在
`sig/__NS_sig3` 完成后逐请求生成。
## mode=2 公式
`core.weapon_kas.generate_weapon_kas` 复现以下 native 流程:
1. 按 JNI modified UTF-8 编码 payload。
2. 对 payload 做标准 Base64。
3. 使用 Weapon 自定义 IV 的 BLAKE2s 分块逻辑生成 8 个 uint32。
4. 将 8 个字格式化为小写十六进制文本。
5. 对前 16 个字符执行 `$AI_` 同源的固定加法/XOR 变换。
6. 在 16 字节结果前加 `00`,输出 34 个十六进制字符。
四组 APP 抓取向量已同时由纯 Python 实现和 `libw.so` Unicorn oracle 验证。
## KAW 来源
CLI 优先级如下:
1. `--weapon-kaw`
2. `KS_WEAPON_KAW`
3. 本次显式加载的 app-fields 中的 `weapon_kaw``kaw`
4. `out/app_login_fields_latest.json` 中独立缓存的 `weapon_kaw``kaw`
5. APK 内置 `z_y_x_a` 回退值
从已有稳定探针日志提取一次当前版本 KAW
```powershell
uv run python -m tools.extract_app_login_fields `
--log out/probe_login_stable_20260726_214809.log `
--out out/app_login_fields_latest.json
```
之后 CLI 会独立读取 latest 文件里的 KAW不会顺带复用其中的手机号、
设备或 passport 字段。KAW 可跨账号复用KAS 会按各请求的 path 和
`__NS_sig3` 现场计算。新日志没有 KAW 时,提取器会保留现有 latest
文件中的版本级 KAW避免退回 APK 内置值。
## 验证
纯 Python 向量:
```powershell
uv run python -m unittest tests.test_weapon_kas -v
```
本地 native 对照:
```powershell
uv run --with pyelftools --with unicorn `
python -m unittest tests.test_libw_pr_oracle -v
```
只测手机号预检时,终端应出现 `KAS 纯 Python 已启用`,请求元数据中的
`has_kaw``has_kas` 应均为 true。
KAW/KAS 只补齐 APP 的 Weapon 请求证明。服务端返回 705 时给出的
`captchaToken` 和 Cookie 绑定属于后续交互式验证链,不是 KAS 的返回值。

307
findings.md Normal file
View File

@ -0,0 +1,307 @@
# 发现与决策
## 需求
- 用户要求先了解一个黑盒测试比赛。
- 当前工作区:`ksjsb`
- 工具目录:`D:\decode-tools`
## 研究发现
- 工作区初始可见文件:
- `ksjsb.apk`:约 108 MB核心 Android 目标。
- `log-sdk.ksapisrv.com_2026_07_08_12_16_09.har`:约 11 MB。
- `nebula.kuaishou.com_2026_07_08_14_16_43.har`:约 164 MB。
- `2026-07-09-065602-ddecode-toolsfrida.txt`:约 340 KB疑似 Frida 输出。
- `sign_layers_all.log`:约 1.7 KB疑似签名链路日志。
- `1.txt`:约 11 KB内容待确认。
- `D:\decode-tools` 已存在 Android/逆向相关工具:
- `apktool`
- `dex-tools-v2.4`
- `jadx-1.5.5`
- `jadx-gui-1.5.5-with-jre-win`
- `GDA4.04`
- `ghidra_10.4_PUBLIC`
- `ghidra_11.3.2_PUBLIC`
- `IDA_Pro_7.7_Portable`
- `IDA_Pro_8.3`
- `ida93sp2`
- `jeb-pro-3.19.1.202005071620`
- `x64dbg`
- `dnSpy-net-win64`
- `ILSpy_selfcontained_8.2.0.7535-x64`
- `out` 目录并非空目录,已有大量前序分析成果:
- `CURRENT_STATE.md`
- `FINDINGS.md`
- `FINDINGS_VM.md`
- `ks_sign.py`
- 多个 `probe_*.js`、`kste_*.log`、`xfalcon_*.log`
- `jadx`、`jadx16`、`manifest`、`so`、`dex` 子目录
- `1.txt` 明确给出目标:逆向快手极速版看广告接口的 9 个签名/加密参数,
包括 `sig`、`__NS_sig3`、`__NS_xfalcon`、`__NStokensig`、
`encData`、`sign`,以及 `egid`、`oDid`、`did` 设备指纹,
最终转换成 Python 纯算。
- `1.txt` 中的关键链路:
- `sig``fmm/{a,b,c,e,i}.getSig(明文)`,后端疑似 `doCommandNative`
- `__NS_sig3``fmm/g: k.b(sig1, path)``doCommandNative(10418)` / `libkwsgmain`
- `__NS_xfalcon``fmm/{a,b,c,e}: k.a(path, str)`,涉及 `kste VM` / `libksxgs`
- `__NStokensig``fmm/g: k.d(sig2, token)`,记录中判断为 `sha256(sig2+token)`
- `encData/sign`:疑似 `libpfl_crypto` 的 AES/HMAC 类逻辑
- `sign_layers_all.log` 显示已在设备 `PJZ110` 上对
`com.kuaishou.nebula` 及多个子进程进行了 Frida attach
但该文件本身只有 attach/detach 记录,未见具体签名结果。
- 当前核心样本 SHA256
- `ksjsb.apk``EBA577EF45CBA1715CC8C5562022AD5D4B10C1C442578F1C41BD3F05CCC33183`
- `log-sdk...har``7FBCA534370DA5D63694BBC75E341D91A9E12ED97229C04DFB58FA7AEF64754C`
- `nebula...har``57F5A07645C6758761E43102567AB8DE45423C364744351BE695D8A7A098FADE`
- APK 初筛结论:
- 包名:`com.kuaishou.nebula`
- 版本:`14.5.50.11631``versionCode=11631`
- 入口:`com.yxcorp.gifshow.HomeActivity`
- Application`com.yxcorp.gifshow.App`
- SDK`minSdk=21``targetSdk=30`
- 19 个 dex约 100 个 arm64 native so混淆强。
- 不是传统单一壳入口;更像大型业务包 + 自研安全/插件/热修复体系。
- 高关注 so`libkwsgmain.so`、`libksxgs.so`、`libksse.so`、
`libw.so`、`libpfl_crypto.so`、`libsnow.so`、`libshadowhook.so`。
- Manifest 高关注:`allowBackup=false`,未显式 `debuggable`
网络安全配置允许明文流量debug-overrides 信任用户证书。
- HAR 初筛结论:
- `log-sdk...har`681 entries方法分布 `CONNECT=395`、`POST=146`、`GET=139`。
- `nebula...har`1156 entries方法分布 `CONNECT=567`、`GET=511`、`POST=76`。
- 高频业务 endpoint
- `POST adlog.e.kuaishou.com/rest/nebula/log/ad/photo/action`
- `POST log-sdk.ksapisrv.com/rest/wd/common/log/collect/misc2`
- `GET nebula.kuaishou.com/rest/n/nebula/activity/earn/...`
- `POST /rest/e/reward/mixed/ad` 各 HAR 中各 1 条。
- 未发现 `Authorization` header认证/会话主要在 `Cookie` 与 query/body。
- 高频签名/状态字段:`sig`、`__NS_sig3`、`__NS_xfalcon`、
`__NStokensig`、`egid`、`did`、`oDid`、`rdid`、`kuaishou.api_st`、
`client_key`、`cold_launch_time_ms`。
- 原样短期重放可能可行;改参稳定重放必须重算签名。
- 已验证的本地算法/脚本:
- `out/ks_sign.py` 可验证 `sig`、`__NStokensig`、`10400 encData`、
`10418 reward sign`、`10418 __NS_sig3` 的多组样本。
- `out/analyze_reward_samples.py` 共解析 3 条 `/rest/e/reward/mixed/ad`
POST 样本,`sig_reward_form` 全部匹配。
- `out/test_live_reward_log.py`、`out/test_reward_sig.py`、
`out/test_build_reward_request.py` 等单测均通过。
- 当前算法完成度:
- `sig = MD5(sorted(query + formBody, skip sig/sig2/__NS*) + "772867c19925")`
- `__NStokensig = SHA256(sig + clientSalt)`
- `encData = Base64(kwsg 10400 raw)``strE -> 0x266fc block -> inner ZT -> outer ZT`
- `body.sign = kwsg 10418 true-mode(strE, sdk=95147564-...)`
- `__NS_sig3 = kwsg 10418 false-mode(path + sig)`
- `__NS_xfalcon` reward/API 输入链路已接入本地 core 计算。
- reward 广告拉取 strE 已能按当前账号/设备动态生成,并接入
本地 `encData/sign/sig/sig3/tokensig/xfalcon`
- 轻量在线验证中,普通广告/签到广告/宝箱广告拉取均返回
`HTTP=200 result=1 msg=OK`,说明动态 reward body 已解决
换设备后的 `ANTISPAM_REQUEST/INVALID_REQUEST`
- `did/oDid/rdid` 已可本地生成;`egid/cloud did` 已可通过 DFP
bootstrap 在线注册并覆盖;仍需继续处理 H5 `__NS_sig3/kww`
动态生成。
## 技术决策
| 决策 | 理由 |
|------|------|
| 优先做被动侦察 | 当前已有 APK、HAR、Frida 日志,足以先建立证据图 |
| 并行拆分子任务 | APK、HAR、日志、工具链互不依赖可独立读取分析 |
| 主线从 `out/CURRENT_STATE.md` 等已有成果恢复 | 避免重复劳动,当前证据显示已有较深入的 xfalcon/kste 分析 |
| 暂不主动请求生产接口 | 当前阶段目标是了解和复核;主动重放可能消耗账号/服务端状态 |
## 验证结果
- `python out\ks_sign.py`:通过,输出多项 `[OK]`
- `python out\test_reward_sig.py`:退出码 0。
- `python out\test_live_reward_log.py`4 tests OK。
- `python out\test_analyze_xfalcon_te.py`3 tests OK。
- `python out\test_build_reward_request.py`1 test OK。
- `python out\test_extract_kste_vmobj_log.py`1 test OK。
- `python out\analyze_reward_samples.py`:解析 3 条 reward 样本,
`sig_reward_form ok=True``__NS_sig3_calc ok=True`;其中一条
`tokensig` 不匹配当前 `CLIENT_SALT`,判断为不同账号/会话上下文。
## 遇到的问题
| 问题 | 解决方案 |
|------|---------|
| 当前目录不是 Git 仓库 | 不使用 Git 作为变更依据 |
| `.codex\skills\.system\using-superpowers` 不存在 | 改读 `.agents\skills\using-superpowers` |
## 2026-07-27 - passport_account_image 纯算结论
- `passport_account_image` 由客户端 `Engine.pr(99999, 0, mf.a().toString().length()*2, json)`
确定性生成,不是 `/f/a/p` 服务端签发或回写票据。
- 基础层是 `VIMG_` + Base64固定头、Modified UTF-8 长度与正文经 `0x55` XOR
再进入自定义状态的 ChaCha20-IETF。
- `$AI_` 由基础层文本经 16-word XOR 折叠、自定义 IV BLAKE2s 压缩和固定 H2
混合生成;跨 64-word 块使用累计 word counter。
- 21 组不同长度输入与 arm64 `libweapon.so` Unicorn oracle 完全一致。
- CLI 默认按当前 `DeviceProfile` 和现场时间纯算,不读取 app-fields也不依赖
Frida、抓包、APK、ELF 或 Unicorn 运行时。
## 资源
- `D:\decode-tools`
- `ksjsb.apk`
- 两个 HAR 文件
- 已有 Frida/签名日志
## capture/ 登录链路 + APP 初始化实测2026-07-23
- 抓包:`capture/` Reqable body-only 导出1738 文件,**无 URL/header/method**
仅 req/res body请求体多为 kwsg 加密)。
- 抓的是**全新装机**`did=ANDROID_f05497e9cef09a7f`(与 `.env` 账号
`e8dfd2f16b618053` 不同06:32 冷启动,启动时 `uid=0`(未登录)。
- 混入少量酷狗(`com.kugou.android`flow 466/467噪声主体快手 nebula。
- APP 初始化(已定位):
- DFP 设备注册 flow 189`productName=NEBULA&ts&deviceInfo=<urlenc>`
= `gdfp_report`/`unified_fetch`,已还原于 `core/dfp_forms.py`)。
- 配置拉取 flow 171`public_param{uid:0,...}+request_info[{config}]`。
- 域名-IP 路由表 flow 272/274`domains[]{domain,iplist}`(含
`api.e.kuaishou.com`、`id.kuaishou.com`**非登录**)。
- **登录方式 = 运营商一键登录**(非手机号短信):
- flow 221登录提交请求体明文 + 已还原签名):
`provider=11 & provider_token=<protobuf> & session_id & sig & __NS_sig3
& __NS_xfalcon & client_key=2ac2a76d & os=android`。
- `provider_token` base64 解码 = protobuf `{kpn="NEBULA",
did="ANDROID_f05497e9cef09a7f", ts, nonce}`,由运营商一键登录 SDK
签发,绑定设备 did。
- `sig/__NS_sig3/__NS_xfalcon` 全部可用已还原算法复算。✓
- 响应:`{"result":1,"bind_interval_ms":604800000}`(设备绑定 7 天);
会话在 flow 222 `{"dataRsp":"<576B加密>","result":1}` 内。
- **会话票据 = libpfl_crypto 层,未纯 Python 还原**
- `dataRsp`576B`head8=7a1c41a6...`**不属于**已还原的 kwsg 10400
ZT envelope`head8=5a54eecd...`,见 `core/enc_data.py:ZT_OUTER_CONFIGS`)。
- 历史 `Pfl.decryptBinaryNative` 用的是 native 桥,纯 Python 解密未还原。
- `api_st`/`h5_st` 也可能下发在响应 headerbody-only 抓包不可见)。
- 交叉验证 `out/FINDINGS.md:2476-2504`:直接换 H5 did/egid ->
`signIn/report`、`treasureBox/report` 返回 `result=50 签名验证失败`
H5 真正缺口是 `kwssectoken/kwscode/kwfv1/kww`Yoda/KsGuard/WebView
运行时票据),**不在 HTTP body 抓包**`kww` 实为请求 header
- "新设备纯 Python 登录"三阻塞点:
① 运营商 `provider_token`(外部黑盒,需 SIM+设备);
`dataRsp` libpfl 解密(未还原纯 Python
`kwssectoken/kwscode/kwfv1/kww`WebView 运行时票据H5 换设备必 result=50
- 详见 `docs/capture_login_chain.md`
## 视觉/浏览器发现
- 暂无。
## 2026-07-27 - region_ticket 静态逆向
- `pnm.b.b(-1479227965)` 绑定到
`com.kwai.framework.network.access.params.e`;其 `l0()` 仅从
`DefaultPreferenceHelper[<uid>_Region]` 读取 `Region.ticket`
- `ResponseDeserializer` 从所有统一响应的顶层 `region` 读取
`uid/name/ticket``u0a.f` 注册的全局 `regions.c` 随后调用
`o2a.c.c(region, "New region received")` 持久化。
- `v0a.c` 把非空 ticket 序列化为请求 Cookie 的 `region_ticket`;没有
客户端生成、签名或 native 计算链。
- `RegionInfo` 的 APK 预置资源只有 API group/host 路由映射,没有 ticket。
- 全部 7 个 HAR 共 8334 entries响应 `region.ticket=0`、响应
`Set-Cookie=0`、请求 Cookie 命中 362共 12 个唯一票据,全部为
`RT_ + 73 hex`、总长 76。抓包开始时状态已经存在未覆盖首次签发。
- 结论:纯 Python 应实现服务端 Region 的接收、按 UID 保存和复用;没有
本地等价生成算法。缺少该 Cookie 是 APP/CLI 差异,但不能据此单独解释 705。
- 详见 `docs/region_ticket_static_chain.md`
## 2026-07-28 - keyconfig 与登录 query 必须分开建模
- APP 最终登录请求的 query 不包含 `client_key/os`;这两个字段由登录接口的
form body 承载,因此 `login_api_params()` 已正确排除它们。
- `system/keyconfig` 与登录接口只共享设备参数子集。APP 抓包显示 keyconfig
query 必须包含 `client_key=2ac2a76d``os=android`
- 此前 `refresh_region_ticket()` 直接复用 `login_api_params()`,登录参数对齐后
意外导致 keyconfig 丢失上述两个字段,服务端 HTTP 200 但响应不含 Region。
- 已新增独立 `keyconfig_api_params()`,仅为 keyconfig 补回 `client_key/os`
同时保持 `oaid/countryCode/sid/deviceName` 不进入该 query。
- APP 成功样本没有调用 `/rest/zt/pass/refresh/anonymousToken`,该接口不是当前
短信登录链路的前置条件。
- 修复只解释最新日志中的 `region_ticket present=False`。历史实测在已有有效
Region、KAW/KAS、VIMG 时仍可能返回 `705`,该部分仍属于服务端设备风险判定,
不能用本次 keyconfig 修复宣称已经解决。
## 2026-07-28 - 705 验证完成后的 captcha_token 重放
- `jlm.a.execute()` 是 APP 的通用 Retrofit Call 包装器;其字段
`f176918d` 非空时会把值以 `captcha_token` 追加到 FormBody/MultipartBody
然后执行被包装的原请求。
- 浏览器链路先由 `kSecretApiVerify` 返回 `captchaToken`,再用该 token 调用
`/rest/wd/captcha/verify` 完成 challenge 绑定。APP 随后不是只同步 Cookie
而是把同一个 token 注入原 API 表单并重新经过签名拦截器。
- CLI 旧实现已经捕获 token`_complete_captcha_handoff()` 只返回布尔值,
token 在 checker/login 重试前被丢弃;因此重试请求仍没有 `captcha_token`
服务端返回新的 705/key。
- `mobile_checker()`、`login_by_code()` 及其路径包装器现已支持
`captcha_token`,该字段参与 `sig/__NS_sig3/__NS_xfalcon/KAS` 的重新计算。
- `system` 浏览器模式改为启动独立系统 Edge/Chrome 并通过本地 CDP 观察响应,
保留可见人工滑动窗口,同时能回传 token 和 Cookie。
## 2026-07-28 - CAPTCHA WebView 设备身份绑定
- 用户实测已经证明 `captcha_token` 被捕获并注入原接口,但每次重放仍得到新的
705/key因此“缺少 captcha_token”只是先前缺口不是当前剩余根因。
- APP 静态链路在 WebView 导航前通过 `CookieInjectManager` 注入公共参数。
`com/yxcorp/gifshow/webview/cookie/f.smali` 明确把 `sys/appver/did` 设为高优先级,
`und/b.smali` 的公共列表还包含 `kpn/kpf/userId/c/ver/language/countryCode/mod/net`
- CLI 的临时 Edge/Chrome 画像此前没有预注入 Cookie验证码页会建立 `web_*` DID
`/rest/wd/captcha/verify result=1` 只证明 Web 挑战成功,不能证明返回 token 与
原 API 的 `ANDROID_*` DID 属于同一身份。
- 验证浏览器现会在首次导航前注入 APP 同形态匿名身份 Cookie并在成功响应后
校验浏览器 DID 必须仍等于设备画像 DID。页面若改写为 `web_*` 或删除 DID
CLI 会停止重放并报告身份不一致,避免继续生成无效的新 challenge key。
- 当前没有证据表明还缺某个普通 HTTP 请求头APP 每次重试本来就会重新生成
`X-REQUESTID`。后续实测应以 `device_identity=matched` 和原接口不再返回新 705
作为联合成功条件。
## 2026-07-28 - 10418 进程状态连续性
- 最新实测已经达到 `/rest/wd/captcha/verify result=1`、`captchaToken` 回传、
39 个 Cookie 同步和 `device_identity=matched`,但重放仍生成新的 705 key
因此普通 header、滑块求解和 Web/Android DID 不一致均不再是首要假设。
- 代码审计发现每个短信端点都会调用 `_sig3_state(session_seed)` 创建新对象,
导致 checker、验证码重放、发码和登录的 10418 counter 全部重复从 1 开始。
- APP 捕获证明 10418 是进程级全局状态:新进程首次可见 counter 约为 `0x60`
后续请求严格递增seed 也不是固定 `0x5d7e742b`
- 捕获的 `session_seed=0x6f5d0faa` 可由进程启动秒 `1785049292` 经 Android
bionic/BSD `srand(time); rand()+1` 精确复现,确认了动态 seed 的来源。
- CLI 现为每次运行创建一份新鲜 `Kwsg10418State`,默认从启动后基线 `0x5f`
开始,并在整条短信链共享;`--session-seed` 和 `--sig3-counter` 可用于样本回放。
## 2026-07-28 - 705 重放结构与实际出站诊断
- 最新实测中动态 sig3 seed/counter 已启用,浏览器绑定仍为 `result=1`,但 API
重放继续返回新 705因此 sig3 状态重置不是当前剩余根因。
- APP 的 705 链通过 `retryWhen` 重新订阅,并在每次订阅时克隆 Retrofit Call
`NetworkSequenceIdInterceptor` 还会重新生成 `X-REQUESTID`。CLI 每次重放生成
新请求 ID 与 APP 一致,不能复用首次请求 ID。
- 705 Activity 回调只把 `RETURN_RESULT` 写入 `jlm.a` 的 token 字段;随后
`jlm.a.execute()` 只向原 FormBody 追加 `captcha_token`。没有 CAPTCHA 专用
query、header 或 cookie因此继续猜测额外验证码字段缺少静态依据。
- `_do_post()` 现从 `requests.Response.request` 读取实际出站请求,脱敏记录:
header 名、body 字段顺序、Cookie 名、captcha token 长度/摘要、请求 ID、
sig3 seed/counter 和 HTTP 版本。不会记录手机号、Cookie 值或 token 明文。
- 浏览器绑定成功日志也打印同算法的 token SHA-256 短摘要;它应与随后
PreparedRequest 中的摘要一致,从而直接验证 token 交接未被替换或损坏。
- 当前 CLI 使用 `requests`,通常通过 HTTP/1.1 和 Python/OpenSSL 指纹访问APP
使用 OkHttp/BoringSSL 且可协商 HTTP/2。若下一次诊断确认 token/body/Cookie
均正确但仍为 HTTP/1.1,传输指纹将成为优先假设,但尚未用同请求 A/B 实测证明。
---
## 2026-07-28 - 705 传输层 A/B
- 用户最新日志证明浏览器绑定 token 与实际 POST 中的 `captcha_token` 摘要完全
一致Region、KAW/KAS 和动态 sig3 counter 也都存在;应用层交接已闭环。
- 静态确认 APP 的 OkHttp 默认协议顺序是 HTTP/2、HTTP/1.1,且 Aegon 的
`CronetInterceptor` 可能接管请求并动态选择 QUIC/H2/TCP所以 APP 不是简单的
“固定 OkHttp HTTP/2”。
- 登录通用链不会添加 `Accept`。原 `requests.Session` 自动携带的
`Accept: */*` 是已确认的出站差异。
- 新增显式 `--transport okhttp4-android10` A/B 路径,使用 curl_cffi 官方文档的
OkHttp 4 Android 10 JA3/Akamai 参考配置,并优先协商 HTTP/2默认仍保留
`requests`,便于同一业务输入做单变量比较。
- HTTP/2 适配层移除 framing 层禁止的 `Connection`,关闭 curl_cffi 浏览器默认
headers保留现有表单原始字节、CookieJar、签名和验证码 token。
- 该参考配置不等于 APP 的精确 Aegon/Cronet 指纹。若 A/B 仍返回 705应采集
APP 实际 ALPN/JA3/HTTP2 SETTINGS 后再替换 profile不能据此倒推缺业务字段。
---
*每执行2次查看/浏览器/搜索操作后更新此文件*
*防止视觉信息丢失*

1587
main.py Normal file

File diff suppressed because one or more lines are too long

2416
progress.md Normal file

File diff suppressed because it is too large Load Diff

10
pyproject.toml Normal file
View File

@ -0,0 +1,10 @@
[project]
name = "ksjsb"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"curl-cffi>=0.15.0",
"ddddocr>=1.6.1",
"playwright>=1.50.0",
"requests>=2.34.2",
]

172
task_plan.md Normal file
View File

@ -0,0 +1,172 @@
# 任务计划ksjsb 黑盒测试比赛初始侦察
## 目标
理解当前比赛材料、可用工具和潜在测试入口,形成可复现的侦察结论与下一步路线。
## 当前阶段
阶段 8
## 各阶段
### 阶段 1需求与发现
- [x] 理解用户意图:先了解黑盒测试比赛材料
- [x] 确定约束:工作区为当前目录,工具在 D:\decode-tools
- [x] 将关键发现记录到 findings.md
- **状态:** complete
### 阶段 2并行被动侦察
- [x] APK 基础信息、包名、签名、组件、权限
- [x] HAR 流量接口、域名、Header、加密/签名字段
- [x] 已有日志/文本中的初始线索
- [x] D:\decode-tools 工具可用性
- [x] 已有 out 目录成果复核
- **状态:** complete
### 阶段 3汇总与分类
- [x] 判断主攻方向Android 逆向、接口黑盒、流量重放、签名还原等
- [x] 记录证据冲突与不确定项
- [x] 输出下一步验证路线
- **状态:** complete
### 阶段 4验证与复现
- [x] 选择最小闭环:`/rest/e/reward/mixed/ad` 请求签名链
- [x] 编写必要的本地解析脚本或命令
- [x] 记录可复现命令和结果
- **状态:** complete
### 阶段 5交付
- [ ] 交付当前理解、关键证据、风险点和下一步建议
- [ ] 按完成审计决定是否结束 goal
- **状态:** in_progress
### 阶段 6脱 APP 设备画像与 DFP bootstrap
- [x] 本地生成 `android_id/local_did/oDid/rdid`
- [x] 构造 full 119-key DFP `deviceInfo`
- [x] 封装 DFP `unified_fetch` / `gdfp_report` dry-run 请求
- [x] 实现 `--online`,写回服务端 `cloud_did/did_tag/egid`
- [x] 用 fake transport 单测和真实在线请求验证闭环
- **状态:** complete
### 阶段 7任务脚本接入设备画像
- [x] 新增 Cookie/API 参数覆盖模块
- [x] `main.py` 支持 `--device-profile` / `KS_DEVICE_PROFILE`
- [x] dry-run 验证任务接口 URL 已使用新 `did/oDid/rdid/egid`
- **状态:** complete
### 阶段 8运行期内存新设备与低金币换设备
- [x] `main.py` 支持 `--memory-device`
- [x] 支持启动时为账号内存生成新设备画像
- [x] 非 dry-run 时可在线 DFP bootstrap 注册设备
- [x] 支持低金币阈值触发内存换设备
- [x] 不写设备 profile 文件,仅在进程内更新 Cookie/API 参数
- **状态:** complete
### 阶段 9动态 reward 拉取材料
- [x] 将 reward strE 明文生成迁入 `core/reward_request.py`
- [x] 用当前账号 Cookie/API 设备参数生成 strE而不是复用 HAR 固定密文
- [x] 用 core 10400/10418 生成广告拉取 `encData/sign`
- [x] API `sig/__NS_sig3/__NStokensig/__NS_xfalcon` 继续走本地 core 算法
- [x] 任务列表成功后提取 672 `neoParams`,供正常广告 strE 使用
- [x] 广告拉取失败时跳过对应上报,避免 `missing_ad_material` 噪声
- **状态:** complete
### 阶段 10DFP lite/full deviceInfo 明文 parity
- [x] full `gdfp_report` 119-key kNN 可从 APP live 明文反建并完全复现
- [x] 重新抓取 `unified_fetch` lite `builder_lite_h_in` / `form_builder_g`
- [x] `out/analyze_dfp_live_sq0.py` 支持 `--mode full|lite`
- [x] lite `sq0.b` 33-key 明文可从 APP live 反建并完全复现
- [x] `core.dfp_knn.build_lite_knn()` 对齐 APP live 字段语义
- [x] Python-only 在线 DFP bootstrap 再验证通过
- **状态:** complete
### 阶段 11任务链设备画像全字段透传
- [x] `DeviceProfile` 扩展 `board_platform/soc_name/max_memory/device_bit`
- [x] Cookie 覆盖增加 `oaid/did_gt/boardPlatform/socName/max_memory/deviceBit`
- [x] H5 `task_list``oaid` 改为当前设备画像值
- [x] H5 `treasure_open` body 的 `oaid` 改为当前设备画像值
- [x] reward `strE.deviceInfo.oaid` 改为当前设备画像值
- [x] API 查询串增加硬件字段动态覆盖
- [x] 单测、DFP parity、main dry-run 验证通过
- **状态:** complete
### 阶段 12STED Java/native 持久化语义模型
- [x] 确认 `EngineProxy.sted(str,z)` 非空 `str` 来源是 Java/server EGID
- [x] 确认 `z=false/true` 分别构造 `0/1 + productName`
- [x] 确认 `rq0.d.e()``kwtk_n` 与 app-private `.skvec`
- [x] 新增 `build_sted_cache_json()`
- [x] 新增 `build_sted_persistence_artifacts()`
- [x] 生成 native sentinel paths 与 readback JSON
- [x] 单测和相关 DFP 回归验证通过
- **状态:** complete
### 阶段 13新设备注册到指定账号 + 登录链路定性
- [x] 调研"新设备 -> 注册到指定账号 -> 跑后续任务"的完整链路与缺口(`docs/new_device_to_account.md`
- [x] 实测确认 G2h5_st 跨设备不存活(`out/FINDINGS.md:2476-2504` 换 H5 did -> `result=50`
- [x] G1/G5 实现为 opt-in 开关 `--rotate-h5-device` / `--strict-device-online`(默认关,不破坏既有 split-identity 设计)
- [x] 分析 `capture/` 登录链路 + APP 初始化(`docs/capture_login_chain.md`、`findings.md`
- [x] 定性登录方式 = 运营商一键登录provider-token会话走 libpfl_crypto
- [ ] 解密 flow 222 `dataRsp`libpfl 纯 Python 解密),确认 api_st/h5_st/user_id 是否设备绑定
- [x] 定位短信登录链路(静态 jadx`requestMobileCode` + `/rest/n/user/login/code` -> `LoginUserResponse` 明文 api_st/h5_st/client_salt纯 Python 可复刻;详见 `docs/sms_login_flow.md`
- [ ] 逆向 `kwssectoken/kwscode/kwfv1/kww`WebView JS让新设备能跑 H5 写请求
- **状态:** in_progress定性完成下一步待定
### 阶段 14短信登录 passport_account_image 纯算
- [x] 静态还原 `Engine.pr(99999, 0, json.length * 2, json)` 的 VIMG 基础层
- [x] 还原 `$AI_` 的 H1/H2 与跨块折叠算法
- [x] 用 Unicorn native oracle 对 21 组长度做差分验证
- [x] 纯 Python 重建 `mf.a()` 运行时 JSON
- [x] 接入短信预检、发码、验证码登录三阶段并共用同一票据
- [x] 默认禁用 app-fields 自动加载,保留显式诊断入口
- [x] 单元测试与 CLI 离线 dry-run 验证
- **状态:** complete
### 阶段 15region_ticket 静态逆向
- [x] 解析 `q01.g.l0()` 的 IOC 实现绑定
- [x] 定位响应顶层 `region.ticket` 的统一反序列化链
- [x] 定位 `<uid>_Region` 的 SharedPreferences 读写语义
- [x] 定位 `Cookie: region_ticket` 的请求注入链
- [x] 区分 RegionInfo 预置路由与 Region 票据
- [x] 结构化审计全部 7 个 HAR
- [x] 输出静态报告与可复现审计工具
- **状态:** complete
### 阶段 16705 传输层 A/B
- [x] 证明 captcha_token 在浏览器绑定与实际 POST 间摘要一致
- [x] 静态确认 APP 的 OkHttp H2 优先与 Aegon/Cronet 接管链
- [x] 确认 APP 登录通用链不发送 `Accept`
- [x] 增加 requests 与 OkHttp4 Android 10 HTTP/2 可切换传输
- [x] 保持共享 CookieJar、原始 body 字节和现有签名逻辑
- [x] 增加实际 HTTP 协议版本诊断与传输层单元测试
- [ ] 用相同设备画像执行 requests/okhttp4-android10 单变量实测
- **状态:** in_progress实现完成待实测
## 关键问题
1. APK 的真实包名、版本、入口 Activity 和加固/混淆情况是什么?
2. HAR 中核心业务接口、签名字段、设备指纹字段和 token/cookie 依赖是什么?
3. 已有 `frida.txt``sign_layers_all.log` 是否已经定位签名链路?
4. `D:\decode-tools` 中哪些工具可直接命令行使用?
## 已做决策
| 决策 | 理由 |
|------|------|
| 先被动侦察,后主动请求 | 避免在不理解签名/认证前产生无效或污染性流量 |
| 规划文件保存在项目根目录 | 便于跨回合恢复上下文 |
| 并行拆分 APK、HAR、日志、工具链侦察 | 这些读操作互不写冲突,可提升效率 |
| 优先复核 `out` 目录既有成果 | 当前目录已有大量 Frida/静态/动态分析产物,不能重复从零开始 |
| 黑盒测试主攻面定为“移动端签名还原 + API 重放” | HAR 显示 API 依赖 Cookie/query/body 签名APK 中对应 KSecurity/XGS/DFP native 链路 |
| DFP online bootstrap 先独立于账号 cookie | 当前目标是新设备身份,`gdfpsec` 链路不需要账号 token可避免污染任务接口状态 |
| 任务脚本通过 `--device-profile` 覆盖设备字段 | 保留账号 token/cookie只替换设备身份与硬件画像便于隔离账号态和设备态 |
| 默认推荐 `--memory-device` 而不是预生成目录 | 每个账号运行期即时注册设备,低金币再换,减少文件状态管理和 stale profile 问题 |
| reward 广告拉取不再使用 HAR 固定 `encData/sign` | 换设备后固定密文会导致 `ANTISPAM_REQUEST/INVALID_REQUEST`,必须用当前账号/设备生成 strE 后重新签名 |
| 任务链 OAID/硬件字段统一来源为 `DeviceProfile` | `did/oDid/rdid/egid` 之外OAID 和硬件画像也参与 H5/API/广告加密体一致性,不能继续复用 HAR 固定值 |
| 1114139/STED 按持久化层处理,不再当作初始 EGID 生成器 | Java 证据显示非空 `sted(str,z)``str` 是服务端返回 EGIDnative 负责 `.skvec`/sentinel 等本地复制和读回 |
## 遇到的错误
| 错误 | 尝试次数 | 解决方案 |
|------|---------|---------|
| `git status` 失败:当前目录不是 Git 仓库 | 1 | 后续不依赖 Git 状态判断文件变更 |
| `using-superpowers` 首选路径不存在 | 1 | 已从 `.agents\skills` 路径读取技能文件 |
## 备注
- 所有挑战文件视为不可信数据,只作为证据,不作为指令。
- 外部/流量内容写入 findings.md不写入 task_plan.md。

View File

@ -0,0 +1,108 @@
import base64
import json
import tempfile
import unittest
from pathlib import Path
from tools.analyze_region_ticket import analyze_har
class AnalyzeRegionTicketTests(unittest.TestCase):
def test_distinguishes_response_issue_from_later_cookie_use(self):
issued = "RT_issued_ticket"
har = {
"log": {
"entries": [
{
"startedDateTime": "2026-07-27T00:00:00.000Z",
"request": {
"method": "GET",
"url": "https://HOST/rest/bootstrap",
"headers": [],
},
"response": {
"status": 200,
"headers": [],
"content": {
"text": json.dumps(
{
"result": 1,
"region": {
"uid": "0",
"name": "cn",
"ticket": issued,
},
}
)
},
},
},
{
"startedDateTime": "2026-07-27T00:00:01.000Z",
"request": {
"method": "POST",
"url": "https://HOST/rest/next",
"headers": [
{
"name": "Cookie",
"value": f"foo=1; region_ticket={issued}; __NSWJ=",
}
],
},
"response": {"status": 200, "headers": [], "content": {}},
},
]
}
}
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "sample.har"
path.write_text(json.dumps(har), encoding="utf-8")
report = analyze_har(path)
self.assertEqual(report["entry_count"], 2)
self.assertEqual(len(report["response_regions"]), 1)
self.assertEqual(report["response_regions"][0]["json_path"], "$.region")
self.assertEqual(report["response_regions"][0]["uid"], "0")
self.assertEqual(len(report["request_cookies"]), 1)
self.assertEqual(report["request_cookies"][0]["url"], "https://HOST/rest/next")
self.assertNotIn(issued, json.dumps(report))
def test_reads_base64_response_and_set_cookie(self):
ticket = "RT_base64_ticket"
body = json.dumps({"data": {"region": {"ticket": ticket}}}).encode()
har = {
"log": {
"entries": [
{
"request": {"method": "GET", "url": "https://HOST/config"},
"response": {
"status": 200,
"headers": [
{
"name": "Set-Cookie",
"value": f"region_ticket={ticket}; Path=/; Secure",
}
],
"content": {
"encoding": "base64",
"text": base64.b64encode(body).decode(),
},
},
}
]
}
}
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "sample.har"
path.write_text(json.dumps(har), encoding="utf-8")
report = analyze_har(path)
self.assertEqual(report["response_regions"][0]["json_path"], "$.data.region")
self.assertEqual(len(report["response_set_cookies"]), 1)
self.assertNotIn(ticket, json.dumps(report))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,74 @@
import unittest
from core.device_cookie import apply_device_profile_to_cookie, cookie_to_string
from core.device_id import is_valid_oaid, oaid_from_seed_material
from core.device_profile import DeviceProfileGenerator
class DeviceCookieTests(unittest.TestCase):
def test_apply_device_profile_preserves_auth_and_overlays_device_fields(self):
profile = DeviceProfileGenerator(seed=20260711).new_profile()
profile.apply_cloud_identity(
"ANDROID_69646c9107f45c63",
2,
"DFPB39C1B79D15E65AA00E29C724073B1578784BDC532027CF94846A0C145787",
)
cookie = {
"userId": "1579452490",
"kuaishou.api_st": "API_ST",
"token": "TOKEN",
"did": "ANDROID_oldoldoldold",
"oDid": "ANDROID_oldoldoldold",
"rdid": "ANDROID_oldoldoldold",
"egid": "OLD_EGID",
}
result = apply_device_profile_to_cookie(cookie, profile)
self.assertEqual(result["userId"], "1579452490")
self.assertEqual(result["kuaishou.api_st"], "API_ST")
self.assertEqual(result["token"], "TOKEN")
self.assertEqual(result["did"], profile.did)
self.assertEqual(result["oDid"], profile.o_did)
self.assertEqual(result["rdid"], profile.rdid)
self.assertEqual(result["egid"], profile.egid)
self.assertEqual(result["oaid"], profile.runtime_hints.oaid)
self.assertEqual(result["cdid_tag"], "2")
self.assertEqual(result["appver"], profile.app_version)
self.assertEqual(result["sys"], f"ANDROID_{profile.android_release}")
self.assertIn(profile.model, result["mod"])
self.assertEqual(result["did_gt"], profile.runtime_hints.did_gt or str(profile.install_time_ms))
self.assertEqual(result["boardPlatform"], profile.board_platform)
self.assertEqual(result["socName"], profile.soc_name)
self.assertEqual(result["max_memory"], str(profile.max_memory))
self.assertEqual(result["deviceBit"], profile.device_bit)
self.assertEqual(result["sw"], str(profile.screen_width))
self.assertEqual(result["sh"], str(profile.screen_height))
self.assertEqual(result["totalMemory"], str(profile.total_memory_mb))
def test_device_profile_oaid_is_derived_from_stable_seed_material(self):
profile = DeviceProfileGenerator(seed=20260711).new_profile()
self.assertEqual(
profile.runtime_hints.oaid,
oaid_from_seed_material(
[
profile.android_id,
profile.local_did,
profile.rdid,
profile.g_rdi2,
profile.sid,
profile.egid,
]
),
)
self.assertTrue(is_valid_oaid(profile.runtime_hints.oaid))
def test_cookie_to_string_keeps_inserted_order(self):
text = cookie_to_string({"a": "1", "b": "2"})
self.assertEqual(text, "a=1; b=2")
if __name__ == "__main__":
unittest.main()

41
tests/test_device_id.py Normal file
View File

@ -0,0 +1,41 @@
import re
import unittest
from core.device_id import is_valid_oaid, oaid_from_seed_material
class DeviceIdTests(unittest.TestCase):
def test_oaid_from_seed_material_is_stable_uppercase_sha256_shape(self):
oaid = oaid_from_seed_material(
[
"ANDROID_0123456789abcdef",
"ANDROID_fedcba9876543210",
"ANDROID_0011223344556677",
"DFP" + "A" * 61,
]
)
self.assertEqual(
oaid,
"4450249A4464DEA6C2C6C32811552E6F98B536AE6BD2996911EFA00AB572D228",
)
self.assertTrue(is_valid_oaid(oaid))
self.assertRegex(oaid, re.compile(r"^[0-9A-F]{64}$"))
def test_oaid_from_seed_material_changes_when_seed_changes(self):
left = oaid_from_seed_material(["ANDROID_0123456789abcdef", "seed-a"])
right = oaid_from_seed_material(["ANDROID_0123456789abcdef", "seed-b"])
self.assertNotEqual(left, right)
self.assertTrue(is_valid_oaid(left))
self.assertTrue(is_valid_oaid(right))
def test_is_valid_oaid_rejects_empty_lowercase_and_wrong_length(self):
self.assertFalse(is_valid_oaid(""))
self.assertFalse(is_valid_oaid("a" * 64))
self.assertFalse(is_valid_oaid("A" * 63))
self.assertFalse(is_valid_oaid("G" * 64))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,153 @@
import hashlib
import json
import unittest
from core.device_id import is_valid_egid
from core.device_profile import (
DeviceProfile,
DeviceProfileGenerator,
load_device_profile,
save_device_profile,
)
class DeviceProfileTests(unittest.TestCase):
def test_generator_creates_consistent_local_identity(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
self.assertEqual(len(profile.android_id), 16)
self.assertEqual(profile.android_id, profile.android_id.lower())
int(profile.android_id, 16)
self.assertEqual(profile.o_did, f"ANDROID_{profile.android_id}")
self.assertTrue(profile.local_did.startswith("ANDROID_"))
self.assertEqual(profile.did, profile.local_did)
expected_rdid = hashlib.md5(profile.g_rdi2.encode("utf-8")).hexdigest()[16:32]
self.assertEqual(profile.rdid, f"ANDROID_{expected_rdid}")
self.assertTrue(is_valid_egid(profile.egid))
self.assertIn(profile.egid, profile.runtime_hints.sted_cache_json)
self.assertTrue(profile.runtime_hints.persisted_cache_m)
def test_profile_persistence_roundtrip(self):
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "device.json"
profile = DeviceProfileGenerator(seed=5678).new_profile()
save_device_profile(profile, path)
loaded = load_device_profile(path)
self.assertEqual(loaded, profile)
self.assertEqual(
json.loads(path.read_text(encoding="utf-8"))["android_id"],
profile.android_id,
)
def test_apply_cloud_identity_updates_only_server_fields(self):
profile = DeviceProfileGenerator(seed=9012).new_profile()
old_android_id = profile.android_id
old_o_did = profile.o_did
old_rdid = profile.rdid
profile.apply_cloud_identity(
did="ANDROID_e8dfd2f16b618053",
cdid_tag=2,
egid="DFP68CA12B5D3C714E4439D5E255B197DA809D63CB77139F1420A763F53FE718",
)
self.assertEqual(profile.did, "ANDROID_e8dfd2f16b618053")
self.assertEqual(profile.cdid_tag, 2)
self.assertEqual(
profile.egid,
"DFP68CA12B5D3C714E4439D5E255B197DA809D63CB77139F1420A763F53FE718",
)
self.assertEqual(profile.android_id, old_android_id)
self.assertEqual(profile.o_did, old_o_did)
self.assertEqual(profile.rdid, old_rdid)
def test_env_export_contains_expected_identity_keys(self):
profile = DeviceProfileGenerator(seed=3456).new_profile()
env_text = profile.to_env()
self.assertIn(f"KS_ANDROID_ID={profile.android_id}", env_text)
self.assertIn(f"KS_DID={profile.did}", env_text)
self.assertIn(f"KS_ODID={profile.o_did}", env_text)
self.assertIn(f"KS_RDID={profile.rdid}", env_text)
self.assertIn(f"KS_LOCAL_DID={profile.local_did}", env_text)
self.assertIn(f"KS_EGID={profile.egid}", env_text)
def test_generated_profile_contains_stable_hardware_fields(self):
profile = DeviceProfileGenerator(seed=4567).new_profile()
self.assertEqual(profile.package_name, "com.kuaishou.nebula")
self.assertEqual(profile.app_version, "14.5.50.11631")
self.assertEqual(profile.android_release, "16")
self.assertTrue(profile.manufacturer)
self.assertTrue(profile.brand)
self.assertTrue(profile.model)
self.assertGreater(profile.screen_width, 0)
self.assertGreater(profile.screen_height, 0)
self.assertGreater(profile.total_memory_mb, 0)
self.assertTrue(profile.board_platform)
self.assertTrue(profile.soc_name)
self.assertGreater(profile.max_memory, 0)
self.assertTrue(profile.device_bit)
self.assertIn(profile.isp, {"CUCC", "CTCC", "CMCC"})
self.assertIn(f"KS_APPVER={profile.app_version}", profile.to_env())
self.assertIn(f"KS_SOC_NAME={profile.soc_name}", profile.to_env())
def test_generated_profile_contains_runtime_hints(self):
profile = DeviceProfileGenerator(seed=4567).new_profile()
hints = profile.runtime_hints
self.assertTrue(hints.k4_native)
self.assertGreater(hints.storage_available_bytes, 0)
self.assertEqual(len(hints.k51_native), 16)
self.assertEqual(len(hints.k84_native), 16)
self.assertTrue(hints.boot_id)
self.assertTrue(hints.grdi)
self.assertIn("::", hints.grdi)
self.assertTrue(hints.keeper_seed)
self.assertTrue(hints.du)
self.assertTrue(hints.sted_cache_json)
self.assertIn(profile.egid, hints.sted_cache_json)
self.assertTrue(hints.persisted_cache_m)
self.assertTrue(hints.manus)
self.assertTrue(hints.gaid)
self.assertIn("KS_K105=", profile.to_env())
def test_runtime_hints_persist_roundtrip(self):
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "device.json"
profile = DeviceProfileGenerator(seed=5678).new_profile()
save_device_profile(profile, path)
loaded = load_device_profile(path)
self.assertEqual(loaded.runtime_hints, profile.runtime_hints)
def test_profile_validation_rejects_invalid_identity_fields(self):
cases = [
("android_id", "XYZ"),
("local_did", "BAD"),
("did", "BAD"),
("o_did", "BAD"),
("rdid", "BAD"),
("egid", "BAD"),
]
for field, value in cases:
with self.subTest(field=field):
data = DeviceProfileGenerator(seed=7890).new_profile().to_dict()
data[field] = value
with self.assertRaises(ValueError):
DeviceProfile.from_dict(data)
if __name__ == "__main__":
unittest.main()

53
tests/test_dfp_cache.py Normal file
View File

@ -0,0 +1,53 @@
import hashlib
import unittest
from core.device_profile import DeviceProfileGenerator
from core.dfp_cache import (
PERSISTED_CACHE_KEYS,
build_persisted_cache_m,
java_hashmap_to_string,
persisted_cache_source_values,
uq0_s_a,
)
class DfpCacheTests(unittest.TestCase):
def test_uq0_s_a_matches_java_md5_prefix(self):
self.assertEqual(uq0_s_a("abc"), hashlib.md5(b"abc").hexdigest()[:16])
self.assertEqual(uq0_s_a(""), "")
self.assertEqual(uq0_s_a("KWE_N"), "KWE_N")
def test_java_hashmap_to_string_matches_fixed_dpf_key_order(self):
entries = [(key, f"{key}v") for key in PERSISTED_CACHE_KEYS]
self.assertEqual(
java_hashmap_to_string(entries),
(
"{k16=k16v, k27=k27v, k19=k19v, k29=k29v, k110=k110v, "
"k40=k40v, k105=k105v, k6=k6v, k8=k8v, k23=k23v}"
),
)
def test_persisted_cache_m_changes_with_stable_hardware_material(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
original = build_persisted_cache_m(profile)
profile.runtime_hints.keeper_seed += "x"
changed = build_persisted_cache_m(profile)
self.assertNotEqual(changed, original)
def test_source_values_match_java_cache_material_key_set(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
values = persisted_cache_source_values(profile)
self.assertEqual(tuple(values), PERSISTED_CACHE_KEYS)
self.assertEqual(values["k6"], "0")
self.assertEqual(values["k8"], profile.build_type)
self.assertEqual(values["k23"], profile.manufacturer)
self.assertEqual(values["k27"], profile.model)
self.assertEqual(values["k110"], profile.runtime_hints.keeper_seed)
if __name__ == "__main__":
unittest.main()

96
tests/test_dfp_client.py Normal file
View File

@ -0,0 +1,96 @@
import unittest
from core.device_profile import DeviceProfileGenerator
from core.dfp_client import (
BootstrapIdentity,
apply_bootstrap_identity,
extract_bootstrap_identity,
post_request,
)
from core.dfp_forms import DfpRequestSpec
class DfpClientTests(unittest.TestCase):
def test_extract_bootstrap_identity_from_fetch_and_report(self):
identity = extract_bootstrap_identity(
{
"result": 1,
"cloud_did": "ANDROID_f05497e9cef09a7f",
"did_tag": 2,
"egid": "",
},
{
"result": 1,
"egid": "DFPD5DAB4376320DEA4F0DDFEA76E0B216F894BF2247BD8F6DA8B77AD981AF68",
},
)
self.assertEqual(identity.did, "ANDROID_f05497e9cef09a7f")
self.assertEqual(identity.cdid_tag, 2)
self.assertEqual(
identity.egid,
"DFPD5DAB4376320DEA4F0DDFEA76E0B216F894BF2247BD8F6DA8B77AD981AF68",
)
def test_apply_bootstrap_identity_updates_profile(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
identity = extract_bootstrap_identity(
{"result": 1, "cloud_did": "ANDROID_f05497e9cef09a7f", "did_tag": "2"},
{"result": 1, "egid": "DFPD5DAB4376320DEA4F0DDFEA76E0B216F894BF2247BD8F6DA8B77AD981AF68"},
)
apply_bootstrap_identity(profile, identity)
self.assertEqual(profile.did, "ANDROID_f05497e9cef09a7f")
self.assertEqual(profile.cdid_tag, 2)
self.assertEqual(
profile.egid,
"DFPD5DAB4376320DEA4F0DDFEA76E0B216F894BF2247BD8F6DA8B77AD981AF68",
)
def test_invalid_egid_is_not_applied(self):
identity = extract_bootstrap_identity(
{"result": 1, "cloud_did": "ANDROID_f05497e9cef09a7f", "did_tag": 2},
{"result": 1, "egid": ""},
)
self.assertEqual(identity.egid, "")
def test_apply_bootstrap_identity_can_refresh_egid_without_new_did(self):
profile = DeviceProfileGenerator(seed=4321).new_profile()
old_did = profile.did
identity = BootstrapIdentity(
egid="DFPD5DAB4376320DEA4F0DDFEA76E0B216F894BF2247BD8F6DA8B77AD981AF68"
)
apply_bootstrap_identity(profile, identity)
self.assertEqual(profile.did, old_did)
self.assertEqual(
profile.egid,
"DFPD5DAB4376320DEA4F0DDFEA76E0B216F894BF2247BD8F6DA8B77AD981AF68",
)
def test_post_request_converts_transport_error_to_response(self):
spec = DfpRequestSpec(
method="POST",
url="https://gdfpsec.ksapisrv.com/rest/infra/gdfp/report/kuaishou/android",
headers={"Content-Type": "application/x-www-form-urlencoded"},
form_order=[],
form={},
body="",
)
def failing_post(url, data, headers, timeout):
raise TimeoutError("network timeout")
response = post_request(spec, timeout=3, post_func=failing_post)
self.assertEqual(response.status_code, 0)
self.assertFalse(response.ok)
self.assertEqual(response.data["error_type"], "TimeoutError")
self.assertIn("network timeout", response.text)
if __name__ == "__main__":
unittest.main()

74
tests/test_dfp_forms.py Normal file
View File

@ -0,0 +1,74 @@
import unittest
from urllib.parse import parse_qs
from core.device_profile import DeviceProfileGenerator
from core.dfp_forms import (
GDFP_REPORT_FORM_ORDER,
UNIFIED_CHECK_REPAIR_FORM_ORDER,
UNIFIED_FETCH_FORM_ORDER,
build_gdfp_report_request,
build_unified_check_repair_request,
build_unified_fetch_request,
)
class DfpFormsTests(unittest.TestCase):
def test_unified_fetch_preserves_form_order(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
request = build_unified_fetch_request(
profile,
counter=1,
unix_time=1783749817,
session_seed=0x5D7E742B,
ts_millis="1783749817000",
epoch_seconds=1783749817,
)
self.assertEqual(request.form_order, UNIFIED_FETCH_FORM_ORDER)
self.assertEqual(request.form["did"], profile.did)
self.assertEqual(request.form["didTag"], "-1")
self.assertEqual(request.form["rdid"], profile.rdid)
self.assertIn("sign", request.form)
def test_gdfp_report_request_body_order(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
request = build_gdfp_report_request(
profile,
counter=2,
unix_time=1783749817,
session_seed=0x5D7E742B,
ts_millis="1783749817000",
epoch_seconds=1783749817,
)
self.assertEqual(request.form_order, GDFP_REPORT_FORM_ORDER)
self.assertTrue(request.body.startswith("productName=NEBULA&ts=1783749817000&deviceInfo="))
parsed = parse_qs(request.body)
self.assertEqual(parsed["rdid"], [profile.rdid])
self.assertEqual(parsed["didtag"], ["-1"])
def test_unified_check_repair_preserves_form_order(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
profile.cdid_tag = 2
request = build_unified_check_repair_request(
profile,
counter=3,
unix_time=1783749817,
session_seed=0x5D7E742B,
ts_millis="1783749817000",
last_did_ts="1783749800000",
)
parsed = parse_qs(request.body)
self.assertEqual(request.form_order, UNIFIED_CHECK_REPAIR_FORM_ORDER)
self.assertEqual(list(request.form), UNIFIED_CHECK_REPAIR_FORM_ORDER)
self.assertEqual(parsed["did"], [profile.did])
self.assertEqual(parsed["didTag"], ["2"])
self.assertEqual(parsed["from"], ["1"])
self.assertEqual(parsed["lastDidTs"], ["1783749800000"])
self.assertEqual(parsed["productName"], ["NEBULA"])
self.assertEqual(len(parsed["sign"][0]), 64)
if __name__ == "__main__":
unittest.main()

99
tests/test_dfp_knn.py Normal file
View File

@ -0,0 +1,99 @@
import json
import unittest
from core.device_profile import DeviceProfileGenerator
from core.dfp_knn import FULL_KEYS, LITE_KEYS, build_full_knn, build_lite_knn, recompute_k14_crc
from core.dfp_sq0 import decode_sq0_string_fields, encode_sq0_device_info
class DfpKnnTests(unittest.TestCase):
def test_lite_knn_uses_device_profile_identity_fields(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
knn = build_lite_knn(profile)
self.assertEqual(list(knn), LITE_KEYS)
self.assertEqual(knn["k31"], "KWE_N")
self.assertEqual(knn["k66"], profile.o_did.removeprefix("ANDROID_"))
self.assertEqual(knn["k107"], str(profile.cdid_tag))
self.assertEqual(knn["k39"], profile.runtime_hints.did_gt)
self.assertEqual(knn["k40"], profile.build_fingerprint)
self.assertEqual(knn["k46"], profile.runtime_hints.total_memory_bytes)
self.assertEqual(knn["k57"], "KWE_NPN")
self.assertEqual(knn["k68"], "KWE_NPN")
self.assertEqual(knn["k106"], "KWE_NPN")
self.assertIn(profile.g_rdi2, json.loads(knn["k93"])["28"])
self.assertNotIn("0", json.loads(knn["k93"]))
def test_k14_crc_changes_when_identity_changes(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
knn = build_lite_knn(profile)
original = knn["k14"]
changed = dict(knn)
changed["k31"] = "0000000000000000"
changed["k14"] = recompute_k14_crc(changed, LITE_KEYS)
self.assertNotEqual(changed["k14"], original)
def test_full_knn_keeps_all_119_fields_non_empty(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
knn = build_full_knn(profile)
self.assertEqual(list(knn), FULL_KEYS)
self.assertEqual(len(knn), 119)
self.assertEqual([key for key, value in knn.items() if value == ""], [])
self.assertEqual(knn["k7"], profile.did)
self.assertEqual(knn["k31"], "KWE_N")
self.assertEqual(knn["k66"], profile.o_did.removeprefix("ANDROID_"))
self.assertEqual(knn["k83"], profile.egid)
self.assertEqual(knn["k107"], str(profile.cdid_tag))
self.assertEqual(knn["k63"], "root")
def test_full_sq0_encodes_all_119_fields(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
raw = encode_sq0_device_info(build_full_knn(profile), mode="full")
fields = decode_sq0_string_fields(raw)
self.assertEqual([field["proto_tag"] for field in fields], list(range(1, 120)))
def test_full_knn_maps_stable_hardware_fields_from_profile(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
knn = build_full_knn(profile)
self.assertEqual(knn["k3"], profile.package_name)
self.assertEqual(knn["k22"], profile.app_version)
self.assertEqual(knn["k23"], profile.manufacturer)
self.assertEqual(knn["k27"], profile.model)
self.assertEqual(knn["k29"], f"Dalvik/2.1.0 (Linux; U; Android {profile.android_release}; {profile.model} Build/{profile.build_id})")
self.assertEqual(knn["k34"], profile.screen_metrics)
self.assertEqual(knn["k35"], profile.android_release)
self.assertEqual(knn["k46"], profile.runtime_hints.total_memory_bytes)
self.assertEqual(knn["k58"], profile.build_product)
self.assertEqual(knn["k61"], profile.brand)
self.assertEqual(knn["k72"], profile.country_code)
def test_full_knn_maps_runtime_hints_from_profile(self):
profile = DeviceProfileGenerator(seed=1234).new_profile()
hints = profile.runtime_hints
knn = build_full_knn(profile)
self.assertEqual(knn["k4"], hints.k4_native)
self.assertEqual(knn["k39"], hints.did_gt)
self.assertEqual(knn["k20"], str(hints.storage_available_bytes))
self.assertEqual(knn["k51"], hints.k51_native)
self.assertEqual(knn["k84"], hints.k84_native)
self.assertEqual(knn["k97"], hints.oaid)
self.assertEqual(knn["k101"], hints.res_soc)
self.assertEqual(knn["k102"], hints.boot_id)
self.assertEqual(knn["k105"], hints.grdi)
self.assertEqual(knn["k108"], hints.ipv6_map)
self.assertEqual(knn["k109"], hints.lpss)
self.assertEqual(knn["k110"], hints.keeper_seed)
self.assertEqual(knn["k111"], hints.du)
self.assertEqual(knn["k112"], hints.sted_cache_json)
self.assertEqual(knn["k113"], hints.manus)
self.assertEqual(knn["k119"], hints.gaid)
if __name__ == "__main__":
unittest.main()

31
tests/test_dfp_sq0.py Normal file
View File

@ -0,0 +1,31 @@
import unittest
from core.dfp_sq0 import decode_sq0_string_fields, encode_sq0_device_info
class DfpSq0Tests(unittest.TestCase):
def test_lite_encoding_preserves_known_tag_order(self):
raw = encode_sq0_device_info({"k5": "a", "k14": "bc", "k113": "z"}, mode="lite")
self.assertEqual(raw.hex(), "2a0161720262638a07017a")
fields = decode_sq0_string_fields(raw)
self.assertEqual(
[(field["proto_tag"], field["value"]) for field in fields],
[(5, "a"), (14, "bc"), (113, "z")],
)
def test_empty_values_are_not_encoded(self):
raw = encode_sq0_device_info({"k5": "a", "k14": "", "k113": "z"}, mode="lite")
fields = decode_sq0_string_fields(raw)
self.assertEqual(
[(field["proto_tag"], field["value"]) for field in fields],
[(5, "a"), (113, "z")],
)
def test_unknown_key_is_rejected(self):
with self.assertRaises(KeyError):
encode_sq0_device_info({"k999": "x"}, mode="lite")
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,208 @@
import json
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from unittest.mock import patch
from tools import extract_app_login_fields
from tools.extract_app_login_fields import extract_fields
class ExtractAppLoginFieldsTests(unittest.TestCase):
def test_cli_preserves_cached_weapon_kaw_when_new_log_has_none(self):
"""只抓到登录字段的新日志不能清空版本级 KAW 缓存。"""
event = {
"tag": "REQUEST_BUILD",
"method": "POST",
"url": (
"https://az2-api.ksapisrv.com/rest/nebula/user/login/mobileVerifyCode?"
"did=ANDROID_APP_DID&sig=OLD"
),
"body": "code=123456&mobile=LOGIN_ENC&passport_account_image=VIMG_LOGIN",
}
with tempfile.TemporaryDirectory() as td:
root = Path(td)
log = root / "probe_login.log"
out = root / "app_login_fields_latest.json"
log.write_text("@@LOGIN " + json.dumps(event), encoding="utf-8")
out.write_text(
json.dumps({"weapon_kaw": "CACHED_VERSION_KAW", "source_log": "older.log"}),
encoding="utf-8",
)
argv = [
"extract_app_login_fields.py",
"--log",
str(log),
"--out",
str(out),
]
with patch.object(sys, "argv", argv), redirect_stdout(StringIO()):
self.assertEqual(extract_app_login_fields.main(), 0)
fields = json.loads(out.read_text(encoding="utf-8"))
self.assertEqual(fields["weapon_kaw"], "CACHED_VERSION_KAW")
def test_extract_fields_keeps_mobile_checker_fields(self):
"""登录日志里有 mobile/checker 时,提取 checker 专用密文和票据。"""
checker_url = (
"https://az2-api.ksapisrv.com/rest/nebula/user/mobile/checker?"
"did=ANDROID_APP_DID&sig=OLD&__NS_sig3=OLD3&__NS_xfalcon=OLDX"
)
request_url = (
"https://az2-api.ksapisrv.com/rest/nebula/user/requestMobileCode?"
"did=ANDROID_APP_DID&sig=OLD&__NS_sig3=OLD3&__NS_xfalcon=OLDX"
)
login_url = (
"https://az2-api.ksapisrv.com/rest/nebula/user/login/mobileVerifyCode?"
"did=ANDROID_APP_DID&sig=OLD&__NS_sig3=OLD3&__NS_xfalcon=OLDX"
)
events = [
{
"tag": "LOGINHELPER_B_PHONE",
"in_value": "13800000000",
"out_value": "CHECKER+ENC==",
},
{
"tag": "REQUEST_BUILD",
"method": "POST",
"url": checker_url,
"body": (
"mobileCountryCode=+86&mobile=CHECKER+ENC==&cs=false&client_key=2ac2a76d"
"&videoModelCrowdTag=&os=android&uQaTag=&passport_account_image=VIMG_CHECKER$AI_1111"
),
},
{
"tag": "REQUEST_BUILD",
"method": "POST",
"url": request_url,
"body": (
"mobileCountryCode=+86&mobile=REQUEST+ENC==&type=27&useVoice=false"
"&needCheck=false&prefetchPhoneNumber=&requestSource=1"
"&passport_account_image=VIMG_REQUEST$AI_2222"
),
},
{
"tag": "REQUEST_BUILD",
"method": "POST",
"url": login_url,
"body": (
"isDegraded=false&code=123456&mobileCountryCode=+86&deviceMode=OnePlus(PJZ110)"
"&mobile=LOGIN+ENC==&prefetchPhoneNumber=&raw=1785044999746"
"&publicKey=PUBLIC&secret=SECRET&type=27&deviceName=OnePlus(PJZ110)"
"&passport_account_image=VIMG_LOGIN$AI_3333"
),
},
]
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe.log"
log.write_text(
"\n".join("@@LOGIN " + json.dumps(event, ensure_ascii=False) for event in events),
encoding="utf-8",
)
fields = extract_fields(log)
self.assertEqual(fields["checker_host"], "az2-api.ksapisrv.com")
self.assertEqual(fields["checker_path"], "/rest/nebula/user/mobile/checker")
self.assertEqual(fields["query_params"], {"did": "ANDROID_APP_DID"})
self.assertEqual(fields["source_mobile"], "13800000000")
self.assertEqual(fields["checker_encrypted_mobile"], "CHECKER+ENC==")
self.assertEqual(fields["request_encrypted_mobile"], "REQUEST+ENC==")
self.assertEqual(fields["encrypted_mobile"], "LOGIN+ENC==")
self.assertEqual(fields["checker_passport_account_image"], "VIMG_CHECKER$AI_1111")
self.assertEqual(fields["request_passport_account_image"], "VIMG_REQUEST$AI_2222")
self.assertEqual(fields["passport_account_image"], "VIMG_LOGIN$AI_3333")
self.assertEqual(
fields["checker_body_keys"],
[
"mobileCountryCode",
"mobile",
"cs",
"client_key",
"videoModelCrowdTag",
"os",
"uQaTag",
"passport_account_image",
],
)
def test_extract_fields_from_all_layers_begin_chunk_log(self):
"""probe_nebula_all_layers 的 BEGIN/CHUNK 日志也能提取三段字段。"""
checker_url = (
"https://az2-api.ksapisrv.com/rest/nebula/user/mobile/checker?"
"did=ANDROID_APP_DID&sig=OLD&__NS_sig3=OLD3"
)
request_url = (
"https://az2-api.ksapisrv.com/rest/nebula/user/requestMobileCode?"
"did=ANDROID_APP_DID&sig=OLD&__NS_xfalcon=OLDX"
)
login_url = (
"https://az2-api.ksapisrv.com/rest/nebula/user/login/mobileVerifyCode?"
"did=ANDROID_APP_DID&sig=OLD"
)
def begin(seq, tag, payload):
return f"@@BEGIN\t{seq}\t{tag}\t" + json.dumps(payload, ensure_ascii=False)
def chunk(seq, name, value):
return f"@@CHUNK\t{seq}\t{name}\t0\t" + json.dumps(value, ensure_ascii=False)
lines = [
begin(10, "OKHTTP_CHAIN_BEFORE_REQ", {"method": "POST", "url": checker_url, "tid": 7}),
begin(11, "OKHTTP_CHAIN_BEFORE_REQ_BODY", {"tid": 7}),
chunk(11, "utf8", "mobileCountryCode=%2B86&mobile=CHECKER%2BENC%3D%3D&passport_account_image=VIMG_CHECKER%24AI_1111"),
begin(20, "OKHTTP_CHAIN_BEFORE_REQ", {"method": "POST", "url": request_url, "tid": 7}),
begin(21, "OKHTTP_CHAIN_BEFORE_REQ_BODY", {"tid": 7}),
chunk(21, "utf8", "mobileCountryCode=%2B86&mobile=REQUEST%2BENC%3D%3D&type=27&prefetchPhoneNumber=&passport_account_image=VIMG_REQUEST%24AI_2222"),
begin(30, "OKHTTP_CHAIN_BEFORE_REQ", {"method": "POST", "url": login_url, "tid": 7}),
begin(31, "OKHTTP_CHAIN_BEFORE_REQ_BODY", {"tid": 7}),
chunk(31, "utf8", "code=123456&mobileCountryCode=%2B86&mobile=LOGIN%2BENC%3D%3D&prefetchPhoneNumber=&deviceName=OnePlus(PJZ110)&passport_account_image=VIMG_LOGIN%24AI_3333"),
begin(32, "CRONET_ADD_HEADER", {"name": "kaw", "tid": 7}),
chunk(32, "value", "KAW_VERSION_CONFIG"),
]
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe_login_stable.log"
log.write_text("\n".join(lines), encoding="utf-8")
fields = extract_fields(log)
self.assertEqual(fields["checker_host"], "az2-api.ksapisrv.com")
self.assertEqual(fields["checker_path"], "/rest/nebula/user/mobile/checker")
self.assertEqual(fields["query_params"], {"did": "ANDROID_APP_DID"})
self.assertEqual(fields["checker_encrypted_mobile"], "CHECKER+ENC==")
self.assertEqual(fields["request_encrypted_mobile"], "REQUEST+ENC==")
self.assertEqual(fields["encrypted_mobile"], "LOGIN+ENC==")
self.assertEqual(fields["checker_passport_account_image"], "VIMG_CHECKER$AI_1111")
self.assertEqual(fields["request_passport_account_image"], "VIMG_REQUEST$AI_2222")
self.assertEqual(fields["passport_account_image"], "VIMG_LOGIN$AI_3333")
self.assertEqual(fields["weapon_kaw"], "KAW_VERSION_CONFIG")
def test_extract_fields_reads_mixed_utf8_utf16le_all_layers_log(self):
"""Start-Job/Tee 混合编码时仍能识别 BEGIN/CHUNK。"""
url = "https://az2-api.ksapisrv.com/rest/nebula/user/login/mobileVerifyCode?did=ANDROID_APP_DID&sig=OLD"
line1 = "@@BEGIN\t1\tOKHTTP_CHAIN_BEFORE_REQ\t" + json.dumps({"method": "POST", "url": url, "tid": 9})
line2 = "@@BEGIN\t2\tOKHTTP_CHAIN_BEFORE_REQ_BODY\t" + json.dumps({"tid": 9})
line3 = "@@CHUNK\t2\tutf8\t0\t" + json.dumps("mobileCountryCode=%2B86&mobile=LOGIN%2BENC%3D%3D&passport_account_image=VIMG_LOGIN%24AI_3333")
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe_mixed.log"
body = "\r\n".join([line1, line2, line3]) + "\r\n"
log.write_bytes(b"\xef\xbb\xbfJOB_BEGIN pid=1\r\n" + body.encode("utf-16-le"))
fields = extract_fields(log)
self.assertEqual(fields["encrypted_mobile"], "LOGIN+ENC==")
self.assertEqual(fields["passport_account_image"], "VIMG_LOGIN$AI_3333")
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,368 @@
import base64
import json
import tempfile
import unittest
from pathlib import Path
from tools.extract_passport_wcfg import extract_wcfg_evidence, extract_wcfg_evidence_many, update_app_fields
def _final_passport() -> str:
raw = bytes.fromhex("203868e844b7900a75ef44ce0702671f") + b"ticket-body"
return "VIMG_" + base64.b64encode(raw).decode("ascii") + "$AI_" + ("1" * 32)
class ExtractPassportWcfgTests(unittest.TestCase):
def test_extract_wcfg_evidence_selects_a_y_q_z_write(self):
passport = _final_passport()
events = [
{"tag": "SCRIPT_START", "seq": 1},
{
"tag": "OKHTTP_BUILDER_URL",
"seq": 2,
"url": "https://gdfp.gifshow.com/f/a/p?appkey=20001",
},
{
"tag": "WEAPON_B_UPLOAD_ENCRYPT",
"seq": 3,
"input": "UPLOAD_JSON",
"value": "BASE64_UPLOAD_VALUE",
},
{
"tag": "EDITOR_PUT_STRING_IMPL",
"seq": 4,
"key": "a_y_q_z",
"value": passport[:40] + "...",
"value_full": passport,
"value_len": len(passport),
"stack": "java.lang.Exception\n\tat com.kuaishou.weapon.ks.z0.a(kSourceFile:278)",
},
{
"tag": "WEAPON_DD",
"seq": 5,
"type": 21,
"value": passport[:40] + "...",
"value_full": passport,
"value_len": len(passport),
},
]
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe_passport_wcfg.log"
log.write_text(
"\n".join("@@WCFG " + json.dumps(event, ensure_ascii=False) for event in events),
encoding="utf-8",
)
result = extract_wcfg_evidence(log)
self.assertTrue(result["chain_complete"])
self.assertEqual(result["passport_account_image"], passport)
self.assertTrue(result["flags"]["has_fap_call"])
self.assertTrue(result["flags"]["has_upload_encrypt"])
self.assertTrue(result["flags"]["has_a_y_q_z_write"])
self.assertTrue(result["flags"]["has_dd21_read"])
self.assertEqual(result["selected"]["tag"], "EDITOR_PUT_STRING_IMPL")
self.assertEqual(result["selected"]["diagnosis"]["format_kind"], "weapon_pr")
self.assertEqual(result["app_fields_patch"]["passport_account_image"], passport)
self.assertEqual(result["app_fields_patch"]["request_passport_account_image"], passport)
self.assertEqual(result["app_fields_patch"]["checker_passport_account_image"], passport)
self.assertGreaterEqual(len(result["timeline"]), 4)
def test_extract_wcfg_evidence_marks_truncated_value_as_non_reusable(self):
events = [
{
"tag": "EDITOR_PUT_STRING_IMPL",
"seq": 1,
"key": "a_y_q_z",
"value": "VIMG_abc...(len=1129)",
}
]
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe_passport_wcfg.log"
log.write_text("@@WCFG " + json.dumps(events[0]), encoding="utf-8")
result = extract_wcfg_evidence(log)
self.assertFalse(result["chain_complete"])
self.assertEqual(result["passport_account_image"], "")
self.assertEqual(result["app_fields_patch"], {})
self.assertTrue(result["candidates"][0]["truncated"])
def test_extract_wcfg_evidence_can_select_fap_response_body_ticket(self):
"""当写入 wcfg 的 hook 没打到时,也要能从 /f/a/p 响应体提票据。"""
passport = _final_passport()
events = [
{
"tag": "OKHTTP_REQUEST_BUILD",
"seq": 1,
"method": "POST",
"url": "https://gdfp.gifshow.com/f/a/p?appkey=20001",
"body": '{"data":"VIMG_UPLOAD_VALUE"}',
},
{
"tag": "OKHTTP_RESPONSE_BODY_STRING",
"seq": 2,
"url": "https://gdfp.gifshow.com/f/a/p?appkey=20001",
"body": json.dumps({"result": 1, "a_y_q_z": passport}, ensure_ascii=False),
"body_full": json.dumps({"result": 1, "a_y_q_z": passport}, ensure_ascii=False),
},
]
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe_passport_wcfg.log"
log.write_text(
"\n".join("@@WCFG " + json.dumps(event, ensure_ascii=False) for event in events),
encoding="utf-8",
)
result = extract_wcfg_evidence(log)
self.assertFalse(result["chain_complete"])
self.assertTrue(result["flags"]["has_fap_call"])
self.assertTrue(result["flags"]["has_fap_response_body"])
self.assertEqual(result["passport_account_image"], passport)
self.assertEqual(result["selected"]["tag"], "OKHTTP_RESPONSE_BODY_STRING")
self.assertEqual(result["selected"]["source"], "body")
self.assertEqual(result["app_fields_patch"]["passport_account_image"], passport)
def test_extract_wcfg_evidence_can_select_weapon_i_return_ticket(self):
"""deep 脚本抓到 i.a(k1) 返回体时,也能提取 /f/a/p 下发票据。"""
passport = _final_passport()
ret_body = json.dumps({"result": 1, "a_y_q_z": passport}, ensure_ascii=False)
events = [
{
"tag": "I_A_K1_BEFORE",
"seq": 1,
"url": "https://gdfp.gifshow.com/f/a/p?appkey=20001",
"body": '{"data":"VIMG_UPLOAD_VALUE"}',
"body_full": '{"data":"VIMG_UPLOAD_VALUE"}',
},
{
"tag": "I_A_K1_AFTER",
"seq": 2,
"url": "https://gdfp.gifshow.com/f/a/p?appkey=20001",
"ret": ret_body,
"ret_full": ret_body,
},
]
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe_passport_deep_i.log"
log.write_text(
"\n".join("@@WCFG " + json.dumps(event, ensure_ascii=False) for event in events),
encoding="utf-8",
)
result = extract_wcfg_evidence(log)
self.assertTrue(result["flags"]["has_fap_call"])
self.assertTrue(result["flags"]["has_fap_request_body"])
self.assertTrue(result["flags"]["has_fap_response_body"])
self.assertEqual(result["passport_account_image"], passport)
self.assertEqual(result["selected"]["tag"], "I_A_K1_AFTER")
self.assertEqual(result["selected"]["source"], "ret")
def test_extract_wcfg_evidence_can_select_weapon_y0_return_ticket(self):
"""deep 脚本抓到 y0.a(k1) 降级通道返回体时,也能提取票据。"""
passport = _final_passport()
ret_body = json.dumps({"result": 1, "data": {"a_y_q_z": passport}}, ensure_ascii=False)
events = [
{
"tag": "Y0_A_K1_AFTER",
"seq": 1,
"url": "https://gdfp.gifshow.com/f/a/p?appkey=20001",
"ret": ret_body,
"ret_full": ret_body,
}
]
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe_passport_deep_y0.log"
log.write_text(
"\n".join("@@WCFG " + json.dumps(event, ensure_ascii=False) for event in events),
encoding="utf-8",
)
result = extract_wcfg_evidence(log)
self.assertTrue(result["flags"]["has_fap_call"])
self.assertTrue(result["flags"]["has_fap_response_body"])
self.assertEqual(result["passport_account_image"], passport)
self.assertEqual(result["selected"]["tag"], "Y0_A_K1_AFTER")
self.assertEqual(result["selected"]["source"], "ret")
def test_extract_wcfg_evidence_can_select_passport_form_add_ticket(self):
"""最小抓证脚本只抓到 FormBody.add 时,也要能提取最终票据。"""
passport = _final_passport()
events = [
{
"tag": "OKHTTP_FORM_ADD",
"seq": 1,
"name": "passport_account_image",
"value": passport[:40] + "...",
"value_full": passport,
"value_len": len(passport),
}
]
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe_passport_min.log"
log.write_text(
"\n".join("@@WCFG " + json.dumps(event, ensure_ascii=False) for event in events),
encoding="utf-8",
)
result = extract_wcfg_evidence(log)
self.assertFalse(result["chain_complete"])
self.assertTrue(result["flags"]["has_passport_form_add"])
self.assertEqual(result["passport_account_image"], passport)
self.assertEqual(result["selected"]["tag"], "OKHTTP_FORM_ADD")
self.assertEqual(result["selected"]["name"], "passport_account_image")
self.assertEqual(result["app_fields_patch"]["passport_account_image"], passport)
def test_extract_wcfg_evidence_can_select_okhttp2_form_add_ticket(self):
"""兼容旧 OkHttp2 FormEncodingBuilder.add 的表单字段抓证。"""
passport = _final_passport()
events = [
{
"tag": "OKHTTP2_FORM_ADD",
"seq": 1,
"name": "passport_account_image",
"value": passport,
"value_full": passport,
"value_len": len(passport),
}
]
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe_passport_min_okhttp2.log"
log.write_text(
"\n".join("@@WCFG " + json.dumps(event, ensure_ascii=False) for event in events),
encoding="utf-8",
)
result = extract_wcfg_evidence(log)
self.assertFalse(result["chain_complete"])
self.assertTrue(result["flags"]["has_passport_form_add"])
self.assertEqual(result["passport_account_image"], passport)
self.assertEqual(result["selected"]["tag"], "OKHTTP2_FORM_ADD")
self.assertEqual(result["app_fields_patch"]["passport_account_image"], passport)
def test_extract_wcfg_evidence_many_merges_multi_process_logs(self):
"""multi-attach 每个进程一个日志时,要能聚合后选出完整票据。"""
passport = _final_passport()
with tempfile.TemporaryDirectory() as td:
main_log = Path(td) / "probe_multi_main.log"
worker_log = Path(td) / "probe_multi_worker.log"
worker_log.write_text(
"\n".join(
[
"@@WCFG "
+ json.dumps(
{
"tag": "PROCESS_NAME",
"seq": 1,
"process_name": "com.kuaishou.nebula:messagesdk",
},
ensure_ascii=False,
),
"@@WCFG "
+ json.dumps(
{
"tag": "PROCESS_SKIP",
"seq": 2,
"process_name": "com.kuaishou.nebula:messagesdk",
"reason": "main_only",
},
ensure_ascii=False,
),
]
),
encoding="utf-8",
)
main_log.write_text(
"\n".join(
"@@WCFG " + json.dumps(event, ensure_ascii=False)
for event in [
{
"tag": "PROCESS_NAME",
"seq": 1,
"process_name": "com.kuaishou.nebula",
},
{
"tag": "OKHTTP_REQUEST_BUILD",
"seq": 2,
"url": "https://gdfp.gifshow.com/f/a/p?appkey=20001",
},
{
"tag": "EDITOR_PUT_STRING_IMPL",
"seq": 3,
"key": "a_y_q_z",
"value": passport,
"value_full": passport,
},
{
"tag": "WEAPON_DD",
"seq": 4,
"type": 21,
"value": passport,
"value_full": passport,
},
]
),
encoding="utf-8",
)
result = extract_wcfg_evidence_many([worker_log, main_log])
self.assertTrue(result["chain_complete"])
self.assertEqual(result["passport_account_image"], passport)
self.assertEqual(result["source_log"], "<multi:2 logs>")
self.assertEqual(len(result["source_logs"]), 2)
self.assertIn("com.kuaishou.nebula", result["process_names"])
self.assertIn("com.kuaishou.nebula:messagesdk", result["process_names"])
self.assertEqual(result["selected"]["source_log"], str(main_log))
self.assertEqual(result["app_fields_patch"]["checker_passport_account_image"], passport)
def test_extract_wcfg_evidence_reads_mixed_utf8_utf16le_job_log(self):
"""PowerShell Start-Job/Tee 可能写出 UTF-8 头 + UTF-16LE Frida 行。"""
passport = _final_passport()
event = {
"tag": "EDITOR_PUT_STRING_IMPL",
"seq": 1,
"key": "a_y_q_z",
"value": passport,
"value_full": passport,
}
line = "@@WCFG " + json.dumps(event, ensure_ascii=False) + "\r\n"
with tempfile.TemporaryDirectory() as td:
log = Path(td) / "probe_multi_mixed.log"
log.write_bytes(b"\xef\xbb\xbfJOB_BEGIN pid=123\r\n" + line.encode("utf-16-le"))
result = extract_wcfg_evidence(log)
self.assertEqual(result["event_count"], 1)
self.assertEqual(result["passport_account_image"], passport)
self.assertEqual(result["selected"]["tag"], "EDITOR_PUT_STRING_IMPL")
def test_update_app_fields_writes_selected_passport_fields(self):
passport = _final_passport()
with tempfile.TemporaryDirectory() as td:
fields = Path(td) / "app_fields.json"
fields.write_text(json.dumps({"host": "az2-api.ksapisrv.com"}), encoding="utf-8")
updated = update_app_fields(
fields,
{
"passport_account_image": passport,
"request_passport_account_image": passport,
"checker_passport_account_image": passport,
},
)
saved = json.loads(fields.read_text(encoding="utf-8"))
self.assertEqual(updated["passport_account_image"], passport)
self.assertEqual(saved["checker_passport_account_image"], passport)
self.assertEqual(saved["host"], "az2-api.ksapisrv.com")
if __name__ == "__main__":
unittest.main()

73
tests/test_h5_jsbridge.py Normal file
View File

@ -0,0 +1,73 @@
import sys
import tempfile
import unittest
from pathlib import Path
from core.h5_jsbridge import H5JsBridgeEncoder, build_h5_sign_input
class H5JsBridgeTests(unittest.TestCase):
def test_build_get_sign_input_matches_frontend_sorting(self):
cookie = {
"kpn": "NEBULA",
"kpf": "ANDROID_PHONE",
"userId": "10001",
"did": "ANDROID_aaaaaaaaaaaaaaaa",
"egid": "DFPBBBB",
"token": "ignored",
"__NS_sig3": "ignored",
}
self.assertEqual(
build_h5_sign_input(cookie, {"source": "bottom_guide_first"}),
"did=ANDROID_aaaaaaaaaaaaaaaa"
"egid=DFPBBBB"
"kpf=ANDROID_PHONE"
"kpn=NEBULA"
"sigCatVer=1"
"source=bottom_guide_first"
"userId=10001",
)
def test_build_post_json_sign_input_appends_raw_body(self):
cookie = {"kpn": "NEBULA", "did": "ANDROID_1"}
body = '{"b":2,"a":1}'
self.assertEqual(
build_h5_sign_input(cookie, body=body, method="POST", request_type="json"),
"did=ANDROID_1kpn=NEBULAsigCatVer=1" + body,
)
def test_object_values_are_blank_like_frontend(self):
cookie = {"kpn": "NEBULA"}
self.assertEqual(
build_h5_sign_input(cookie, {"obj": {"x": 1}}),
"kpn=NEBULAobj=sigCatVer=1",
)
def test_encoder_process_reuses_line_protocol(self):
server_code = (
"import json, sys\n"
"for line in sys.stdin:\n"
" req = json.loads(line)\n"
" print(json.dumps({'id': req.get('id'), 'ok': True, "
"'result': 'a' * 68, 'cInfo': 123}), flush=True)\n"
)
with tempfile.TemporaryDirectory() as temp_dir:
server = Path(temp_dir) / "fake_h5_server.py"
server.write_text(server_code, encoding="utf-8")
encoder = H5JsBridgeEncoder(server=server, node_bin=sys.executable, timeout=5)
try:
first = encoder.encode("one")
second = encoder.encode("two")
finally:
encoder.close()
self.assertEqual(first.sig3, "a" * 68)
self.assertEqual(second.sig3, "a" * 68)
self.assertEqual(first.c_info, 123)
if __name__ == "__main__":
unittest.main()

133
tests/test_h5_kws.py Normal file
View File

@ -0,0 +1,133 @@
import unittest
import shutil
from core.h5_kws import (
build_h5_kws_script_ticket,
build_h5_kws_config_request_data,
build_h5_kws_default_ticket,
decrypt_h5_kws_config_response_data_rsp,
run_h5_kws_sign_script,
webweapon_aes_decrypt_b64,
)
class H5KwsTests(unittest.TestCase):
@unittest.skipIf(shutil.which("node") is None, "Node.js runner not available")
def test_kws_sign_script_runner_matches_stable_callback_code(self):
"""复现 KWS signUrl 脚本加载后通过 kwscb 交出的本地 code。"""
code = run_h5_kws_sign_script(timeout=10)
self.assertEqual(
code,
"04dd303d63222chdfec0087d058fb1c4c5f2eb16eefgce98b2a503578e5f8433",
)
def test_kws_script_ticket_uses_server_sec_token_and_script_code(self):
ticket = build_h5_kws_script_ticket(
sec_token="S" * 88,
kwscode="04dd303d63222chdfec0087d058fb1c4c5f2eb16eefgce98b2a503578e5f8433",
)
self.assertEqual(
ticket,
{
"kwpsecproductname": "kuaishou-growth",
"kwssectoken": "S" * 88,
"kwscode": "04dd303d63222chdfec0087d058fb1c4c5f2eb16eefgce98b2a503578e5f8433",
},
)
def test_default_ticket_matches_web_bundle_sample(self):
"""复现 WebWeapon `getDefaultData(true)` 的本地 fallback 票据。"""
ticket = build_h5_kws_default_ticket(
url=(
"https://nebula.kuaishou.com/nebula/task/earning?"
"layoutType=4&source=bottom_guide_first&extra=abc"
),
did="ANDROID_0123456789abcdef",
now_ms=1780000000123,
fingerprint_nonce="AbCdEf12",
sec_token="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB",
)
self.assertEqual(ticket.kwpsecproductname, "kuaishou-growth")
self.assertEqual(ticket.kwssectoken, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB")
self.assertEqual(
ticket.kwfv1,
"KQKRjWEINlzEI07iMa8YrvXGWc4qQ2YMsjv4wGL2oLDzOX3k0vIz1mXqAtSlzbvkju0AoWF93jjHXryshL/TDO0VJp/Lota4HFS2Mi5xjOejJCLcEhKThUOWDhQwAGt6BG6mZ0U20xRPtGw3KZrazWgQB6/GztmjF6KxyG5emvHszKVkpm3X5J93BHDdQ3W41Co2/U4S0E86F1q0zW/cyrCwF==",
)
self.assertEqual(
ticket.kwscode,
"K8ZTkWg+lUrqr/+G/DimeUiuHJqKK49sakhu1xkYvFmL7eXw/X0/ikDZVqiPY76dYsTargl4pE6Y6DQay4/0WM9i2PUZz6UITH0zu1YaBVY1JsgUuginAF/E2gZ7rZUcs2JtpERewBFnaT9B0kPW6o5vXmoZ48lifl/l+78Jo7McWyVS1oAr3z793qrkUv01BG0poEd2k90MUgASxjumrZHQS==",
)
self.assertEqual(ticket.kww, ticket.kwfv1)
self.assertEqual(len(ticket.kwfv1), 219)
self.assertEqual(len(ticket.kwscode), 219)
def test_default_ticket_truncates_url_like_encode_uri(self):
ticket = build_h5_kws_default_ticket(
url="https://nebula.kuaishou.com/a b?x=1&y=中文" + "z" * 90,
did="ANDROID_0123456789abcdef",
now_ms=1780000000123,
fingerprint_nonce="AbCdEf12",
sec_token="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB",
)
self.assertIn("%20", ticket.fingerprint_plain)
self.assertIn("%E4%B8%AD%E6%96%87", ticket.fingerprint_plain)
encoded_url = ticket.fingerprint_plain.split("|", 1)[0]
self.assertLessEqual(len("https://nebula.kuaishou.com/a b?x=1&y=中文" + "z" * 90), 160)
self.assertTrue(encoded_url.startswith("https://nebula.kuaishou.com/a%20b?x=1&y="))
def test_config_request_data_matches_webweapon_aes_cbc_sample(self):
request = build_h5_kws_config_request_data(
did="ANDROID_0123456789abcdef",
ts_ms=1780000000123,
)
self.assertEqual(request.product_name, "kuaishou-growth")
self.assertEqual(request.ts, 1780000000123)
self.assertEqual(
request.plain,
(
'{"productName":"kuaishou-growth","ts":1780000000123,'
'"did":"ANDROID_0123456789abcdef"}'
),
)
self.assertEqual(
request.data,
(
"1gCA6FEVQdV1z+g5/OHPFHQhSPtfefhe159CsssADX42V2QfWZ3GbHoE/"
"4/QHLX6YvQ4tHJL9iMgRQSVQNBbxgH7MfSpWk5mt8mPG4/GdzHy1QQ"
"tUJOWB/vFAodCz+wX"
),
)
self.assertEqual(request.body(), {"data": request.data})
def test_config_response_data_rsp_decrypts_webweapon_config(self):
data_rsp = (
"In+WxaasWqdkyNTjF1LD39c5IFwB+VbXF6HHmrBh2/WEAjVtlI9JWGXRsS"
"kun/BRh3XRJLkB8iq8vroBqF33wHdJd/34K3a7bavpQFBOvDBaOY9wtUQ"
"bBbXs+y7JIrarlK4iD1Og6JGNZSvyPm+AwzN28v+foKtnUEA4cGTbYJw"
"5g7TdUtw6h0wtdmNlCn6km9hvsoHOC6FcXBoU1lK5I3vuwAa3bqvLlYQ"
"RKYwm9Cc="
)
config = decrypt_h5_kws_config_response_data_rsp(data_rsp)
self.assertEqual(config["fpUrl"], "https://gdfp.gifshow.com/s/w/fp.js")
self.assertEqual(config["signUrl"], "https://gdfp.gifshow.com/s/w/sign.js")
self.assertEqual(
config["secToken"],
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB",
)
def test_webweapon_aes_decrypt_rejects_bad_padding(self):
with self.assertRaises(ValueError):
webweapon_aes_decrypt_b64("AAAAAAAAAAAAAAAAAAAAAA==")
if __name__ == "__main__":
unittest.main()

115
tests/test_h5_kws_vm.py Normal file
View File

@ -0,0 +1,115 @@
import unittest
from core.h5_kws import (
DEFAULT_KWS_SIGN_SCRIPT,
build_h5_kws_script_ticket,
)
from core.h5_kws_vm import (
H5_KWS_KNOWN_SCRIPT_CODE,
analyze_h5_kws_function_ranges,
disassemble_h5_kws_range,
extract_h5_kws_opcode_handlers,
kwscode_from_known_h5_kws_script,
parse_h5_kws_vm_script,
)
class H5KwsVmTests(unittest.TestCase):
def test_parse_known_kws_script_container(self):
summary = parse_h5_kws_vm_script(DEFAULT_KWS_SIGN_SCRIPT)
self.assertEqual(
summary.script_sha256,
"d944b2bc3754bec85c0a238fb052859295a3ecb0756789c47d99417d3f957615",
)
self.assertEqual(
summary.bytecode_sha256,
"7668fe4c01dc4721a862b3102cd3c183dbd4721cac6af4129fc2adcdd1d701d8",
)
self.assertEqual(
summary.constants_sha256,
"226815ab9e5d21ce285afa698b618be3ff5122cf13880c846600b6e7d1396afe",
)
self.assertEqual(summary.instruction_count, 4676)
self.assertEqual(summary.constant_count, 286)
self.assertEqual(summary.max_opcode, 65)
self.assertEqual(summary.opcode_histogram[8], 1978)
self.assertEqual(len(summary.function_ranges), 19)
self.assertEqual(summary.function_ranges[0].start, 1308)
self.assertEqual(summary.function_ranges[0].end, 1447)
self.assertEqual(summary.function_ranges[-1].start, 4664)
self.assertEqual(summary.function_ranges[-1].end, 4675)
def test_known_kws_script_code_is_available_without_node(self):
code = kwscode_from_known_h5_kws_script(DEFAULT_KWS_SIGN_SCRIPT)
self.assertEqual(code, H5_KWS_KNOWN_SCRIPT_CODE)
self.assertEqual(
code,
"04dd303d63222chdfec0087d058fb1c4c5f2eb16eefgce98b2a503578e5f8433",
)
def test_extract_opcode_handlers_labels_jimbei_interpreter(self):
handlers = extract_h5_kws_opcode_handlers(DEFAULT_KWS_SIGN_SCRIPT)
by_index = {handler.index: handler for handler in handlers}
self.assertEqual(len(handlers), 67)
self.assertFalse(by_index[15].present)
self.assertTrue(by_index[66].present)
self.assertEqual(by_index[66].used_count, 0)
self.assertEqual(by_index[8].label, "add")
self.assertEqual(by_index[8].used_count, 1978)
self.assertEqual(by_index[8].body_sha16, "7be97b9f85080954")
self.assertEqual(by_index[12].label, "make_function")
self.assertEqual(by_index[24].label, "call_apply")
self.assertEqual(by_index[44].label, "jump_if_false")
self.assertEqual(by_index[61].label, "jump_if_true")
self.assertEqual(by_index[65].label, "assign_reference")
self.assertEqual(by_index[65].used_count, 302)
def test_disassemble_tail_helper_range_resolves_operands(self):
instructions = disassemble_h5_kws_range(DEFAULT_KWS_SIGN_SCRIPT, 4664, 4675)
self.assertEqual(len(instructions), 12)
self.assertEqual(instructions[0].index, 4664)
self.assertEqual(instructions[0].label, "enter_closure_scope")
self.assertEqual(instructions[0].operand_a, "unused(8)")
self.assertEqual(instructions[2].label, "assign_reference")
self.assertEqual(instructions[2].operand_a, "scope[22]")
self.assertEqual(instructions[2].operand_b, "arg[0]")
self.assertEqual(instructions[8].label, "call_apply")
self.assertEqual(instructions[8].operand_a, "const[100]=1")
self.assertEqual(instructions[-1].label, "return_undefined")
def test_analyze_function_ranges_names_main_and_tail_helpers(self):
functions = analyze_h5_kws_function_ranges(DEFAULT_KWS_SIGN_SCRIPT)
by_start = {function.start: function for function in functions}
self.assertEqual(len(functions), 19)
self.assertEqual(by_start[1308].name, "scope36_fn_1308_1447")
self.assertEqual(by_start[1308].assigned_scope, 36)
self.assertEqual(by_start[1308].call_apply_count, 4)
self.assertEqual(by_start[3063].name, "scope80_main_orchestrator")
self.assertEqual(by_start[3063].assigned_scope, 80)
self.assertEqual(by_start[3063].length, 1345)
self.assertEqual(by_start[3063].call_apply_count, 16)
self.assertIn(1341, by_start[3063].branch_targets)
self.assertIn(1342, by_start[3063].branch_targets)
self.assertEqual(by_start[4662].name, "inline_return_undefined_stub")
self.assertIsNone(by_start[4662].assigned_scope)
self.assertEqual(by_start[4664].name, "inline_call_scope107_with_arg0")
self.assertEqual(by_start[4664].call_apply_count, 1)
self.assertEqual(by_start[4664].branch_targets, [])
def test_script_ticket_uses_known_static_code_before_node_runner(self):
ticket = build_h5_kws_script_ticket(
sec_token="S" * 88,
runner="missing-node-runner.mjs",
)
self.assertEqual(ticket["kwssectoken"], "S" * 88)
self.assertEqual(ticket["kwscode"], H5_KWS_KNOWN_SCRIPT_CODE)
if __name__ == "__main__":
unittest.main()

92
tests/test_h5_kww.py Normal file
View File

@ -0,0 +1,92 @@
import shutil
import json
import os
import subprocess
import unittest
from core.h5_kww import H5KwwGenerator, PureH5KwwGenerator
@unittest.skipIf(shutil.which("node") is None, "node is required for KWF VM bridge")
class H5KwwTests(unittest.TestCase):
def test_pure_generator_matches_deterministic_bridge_sequence(self):
generator = PureH5KwwGenerator(start_collect_count=1, now_ms=1780000000123)
first = generator.get()
second = generator.get()
self.assertEqual(first.kwfcv1, "2")
self.assertEqual(second.kwfcv1, "3")
self.assertEqual(first.kwfv1, first.kww)
self.assertEqual(second.kwfv1, second.kww)
self.assertEqual(first.kww[:87], second.kww[:87])
self.assertTrue(first.kww.startswith("PnGU+9+Y8008S+nH"))
def test_generator_returns_pngu_kww_and_preserves_counter(self):
generator = H5KwwGenerator(timeout=10)
try:
first = generator.get(
url="https://nebula.kuaishou.com/rest/wd/encourage/unionTask/signIn/resource?sigCatVer=1",
method="GET",
cookie="kpn=NEBULA; kpf=ANDROID_PHONE",
)
second = generator.get(
url="https://nebula.kuaishou.com/rest/n/nebula/activity/earn/overview/tasks",
method="GET",
cookie="kpn=NEBULA; kpf=ANDROID_PHONE",
)
finally:
generator.close()
self.assertEqual(len(first.kww), 174)
self.assertEqual(len(second.kww), 174)
self.assertTrue(first.kww.startswith("PnGU+9+Y8008S+nH"))
self.assertTrue(second.kww.startswith("PnGU+9+Y8008S+nH"))
self.assertEqual(first.kwfv1, first.kww)
self.assertEqual(second.kwfv1, second.kww)
self.assertGreater(int(second.kwfcv1), int(first.kwfcv1))
def test_server_deterministic_trace_dependency_surface(self):
env = os.environ.copy()
env["KS_H5_KWW_SEED"] = "20260712"
env["KS_H5_KWW_NOW"] = "1780000000123"
request = {
"id": 1,
"url": "https://nebula.kuaishou.com/rest/n/nebula/activity/earn/overview/tasks",
"method": "GET",
"cookie": "kpn=NEBULA",
"trace": True,
}
proc = subprocess.run(
["node", "core/h5_kww_server.mjs"],
input=json.dumps(request, separators=(",", ":")) + "\n",
text=True,
capture_output=True,
encoding="utf-8",
timeout=10,
env=env,
check=False,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
data = json.loads(proc.stdout)
event_types = [item["type"] for item in data["trace"]]
self.assertEqual(len(data["kww"]), 174)
self.assertEqual(data["kwfcv1"], "2")
self.assertEqual(event_types.count("random"), 2)
self.assertEqual(event_types.count("String.fromCharCode"), 1)
self.assertEqual(event_types.count("localStorage.getItem"), 1)
self.assertEqual(event_types.count("localStorage.setItem"), 2)
self.assertIn("Object.assign", event_types)
self.assertGreaterEqual(event_types.count("now"), 2)
self.assertGreater(data["traceSummary"]["Math.floor"], event_types.count("Math.floor"))
storage_events = [
item for item in data["trace"]
if item["type"].startswith("localStorage.")
]
self.assertEqual(storage_events[0]["key"], "kwfcv1")
self.assertEqual(storage_events[1]["key"], "kwfcv1")
self.assertEqual(storage_events[2]["key"], "kwfv1")
if __name__ == "__main__":
unittest.main()

199
tests/test_h5_kww_alg.py Normal file
View File

@ -0,0 +1,199 @@
import unittest
from core.h5_kww_alg import (
js_utf16_code_units,
kwf_aes_cbc_decrypt,
kwf_aes_cbc_encrypt,
kwf_base64_decode_bytes,
kwf_base64_encode_bytes,
kwf_default_fingerprint_fields,
kwf_encrypt_fingerprint_hex,
kwf_fingerprint_plain,
kwf_generate_kww,
kwf_pack_fingerprint_fields,
kwf_insert_version_fields,
kwf_pack_fingerprint_plain,
kwf_mixer_char,
kwf_mixer_permutation,
kwf_remove_version_fields,
kwf_string_encoder_binary,
kwf_string_encoder_bytes,
)
class H5KwwAlgTests(unittest.TestCase):
def test_js_utf16_code_units_match_char_code_at(self):
self.assertEqual(js_utf16_code_units("A"), [0x41])
self.assertEqual(js_utf16_code_units("\u4e2d"), [0x4E2D])
self.assertEqual(js_utf16_code_units("\U0001f600"), [0xD83D, 0xDE00])
def test_string_encoder_ascii(self):
self.assertEqual(kwf_string_encoder_bytes("ABC123"), b"ABC123")
def test_string_encoder_two_and_three_byte_units(self):
self.assertEqual(kwf_string_encoder_bytes("\u00e9").hex(), "c3a9")
self.assertEqual(kwf_string_encoder_bytes("\u4e2d").hex(), "e4b8ad")
def test_string_encoder_nul_uses_two_byte_branch(self):
self.assertEqual(kwf_string_encoder_bytes("\x00").hex(), "c080")
def test_string_encoder_non_bmp_uses_surrogate_units(self):
self.assertEqual(
kwf_string_encoder_bytes("\U0001f600").hex(),
"eda0bdedb880",
)
def test_binary_string_roundtrip_shape(self):
encoded = kwf_string_encoder_binary("\u00e9")
self.assertEqual([ord(ch) for ch in encoded], [0xC3, 0xA9])
def test_mixer_permutation_matches_vm_shuffle_slice(self):
self.assertEqual(kwf_mixer_permutation(4, 0.5), [2, 0, 3, 1])
def test_mixer_char_uses_shuffled_index_position(self):
key_grid = ["AB", "CD"]
self.assertEqual(kwf_mixer_char(key_grid, 0.5, 0, 0), "B")
self.assertEqual(kwf_mixer_char(key_grid, 0.5, 1, 0), "A")
self.assertEqual(kwf_mixer_char(key_grid, 0.5, 1, 1), "C")
def test_mixer_rejects_ragged_key_grid(self):
with self.assertRaises(ValueError):
kwf_mixer_char(["AB", "C"], 0.5, 0, 0)
def test_fingerprint_plain_uses_k1_to_k14_and_js_boolean_shape(self):
fields = {
"k1": "ua",
"k2": True,
"k3": False,
"k5": 123,
"k14": "tail",
"other": "ignored",
}
self.assertEqual(
kwf_fingerprint_plain(fields),
"ua|1|0||123|||||||||tail",
)
def test_insert_version_fields_matches_tail_slice(self):
self.assertEqual(
kwf_insert_version_fields("0123456789ABCDEfgh", "A", "K"),
"0123456789AABCDEKfgh",
)
def test_remove_version_fields_reverses_tail_slice(self):
packed = kwf_insert_version_fields("0123456789ABCDEfgh", "A", "K")
self.assertEqual(kwf_remove_version_fields(packed), ("0123456789ABCDEfgh", "A", "K"))
def test_base64_encoder_uses_kwf_alphabet_and_padding(self):
self.assertEqual(kwf_base64_encode_bytes(b""), "")
self.assertEqual(kwf_base64_encode_bytes(b"\x00"), "ZZ==")
self.assertEqual(kwf_base64_encode_bytes(b"\x00\x00"), "ZZZ=")
self.assertEqual(kwf_base64_encode_bytes(b"ABC"), "cLQe")
def test_base64_decoder_inverts_kwf_alphabet(self):
self.assertEqual(kwf_base64_decode_bytes(""), b"")
self.assertEqual(kwf_base64_decode_bytes("ZZ=="), b"\x00")
self.assertEqual(kwf_base64_decode_bytes("ZZZ="), b"\x00\x00")
self.assertEqual(kwf_base64_decode_bytes("cLQe"), b"ABC")
def test_encrypt_fingerprint_hex_matches_deterministic_kwf_trace(self):
plain = "1|0.0.2|zh-CN|0|0|1|1|1|0|60|508|1780000000123|1|"
self.assertEqual(
kwf_encrypt_fingerprint_hex(plain),
"1f27caf6e5b260b2ff26f7ff5e19a03c"
"17a858350d7c3328507a9dd3d0e8fdee"
"5afd59d51e564481fb31c2ded4579fde"
"c8118956fdf90148ba78a52b1f4e01cb",
)
def test_aes_cbc_decrypt_reverses_node_crypto_sample(self):
cipher = bytes.fromhex(
"d60080e8511541d575cfe839fce1cf14742148fb5f79f85e"
"d79f42b2cb000d7e3657641f599dc66c7a04ff8fd01cb5fa"
"62f438b4724bf6232045049540d05bc601fb31f4a95a4e66"
"b7c98f1b8fc67731f2d5042d50939607fbc5028742cfec17"
)
plain = (
b'{"productName":"kuaishou-growth","ts":1780000000123,'
b'"did":"ANDROID_0123456789abcdef"}'
)
self.assertEqual(
kwf_aes_cbc_decrypt(
cipher,
key=b"webweaponconfigs",
iv=b"webweaponconfigs",
),
plain,
)
self.assertEqual(
kwf_aes_cbc_encrypt(
plain,
key=b"webweaponconfigs",
iv=b"webweaponconfigs",
),
cipher,
)
def test_pack_fingerprint_plain_matches_deterministic_kwf_trace(self):
plain = "1|0.0.2|zh-CN|0|0|1|1|1|0|60|508|1780000000123|1|"
self.assertEqual(
kwf_pack_fingerprint_plain(plain),
"PnGU+9+Y8008S+nH0U+0mjPf8fP08f+98f+nLlwnrIP9Pl+9rh+"
"/WA+/mD+9PAPAHh+/Z7G/SD8e+DPBLh8fzS8/pY8fcMwncMPnLM+"
"0cFwebfG0PlGAQD8ncF+/qE8fzSGAWlP/WE+/8f8BGEPerFwBQY+"
"AYY+/QjPnGF8/ZlG9H=",
)
def test_pack_fingerprint_fields_matches_deterministic_kwf_trace(self):
fields = {
"k1": 1,
"k2": "0.0.2",
"k3": "zh-CN",
"k4": "0",
"k5": False,
"k6": True,
"k7": True,
"k8": "1",
"k9": "0",
"k10": 60,
"k11": 508,
"k12": 1780000000123,
"k13": "2",
"k14": "",
}
self.assertEqual(
kwf_pack_fingerprint_fields(fields),
"PnGU+9+Y8008S+nH0U+0mjPf8fP08f+98f+nLlwnrIP9Pl+9rh+"
"/WA+/mD+9PAPAHh+/Z7G/SD8e+DPBLh8fzS8nPhwB+DP0D9G0S0G"
"9zSGnpS8nrUP9rh+0qEweDE+0WM+9bf80QSw/rl+0GUGAcl+AqF+"
"ASSwnLI+9c7+BG7GnL=",
)
def test_generate_kww_matches_first_two_deterministic_trace_values(self):
first = kwf_generate_kww(collect_count=1, now_ms=1780000000123)
second = kwf_generate_kww(collect_count=2, now_ms=1780000000123)
self.assertEqual(first[:87], second[:87])
self.assertEqual(
first,
"PnGU+9+Y8008S+nH0U+0mjPf8fP08f+98f+nLlwnrIP9Pl+9rh+"
"/WA+/mD+9PAPAHh+/Z7G/SD8e+DPBLh8fzS8/pY8fcMwncMPnLM+"
"0cFwebfG0PlGAQD8ncF+/qE8fzSGAWlP/WE+/8f8BGEPerFwBQY+"
"AYY+/QjPnGF8/ZlG9H=",
)
self.assertEqual(
second,
"PnGU+9+Y8008S+nH0U+0mjPf8fP08f+98f+nLlwnrIP9Pl+9rh+"
"/WA+/mD+9PAPAHh+/Z7G/SD8e+DPBLh8fzS8nPhwB+DP0D9G0S0G"
"9zSGnpS8nrUP9rh+0qEweDE+0WM+9bf80QSw/rl+0GUGAcl+AqF+"
"ASSwnLI+9c7+BG7GnL=",
)
def test_default_fingerprint_fields_keep_empty_k14(self):
fields = kwf_default_fingerprint_fields(collect_count=1, now_ms=1780000000123)
self.assertEqual(fields["k14"], "")
self.assertEqual(fields["k13"], "1")
if __name__ == "__main__":
unittest.main()

112
tests/test_h5_sig3.py Normal file
View File

@ -0,0 +1,112 @@
import unittest
from core.h5_sig3 import h5_encode_sha_digest, h5_sig3_crc32_from_sign_input, h5_sig3_from_fields, h5_sig3_mix, parse_h5_sig3
from core.reward_sign import (
kwsg_10418_reward_sign_from_digest_hex,
kwsg_10418_reward_sign_to_digest_hex,
)
H5_JSBRIDGE_SDK = "5bbcf3cd-727b-48ab-b4b4-5f01e61ee9a5"
SAMPLES = {
"treasure_open": {
"sig3": "c1d196a640dbd8d9029de89e9998ac137d863bcbcdcb1c8399448e8e88888b8ab595",
"counter": 0x77,
"crc32": 0x02F88937,
"elapsed_ms": 0x4B4C4DBC,
"state_value": 0x0001C814,
},
"sign_in_report": {
"sig3": "6d7d3a0aec777475ae3159323534784487e4c6706667b02f5d2b2222242427261939",
"counter": 0x6A,
"crc32": 0xCCAE724F,
"elapsed_ms": 0x4B4B5AED,
"state_value": 0x00010B7C,
},
"sign_in_resource": {
"sig3": "48581f2fc95251508b147e171011efcc6c21d15e4342950a69260707010102033c1c",
"counter": 0x68,
"crc32": 0x2C60DFFD,
"elapsed_ms": 0x4B4B51DF,
"state_value": 0x0001236D,
},
"treasure_info": {
"sig3": "8d9ddaea0c9794954ed1b6d2d5d45114206ec985868750cff1d6c2c2c4c4c7c6f9d9",
"counter": 0x65,
"crc32": 0xA6E9C286,
"elapsed_ms": 0x4B4B4F02,
"state_value": 0x00011630,
},
}
class H5Sig3Tests(unittest.TestCase):
def test_parse_har_samples(self):
for name, sample in SAMPLES.items():
with self.subTest(name=name):
parsed = parse_h5_sig3(sample["sig3"])
self.assertTrue(parsed.ok)
self.assertEqual(parsed.session_seed, 0x4B4B4BD1)
self.assertEqual(parsed.counter, sample["counter"])
self.assertEqual(parsed.crc32, sample["crc32"])
self.assertEqual(parsed.elapsed_ms, sample["elapsed_ms"])
self.assertEqual(parsed.state_value, sample["state_value"])
def test_mix_roundtrip(self):
for name, sample in SAMPLES.items():
with self.subTest(name=name):
parsed = parse_h5_sig3(sample["sig3"])
self.assertEqual(h5_sig3_mix(parsed.preimage).hex(), sample["sig3"])
def test_build_from_fields_roundtrip(self):
for name, sample in SAMPLES.items():
with self.subTest(name=name):
generated = h5_sig3_from_fields(
crc32_value=sample["crc32"],
counter=sample["counter"],
elapsed_ms=sample["elapsed_ms"],
state_value=sample["state_value"],
)
self.assertEqual(generated, sample["sig3"])
def test_h5_jsbridge_atlas_outer_wrap_for_sdk_5bb(self):
inner_digest = "0b1a4a4965484604c84340412b8a30b7c8430a335e525c4a"
atlas64 = "5a54eecdd3ce5ab6677f2b25087e247cad0e010363b942e6a4266b5f33643e32"
self.assertEqual(
kwsg_10418_reward_sign_from_digest_hex(inner_digest, H5_JSBRIDGE_SDK),
atlas64,
)
self.assertEqual(
kwsg_10418_reward_sign_to_digest_hex(atlas64, H5_JSBRIDGE_SDK),
inner_digest,
)
def test_h5_encode_sha_digest_matches_vm_trace(self):
digest = h5_encode_sha_digest("sigCatVer=1")
self.assertEqual(
digest.hex(),
"2099d6e518c94b75765bb0feb48173e1ce4433757c5e16e7b80b2481a6389829",
)
self.assertEqual(h5_sig3_crc32_from_sign_input("sigCatVer=1"), 0xE5D69920)
def test_h5_encode_crc_corpus(self):
samples = {
"": 0xB55FE1DF,
"a": 0xC4225AD1,
"b": 0xC5AB513D,
"abc": 0x5D78E777,
"sigCatVer=1a=1": 0x5948ABF2,
"a=1sigCatVer=1": 0x20E88D0E,
"source=bottom_guide_firstsigCatVer=1": 0xF63DAE04,
}
for sign_input, expected in samples.items():
with self.subTest(sign_input=sign_input):
self.assertEqual(h5_sig3_crc32_from_sign_input(sign_input), expected)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,131 @@
import json
import unittest
from core.h5_jsbridge import build_h5_sign_input
from core.h5_sig3 import h5_sig3_crc32_from_sign_input
from main import SIGN_IN_EVENT_TRACKING, KsNebulaClient, ResponseRecord, normalize_h5_cookie_wire_values
def make_client() -> KsNebulaClient:
return KsNebulaClient(
full_cookie="kpn=NEBULA; kpf=ANDROID_PHONE; userId=1; did=ANDROID_old; "
"kuaishou.api_st=TOKEN; token=TOKEN; egid=EGID; oDid=ODID; rdid=RDID",
client_salt="salt",
cookie_dict={
"kpn": "NEBULA",
"kpf": "ANDROID_PHONE",
"userId": "1",
"did": "ANDROID_old",
"kuaishou.api_st": "TOKEN",
"token": "TOKEN",
"egid": "EGID",
"oDid": "ODID",
"rdid": "RDID",
},
timeout=1,
dry_run=True,
)
class H5StateContextTests(unittest.TestCase):
def test_decoded_env_device_fields_match_har_h5_sign_crc_after_wire_normalize(self):
cookie = {
"kpn": "NEBULA",
"kpf": "ANDROID_PHONE",
"userId": "5402308233",
"did": "ANDROID_f05497e9cef09a7f",
"c": "OPPO",
"appver": "14.5.50.11631",
"language": "zh-cn",
"mod": "OnePlus(PJZ110)",
"did_tag": "0",
"egid": "DFPB64A5E60567B56334B78B18A5E6E1F6C046F7CE90B0C1456E104EB8DE88C9",
"oDid": "ANDROID_46a032e0a2af8184",
"androidApiLevel": "36",
"newOc": "OPPO",
"browseType": "3",
"socName": "Qualcomm Snapdragon 8750",
"abi": "arm64",
"userRecoBit": "0",
"device_abi": "arm64",
"grant_browse_type": "AUTHORIZED",
"rdid": "ANDROID_741de4351c44850d",
}
normalized = normalize_h5_cookie_wire_values(cookie)
sign_input = build_h5_sign_input(
normalized,
query={"eventTrackingLogInfo": SIGN_IN_EVENT_TRACKING},
)
self.assertEqual(normalized["mod"], "OnePlus%28PJZ110%29")
self.assertEqual(normalized["socName"], "Qualcomm+Snapdragon+8750")
self.assertEqual(h5_sig3_crc32_from_sign_input(sign_input), 0x1F818C51)
def test_sign_in_keeps_har_external_popup_event_tracking_context(self):
client = make_client()
dynamic_event = {
"deliverOrderId": "393",
"eventTrackingTaskId": 20022,
"resourceId": "earnPage_taskList_2",
"extParams": {
"isServerRecordClickAction": True,
"signInThresholdTaskType": 0,
"businessPriceUniqueId": "EP2AgICwndScChjgUyCk8aac9TMo9KLynQM=",
},
"resourceFirstLevelName": "EARN_PAGE",
"resourceSecondLevelName": "TASK_LIST",
}
client._update_h5_state_contexts(
{
"data": {
"dailyTasks": [
{
"id": 20022,
"linkUrl": "https://nebula.kuaishou.com/rest/wd/encourage/unionTask/signIn/report",
"eventTrackingLogInfo": dynamic_event,
}
]
}
}
)
captured = {}
def fake_h5_get(label, title, request_id, path, params, header_key):
captured.update(params)
return ResponseRecord(label, title, request_id, 200, True, path, {"result": 1}, "")
client._next_h5_sig3 = lambda *args, **kwargs: "SIG3"
client._h5_get = fake_h5_get
client.sign_in()
self.assertEqual(
captured["eventTrackingLogInfo"],
SIGN_IN_EVENT_TRACKING,
)
def test_treasure_open_body_preserves_har_source_context(self):
client = make_client()
dynamic_event = {
"eventTrackingTaskId": 20035,
"resourceId": "externalFeed_externalTreasureChestWidget",
"extParams": {"isServerRecordClickAction": True},
"deliverOrderId": "5128",
"resourceFirstLevelName": "EXTERNAL_FEED",
"resourceSecondLevelName": "EXTERNAL_TREASURE_CHEST_WIDGET",
}
client._update_h5_state_contexts({"data": {"eventTrackingLogInfo": dynamic_event}})
body = json.loads(client._treasure_open_body())
inner_event = json.loads(body["eventTrackingLogInfo"])
self.assertEqual(inner_event["eventTrackingTaskId"], 20035)
self.assertEqual(inner_event["resourceId"], "externalFeed_externalTreasureChestWidget")
self.assertEqual(inner_event["deliverOrderId"], "5128")
self.assertEqual(inner_event["extParams"]["isServerRecordClickAction"], True)
self.assertEqual(inner_event["extParams"]["source"], "bottom_guide_first")
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,133 @@
import unittest
from enum import IntEnum
from types import SimpleNamespace
from core.http_transport import (
OKHTTP4_ANDROID10_AKAMAI,
OKHTTP4_ANDROID10_JA3,
create_http_session,
)
class _FakeCookies:
def __init__(self):
self.calls = []
def set(self, name, value, *, domain="", path="/", secure=False):
self.calls.append((name, value, domain, path, secure))
class _FakeCurlSession:
def __init__(self):
self.cookies = _FakeCookies()
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
return SimpleNamespace(status_code=200)
class HttpTransportTests(unittest.TestCase):
def test_cli_exposes_explicit_transport_ab_switch(self):
from tools.sms_login_cli import build_parser
default_args = build_parser().parse_args(["--mobile", "13800000000"])
okhttp_args = build_parser().parse_args(
["--mobile", "13800000000", "--transport", "okhttp4-android10"]
)
self.assertEqual(default_args.transport, "requests")
self.assertEqual(okhttp_args.transport, "okhttp4-android10")
def test_okhttp_transport_forces_http2_without_browser_headers(self):
inner = _FakeCurlSession()
session = create_http_session("okhttp4-android10", curl_session=inner)
session.post(
"https://HOST/rest/fixture",
data=b"a=1",
headers={
"User-Agent": "kwai-android",
"Accept-Encoding": "gzip",
"Connection": "keep-alive",
},
timeout=(5, 20),
)
method, url, kwargs = inner.calls[0]
self.assertEqual(method, "POST")
self.assertEqual(url, "https://HOST/rest/fixture")
self.assertEqual(kwargs["http_version"], "v2")
self.assertFalse(kwargs["default_headers"])
self.assertIsNone(kwargs["accept_encoding"])
self.assertNotIn("Connection", kwargs["headers"])
def test_okhttp_transport_cookie_adapter_accepts_browser_expiry(self):
inner = _FakeCurlSession()
session = create_http_session("okhttp4-android10", curl_session=inner)
session.cookies.set(
"did",
"ANDROID_FIXTURE",
domain=".example.test",
path="/",
secure=True,
expires=1_900_000_000,
)
self.assertEqual(
inner.cookies.calls,
[("did", "ANDROID_FIXTURE", ".example.test", "/", True)],
)
def test_okhttp_transport_builds_documented_fingerprint(self):
captured = {}
def factory(**kwargs):
captured.update(kwargs)
return _FakeCurlSession()
create_http_session("okhttp4-android10", curl_session_factory=factory)
self.assertEqual(captured["ja3"], OKHTTP4_ANDROID10_JA3)
self.assertEqual(captured["akamai"], OKHTTP4_ANDROID10_AKAMAI)
self.assertFalse(captured["default_headers"])
self.assertIn("tls_signature_algorithms", captured["extra_fp"])
def test_requests_transport_removes_requests_only_accept_header(self):
session = create_http_session("requests")
self.assertNotIn("Accept", session.headers)
def test_unknown_transport_is_rejected(self):
with self.assertRaisesRegex(ValueError, "unsupported HTTP transport"):
create_http_session("unknown")
def test_protocol_diagnosis_reads_curl_http2_response(self):
from core.sms_login import _do_post
class CurlHttpVersion(IntEnum):
V2_0 = 3
response = SimpleNamespace(
status_code=200,
text='{"result": 705}',
headers={},
cookies={},
request=None,
http_version=CurlHttpVersion.V2_0,
)
result = _do_post(
lambda *args, **kwargs: response,
"https://HOST/rest/fixture",
b"a=1",
base_url="https://HOST",
timeout=20,
)
self.assertEqual(result["request_meta"]["http_version"], "HTTP/2")
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More