使用天翼家庭云来提速家用宽带, 以及反编译天翼家庭云apk获得Signature算法

2019年8月3日 这个已经无法使用, 电信已经关闭接口了

折腾文, 记录下自己折腾的过程. 最近发现iPhone上的电信推出来的天翼家庭云app, 里面有一个提速功能, 可以让宽带速度提升到500M下行, 50M上行. 然后就想能否持续为其他应用加速…果然查了可行, 但是网上的方法只提到了Signature以及SessionKey 2个参数, 但是我实测还需要Date的参数, 似乎Signature参数是通过Date进行计算出来的.

(English version translate by GPT-3.5)

效果图(原宽带 200M / 30M)

下载

download

说明: 下载蜗牛游戏出品的一款航海世纪游戏, 测得速度.

上传速度

upload

说明: 上传速度是由其他城市(由于电信的宽带提供公网IP, 因此我做了资源的公网暴露)的服务器下载存储在个人家用服务器的资源, 所得出的下载速度即为家用服务器所使用宽带的上传速度, 由于资源属于私人资源, 不公开, 因此以上包含私人内容(包括IP, 下载地址, 具体大小等)均做遮掩.

原文参考

使用家庭云为电信宽带提速教程

准备工作

  1. 准备好 Charles(抓包工具) 下载 Charles Web Debugging Proxy HTTP Monitor / HTTP Proxy / HTTPS
  2. Android反编译工具 dex2jar 下载 dex2jar SourceForge
  3. Jd-GUI 下载 Jd-GUI benow.ca 下载Jd-GUI Github
  4. 天翼家庭云 Android版本 下载天翼家庭云 Android 189.cn,地址无效 下载天翼家庭云 Android 7.3.0 地址已经无效

先按照原文思路来一次

  1. 首先, 按照原文参考的文章, 我先来一遍(从StartQos之前, 本文将不在阐述)

  2. 从Charles中我得到了这些参数(我看到请求返回了400, 但是实际已经提速了, 所以我就认为400是一个正常的返回code)
    params

  3. 我看到了文章所阐述的SessionKey和Signature, 我立刻使用PostMan进行模拟发送, 得到了这样的消息(其中Body为空, 尽管上面需要什么prodCode啊, clientType啊等等, 先不填)
    errors

  4. 发现返回了并不是我的预期值, 似乎需要Date参数, 添加Date后, 得到了预期结果.

    expectedResponse

  5. 但是我修改了Date, 就得到

    1
    2
    3
    4
    5
    <?xml version="1.0" encoding="UTF-8"?>
    <error>
    <code>InvalidArgument</code>
    <message>sessionsignature is not match</message>
    </error>
  6. 所以从上得知, 应该是天翼做了升级, 追加了Date进行验证.

