androguard 具体使用

curl 'https://dlive.guangbocloud.com/api/public/third/channel/tv/page?page=1&size=10&tvMode=0' -H 'Host: dlive.guangbocloud.com' -H 'X-DFSX-Signature: d8175db1f4a4625054e53c2cb591a284' -H 'Accept: */*' -H 'timestamp: 1785203060916' -H 'nonce: x9AgabF3kmm9KenX' -H 'Accept-Encoding: gzip, deflate, br' -H 'X-DFSX-mainUsername: jntv' -H 'Accept-Language: zh-Hans-CN;q=1, zh-Hant-CN;q=0.9, en-CN;q=0.8, zh-Hant-HK;q=0.7' -H 'appId: txqc-app' -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148' -H 'Connection: keep-alive' -H 'X-DFSX-Timestamp: 1785203063' -H 'sign: f51ed69a8e5c3f258517920af32dec46'

1. 加载APK + 获取DEX

import logging
logging.disable(logging.CRITICAL)  # 关掉烦人的debug日志

from androguard.core.apk import APK
from androguard.core.dex import DEX

a = APK("/root/uploads/tianxiaquanc.apk")
dex_list = a.get_all_dex()  # 返回5个DEX的字节码列表

测试的时候发现打印了大量的DEBUG
image
logging.disable()只管Pythonlogging模块,但androguard内部用的是 loguru,两套独立的日志系统,所以你关不掉。

接下来改代码

from loguru import logger
logger.remove()  # 删除loguru的所有handler,彻底禁用输出

import logging
logging.disable(logging.CRITICAL)

from androguard.core.apk import APK
from androguard.core.dex import DEX

a = APK("/Users/joe/Downloads/tianxiaquanc.apk")
dex_list = list(a.get_all_dex())  # 加 list()!不然是generator
print(f"DEX数量: {len(dex_list)}")
# 输出是generator → a.get_all_dex() 返回的是 <generator>,不是list。必须加 list() 转换才能用索引访问和打印长度

debug日志没关掉 → 因为androguard用的是loguru不是logging,需要 logger.remove() 先删handler

如果你不想装loguru,也可以用stderr重定向的粗暴方式:

import sys, io
sys.stderr = io.StringIO()  # 吞掉所有stderr输出(loguru写stderr)

from androguard.core.apk import APK
from androguard.core.dex import DEX

a = APK("/Users/joe/Downloads/tianxiaquanc.apk")
dex_list = list(a.get_all_dex())
print(f"DEX数量: {len(dex_list)}")

sys.stderr = sys.__stderr__  # 恢复正常stderr,方便后续调试

2. 字符串搜索 - 定位关键类(最关键的一步)

python

  • 只搜classes.dex(第0个DEX),strings结果最集中
d = DEX(dex_list[0])

for s in d.get_strings():
    if 'DFSX' in s or 'generateSign' in s or 'HeaderParams' in s:
        print(s)

输出结果直接锁定目标:

