看东方API接口签名算法逆向分析

16

一次完整的Android APP逆向之旅,揭秘双重签名机制的实现原理

写在前面

最近在做一个NBA直播转发的项目,需要调用"看东方"APP的直播接口来获取比赛流地址。一开始我以为这会是个简单的HTTP请求,结果抓包之后发现,这个APP使用了一套相当复杂的双重签名机制

经过几天的逆向分析和调试,我成功破解了这套签名算法,并用Python完整实现了它。于是写了这篇文章记录了整个逆向过程。

重要前提

这次逆向能够成功,有一个关键前提:我提前破解了看东方APP的VIP限制。

为什么需要先破解APP?

  • API接口受VIP保护:很多核心接口(如获取直播流)需要VIP会员才能调用
  • 本地测试环境:破解后的APP可以在本地自由测试,不受限制地观察请求和响应
  • 完整的数据流:能够看到完整的请求参数和响应数据,包括各种VIP专属内容
  • 快速迭代验证:可以反复测试签名算法,快速验证是否正确

破解过程:

  1. 反编译APK,定位VIP判断逻辑
  2. 修改smali代码,让所有VIP判断返回"已开通"
  3. 重新打包签名,安装测试
  4. 详细的破解清单参见: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使用了双重签名

  1. 所有接口的Body中都有sign字段(Body签名)
  2. 核心接口(获取直播流)的Header中还有额外的signsecrettime(Header签名)

而且signtime强相关,每次请求都不同,这说明签名算法中用到了时间戳。


二、逆向:反编译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();
    }
}

算法很简单但很巧妙:

  1. 输入:毫秒时间戳
  2. 计算:(77 + 时间戳) % 100,得到0-99的索引
  3. 服务器下载的映射表中取出对应的密钥

为什么是77?

这是开发者设置的一个"魔数"(Magic Number),用来增加破解难度。如果直接用时间戳 % 100,规律太明显;加上77之后,就需要逆向才能知道这个偏移量。

我是怎么获取这100个密钥的?

既然密钥是从服务器下载的,我有两个方法获取:

  1. 方法一(推荐): 抓包获取 - 启动APP后抓包,找到下载密钥的请求
  2. 方法二: 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中
  • 签名参数signtimechannelidplatform
  • 算法特点:使用固定密钥,不需要动态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 的签名算法要简单得多,这可能是因为:

  1. 这些接口只提供数据展示,不涉及核心业务
  2. 前端H5页面需要调用,不能使用过于复杂的签名
  3. 开发团队可能不同,采用了不同的安全策略

七、接口总览

在整个项目中,我调用了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)

有趣的是,核心的直播流接口使用了双重签名保护,而数据统计类接口则是简单的签名。这说明开发者重点保护的是视频资源,而对数据展示类接口相对宽松。


八、附录

VIP破解修改清单

在线体验地址