折腾开始

  1. 我想, 既然加了Date验证, 那服务端很有可能加了对时间判断, 可是IOS版的不能反编译(汇编啥的不算), 所以我用Android平台上的dex2jar工具进行反编译操作, 研究下这个Date怎么玩的, 然后这个Signature又是怎么算出来的.

  2. 下载好天翼家庭云 Android版, 这里用7.3.0版本(2019-2-9此刻的最新版本), 准备好dex2jar, 反编译可以参考Android APK 反编译实践, 或者自行搜索dex2jar的使用, 本文不再阐述.

  3. 反编译过程会提示错误, 不用管它, 此时得到了3个文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    D:\********>cd D:\********\dex2jar-2.0

    D:\********\dex2jar-2.0>d2j-dex2jar.bat C:\********\classes.dex
    dex2jar C:\********\classes.dex -> .\classes-dex2jar.jar
    Detail Error Information in File .\classes-error.zip
    Please report this file to http://code.google.com/p/dex2jar/issues/entry if possible.

    D:\********\dex2jar-2.0>d2j-dex2jar.bat C:\********\classes2.dex
    dex2jar C:\********\classes2.dex -> .\classes2-dex2jar.jar
    Detail Error Information in File .\classes2-error.zip
    Please report this file to http://code.google.com/p/dex2jar/issues/entry if possible.

    D:\********\dex2jar-2.0>d2j-dex2jar.bat C:\********\classes3.dex
    dex2jar C:\********\classes3.dex -> .\classes3-dex2jar.jar
    Detail Error Information in File .\classes3-error.zip
    Please report this file to http://code.google.com/p/dex2jar/issues/entry if possible.

    D:\********\dex2jar-2.0>

    fileList

  4. 用jd-gui来将这些文件都提取出源码来后, 解压这些源码, 这样可以方便使用Sublime等工具进行全文搜索(Jd-GUI搜索我嫌麻烦, 这一步可以跳过.)
    tyunSourceFilesUnpacked

  5. 调用的接口既然是startQos.action, 这就是一个关键字, 先全文搜索这个startQos来获取一点线索, 全文搜索得到了这些, 从这里看出最可疑的就是第三段, 那个来自classes2.jar里面的StartQosRequest.java这个文件(p.s. 这么快就找到了, 太出乎预料了…)

    globalSearchResult

  6. 使用Jd-GUI可以看到代码如下, 从这段代码看到, 它调用了send方法, 然后参数就是请求地址, 但是在调用请求之前, 调用了addSessionHeaders, 这应该就是这次的目标了(加密都不加全一点….), 不犹豫, 方法直接进去!

    jd-gui code

  7. 进入addSessionHeaders方法中, 会看到它又调用了HelperUtil.addSessionHeader(this.mHttpRequest, paramSession, paramString);方法, 再进去, 来到了这里, 看到这次的目标了.

    addSessionHeader source

    这一行, 特别明显

    1
    getSignatrue(paramString, str2, paramSession.getSessionSecret(), paramHttpRequestBase.getMethod(), str1)

    很明显, 这里应该就是那个Sign的算法了, 进去后, 代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public static String getSignatrue(String paramString1, String paramString2, String paramString3, String paramString4, String paramString5)
    {
    StringBuilder localStringBuilder1 = new StringBuilder();
    StringBuilder localStringBuilder2 = new StringBuilder();
    localStringBuilder2.append(FamilyConfig.SessionKey);
    localStringBuilder2.append("=");
    localStringBuilder1.append(localStringBuilder2.toString());
    localStringBuilder1.append(paramString2);
    localStringBuilder1.append("&Operate=");
    localStringBuilder1.append(paramString4);
    if (paramString1.startsWith("/")) {
    localStringBuilder1.append("&RequestURI=");
    } else {
    localStringBuilder1.append("&RequestURI=/");
    }
    localStringBuilder1.append(paramString1);
    localStringBuilder1.append("&Date=");
    localStringBuilder1.append(paramString5);
    DLog.v("httpSignature", localStringBuilder1.toString());
    return CodecUtil.hmacsha1(localStringBuilder1.toString(), paramString3);
    }

    再进到CodecUtil.hmacsha1后, 如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23

    public static String hmacsha1(String paramString1, String paramString2)
    {
    try
    {
    try
    {
    Mac localMac = Mac.getInstance("HmacSHA1");
    localMac.init(new SecretKeySpec(paramString2.getBytes(), "HmacSHA1"));
    paramString1 = localMac.doFinal(paramString1.getBytes());
    }
    catch (InvalidKeyException paramString1)
    {
    paramString1.printStackTrace();
    }
    }
    catch (NoSuchAlgorithmException paramString1)
    {
    for (;;) {}
    }
    paramString1 = null;
    return ByteFormat.toHex(paramString1);
    }

    分析: 从以上代码得出, 这个Sign需要以下参数:

    1. 第一个paramString是固定的值 family/qos/startQos.action
    2. 第二个paramString是sessionKey, 它调用了paramSession.getSessionKey()
    3. 第三个paramString是sessionSecret, 一个新的参数
    4. 第四个paramString是request的请求方式, 因为请求是POST, 所以它的值也是固定的POST
    5. 第五个paramString是那个时间Date. 最后调用hmacsha1使用HmacSHA1算法进行sha1加密, 得出Signature
    6. syncServerTime方法中使用了SystemClock.elapsedRealtime(), 这里Android用来获取开机时间的方法, 即开机的那个时间到现在的时间数, 然后FamilyConfig.pre_elapsed_time可以猜测出这是上次启动的时间, 但是服务器怎么知道我什么时候开机的? 我这里2个值就使用写死的固定值.
  8. 将这些代码复制出来, 然后稍作整理, 最后整理后的代码如下, 其中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    private static final String SESSION_KEY= "SessionKey";
    private static final String ACCESS_URL = "family/qos/startQos.action";

    private static String syncServerDate() {
    SimpleDateFormat localSimpleDateFormat = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
    Date localObject1 = new Date();
    localSimpleDateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
    String str = localSimpleDateFormat.format((Date)localObject1);
    long l1 = 16000; // 原 SystemClock.elapsedRealtime() 系统启动时间, 随便填
    long l2 = 12500; // 原 FamilyConfig.pre_elapsed_time, 上次系统启动时间, 随便填
    Date localObject2 = new Date(localObject1.getTime() + (l1 - l2));
    if (localObject2 != null) {
    try {
    localSimpleDateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
    return localSimpleDateFormat.format((Date)localObject2);
    } catch (Exception localException2) {
    localException2.printStackTrace();
    }
    }
    return str;
    }
    public static String getSignatrue(String accessUrl, String sessionKey, String sessionSecret, String requestMethod, String syncServerDate)
    {
    StringBuilder localStringBuilder1 = new StringBuilder();
    StringBuilder localStringBuilder2 = new StringBuilder();
    localStringBuilder2.append(SESSION_KEY);
    localStringBuilder2.append("=");
    localStringBuilder1.append(localStringBuilder2.toString());
    localStringBuilder1.append(sessionKey);
    localStringBuilder1.append("&Operate=");
    localStringBuilder1.append(requestMethod);
    if (accessUrl.startsWith("/")) {
    localStringBuilder1.append("&RequestURI=");
    } else {
    localStringBuilder1.append("&RequestURI=/");
    }
    localStringBuilder1.append(accessUrl);
    localStringBuilder1.append("&Date=");
    localStringBuilder1.append(syncServerDate);
    return hmacsha1(localStringBuilder1.toString(), sessionSecret);
    }

    public static String hmacsha1(String paramString1, String paramString2) {
    try {
    Mac localMac = Mac.getInstance("HmacSHA1");
    localMac.init(new SecretKeySpec(paramString2.getBytes(), "HmacSHA1"));
    return toHex(localMac.doFinal(paramString1.getBytes()));
    } catch (Exception e) {
    e.printStackTrace();
    }
    return null;
    }

    public static String toHex(byte[] paramArrayOfByte) {
    if ((paramArrayOfByte != null) && (paramArrayOfByte.length != 0)) {
    StringBuilder localStringBuilder = new StringBuilder();
    int i = 0;
    while (i < paramArrayOfByte.length) {
    localStringBuilder.append(HEX[(paramArrayOfByte[i] >> 4 & 0xF)]);
    localStringBuilder.append(HEX[(paramArrayOfByte[i] & 0xF)]);
    i += 1;
    }
    return localStringBuilder.toString();
    }
    return "";
    }
  9. 以上就知道了Sign的算法, 现在有新的问题出来了, 这个Secret怎么得到?

