Speed up home broadband with Tianyi Home Cloud, and decompile Tianyi Home Cloud APK to obtain the Signature algorithm

August 3, 2019, this method is no longer working as the interface has been closed by China Telecom

This article is a record of my tinkering process. Recently, I discovered the Tianyi Home Cloud app launched by China Telecom on my iPhone, which has a speed-up feature that can boost the broadband speed to 500Mbps downstream and 50Mbps upstream. I wondered if it could be used to continuously accelerate other applications. I found that it was possible, but the methods mentioned online only mentioned two parameters, Signature and SessionKey. However, in my tests, I found that the Date parameter was also required, and it seemed that the Signature parameter was calculated based on the Date.(English version Translated by GPT-3.5, 返回中文)

Screenshots (original broadband speed 200Mbps / 30Mbps)

Download speed

download

Note: Download speed measured by downloading a game produced by Snail Games.

Upload speed

upload

Note: The upload speed is the result of downloading resources from a server in another city (China Telecom’s broadband provides a public IP address, so I exposed my personal server’s resources to the public network). The download speed represents the upload speed of my home server’s broadband. As the resources are private, I have blurred out any personal content (including IP address, download link, specific size, etc.) in the screenshot.

Reference

Speed up China Telecom broadband using Tianyi Home Cloud

Preparation

  1. Prepare Charles (a network traffic analyzer tool) Download Charles Web Debugging Proxy HTTP Monitor / HTTP Proxy / HTTPS
  2. Dex2jar, an Android decompilation tool Download dex2jar SourceForge
  3. Jd-GUI Download Jd-GUI benow.ca Download Jd-GUI Github
  4. Tianyi Home Cloud Android version Download Tianyi Home Cloud Android 189.cn (Invalid link) Download Tianyi Home Cloud Android 7.3.0 (Invalid link)

Following the original article

  1. First, following the steps in the original article, I went through the process (starting from before StartQos) without further explanation in this article.

  2. From Charles, I obtained these parameters (I noticed that the request returned a 400 code, but the speed had actually increased, so I assumed that 400 was a normal code)
    params

  3. I saw the SessionKey and Signature mentioned in the article and immediately used Postman to simulate the request. I got a response like this (even though the body is empty, I didn’t fill in the required parameters such as prodCode and clientType for now)
    errors

  4. I found that the response was not what I expected, and it seemed that the Date parameter was also required. After adding the Date parameter, I got the expected result.
    expectedResponse

  5. However, when I changed the Date, I got a response like this:

    1
    2
    3
    4
    5
    <?xml version="1.0" encoding="UTF-8"?>
    <error>
    <code>InvalidArgument</code>
    <message>sessionsignature is not match</message>
    </error>
  6. From this, it can be inferred that Tianyi Home Cloud has been upgraded and Date has been added for verification.

