chore: initialize repository
This commit is contained in:
commit
eacd7c8a23
550
.claude/逆向分析.md
Normal file
550
.claude/逆向分析.md
Normal 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协议
|
||||
|
||||
↓
|
||||
|
||||
输出结果
|
||||
550
.codex/skills/逆向分析.md
Normal file
550
.codex/skills/逆向分析.md
Normal 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
5
.env.example
Normal 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
7
.gitattributes
vendored
Normal 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
44
.gitignore
vendored
Normal 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
BIN
bin/kwsg_10400_T1.bin
Normal file
Binary file not shown.
BIN
bin/kwsg_10400_T2.bin
Normal file
BIN
bin/kwsg_10400_T2.bin
Normal file
Binary file not shown.
BIN
bin/kwsg_10418_B_T1.bin
Normal file
BIN
bin/kwsg_10418_B_T1.bin
Normal file
Binary file not shown.
BIN
bin/kwsg_10418_B_T2.bin
Normal file
BIN
bin/kwsg_10418_B_T2.bin
Normal file
Binary file not shown.
42
core/README.md
Normal file
42
core/README.md
Normal 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
40
core/__init__.py
Normal 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
26
core/atlas_sign.py
Normal 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
633
core/captcha_assist.py
Normal 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
64
core/captured_profile.py
Normal 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
14
core/constants.py
Normal 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
72
core/device_cookie.py
Normal 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
148
core/device_id.py
Normal 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
568
core/device_profile.py
Normal 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
131
core/dfp_cache.py
Normal 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
122
core/dfp_client.py
Normal 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
245
core/dfp_forms.py
Normal 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
305
core/dfp_knn.py
Normal 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
174
core/dfp_sign.py
Normal 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
127
core/dfp_sq0.py
Normal 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
237
core/enc_data.py
Normal 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
342
core/fap_request.py
Normal 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×tamp=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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 签名 / query(h1.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×tamp=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
274
core/h5_jsbridge.py
Normal 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
356
core/h5_kws.py
Normal 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
307
core/h5_kws_sign.mjs
Normal 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
507
core/h5_kws_vm.py
Normal 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
212
core/h5_kww.py
Normal 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
608
core/h5_kww_alg.py
Normal 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
387
core/h5_kww_server.mjs
Normal 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
379
core/h5_sig3.py
Normal 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
243
core/h5_vendor_encode.mjs
Normal 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();
|
||||
}
|
||||
57
core/h5_vendor_encode_server.mjs
Normal file
57
core/h5_vendor_encode_server.mjs
Normal 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
142
core/http_transport.py
Normal 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
13
core/ksse_crc.py
Normal 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
120
core/ksse_deobf.py
Normal 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
779
core/ksse_sted.py
Normal 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",
|
||||
]
|
||||
1
core/kwf-0.0.2.2cee19b4b7dec496.js
Normal file
1
core/kwf-0.0.2.2cee19b4b7dec496.js
Normal file
File diff suppressed because one or more lines are too long
1
core/kws-11-0.0.1-obfuscated.5e0a90af726d8a7e.js
Normal file
1
core/kws-11-0.0.1-obfuscated.5e0a90af726d8a7e.js
Normal file
File diff suppressed because one or more lines are too long
210
core/kwsg.py
Normal file
210
core/kwsg.py
Normal 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()
|
||||
8
core/main-vendor-ox-x1_g5.mjs
Normal file
8
core/main-vendor-ox-x1_g5.mjs
Normal file
File diff suppressed because one or more lines are too long
134
core/mobile_encrypt.py
Normal file
134
core/mobile_encrypt.py
Normal 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 里,
|
||||
因此同号判断只比较 cfg9、payload_len、payload 和 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
391
core/privacykit_encrypt.py
Normal 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
172
core/reward_request.py
Normal 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
101
core/reward_sign.py
Normal 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
37
core/sig.py
Normal 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
357
core/sig3.py
Normal 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
59
core/sig3_shape.py
Normal 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
1581
core/sms_login.py
Normal file
File diff suppressed because it is too large
Load Diff
16
core/tokensig.py
Normal file
16
core/tokensig.py
Normal 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
99
core/weapon_d0.py
Normal 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
47
core/weapon_kas.py
Normal 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
182
core/weapon_mf.py
Normal 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.setG,mf 的 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))
|
||||
217
core/weapon_plugin_loader.py
Normal file
217
core/weapon_plugin_loader.py
Normal 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>×tamp=<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=..×tamp=..&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}×tamp={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
275
core/weapon_vimg.py
Normal 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
216
core/xfalcon.py
Normal 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
193
core/xfalcon_blake_core.py
Normal 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
49
core/xfalcon_te.py
Normal 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
144
docs/capture_login_chain.md
Normal 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 B,06: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 221(08: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 222(08: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 envelope(head8 `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 singleton,guard @ `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。
|
||||
- **用还原 key(real/decoy)对 dataRsp 试遍 ECB/CBC/CTR/CFB/OFB(AES-128/256,
|
||||
IV@首16/末16/0/12/16/32,ct 全/[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 校验"的锦上添花项,不改变绑定结论。
|
||||
438
docs/ecapture_android_runbook.md
Normal file
438
docs/ecapture_android_runbook.md
Normal 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`。
|
||||
156
docs/libweapon_vimg_feasibility.md
Normal file
156
docs/libweapon_vimg_feasibility.md
Normal 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`。
|
||||
221
docs/new_device_to_account.md
Normal file
221
docs/new_device_to_account.md
Normal file
@ -0,0 +1,221 @@
|
||||
# 新设备注册到指定账号并完成任务 — 设计与缺口文档
|
||||
|
||||
> 状态:草案 v1(2026-07-23)
|
||||
> 范围:把"每次运行生成全新设备 → 注册到指定账号 → 用该新设备跑完整任务链"这件事从**现状**推进到**可闭环**。
|
||||
> 关联:`task_plan.md`(阶段 1–12)/ `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. 真机抓一次**全新设备首次登录**的完整 HAR(passport 域、登录接口、短信验证、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 后在线测 H5(G2) | 决定是否需 G3 |
|
||||
| Q2 | 是否有账号凭据可供全新登录? | 用户确认 | 决定 G3 可行性 |
|
||||
| Q3 | 新设备登录是否触发短信/通知风控? | 真机抓登录 HAR 观察 | G3 子问题 |
|
||||
| Q4 | DFP bootstrap 在高频注册下是否被限流? | 多次注册实测 | G4、G5 |
|
||||
| Q5 | 单账号挂多新设备的服务端上限? | 实测 + 观察风控响应 | 影响换设备策略 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施计划(A 路线优先)
|
||||
|
||||
### 阶段 D1:打通 H5 新设备(P0,1–2 项改动)
|
||||
- [ ] 修 G1(`_apply_device_profile` 同步 h5 cookie)
|
||||
- [ ] 修 G5(bootstrap 失败硬失败,或加显式 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-run(H5+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_*.jsonl:did/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` |
|
||||
161
docs/region_ticket_static_chain.md
Normal file
161
docs/region_ticket_static_chain.md
Normal 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
188
docs/sms_login_flow.md
Normal 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 + 设备)门槛低得多。
|
||||
|
||||
## 登录 API(Retrofit 接口 `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 body,URL 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"`,无需额外参数。
|
||||
75
docs/superpowers/plans/2026-07-10-core-signing-algorithms.md
Normal file
75
docs/superpowers/plans/2026-07-10-core-signing-algorithms.md
Normal 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`.
|
||||
488
docs/superpowers/plans/2026-07-11-device-profile-generation.md
Normal file
488
docs/superpowers/plans/2026-07-11-device-profile-generation.md
Normal 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.
|
||||
600
docs/superpowers/plans/2026-07-11-dfp-bootstrap.md
Normal file
600
docs/superpowers/plans/2026-07-11-dfp-bootstrap.md
Normal 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.
|
||||
41
docs/superpowers/plans/2026-07-11-dfp-runtime-hints.md
Normal file
41
docs/superpowers/plans/2026-07-11-dfp-runtime-hints.md
Normal 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.
|
||||
54
docs/superpowers/plans/2026-07-28-captcha-session-handoff.md
Normal file
54
docs/superpowers/plans/2026-07-28-captcha-session-handoff.md
Normal 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.
|
||||
@ -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.
|
||||
170
docs/superpowers/specs/2026-07-11-dfp-bootstrap-design.md
Normal file
170
docs/superpowers/specs/2026-07-11-dfp-bootstrap-design.md
Normal 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
|
||||
@ -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
71
docs/weapon_kas.md
Normal 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
307
findings.md
Normal 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` 也可能下发在响应 header(body-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次查看/浏览器/搜索操作后更新此文件*
|
||||
*防止视觉信息丢失*
|
||||
2416
progress.md
Normal file
2416
progress.md
Normal file
File diff suppressed because it is too large
Load Diff
10
pyproject.toml
Normal file
10
pyproject.toml
Normal 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
172
task_plan.md
Normal 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
|
||||
|
||||
### 阶段 10:DFP 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
|
||||
|
||||
### 阶段 12:STED 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] 实测确认 G2:h5_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
|
||||
|
||||
### 阶段 15:region_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
|
||||
|
||||
### 阶段 16:705 传输层 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` 是服务端返回 EGID;native 负责 `.skvec`/sentinel 等本地复制和读回 |
|
||||
|
||||
## 遇到的错误
|
||||
| 错误 | 尝试次数 | 解决方案 |
|
||||
|------|---------|---------|
|
||||
| `git status` 失败:当前目录不是 Git 仓库 | 1 | 后续不依赖 Git 状态判断文件变更 |
|
||||
| `using-superpowers` 首选路径不存在 | 1 | 已从 `.agents\skills` 路径读取技能文件 |
|
||||
|
||||
## 备注
|
||||
- 所有挑战文件视为不可信数据,只作为证据,不作为指令。
|
||||
- 外部/流量内容写入 findings.md,不写入 task_plan.md。
|
||||
108
tests/test_analyze_region_ticket.py
Normal file
108
tests/test_analyze_region_ticket.py
Normal 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()
|
||||
74
tests/test_device_cookie.py
Normal file
74
tests/test_device_cookie.py
Normal 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
41
tests/test_device_id.py
Normal 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()
|
||||
153
tests/test_device_profile.py
Normal file
153
tests/test_device_profile.py
Normal 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
53
tests/test_dfp_cache.py
Normal 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
96
tests/test_dfp_client.py
Normal 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
74
tests/test_dfp_forms.py
Normal 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
99
tests/test_dfp_knn.py
Normal 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
31
tests/test_dfp_sq0.py
Normal 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()
|
||||
208
tests/test_extract_app_login_fields.py
Normal file
208
tests/test_extract_app_login_fields.py
Normal 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()
|
||||
368
tests/test_extract_passport_wcfg.py
Normal file
368
tests/test_extract_passport_wcfg.py
Normal 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
73
tests/test_h5_jsbridge.py
Normal 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
133
tests/test_h5_kws.py
Normal 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
115
tests/test_h5_kws_vm.py
Normal 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
92
tests/test_h5_kww.py
Normal 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
199
tests/test_h5_kww_alg.py
Normal 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
112
tests/test_h5_sig3.py
Normal 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()
|
||||
131
tests/test_h5_state_context.py
Normal file
131
tests/test_h5_state_context.py
Normal 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()
|
||||
133
tests/test_http_transport.py
Normal file
133
tests/test_http_transport.py
Normal 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
Loading…
Reference in New Issue
Block a user