尝试抓包获得Secret的相关信息

猜测: 一般secret是用来和服务器通信时使用的token, 很有可能是app和服务端使用的令牌, 而且作为这么重要的内容, 估计电信不会傻到用http来传输, 估计这里使用的是https了(结果与猜测一致), Charles是支持使用https进行抓包的, 这里需要做一些配置.

Charles配置Https抓包

  1. Android设备参考此处, 以下是IOS的配置, 首先, 回到Charles, 选择Tab栏 Help -> SSL Proxying -> Install Charles Root Certificate on a Mobile Device or Remote Browser

    我这里其实已经设置好了SSL, 所以可以看到https的包也能抓到

    iosCharlesHttps

  2. 点击后出现如下图, 告诉我们在设备中配置Charles为192.168.1.100:xxxx这个地址(这句话也同时告诉了我们, 手机和运行Charles的电脑要处于同一个局域网), 然后, 然后使用手机浏览器访问chls.pro/ssl来下载和安装这个证书, 并且有特别提示, 对于IOS10及以上的系统, 必须进入 设置 -> 通用 -> 关于本机 -> 证书信任设置 -> 在针对根证书启用完全信任中打开Charles证书的完全信任

    installing

  3. 照着它的方法做, 基本步骤如下, 如果你有Apple Watch, 可能会有第二张图的提示, 选择iPhone, 最后别忘了去设置信任证书

    stepForInstallSSLCapture

  4. 这样做应该就可以了, 去Charles菜单栏 Proxy -> SSL Proxying Settings..., 打开后如右图, 直接设置*, 端口443, 用来捕获所有的443端口的SSL请求(tips. ssl不一定就是443端口, 还有8443)

    settingCharlesOpenSSLCapture

再次尝试抓包获得Secret的相关信息

  1. 重新运行家庭云, 看到包都抓到了, 不断寻找后, 定位到这么一条(直接说结果吧, 就是它.)

    This, itsThis

  2. 经过测试后, 发现这就是我们要的sessionKey, 将其值放在变量中, 算出Signanture, 最后发送请求, 得到了与app中发送相同的结果

    我也尝试去获得它是如何取得sessionKey和sessionSecret, 但是折腾了一半不想折腾了..就放弃了…

  3. 2019年3月30日补充: 表示这篇文章中获得是sessionKey, 到今天为止依然能用….

  4. 不要忘了及时关闭charles证书信任, 对一个自签根证书设为信任是有一定风险的.

试着自动化

然后就可以写一个java, 然后利用Crontab来定时发送, 或其他想得到的操作来让其维持提速(似乎需要10分钟发一次提速就可以了, 所以代码中也是10分钟的线程睡眠时间).

play

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
package com.ruterfu;

import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import okhttp3.FormBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;