X-DFSX-Signature
X-DFSX-Timestamp
X-DFSX-mainUsername
generateSign
generateNonce
HeaderParams{nonce='
Lcom/cdvcloud/base/utils/HeaderUtils$HeaderParams;

问题:DFSX、generateSign、HeaderParams是固定的吗
答案:

不是固定的,这些关键词是根据你提供的curl请求反推出来的。

具体推导过程是这样的:

关键词 怎么推出来的
DFSX curl请求头里直接就有 X-DFSX-Signature、X-DFSX-Timestamp、X-DFSX-mainUsername,所以搜 DFSX
generateSign curl里有 sign 参数(32位hex=MD5特征),猜测方法名可能叫 generateSign / getSign / makeSign,搜出来刚好叫 generateSign
HeaderParams curl的请求头参数组合(nonce+timestamp+appId+sign)很像一个参数封装类的toString输出,搜 HeaderParams 试试,果然有

所以实际逆向时,你的搜索关键词应该来自抓包数据,通用流程是:

1. 从curl/抓包中提取特征字符串(Header名、参数名、值格式)
2. 在DEX字符串表中搜索这些特征 → 定位类名和方法名
3. 再反编译这些类 → 还原算法

不同app的命名完全不同。比如:

  • 天下泉城用的是 DFSX + HeaderUtils + generateSign
  • 天翼(tv189)可能用的是 ctyun + SignHelper + calcSign【未验证】
  • Bestv可能用的是 bestv + AuthManager + createToken【未验证】

搜索思路是通用的:抓包 → 提取特征词 → strings搜索 → 定位类 → 反编译。

3. 反编译smali - 读取方法指令

d = DEX(dex_list[0])

for cls in d.get_classes():
    cls_name = cls.get_name()
    # 只看我们关心的类
    if 'HeaderUtils' in cls_name:
        for method in cls.get_methods():
            m_name = method.get_name()
            print(f"  Method: {m_name}")
            code = method.get_code()
            if code:
                bc = code.get_bc()
                for inst in bc.get_instructions():
                    # 每条smali指令:操作码 + 参数
                    print(f"    {inst.get_name()} {inst.get_output()}")

这就是还原出 generateSign() 签名拼接逻辑的关键——从smali指令中看到:

const-string v1, "txqc_send_dataappId=txqc-apptimestamp="  ← 盐值!
invoke-virtual v0, v1, StringBuilder.append(String)
invoke-virtual v0, v2/v3, StringBuilder.append(J)          ← timestamp

image

4. 提取常量值 - 读取字段初始值

for cls in d.get_classes():
    if 'OnAirConsts' in cls.get_name():
        for field in cls.get_fields():
            f_name = field.get_name()
            if 'TV_NAME' in f_name or 'APP_SEC' in f_name:
                val = field.get_init_value()
                print(f"{f_name} = {val.get_value()}")

直接拿到:

TV_NAME_SER = 401b38e85b0640b9a6d8f13ad4e1bcc4     ← DFSX密钥
TV_NAME_KEY = jntv                                 ← mainUsername
APP_SECRECT_NEW = 5fd862d715e58519                 ← YS密钥

5. 搜索DFSX引用 - 找到sortJoinAndMD5

# 搜索引用"X-DFSX-Signature"字符串的类和方法
for cls in d.get_classes():
    for method in cls.get_methods():
        code = method.get_code()
        if code:
            bc = code.get_bc()
            for inst in bc.get_instructions():
                if 'DFSX' in inst.get_output():
                    # 输出整个方法的所有指令
                    for inst2 in bc.get_instructions():
                        print(f"    {inst2.get_name()} {inst2.get_output()}")
                    break

从 DefaultHttpManager.callForStringData 中看到了完整流程:sortJoinAndMD5(params, timestamp, false) → 结果赋给 X-DFSX-Signature。
这个结果是

invoke-static Lcom/cdvcloud/base/utils/HeaderUtils;->buildTokenHeader()Ljava/util/Map;
    move-result-object v5
    sget-object v0, Lcom/cdvcloud/base/onair/OnAirConsts;->DOMAIN_NAME_TV Ljava/lang/String;
    invoke-virtual v6, v0, Ljava/lang/String;->indexOf(Ljava/lang/String;)I
    move-result v0
    const/4 v1, -1
    if-le v0, v1, +024h
    invoke-static Ljava/lang/System;->currentTimeMillis()J
    move-result-wide v0
    const-wide/16 v2, 1000
    div-long/2addr v0, v2
    invoke-static v0, v1, Ljava/lang/String;->valueOf(J)Ljava/lang/String;
    move-result-object v0
    const/4 v1, 0
    const/4 v2, 1
    invoke-static v1, v0, v2, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->sortJoinAndMD5(Ljava/util/Map; Ljava/lang/String; Z)Ljava/lang/String;
    move-result-object v1
    sget-object v2, Lcom/cdvcloud/base/onair/OnAirConsts;->TV_NAME_KEY Ljava/lang/String;
    const-string v3, "X-DFSX-mainUsername"
    invoke-interface v5, v3, v2, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    const-string v2, "X-DFSX-Timestamp"
    invoke-interface v5, v2, v0, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    const-string v0, "X-DFSX-Signature"
    invoke-interface v5, v0, v1, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    iget-object v0, v4, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->httpService Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;
    invoke-static v7, Lcom/alibaba/fastjson/JSON;->toJSONString(Ljava/lang/Object;)Ljava/lang/String;
    move-result-object v1
    invoke-direct v4, v1, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->buildParams(Ljava/lang/String;)Lokhttp3/RequestBody;
    move-result-object v1
    invoke-interface v0, v5, v6, v1, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;->commonPostHeaderApiNoP(Ljava/util/Map; Ljava/lang/String; Lokhttp3/RequestBody;)Lretrofit2/Call;
    move-result-object v5
    invoke-virtual v4, v5, v8, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->wrapCallback(Lretrofit2/Call; Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpCallback;)V
    if-eqz v7, +007h
    invoke-virtual v7, Ljava/util/ArrayList;->toString()Ljava/lang/String;
    move-result-object v5
    goto +3h
    const-string v5, ""
    invoke-direct v4, v6, v5, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->logCatUrlParams(Ljava/lang/String; Ljava/lang/String;)V
    return-void 
    const-string v0, ""
    const/4 v1, 1
    if-nez v8, +01ch
    if-ne v6, v1, +00eh
    iget-object v6, v5, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->httpService Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;
    invoke-static Lcom/cdvcloud/base/utils/HeaderUtils;->buildTokenHeader()Ljava/util/Map;
    move-result-object v1
    invoke-interface v6, v1, v7, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;->commonGetHeaderApi(Ljava/util/Map; Ljava/lang/String;)Lretrofit2/Call;
    move-result-object v6
    goto/16 +0eah
    iget-object v6, v5, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->httpService Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;
    invoke-static Lcom/cdvcloud/base/utils/HeaderUtils;->buildTokenHeader()Ljava/util/Map;
    move-result-object v1
    invoke-interface v6, v1, v7, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;->commonPostHeaderApi(Ljava/util/Map; Ljava/lang/String;)Lretrofit2/Call;
    move-result-object v6
    goto/16 +0deh
    if-ne v6, v1, +069h
    invoke-static Lcom/cdvcloud/base/utils/HeaderUtils;->buildTokenHeader()Ljava/util/Map;
    move-result-object v6
    sget-object v1, Lcom/cdvcloud/base/onair/OnAirConsts;->DOMAIN_NAME_TV Ljava/lang/String;
    invoke-virtual v7, v1, Ljava/lang/String;->indexOf(Ljava/lang/String;)I
    move-result v1
    const/4 v2, -1
    if-le v1, v2, +023h
    invoke-static Ljava/lang/System;->currentTimeMillis()J
    move-result-wide v1
    const-wide/16 v3, 1000
    div-long/2addr v1, v3
    invoke-static v1, v2, Ljava/lang/String;->valueOf(J)Ljava/lang/String;
    move-result-object v1
    const/4 v2, 0
    invoke-static v8, v1, v2, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->sortJoinAndMD5(Ljava/util/Map; Ljava/lang/String; Z)Ljava/lang/String;
    move-result-object v2
    sget-object v3, Lcom/cdvcloud/base/onair/OnAirConsts;->TV_NAME_KEY Ljava/lang/String;
    const-string v4, "X-DFSX-mainUsername"
    invoke-interface v6, v4, v3, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    const-string v3, "X-DFSX-Timestamp"
    invoke-interface v6, v3, v1, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    const-string v1, "X-DFSX-Signature"
    invoke-interface v6, v1, v2, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    new-instance v1, Ljava/lang/StringBuilder;
    invoke-direct v1, Ljava/lang/StringBuilder;-><init>()V
    const-string v2, "mH===传参==="
    invoke-virtual v1, v2, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    invoke-static v6, Lcom/alibaba/fastjson/JSON;->toJSONString(Ljava/lang/Object;)Ljava/lang/String;
    move-result-object v2
    invoke-virtual v1, v2, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    invoke-virtual v1, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
    move-result-object v1
    const-string v2, "qqqqqq"
    invoke-static v2, v1, Landroid/util/Log;->d(Ljava/lang/String; Ljava/lang/String;)I
    new-instance v1, Ljava/lang/StringBuilder;
    invoke-direct v1, Ljava/lang/StringBuilder;-><init>()V
    const-string v3, "requestParam===传参==="
    invoke-virtual v1, v3, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    invoke-static v8, Lcom/alibaba/fastjson/JSON;->toJSONString(Ljava/lang/Object;)Ljava/lang/String;
    move-result-object v3
    invoke-virtual v1, v3, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    invoke-virtual v1, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
    move-result-object v1
    invoke-static v2, v1, Landroid/util/Log;->d(Ljava/lang/String; Ljava/lang/String;)I
    iget-object v1, v5, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->httpService Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;
    invoke-interface v1, v6, v7, v8, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;->commonGetHeaderApi(Ljava/util/Map; Ljava/lang/String; Ljava/util/Map;)Lretrofit2/Call;
    move-result-object v6
    goto +74h
    sget-object v6, Lcom/cdvcloud/base/onair/OnAirConsts;->pushAppMsg Ljava/lang/String;
    invoke-virtual v7, v6, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
    move-result v6
    if-eqz v6, +063h
    invoke-static Lcom/cdvcloud/base/utils/HeaderUtils;->generateHeaderParams()Lcom/cdvcloud/base/utils/HeaderUtils$HeaderParams;
    move-result-object v6
    new-instance v1, Ljava/util/HashMap;
    invoke-direct v1, Ljava/util/HashMap;-><init>()V
    iget-object v2, v6, Lcom/cdvcloud/base/utils/HeaderUtils$HeaderParams;->nonce Ljava/lang/String;
    const-string v3, "nonce"
    invoke-interface v1, v3, v2, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    new-instance v2, Ljava/lang/StringBuilder;
    invoke-direct v2, Ljava/lang/StringBuilder;-><init>()V
    iget-wide v3, v6, Lcom/cdvcloud/base/utils/HeaderUtils$HeaderParams;->timestamp J
    invoke-virtual v2, v3, v4, Ljava/lang/StringBuilder;->append(J)Ljava/lang/StringBuilder;
    invoke-virtual v2, v0, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    invoke-virtual v2, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
    move-result-object v2
    const-string v3, "timestamp"
    invoke-interface v1, v3, v2, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    iget-object v2, v6, Lcom/cdvcloud/base/utils/HeaderUtils$HeaderParams;->appId Ljava/lang/String;
    const-string v3, "appId"
    invoke-interface v1, v3, v2, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    iget-object v6, v6, Lcom/cdvcloud/base/utils/HeaderUtils$HeaderParams;->sign Ljava/lang/String;
    const-string v2, "sign"
    invoke-interface v1, v2, v6, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    const-string v6, "Content-Type"
    const-string v2, "application/json"
    invoke-interface v1, v6, v2, Ljava/util/Map;->put(Ljava/lang/Object; Ljava/lang/Object;)Ljava/lang/Object;
    invoke-virtual v1, Ljava/lang/Object;->toString()Ljava/lang/String;
    move-result-object v6
    const-string v2, "pushAppMsg==Header==="
    invoke-static v2, v6, Landroid/util/Log;->d(Ljava/lang/String; Ljava/lang/String;)I
    invoke-virtual v8, Ljava/lang/Object;->toString()Ljava/lang/String;
    move-result-object v6
    const-string v2, "pushAppMsg=rParam="
    invoke-static v2, v6, Landroid/util/Log;->d(Ljava/lang/String; Ljava/lang/String;)I
    const-string v6, "pushAppMsg=requestUrl="
    invoke-static v6, v7, Landroid/util/Log;->d(Ljava/lang/String; Ljava/lang/String;)I
    iget-object v6, v5, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->httpService Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;
    invoke-static v8, Lcom/alibaba/fastjson/JSON;->toJSONString(Ljava/lang/Object;)Ljava/lang/String;
    move-result-object v2
    invoke-direct v5, v2, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->buildParams(Ljava/lang/String;)Lokhttp3/RequestBody;
    move-result-object v2
    invoke-interface v6, v1, v7, v2, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;->commonPostHeaderApi(Ljava/util/Map; Ljava/lang/String; Lokhttp3/RequestBody;)Lretrofit2/Call;
    move-result-object v6
    goto +bh
    invoke-static Lcom/cdvcloud/base/utils/HeaderUtils;->buildTokenHeader()Ljava/util/Map;
    move-result-object v6
    iget-object v1, v5, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->httpService Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;
    invoke-interface v1, v6, v7, v8, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpService;->commonPostHeaderApi(Ljava/util/Map; Ljava/lang/String; Ljava/util/Map;)Lretrofit2/Call;
    move-result-object v6
    invoke-virtual v5, v6, v9, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->wrapCallback(Lretrofit2/Call; Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpCallback;)V
    if-eqz v8, +006h
    invoke-virtual v8, Ljava/lang/Object;->toString()Ljava/lang/String;
    move-result-object v0
    invoke-direct v5, v7, v0, Lcom/cdvcloud/base/http/retrofit/defaultimp/DefaultHttpManager;->logCatUrlParams(Ljava/lang/String; Ljava/lang/String;)V
    return-void 

这段smali代码其实是 一个大方法里的多个分支,对应不同URL的不同签名逻辑。我帮你逐步拆解:

smali阅读技巧
smali本质就是Java的字节码文本形式,对照规则很简单:

smali Java等价
const-string v3, "X-DFSX-Signature" v3 = "X-DFSX-Signature"
invoke-static ... sortJoinAndMD5 result = sortJoinAndMD5(...)
invoke-interface v5, v3, v2, Map.put map.put(v3, v2)
if-le v0, v1, +024h if (v0 > v1) goto +024h
sget-object v0, OnAirConsts.TV_NAME_KEY v0 = OnAirConsts.TV_NAME_KEY

逐步解读这段代码

这段代码有 3个关键分支,根据URL类型走不同签名逻辑:

分支1:guangbocloud域名(DFSX签名)—— 这是你需要的
invoke-static HeaderUtils.buildTokenHeader()    → v5 = 先构建基础token header
sget-object v0, OnAirConsts.DOMAIN_NAME_TV     → v0 = "https://dlive.guangbocloud.com/"
invoke-virtual v6.indexOf(v0)                   → 判断URL是否包含guangbocloud域名
if-le v0, v1                                    → 如果包含,走DFSX签名分支 ↓

invoke-static System.currentTimeMillis()         → 获取毫秒时间戳
div-long/2addr v0, v2                            → 除以1000 → 秒级时间戳
invoke-static String.valueOf(v0)                 → v0 = 秒级时间戳字符串

invoke-static sortJoinAndMD5(v1=null, v0=ts, v2=true/false) → v1 = DFSX签名结果!

sget-object v2, OnAirConsts.TV_NAME_KEY         → v2 = "jntv"
invoke-interface v5.put("X-DFSX-mainUsername", v2)  → 设置mainUsername
invoke-interface v5.put("X-DFSX-Timestamp", v0)     → 设置Timestamp
invoke-interface v5.put("X-DFSX-Signature", v1)     → 设置Signature ← 核心!

还原成Java伪代码:

if (url.contains("dlive.guangbocloud.com")) {
    String tsSeconds = String.valueOf(System.currentTimeMillis() / 1000);
    String signature = sortJoinAndMD5(params, tsSeconds, isPost);
    headerMap.put("X-DFSX-mainUsername", OnAirConsts.TV_NAME_KEY);  // "jntv"
    headerMap.put("X-DFSX-Timestamp", tsSeconds);
    headerMap.put("X-DFSX-Signature", signature);
}
分支2:pushAppMsg(sign签名)
invoke-static HeaderUtils.generateHeaderParams() → v6 = HeaderParams对象
iget-object v2, v6.nonce                          → 取出nonce
invoke-interface v1.put("nonce", v2)               → header.put("nonce", ...)
invoke-interface v1.put("timestamp", ts+...)       → header.put("timestamp", ...)
invoke-interface v1.put("appId", v6.appId)         → header.put("appId", "txqc-app")
iget-object v6, v6.sign                            → 取出sign
invoke-interface v1.put("sign", v6)                → header.put("sign", ...)

还原成Java:

HeaderParams params = HeaderUtils.generateHeaderParams();
headerMap.put("nonce", params.nonce);
headerMap.put("timestamp", params.timestamp + "");
headerMap.put("appId", params.appId);    // "txqc-app"
headerMap.put("sign", params.sign);       // MD5计算结果
分支3:其他URL(无特殊签名,只用token header)
// 不含guangbocloud域名、也不是pushAppMsg的请求
// 只用基础 buildTokenHeader()
headerMap = HeaderUtils.buildTokenHeader();
// 无DFSX签名、无sign
总结:这个方法告诉你什么
信息 含义
sortJoinAndMD5 的参数 v8=null, v0=秒级时间戳, v2=true/false GET传false(0),POST传true(1)
OnAirConsts.TV_NAME_KEY = "jntv" 这就是 X-DFSX-mainUsername 的值
只有URL含 guangbocloud 才走DFSX签名 其他请求不需要DFSX
开发者留了调试日志 "qqqqqq" / "mH=传参=" 顺手看到了,不影响签名

所以读smali的核心技巧就是:先找 const-string(常量) invoke-xxx(方法调用),这两类指令直接暴露了业务逻辑。中间的变量赋值跳转细节可以先忽略,抓住主线就行。

实际调试中踩的坑:

  • androguard v4.x API变了,DalvikVMFormat 改成了 DEX,导入路径从 core.bytecodes 变到 core.dex
  • debug日志会淹没输出,必须 logging.disable(logging.CRITICAL) + sys.stderr = open('/dev/null', 'w')
  • 字段初始值返回的是 EncodedValue 对象,要 .get_value() 才能取到实际值
  • 大DEX(1.5MB的49模块)遍历很慢,先用 strings 精确定位再反编译特定类更高效

6. 完整流程

"""
天下泉城 APK 签名逆向完整流程
7步:定位 → 反编译 → 常量提取 → 验证 → PHP转换
"""

# ============================================================
# 第0步:环境准备(关闭androguard烦人的debug日志)
# ============================================================
from loguru import logger
logger.remove()  # loguru的handler全删,彻底静默

import logging
logging.disable(logging.CRITICAL)

import hashlib

from androguard.core.apk import APK
from androguard.core.dex import DEX

APK_PATH = "/Users/joe/Downloads/tianxiaquanc.apk"  # 改成你的路径

a = APK(APK_PATH)
dex_list = list(a.get_all_dex())  # 注意加list(),不然是generator
print(f"DEX数量: {len(dex_list)}")

# ============================================================
# 第①步:字符串搜索 - 定位关键类和方法名
# ============================================================
# 思路:从curl抓包中提取特征词,在DEX字符串表中搜索
# 你的curl里有:X-DFSX-Signature, sign, nonce, timestamp, appId

print("\n========== 第①步:字符串搜索 ==========")

d = DEX(dex_list[0])  # 先搜主DEX(classes.dex)

keywords = ['DFSX', 'generateSign', 'HeaderParams', 'nonce', 'appId', 'txqc', 'secret']
found = {}
for s in d.get_strings():
    for kw in keywords:
        if kw in s:
            if kw not in found:
                found[kw] = []
            found[kw].append(s)

for kw, strings in found.items():
    print(f"\n关键词 '{kw}' 找到 {len(strings)} 个字符串:")
    for s in strings[:10]:  # 只展示前10个
        print(f"  {s}")

# ============================================================
# 第②步:搜索DFSX引用 - 找到谁在调用DFSX签名
# ============================================================
# 思路:在方法指令中搜索"X-DFSX-Signature"字符串引用
# 找到调用方 → 知道签名的上下文(什么URL才需要DFSX签名)

print("\n========== 第②步:DFSX引用搜索 ==========")

dfsx_methods = []
for cls in d.get_classes():
    cls_name = cls.get_name()
    for method in cls.get_methods():
        m_name = method.get_name()
        code = method.get_code()
        if code:
            bc = code.get_bc()
            for inst in bc.get_instructions():
                if 'DFSX' in inst.get_output():
                    dfsx_methods.append((cls_name, m_name))
                    print(f"  类: {cls_name}, 方法: {m_name} 中引用了DFSX")
                    break

# ============================================================
# 第③步:反编译 HeaderUtils - 还原sign算法
# ============================================================
# 思路:从①步知道sign来自 HeaderUtils.generateSign()
# 反编译这个方法 → 看拼接字符串的具体格式

print("\n========== 第③步:反编译HeaderUtils(sign算法)==========")

for cls in d.get_classes():
    cls_name = cls.get_name()
    if 'HeaderUtils' in cls_name:
        print(f"\n类: {cls_name}")
        for method in cls.get_methods():
            m_name = method.get_name()
            code = method.get_code()
            if code and m_name in ['generateSign', 'generateHeaderParams', 
                                    'generateRandomString', 'md5']:
                print(f"  方法: {m_name}")
                bc = code.get_bc()
                for inst in bc.get_instructions():
                    print(f"    {inst.get_name()} {inst.get_output()}")

# ============================================================
# 第④步:人工还原sign算法(从smali翻译成Python)
# ============================================================
# 思路:把③步的smali指令翻译成可理解的算法
# generateSign() 的 smali → 拼接字符串格式
# generateHeaderParams() → nonce + timestamp + sign 的组合方式

print("\n========== 第④步:还原sign算法 ==========")

# 从③步smali还原出的算法:
# signStr = "txqc_send_data" + "appId=txqc-app" + "timestamp=" + ts_ms + "nonce=" + nonce
# sign = md5(signStr).toUpperCase()

# 用curl中的已知参数验证:
timestamp_ms = 1785047748445
nonce = "1Ajx00RkjTQNIY5s"

sign_str = "txqc_send_dataappId=txqc-apptimestamp=" + str(timestamp_ms) + "nonce=" + nonce
sign_calc = hashlib.md5(sign_str.encode()).hexdigest().upper()

sign_actual = "976c500e296e24e4798fe5598262a18b"

print(f"  拼接字符串: {sign_str}")
print(f"  计算sign:   {sign_calc}")
print(f"  实际sign:   {sign_actual}")
print(f"  匹配结果:   {sign_calc.lower() == sign_actual}  {'✓ 验证成功!' if sign_calc.lower() == sign_actual else '✗ 不匹配'}")

# ============================================================
# 第⑤步:反编译 sortJoinAndMD5 - 还原DFSX签名算法
# ============================================================
# 思路:从②步知道DFSX签名来自 DefaultHttpManager.sortJoinAndMD5()
# 反编译这个方法 → 看参数排序+拼接+MD5的具体逻辑

print("\n========== 第⑤步:反编译sortJoinAndMD5(DFSX签名)==========")

for cls in d.get_classes():
    cls_name = cls.get_name()
    if 'DefaultHttpManager' in cls_name:
        for method in cls.get_methods():
            m_name = method.get_name()
            if 'sortJoinAndMD5' in m_name:
                print(f"  类: {cls_name}, 方法: {m_name}")
                code = method.get_code()
                if code:
                    bc = code.get_bc()
                    for inst in bc.get_instructions():
                        print(f"    {inst.get_name()} {inst.get_output()}")

# ============================================================
# 第⑥步:提取OnAirConsts常量值 - 获取密钥和盐值
# ============================================================
# 思路:⑤步中看到 sortJoinAndMD5 引用了 TV_NAME_SER
# 需要提取这个常量的实际值

print("\n========== 第⑥步:提取OnAirConsts常量 ==========")

target_fields = ['TV_NAME_SER', 'TV_NAME_KEY', 'APP_SECRECT_NEW', 
                  'APP_SECRECT', 'ENCRYPT_KEY', 'DOMAIN_NAME_TV', 'DOMAIN_NAME']

for cls in d.get_classes():
    cls_name = cls.get_name()
    if 'OnAirConsts' in cls_name:
        print(f"类: {cls_name}")
        for field in cls.get_fields():
            f_name = field.get_name()
            if f_name in target_fields:
                val = field.get_init_value()
                if val:
                    print(f"  {f_name} = {val.get_value()}")

# ============================================================
# 第⑥步续:验证DFSX-Signature算法
# ============================================================
# 从⑤步smali还原出的DFSX签名算法:
# 1. URL参数按key字母升序排列
# 2. 拼接: key1=value1&key2=value2...&timestamp=秒级时间戳&secret=TV_NAME_SER
# 3. MD5 → 小写hex

print("\n========== DFSX-Signature验证 ==========")

dfsx_ts = 1785047752  # 秒级时间戳
TV_NAME_SER = "401b38e85b0640b9a6d8f13ad4e1bcc4"

# curl中的URL参数
params = {"page": "1", "size": "10", "tvMode": "0"}

# 排序拼接
sorted_keys = sorted(params.keys())
parts = [f"{k}={params[k]}" for k in sorted_keys]
params_str = "&".join(parts)

dfsx_str = params_str + "&timestamp=" + str(dfsx_ts) + "&secret=" + TV_NAME_SER
dfsx_calc = hashlib.md5(dfsx_str.encode()).hexdigest()

dfsx_actual = "647982df3761c771fbde263864b6149a"

print(f"  拼接字符串: {dfsx_str}")
print(f"  计算DFSX:   {dfsx_calc}")
print(f"  实际DFSX:   {dfsx_actual}")
print(f"  匹配结果:   {dfsx_calc == dfsx_actual}  {'✓ 验证成功!' if dfsx_calc == dfsx_actual else '✗ 不匹配'}")

# ============================================================
# 第⑦步:转换成PHP代码(最终产出)
# ============================================================

print("\n========== 第⑦步:PHP代码 ==========")

php_code = '''
<?php
class GuangboCloudSign {
    const SECRET     = '401b38e85b0640b9a6d8f13ad4e1bcc4';  // TV_NAME_SER
    const SIGN_SALT  = 'txqc_send_data';                     // generateSign盐值
    const APP_ID     = 'txqc-app';                            // appId
    const MAIN_USER  = 'jntv';                                // TV_NAME_KEY

    // 生成nonce (12位随机字符串)
    public static function generateNonce($length = 12) {
        $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
        $nonce = '';
        for ($i = 0; $i < $length; $i++) {
            $nonce .= $chars[random_int(0, strlen($chars) - 1)];
        }
        return $nonce;
    }

    // 生成sign
    // 算法: MD5("txqc_send_dataappId=txqc-apptimestamp={ms}nonce={nonce}").toUpperCase()
    public static function generateSign($timestampMs, $nonce) {
        $str = self::SIGN_SALT . 'appId=' . self::APP_ID 
             . 'timestamp=' . $timestampMs . 'nonce=' . $nonce;
        return strtoupper(md5($str));
    }

    // 生成X-DFSX-Signature
    // 算法: URL参数按key排序拼接 + &timestamp={秒} + &secret={TV_NAME_SER} → MD5小写
    public static function generateDFSXSignature($params, $timestampS) {
        if (empty($params)) {
            $str = 'timestamp=' . $timestampS . '&secret=' . self::SECRET;
        } else {
            ksort($params);
            $parts = [];
            foreach ($params as $k => $v) {
                $parts[] = $k . '=' . $v;
            }
            $str = implode('&', $parts) 
                 . '&timestamp=' . $timestampS . '&secret=' . self::SECRET;
        }
        return md5($str);
    }

    // 构建完整请求头
    public static function buildHeaders($urlParams = []) {
        $tsMs = intval(microtime(true) * 1000);  // 毫秒
        $tsS  = intval(time());                   // 秒
        $nonce = self::generateNonce();

        return [
            'appId'              => self::APP_ID,
            'nonce'              => $nonce,
            'timestamp'          => $tsMs,
            'sign'               => self::generateSign($tsMs, $nonce),
            'X-DFSX-Signature'   => self::generateDFSXSignature($urlParams, $tsS),
            'X-DFSX-Timestamp'   => $tsS,
            'X-DFSX-mainUsername' => self::MAIN_USER,
        ];
    }

    // 获取电视频道列表
    public static function getTvChannels($page = 1, $size = 10, $tvMode = 0) {
        $urlParams = ['page' => $page, 'size' => $size, 'tvMode' => $tvMode];
        $headers = self::buildHeaders($urlParams);
        $url = 'https://dlive.guangbocloud.com/api/public/third/channel/tv/page?' 
             . http_build_query($urlParams);

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_HTTPHEADER     => array_map(fn($k,$v) => "$k: $v", 
                                                  array_keys($headers), array_values($headers)),
            CURLOPT_TIMEOUT        => 15,
        ]);
        $resp = curl_exec($ch);
        curl_close($ch);
        return json_decode($resp, true);
    }
}

// 使用
$result = GuangboCloudSign::getTvChannels(1, 10, 0);
foreach ($result['data'] as $ch) {
    $m3u8 = array_filter($ch['push_play_urls'], fn($u) => strpos($u, '.m3u8') !== false);
    echo $ch['name'] . " → " . ($m3u8[0] ?? '无') . "\\n";
}
'''

print(php_code)

每一步的输入→输出关系:

① strings搜索     →  得到类名/方法名 (HeaderUtils, generateSign, OnAirConsts)
② DFSX引用搜索      →  知道调用链 (DefaultHttpManager → sortJoinAndMD5 → DFSX签名)
③ 反编译HeaderUtils →  得到sign的smali指令
④ 人工还原sign     →  smali翻译成Python算法 + 用已知参数验证
⑤ 反编译sortJoinAndMD5 →  得到DFSX签名的smali指令  
⑥ 常量提取+验证    →  得到密钥值 + 用已知参数验证DFSX算法
⑦ PHP转换          →  最终可用的PHP代码

核心原则:每一步都用已知curl参数验证,确保翻译无误才继续下一步。

疑问
image

到底怎么来的啊
好问题,一步一步对应给你看。先看 generateSign() 的原始smali:

Method: generateSign                                              ← 方法名 generateSign

  new-instance v0, StringBuilder
  invoke-direct v0, StringBuilder.<init>()                        ← v0 = new StringBuilder()

  const-string v1, "txqc_send_dataappId=txqc-apptimestamp="      ← v1 = 固定字符串
  invoke-virtual v0, v1, StringBuilder.append(String)            ← v0.append(v1)

  invoke-virtual v0, v2/v3, StringBuilder.append(J)              ← v0.append(v2/v3) ← J是long类型=时间戳

  const-string v2, "nonce="                                       ← v2 = "nonce="
  invoke-virtual v0, v2, StringBuilder.append(String)            ← v0.append(v2)

  invoke-virtual v0, v4, StringBuilder.append(String)            ← v0.append(v4) ← v4是字符串=nonce值

  invoke-virtual v0, StringBuilder.toString()                     ← 拼接完成
  move-result-object v2                                           ← v2 = 拼接结果字符串
  return-object v2                                                ← return v2

逐行翻译成Java是这样:

smali行 Java等价
const-string v1, "txqc_send_dataappId=txqc-apptimestamp=" String v1 = "txqc_send_dataappId=txqc-apptimestamp="
invoke-virtual v0, v1, StringBuilder.append(String) sb.append(v1)
invoke-virtual v0, v2/v3, StringBuilder.append(J) sb.append(timestamp) ← J = long
const-string v2, "nonce=" String v2 = "nonce="
invoke-virtual v0, v2, StringBuilder.append(String) sb.append(v2)
invoke-virtual v0, v4, StringBuilder.append(String) sb.append(nonce) ← v4是传入的字符串参数
// 还原成Java就是:
String generateSign(long timestamp, String nonce) {   // 参数:long + String
    StringBuilder sb = new StringBuilder();
    sb.append("txqc_send_dataappId=txqc-apptimestamp=");   // 固定盐值
    sb.append(timestamp);                                    // 毫秒时间戳
    sb.append("nonce=");                                    // 固定前缀
    sb.append(nonce);                                       // 随机字符串
    return sb.toString();                                    // 返回拼接结果
}

但是光拼接不加密,所以要看 调用者 generateHeaderParams():

Method: generateHeaderParams
  invoke-static Date.<init>()                                  → new Date()
  invoke-virtual Date.getTime()                                → timestamp_ms = getTime()
  invoke-static generateRandomString()                         → nonce = generateRandomString()
  
  # 下面调用 generateSign(timestamp_ms, nonce) 得到明文
  invoke-static generateSign(timestamp_ms, nonce)              → raw_str

  # 然后MD5
  invoke-static md5(raw_str)                                   → encrypted = md5(raw_str)
  invoke-virtual toString()
  invoke-virtual toUpperCase()                                 → sign = encrypted.toUpperCase()
  
  # 最后塞进HeaderParams对象
  new HeaderParams(nonce, timestamp, "txqc-app", sign)

再看 md5() 方法的smali:

Method: md5
  const-string v0, "MD5"
  invoke-static MessageDigest.getInstance("MD5")               → MessageDigest.getInstance("MD5")
  invoke-virtual string.getBytes()                             → input.getBytes()
  invoke-virtual digest.update(bytes)                          → md.update(bytes)
  invoke-virtual digest.digest()                               → byte[] result = md.digest()
  
  # 遍历byte[]转hex
  for (byte b : result) {
      String.format("%02x", b)                                 → 小写hex
      sb.append(hex)
  }
  return sb.toString()                                         → 返回hex字符串

所以整个链子是:

generateHeaderParams():
  timestamp = Date.getTime()              → 毫秒时间戳
  nonce     = generateRandomString()      → 12位随机串
  raw       = generateSign(ts, nonce)     → 调用generateSign拼接明文
  encrypted = md5(raw)                    → MD5加密
  sign      = encrypted.toUpperCase()     → 转大写  ← 关键:md5()输出小写,这里转大写
  return HeaderParams(nonce, ts, "txqc-app", sign)

generateSign(ts, nonce):
  → "txqc_send_dataappId=txqc-apptimestamp=" + ts + "nonce=" + nonce

验证环节就是反过来:

假设已知:
  timestamp_ms = 1785047748445
  nonce       = "1Ajx00RkjTQNIY5s"
  
拼接:
  sign_str = "txqc_send_dataappId=txqc-apptimestamp=1785047748445nonce=1Ajx00RkjTQNIY5s"
  
MD5 + 转大写:
  sign = md5(sign_str).upper()
       = "976C500E296E24E4798FE5598262A18B"

核对curl里的sign:
  实际sign = "976c500e296e24e4798fe5598262a18b"
  
  976C500E296E24E4798FE5598262A18B == 976C500E296E24E4798FE5598262A18B  ✓

读懂smali的核心翻译对照表:

smali Java 含义
const-string v0, "..." String v0 = "..." 固定字符串
invoke-virtual sb, v0, StringBuilder.append(String) sb.append(v0) 拼接字符串
invoke-virtual sb, v1/v2, StringBuilder.append(J) sb.append(v1) 拼接long(时间戳)
invoke-static XXX.method() ClassName.method() 调静态方法
invoke-virtual obj.toString() obj.toString() 转字符串
move-result-object v0 v0 = 上一步结果 获取返回值
const-string v0, "MD5" v0 = "MD5" 算法名
invoke-static MessageDigest.getInstance(v0) MessageDigest.getInstance("MD5") 获取MD5实例
invoke-virtual input.getBytes() input.getBytes() 字符串转byte[]
invoke-virtual digest.digest() digest.digest() MD5计算
String.format("%02x", byte) String.format("%02x", b) 转小写hex

简单总结就是:

  • const-string → 看到具体的盐值字符串
  • StringBuilder.append → 这是拼接规则
  • MessageDigest.getInstance("MD5") → 确认加密算法
  • toUpperCase → 注意大小写转换
  • 最后用curl里的已知参数算一遍验证
posted @ 2026-09-04 17:01  流年中渲染了微笑  阅读(3)  评论(0)    收藏  举报