Further exploration

  1. I thought, since Date verification has been added, the server is likely to have a time check. However, I could not decompile the iOS version (assembly language etc.), so I used the dex2jar tool on the Android platform to decompile it and study how Date and Signature are calculated.

  2. I downloaded the Tianyi Home Cloud Android version, specifically version 7.3.0 (the latest version as of February 9, 2019). I also prepared dex2jar. Decompilation can be done following this guide Android APK Decompilation Practice, or you can search for how to use dex2jar on your own. This article will not go into further explanation.

  3. Decompilation process may show errors, but don’t worry. At this point, I obtained three files:

    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. Use jd-gui to extract the source code from these files. Unpack the source code to facilitate full-text search using tools such as Sublime (I find Jd-GUI search to be cumbersome and unnecessary in this step).
    tyunSourceFilesUnpacked

  5. Since the called interface is startQos.action, this is a keyword, so I performed a global search for startQos to get some clues. The search results reveal something suspicious in the third section, the StartQosRequest.java file inside the classes2.jar (Wow, found it so quickly, it exceeded my expectations…)
    globalSearchResult

  6. Using Jd-GUI, I found the following code. From this code, it can be seen that it calls the send method, with the request address as the parameter. However, before making the request, it calls the addSessionHeaders method, which is the target we are looking for (they didn’t even bother to encrypt it properly!). Without hesitation, let’s dive straight into the method!
    jd-gui code

  7. Inside the addSessionHeaders method, it calls the HelperUtil.addSessionHeader(this.mHttpRequest, paramSession, paramString)method, so let’s go deeper. In the next method, we can find our target.
    addSessionHeader source
    This line is especially obvious:

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

    It seems that this is the algorithm for calculating the Signature. Upon entering it, we find the following code:

    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);
    }

    When we go deeper into the CodecUtil.hmacsha1 method, we find the following:

    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);
    }

    Analysis: Based on the above code, the Sign algorithm requires the following parameters:

  8. The first paramString is a fixed value: family/qos/startQos.action

  9. The second paramString is the sessionKey. It calls paramSession.getSessionKey()

  10. The third paramString is the sessionSecret, a new parameter

  11. The fourth paramString is the request method, which is fixed as POST because the request is a POST request

  12. The fifth paramString is the Date. Finally, the hmacsha1 method is called to encrypt using the HmacSHA1 algorithm, producing the Signature

  13. The syncServerTime method uses SystemClock.elapsedRealtime(), which is an Android method used to get the time since the system was booted, represented as the elapsed time between the system startup and the current time. FamilyConfig.pre_elapsed_time can be inferred as the time of the last startup. However, how does the server know when my device was started up? I will simply use fixed values for these two parameters.

  14. Copy these codes, reorganize them slightly, and the final reorganized code is as follows:

    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 "";
    }
  15. Now we know the Sign algorithm, but a new question arises: How do we obtain the Secret?

Attempting to capture the relevant information for Secret

Guess: Generally, a secret token is used for communication between the app and the server, most likely as a token for authentication. As it is such an important piece of information, China Telecom probably wouldn’t transmit it over HTTP, so it’s likely using HTTPS (as confirmed by the guess). Charles supports capturing HTTPS traffic, and some configuration is required.

Configuring Charles for HTTPS capture

  1. For Android devices, refer to this link. The following instructions are for iOS. First, go back to Charles and select the “Help” tab, then choose “SSL Proxying” > “Install Charles Root Certificate on a Mobile Device or Remote Browser”

    I already set up the SSL capture, so I can capture HTTPS requests in the screenshot.

    iosCharlesHttps

  2. After clicking, the following screen appears, instructing us to configure Charles in the device with the address “192.168.1.100:xxxx” (This also tells us that the phone and the computer running Charles need to be on the same local network). Then, use the phone’s browser to visit chls.pro/ssl and download and install the certificate. There is also a special note: “For iOS 10 and above, you must go to Settings -> General -> About -> Certificate Trust Settings and enable complete trust for the Charles certificate.”

    installing

  3. Follow the instructions. The basic steps are as follows: “If you have an Apple Watch, you may see the prompt in the second image. Choose ‘iPhone’ and don’t forget to go to the settings to trust the certificate.”

    stepForInstallSSLCapture

  4. Once completed, go to the Charles menu bar, choose “Proxy” > “SSL Proxying Settings…”, and open the settings. Set * as the host and port 443 to capture all SSL requests on the 443 port (note: SSL is not necessarily on the 443 port, it can also be on port 8443).

    settingCharlesOpenSSLCapture

Capturing the relevant information for Secret again

  1. Run Tianyi Home Cloud again and noticed that the packets were captured. After some searching, I pinpointed the following (to cut to the chase, this is it).

    This, itsThis

  2. After testing, I found that this was the sessionKey we needed. I stored its value in a variable, calculated the Signature, sent the request, and obtained the same result as what the app sent.

    I also tried to figure out how it obtained the sessionKey and sessionSecret, but I gave up halfway… It became too much of a hassle…

  3. March 30, 2019, update: It should be noted that sessionKey obtained in this article is still valid…

  4. Don’t forget to close the certificate trust for Charles in a timely manner. There are certain risks associated with trusting a self-signed root certificate.

Attempting Automation

Now, you can write a Java program and use Crontab to schedule it to run regularly, or perform other operations to maintain the speed boost (it seems that sending the acceleration request every 10 minutes is sufficient, so the code also includes a 10-minute thread sleep).

play

The code is as follows:

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());
}
}