/**
* @author Ruter
*/
public class Main {
private static final String SESSION_KEY= "SessionKey";
private static final String ACCESS_URL = "family/qos/startQos.action";
private static final String UP_QOS_URL = "http://api.cloud.189.cn/family/qos/startQos.action";
private static int count = 0;

private static final char[] HEX = { 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 65, 66, 67, 68, 69, 70 };
public static void main(String[] param) {
if(param == null || (param.length != 2 && param.length != 3)) {
System.out.println("example: java -jar {CURRENT}.jar --session=xxxxx --secret=yyyyyy");
return;
} else {
String session = "";
String secret = "";
boolean loop = false;
for(String s : param) {
if(s.startsWith("--session=")) {
session = s.substring(10);
} else if(s.startsWith("--secret=")) {
secret = s.substring(9);
} else if(s.startsWith("--loop=true")) {
loop = true;
}
}
if(session.length() == 0 || secret.length() == 0) {
System.out.println("example: java -jar {CURRENT}.jar --session=xxxxx --secret=yyyyyy");
return;
} else {
try {
if(loop) {
while(true) {
String[] response = runExec(session, secret);
printInfo("Running time " + (++count) + ", response code: " + response[0] + ", content: " + response[1]);
Thread.sleep(10 * 60 * 1000);
}
} else {
String[] response = runExec(session, secret);
printInfo("Request sent, response code: " + response[0] + ", content: " + response[1]);
}

} catch (Exception e) {
e.printStackTrace();
}
}
}
}

private static void printInfo(String string) {
System.out.println(getTime() + " Thread " + Thread.currentThread().getId() + " - INFO - " + string);
}
private static String[] runExec(String session, String secret) throws IOException {
String date = syncServerDate();
String signature = getSignatrue(ACCESS_URL, session, secret, "POST", date);
OkHttpClient okHttpClient = new OkHttpClient();
RequestBody formBody = new FormBody.Builder().add("prodCode", 76 + "").build();
Request req = new Request.Builder()
.post(formBody)
.url(UP_QOS_URL)
.addHeader("SessionKey", session)
.addHeader("Signature", signature)
.addHeader("Date", date)
.build();
try(Response response = okHttpClient.newCall(req).execute()) {
String content = response.body().string();
int start = content.indexOf("<message>");
int end = content.indexOf("</message>");
if(start != -1 && end > start) {
return new String[] {response.code() + "", content.substring(start + 9, end)};
} else {
return new String[] {response.code() + "", null};
}

}

}
private static String syncServerDate() {
SimpleDateFormat localSimpleDateFormat = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
Date localObject1 = new Date();
localSimpleDateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
String str = localSimpleDateFormat.format((Date)localObject1);
long l1 = 16000; // 原 SystemClock.elapsedRealtime() 系统启动时间, 随便填
long l2 = 12500; // 原 FamilyConfig.pre_elapsed_time, 上次系统启动时间, 随便填
Date localObject2 = new Date(localObject1.getTime() + (l1 - l2));
if (localObject2 != null) {
try {
localSimpleDateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
return localSimpleDateFormat.format((Date)localObject2);
} catch (Exception localException2) {
localException2.printStackTrace();
}
}
return str;
}
public static String getSignatrue(String accessUrl, String sessionKey, String sessionSecret, String requestMethod, String syncServerDate)
{
StringBuilder localStringBuilder1 = new StringBuilder();
StringBuilder localStringBuilder2 = new StringBuilder();
localStringBuilder2.append(SESSION_KEY);
localStringBuilder2.append("=");
localStringBuilder1.append(localStringBuilder2.toString());
localStringBuilder1.append(sessionKey);
localStringBuilder1.append("&Operate=");
localStringBuilder1.append(requestMethod);
if (accessUrl.startsWith("/")) {
localStringBuilder1.append("&RequestURI=");
} else {
localStringBuilder1.append("&RequestURI=/");
}
localStringBuilder1.append(accessUrl);
localStringBuilder1.append("&Date=");
localStringBuilder1.append(syncServerDate);
return hmacsha1(localStringBuilder1.toString(), sessionSecret);
}

public static String hmacsha1(String paramString1, String paramString2) {
try {
Mac localMac = Mac.getInstance("HmacSHA1");
localMac.init(new SecretKeySpec(paramString2.getBytes(), "HmacSHA1"));
return toHex(localMac.doFinal(paramString1.getBytes()));
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

public static String toHex(byte[] paramArrayOfByte) {
if ((paramArrayOfByte != null) && (paramArrayOfByte.length != 0)) {
StringBuilder localStringBuilder = new StringBuilder();
int i = 0;
while (i < paramArrayOfByte.length) {
localStringBuilder.append(HEX[(paramArrayOfByte[i] >> 4 & 0xF)]);
localStringBuilder.append(HEX[(paramArrayOfByte[i] & 0xF)]);
i += 1;
}
return localStringBuilder.toString();
}
return "";
}
public static String getTime() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return sdf.format(System.currentTimeMillis());
}
}