看东方API接口签名算法逆向分析
一次完整的Android APP逆向之旅,揭秘双重签名机制的实现原理
写在前面
最近在做一个NBA直播转发的项目,需要调用"看东方"APP的直播接口来获取比赛流地址。一开始我以为这会是个简单的HTTP请求,结果抓包之后发现,这个APP使用了一套相当复杂的双重签名机制。
经过几天的逆向分析和调试,我成功破解了这套签名算法,并用Python完整实现了它。于是写了这篇文章记录了整个逆向过程。
重要前提
这次逆向能够成功,有一个关键前提:我提前破解了看东方APP的VIP限制。
为什么需要先破解APP?
- API接口受VIP保护:很多核心接口(如获取直播流)需要VIP会员才能调用
- 本地测试环境:破解后的APP可以在本地自由测试,不受限制地观察请求和响应
- 完整的数据流:能够看到完整的请求参数和响应数据,包括各种VIP专属内容
- 快速迭代验证:可以反复测试签名算法,快速验证是否正确
破解过程:
- 反编译APK,定位VIP判断逻辑
- 修改smali代码,让所有VIP判断返回"已开通"
- 重新打包签名,安装测试
- 详细的破解清单参见:
VIP破解修改清单.txt
有了破解版APP作为"参照物",我才能:
- 抓取到所有API接口的完整请求
- 对比不同请求的签名差异
- 追踪密钥的获取和使用流程
- 验证Python实现的签名是否正确
所以说,这次API签名逆向,实际上是建立在APP破解基础之上的二次逆向工程。
一、初识:抓包发现签名机制
抓包准备
工具:Reqable
配置好证书后,打开"看东方"APP,随便点进一场NBA直播,Reqable立刻捕获到了一堆请求。我重点关注了两个接口:
接口1:获取赛程列表
POST https://bp-api.bestv.cn/cms/liveSports/getLiveCompetitionList
接口2:获取直播流地址
POST https://bp-api.bestv.cn/cms/api/live/studio/id/v4
第一次尝试:直接请求
我复制了Reqable中的请求参数,用Python的requests库直接发送请求:
import requests
url = "https://bp-api.bestv.cn/cms/liveSports/getLiveCompetitionList"
payload = {
"date": "",
"userId": "0",
"version": 5007,
# ... 其他参数
}
response = requests.post(url, json=payload)
print(response.json())
结果返回:
{
"code": 401,
"msg": "签名验证失败"
}
果然没这么简单。
仔细观察请求参数
我仔细对比了几次请求,发现了一些规律:
赛程接口的Body中:
{
"date": "",
"userId": "0",
"version": 5007,
"time": "20251023142030",
"channelid": "199999",
"sign": "a3b2c1d4e5f6..." // ← 这个sign每次都不一样!
}
直播流接口更复杂,Header中也有签名:
POST /cms/api/live/studio/id/v4
Content-Type: application/json
sign: d8e9f0a1b2c3... // ← Header签名
secret: c6c83221c08d5ba7... // ← 神秘的secret
time: 1729234567890 // ← 毫秒时间戳
{
"id": "7361",
"userId": "0",
"time": "20251023142030",
"sign": "f1e2d3c4b5a6..." // ← Body签名
// ... 其他参数
}
看到这里我意识到,这个APP使用了双重签名:
- 所有接口的Body中都有
sign字段(Body签名) - 核心接口(获取直播流)的Header中还有额外的
sign、secret、time(Header签名)
而且sign和time强相关,每次请求都不同,这说明签名算法中用到了时间戳。
二、逆向:反编译APK寻找算法
反编译APK
既然抓包拿不到算法,那就只能硬啃代码了。
使用JADX反编译APK:
jadx-gui kandongfang.apk
打开后,我在搜索框中输入"sign",试图找到签名相关的代码。经过大量搜索和阅读,最终在几个关键类中找到了线索:
b.java- 签名生成的核心类f.java- 参数处理工具类e.java- MD5加密工具类
发现固定密钥
在b.java中,我找到了第一个重要线索:
public class b {
public static final String SECRET_KEY = "C8F5954G8B61A93EDT4594BB8C318852";
// ... 其他代码
}
一个硬编码的32位密钥!看起来是用于签名计算的。
发现动态密钥的获取方式
继续追踪Header中secret的来源,在 f.java 第69行找到了关键代码:
public static String f(Long l2) {
try {
// x0.Q1 = "liveSign",从SharedPreferences读取缓存的密钥表
String q2 = x0.a.q(x0.Q1);
return !TextUtils.isEmpty(q2) ?
JSON.parseObject(q2).get(String.valueOf(l2)).toString() : "";
} catch (Exception e2) {
e2.printStackTrace();
return "";
}
}
原来密钥是从SharedPreferences中读取的!key为liveSign。
这说明这个密钥映射表不是硬编码在代码里的,而是动态下载的。APP启动时会从服务器获取一个包含100个密钥的JSON,缓存到本地。
抓包获取密钥映射表
既然是从服务器下载的,那我就重新抓包,这次在APP启动阶段仔细观察。
果然,在APP启动初始化时,捕获到了一个返回JSON格式的请求,里面包含了完整的密钥映射表:
{
"0": "0e4dac5a9587862b0706c5fd2465c0de",
"1": "d61ebbbb86f1434da9f70d549acc2a51",
"2": "0944fdccd6a0592c26498b53cf5ca564",
"3": "5ebcded7a926a8aecef837da950e901d",
// ... 中间省略 ...
"67": "c6c83221c08d5ba7050213226e58e109",
// ... 继续省略 ...
"99": "3bd43e4b28686bd00ddf315a118f21d0"
}
一共100个动态密钥!从"0"到"99",每个都是32位的MD5格式字符串。
这就是Header中那个神秘的secret的来源!
动态Secret的计算逻辑
在b.java的第56行,我找到了计算secret的方法:
public static void a(String str) {
try {
long currentTimeMillis = System.currentTimeMillis();
LinkedHashMap linkedHashMap = new LinkedHashMap();
linkedHashMap.put("userId", "0");
linkedHashMap.put("channelId", "199999");
linkedHashMap.put("time", currentTimeMillis + "");
linkedHashMap.put("path", "/cms/api/live/studio/id/v4");
// 关键:根据时间戳计算索引,从服务器下载的映射表中取密钥
String f2 = f.f(Long.valueOf((77 + currentTimeMillis) % 100));
linkedHashMap.put("secret", f2);
// 生成Header签名
String a2 = e.a(f.c(linkedHashMap));
// ...
} catch (Exception e2) {
e2.printStackTrace();
}
}
算法很简单但很巧妙:
- 输入:毫秒时间戳
- 计算:
(77 + 时间戳) % 100,得到0-99的索引 - 从服务器下载的映射表中取出对应的密钥
为什么是77?
这是开发者设置的一个"魔数"(Magic Number),用来增加破解难度。如果直接用时间戳 % 100,规律太明显;加上77之后,就需要逆向才能知道这个偏移量。
我是怎么获取这100个密钥的?
既然密钥是从服务器下载的,我有两个方法获取:
- 方法一(推荐): 抓包获取 - 启动APP后抓包,找到下载密钥的请求
- 方法二: Root手机,读取SharedPreferences中的
liveSign字段
我使用的是抓包方法,在APP启动过程中捕获到了完整的100个密钥JSON数据。
举个例子:
时间戳 = 1729234567890
索引 = (77 + 1729234567890) % 100 = 67
secret = SECRET_MAP["67"] = "c6c83221c08d5ba7050213226e58e109"
三、深入:破解两种签名算法
Body签名算法
在f.java中找到了Body签名的生成方法:
public static String b(Map<String, String> params) {
// 1. 获取所有key并排序(排除sign字段)
List<String> keys = new ArrayList<>(params.keySet());
keys.remove("sign");
Collections.sort(keys);
// 2. 拼接参数
StringBuilder sb = new StringBuilder();
for (String key : keys) {
String value = params.get(key);
// 跳过空值、null和JSON对象/数组
if (value != null && !value.isEmpty() && !isJson(value)) {
sb.append(key).append("=").append(value).append("&");
}
}
// 3. 去除尾部&,追加固定密钥SECRET_KEY
String signString = sb.toString().replaceAll("&$", "") + SECRET_KEY;
// 4. MD5加密
return MD5(signString);
}
完整流程示例:
假设请求参数是:
{
"userId": "0",
"version": "5007",
"platform": "android",
"time": "20251023142030",
"channelid": "199999"
}
Step 1 - 排序:
channelid, platform, time, userId, version
Step 2 - 拼接:
channelid=199999&platform=android&time=20251023142030&userId=0&version=5007
Step 3 - 追加密钥:
channelid=199999&platform=android&time=20251023142030&userId=0&version=5007C8F5954G8B61A93EDT4594BB8C318852
Step 4 - MD5:
sign = md5("channelid=199999&...C8F5954G8B61...")
= "a3b2c1d4e5f6g7h8i9j0k1l2m3n4o5p6"
Header签名算法
在b.java的第47-66行,找到了Header签名的生成方法:
public static String[] a(String path, long timestamp) {
// 1. 计算动态secret
String secret = a(timestamp); // 调用上面的方法
// 2. 构建参数(注意:固定5个参数)
Map<String, String> params = new LinkedHashMap<>();
params.put("userId", "0");
params.put("channelId", "199999");
params.put("time", String.valueOf(timestamp));
params.put("path", path);
params.put("secret", secret);
// 3. 排序拼接
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
StringBuilder sb = new StringBuilder();
for (String key : keys) {
sb.append(key).append("=").append(params.get(key)).append("&");
}
String signString = sb.toString().replaceAll("&$", "");
// 4. MD5加密(注意:不追加SECRET_KEY)
String sign = MD5(signString);
return new String[]{sign, secret};
}
关键区别:
- Body签名:参数多变,最后追加固定密钥
- Header签名:参数固定,不追加密钥,但使用动态secret参与计算
完整流程示例:
输入:
path = "/cms/api/live/studio/id/v4"
timestamp = 1729234567890
Step 1 - 计算secret:
index = (77 + 1729234567890) % 100 = 67
secret = SECRET_MAP["67"] = "c6c83221c08d5ba7050213226e58e109"
Step 2 - 构建参数:
{
"userId": "0",
"channelId": "199999",
"time": "1729234567890",
"path": "/cms/api/live/studio/id/v4",
"secret": "c6c83221c08d5ba7050213226e58e109"
}
Step 3 - 排序拼接:
channelId=199999&path=/cms/api/live/studio/id/v4&secret=c6c83221c08d5ba7050213226e58e109&time=1729234567890&userId=0
Step 4 - MD5(不追加SECRET_KEY):
sign = md5("channelId=199999&path=...&userId=0")
= "d8e9f0a1b2c3d4e5f6g7h8i9j0k1l2m3"
返回值:
(sign="d8e9f0a1...", secret="c6c83221...")
四、实现:Python代码重现算法
完整的签名生成器
import hashlib
import time
from datetime import datetime
class SignatureGenerator:
"""看东方APP签名生成器"""
# 固定密钥
SECRET_KEY = "C8F5954G8B61A93EDT4594BB8C318852"
# 动态密钥映射表(完整100个)
SECRET_MAP = {
"0": "0e4dac5a9587862b0706c5fd2465c0de",
"1": "d61ebbbb86f1434da9f70d549acc2a51",
"2": "0944fdccd6a0592c26498b53cf5ca564",
"3": "5ebcded7a926a8aecef837da950e901d",
# ... 省略中间的密钥 ...
"67": "c6c83221c08d5ba7050213226e58e109",
# ... 省略 ...
"99": "3bd43e4b28686bd00ddf315a118f21d0",
}
@staticmethod
def calculate_secret(timestamp):
"""计算动态secret"""
index = str((77 + timestamp) % 100)
return SignatureGenerator.SECRET_MAP.get(index, SECRET_MAP['0'])
@staticmethod
def md5_hash(text):
"""MD5哈希"""
return hashlib.md5(text.encode('utf-8')).hexdigest()
@staticmethod
def generate_sign(params_dict):
"""生成Body签名"""
# 1. 排序参数(排除sign)
sorted_keys = sorted([k for k in params_dict.keys() if k != 'sign'])
# 2. 拼接参数
param_parts = []
for key in sorted_keys:
value = str(params_dict[key])
# 跳过空值、null、JSON对象/数组
if not value or value == 'null':
continue
if (value.startswith('{') and value.endswith('}')) or \
(value.startswith('[') and value.endswith(']')):
continue
param_parts.append(f"{key}={value}")
# 3. 拼接并追加SECRET_KEY
sign_string = '&'.join(param_parts) + SignatureGenerator.SECRET_KEY
# 4. MD5加密
return SignatureGenerator.md5_hash(sign_string)
@staticmethod
def generate_header_sign(path, timestamp):
"""生成Header签名"""
# 1. 计算secret
secret = SignatureGenerator.calculate_secret(timestamp)
# 2. 构建参数
params = {
"userId": "0",
"channelId": "199999",
"time": str(timestamp),
"path": path,
"secret": secret
}
# 3. 排序拼接(不追加SECRET_KEY)
sorted_keys = sorted(params.keys())
sign_string = '&'.join([f"{k}={params[k]}" for k in sorted_keys])
# 4. MD5加密
sign = SignatureGenerator.md5_hash(sign_string)
return sign, secret
实战测试:获取NBA赛程
import requests
def get_nba_schedule():
"""获取NBA直播赛程"""
url = "https://bp-api.bestv.cn/cms/liveSports/getLiveCompetitionList"
# 构建参数
payload = {
"date": "",
"devid": "1899999",
"userId": "0",
"version": 5007,
"platform": "android",
"time": datetime.now().strftime("%Y%m%d%H%M%S"),
"udid": "c019e482cc7f4e4b",
"channelid": "199999",
"direction": "INIT"
}
# 生成签名
sign_gen = SignatureGenerator()
payload["sign"] = sign_gen.generate_sign(payload)
# 发送请求
headers = {
'user-agent': 'bestv app android 5007 xiaomi',
'content-type': 'application/json'
}
response = requests.post(url, headers=headers, json=payload)
return response.json()
# 测试
result = get_nba_schedule()
if result.get('code') == 0:
print("签名验证成功!")
print(f"获取到 {len(result['dt'])} 天的赛程")
else:
print(f"失败: {result}")
运行结果:
签名验证成功!
获取到 7 天的赛程
成功了!第一次看到code: 0的返回,那种成就感无法言表。
实战测试:获取直播流(双重签名)
def get_live_streams(studio_id):
"""获取直播流地址"""
url = "https://bp-api.bestv.cn/cms/api/live/studio/id/v4"
# 当前毫秒时间戳
timestamp = int(time.time() * 1000)
# 构建Body参数
payload = {
"devid": "1899999",
"userId": "0",
"version": 5007,
"platform": "android",
"id": str(studio_id),
"time": datetime.now().strftime("%Y%m%d%H%M%S"),
"udid": "c019e482cc7f4e4b",
"channelid": "199999"
}
# 生成Body签名
sign_gen = SignatureGenerator()
payload["sign"] = sign_gen.generate_sign(payload)
# 生成Header签名
path = "/cms/api/live/studio/id/v4"
header_sign, secret = sign_gen.generate_header_sign(path, timestamp)
# 构建请求头
headers = {
'user-agent': 'bestv app android 5007 xiaomi',
'content-type': 'application/json',
'sign': header_sign, # Header签名
'secret': secret, # 动态密钥
'time': str(timestamp) # 毫秒时间戳
}
print(f" 请求直播流 {studio_id}")
print(f" Body签名: {payload['sign']}")
print(f" Header签名: {header_sign}")
print(f" Secret: {secret}")
response = requests.post(url, headers=headers, json=payload)
return response.json()
# 测试
result = get_live_streams(7361)
if result.get('code') == 0:
streams = result['dt']['liveStudioStreamRelVoList']
print(f"成功获取 {len(streams)} 个流")
for stream in streams:
print(f"\n{stream['title']}")
for quality in stream['qualitys']:
print(f" {quality['qualityName']}: {quality['qualityUrl']}")
运行结果:
请求直播流 7361
Body签名: a3b2c1d4e5f6g7h8i9j0k1l2m3n4o5p6
Header签名: d8e9f0a1b2c3d4e5f6g7h8i9j0k1l2m3
Secret: c6c83221c08d5ba7050213226e58e109
成功获取 3 个流
主直播
蓝光: https://cdn.example.com/live.m3u8
超清: https://cdn.example.com/live_hd.m3u8
高清: https://cdn.example.com/live_sd.m3u8
完美!双重签名也通过了验证。
五、应用:构建NBA直播转发服务
有了签名算法,就可以构建完整的直播转发服务了。
Flask后端架构
from flask import Flask, render_template, jsonify
import requests
app = Flask(__name__)
sign_gen = SignatureGenerator()
@app.route('/')
def index():
"""首页:显示今日比赛"""
return render_template('index.html')
@app.route('/api/games')
def api_games():
"""API:获取比赛列表"""
# 调用签名接口获取赛程
schedule = get_nba_schedule()
games = []
for day in schedule['dt']:
for game in day['playListVo']:
if 'sportStudioRecommendStreamVo' in game:
games.append({
'studioId': game['sportStudioRecommendStreamVo']['studioId'],
'homeTeam': game['homeTeam'],
'visitTeam': game['visitTeam'],
'startTime': game['startTime'],
'status': game['sportStudioRecommendStreamVo']['status']
})
return jsonify({'success': True, 'games': games})
@app.route('/api/streams/<int:studio_id>')
def api_streams(studio_id):
"""API:获取直播流"""
streams = get_live_streams(studio_id)
return jsonify({'success': True, 'streams': streams})
@app.route('/watch/<int:studio_id>')
def watch(studio_id):
"""观看页面"""
return render_template('watch.html', studio_id=studio_id)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=xxxx)
六、cms-ff.ibbtv.cn 接口的签名算法
签名特点
与 bp-api.bestv.cn 的复杂双重签名不同,cms-ff.ibbtv.cn 域名的接口使用了一种更简单的固定密钥签名算法:
- 签名位置:只在Header中,不在Body中
- 签名参数:
sign、time、channelid、platform - 算法特点:使用固定密钥,不需要动态secret
签名算法详解
从抓包数据可以看到,相同的时间戳对应相同的签名:
time: 1761186713057 → sign: 622953635b5dfad28c4e75fa020880b2
time: 1761186713058 → sign: c2fceb2cbe411657039a633a3ec3d9b9
关键发现:签名只与时间戳相关,与请求Body无关!
签名生成步骤
Step 1 - 构建签名字符串:
channelId=139999&signKey=pdfcac9f349086bc3b233c562d9730ew&time={毫秒时间戳}
Step 2 - MD5加密:
sign = md5("channelId=139999&signKey=pdfcac9f349086bc3b233c562d9730ew&time=1761186713057")
= "622953635b5dfad28c4e75fa020880b2"
固定密钥
通过逆向分析前端JS代码,找到了固定密钥:
signKey = "pdfcac9f349086bc3b233c562d9730ew"
这个密钥硬编码在前端代码中,用于所有 cms-ff.ibbtv.cn 域名的接口签名。
Python实现
import hashlib
import time
class CMSFFSignatureGenerator:
"""cms-ff.ibbtv.cn 接口签名生成器"""
SIGN_KEY = "pdfcac9f349086bc3b233c562d9730ew" # 固定密钥
@staticmethod
def generate_sign(timestamp):
"""
生成Header签名
参数:
- timestamp: 毫秒时间戳
返回:
- sign: MD5签名
"""
# 构建签名字符串
sign_string = f"channelId=139999&signKey={CMSFFSignatureGenerator.SIGN_KEY}&time={timestamp}"
# MD5加密
sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest()
return sign
# 使用示例
timestamp = int(time.time() * 1000)
sign = CMSFFSignatureGenerator.generate_sign(timestamp)
headers = {
'content-type': 'application/json; charset=UTF-8',
'channelid': '139999',
'platform': 'android',
'sign': sign,
'time': str(timestamp)
}
实战测试:获取球员统计
import requests
def get_player_stats(match_id, team_type='homePlayers'):
"""获取球员统计数据"""
url = "https://cms-ff.ibbtv.cn/lotteryRecommend/matchScore/getPlayerScoreList"
timestamp = int(time.time() * 1000)
# 生成签名
sign_gen = CMSFFSignatureGenerator()
sign = sign_gen.generate_sign(timestamp)
# 构建请求头
headers = {
'content-type': 'application/json; charset=UTF-8',
'user-agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36',
'channelid': '139999',
'platform': 'android',
'sign': sign,
'time': str(timestamp)
}
# 构建请求体
payload = {
"matchId": str(match_id),
"teamType": team_type,
"page": 0,
"limit": 100,
"huPuLanguageType": "CHINESE"
}
print(f" 签名字符串: channelId=139999&signKey=***&time={timestamp}")
print(f" 生成签名: {sign}")
response = requests.post(url, headers=headers, json=payload)
return response.json()
# 测试
result = get_player_stats(3317, 'homePlayers')
if result.get('code') == 0:
players = result['dt']
print(f"成功获取 {len(players)} 名球员数据")
for player in players[:3]: # 显示前3名
print(f" {player['name']}: {player['pts']}分 {player['reb']}板 {player['asts']}助")
运行结果:
签名字符串: channelId=139999&signKey=***&time=1761186713057
生成签名: 622953635b5dfad28c4e75fa020880b2
成功获取 12 名球员数据
图马尼-卡马拉: 6分 0板 0助
德尼-阿夫迪亚: 5分 0板 0助
多诺万-克林根: 3分 1板 0助
可以看出,cms-ff.ibbtv.cn 的签名算法要简单得多,这可能是因为:
- 这些接口只提供数据展示,不涉及核心业务
- 前端H5页面需要调用,不能使用过于复杂的签名
- 开发团队可能不同,采用了不同的安全策略
七、接口总览
在整个项目中,我调用了6个接口,其中5个需要签名:
需要签名的接口
1. 获取直播赛程列表
POST https://bp-api.bestv.cn/cms/liveSports/getLiveCompetitionList
签名类型:Body签名
2. 获取直播流地址
POST https://bp-api.bestv.cn/cms/api/live/studio/id/v4
签名类型:Body签名 + Header签名(双重签名)
3. 获取完整赛程
POST https://bp-api.bestv.cn/cms/liveSports/getHupuNbaScheduleList
签名类型:Body签名
4. 获取比赛技术统计
POST https://cms-ff.ibbtv.cn/lotteryRecommend/matchScore/getMatchScoreResult
签名类型:Header签名(sign字段)
5. 获取球员统计
POST https://cms-ff.ibbtv.cn/lotteryRecommend/matchScore/getPlayerScoreSum
签名类型:Header签名(sign字段)
无需签名的接口(来自第三方)
6. 获取NBA球队排名
GET https://app.sports.qq.com/rank/rankByColumnTabV73
签名类型:无需签名(来自腾讯体育API)
有趣的是,核心的直播流接口使用了双重签名保护,而数据统计类接口则是简单的签名。这说明开发者重点保护的是视频资源,而对数据展示类接口相对宽松。