某音乐软件二进制网络流逆向分析
2024-06-08
1. 前言
本文章对某音乐 APP 的二进制加密数据流进行分析,试图解密还原其请求和响应中的原文
注意:本文内容仅用于学习交流,请勿用于任何违反 APP 用户协议之处
APP 版本:13.7.0.8
工具及环境:
-
一台已 Root 的 Android 手机
-
激活 TrustMealready 模块
-
安装 Reqable
-
安装 Frida Server
-
-
一台 Windows 电脑
-
安装 Reqable
-
安装 jadx-gui
-
安装 Frida 环境
-
抓包
Reqable 开启协同模式并开启抓包。
抓包结果中存在大量包含 musics.cfg
结尾的请求,查看其请求体和响应体都是二进制格式。
请求体分析
由于该 APP 并没有使用任何加固,所以直接使用 jadx 分析该 app
先在请求头中发现特殊的 M-Encoding
字段,使用 jadx 进行全局搜索
其中 com.tencent.qqmusicplayerprocess.network.base.Request
的 m210698S
函数比较符合,进入查看一下
/* renamed from: S */
public void m210698S(byte[] bArr) {
byte[] bArr2 = SwordSwitches.switches24;
if (bArr2 != null && ((bArr2[519] >> 4) & 1) > 0 && SwordProxy.proxyOneArg(bArr, this, 261757).isSupported) {
return;
}
if (this.f189318a.f189661t) {
C55473c c55473c = C55473c.f184011b;
bArr = c55473c.mo202363c(bArr);
this.f189318a.m211137a("Content-Encoding", c55473c.mo202362b());
}
if (this.f189318a.m211143h() == 1) {
byte[] m202364a = InterfaceC55471a.INSTANCE.m202364a(C55472b.f184010b.mo202363c(bArr));
if (m202364a != null) {
this.f189318a.m211137a("M-Encoding", Keys.API_PARAM_KEY_M1);
bArr = m202364a;
} else {
this.f189318a.m211145j("M-Encoding");
}
}
this.f189322e = bArr;
this.f189330m = m210678b(bArr);
}
该函数的入参是 byte[] bArr
合理怀疑是原文转成数组了,使用 Frida 进行 hook,查看一下入参
Java.perform(function () {
let Request = Java.use("com.tencent.qqmusicplayerprocess.network.base.Request");
Request["S"].implementation = function (bArr) {
if (bArr) {
let str = "";
try {
str = Java.use("java.lang.String").$new(bArr);
} catch (e) {
console.log("Error converting byte[] to string: " + e);
str = Array.from(bArr).map(b => String.fromCharCode(b)).join('');
}
console.log(`bArr as string: ${str}`);
} else {
console.log("bArr is null or undefined");
}
return this["S"](bArr);
};
});
结果表明该函数的入参确实是请求的原文,对该函数涉及 bArr
入参的调用进行分析,共两处调用
这里因为两处调用处于不同的 if 分支,而 M-Encoding
字段位于第二处,于是先对 C55472b.f184010b.mo202363c
函数进行分析
package com.tencent.qqmusiccommon.cgi.zipper;
import com.tencent.qqmusic.sword.SwordProxy;
import com.tencent.qqmusic.sword.SwordProxyResult;
import com.tencent.qqmusic.sword.SwordSwitches;
import com.tencent.qqmusicplayerprocess.network.util.encoding.C56809a;
import com.tencent.wns.http.C59898b;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.zip.Inflater;
import kotlin.Metadata;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@Metadata(m300174bv = {}, m300175d1 = {"\u0000\u0018\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\u0010\u0012\n\u0002\b\u0005\n\u0002\u0010\u000e\n\u0002\b\u0004\bÆ\u0002\u0018\u00002\u00020\u0001B\t\b\u0002¢\u0006\u0004\b\n\u0010\u000bJ\u0014\u0010\u0004\u001a\u0004\u0018\u00010\u00022\b\u0010\u0003\u001a\u0004\u0018\u00010\u0002H\u0002J\u0014\u0010\u0006\u001a\u0004\u0018\u00010\u00022\b\u0010\u0005\u001a\u0004\u0018\u00010\u0002H\u0016J\u0014\u0010\u0007\u001a\u0004\u0018\u00010\u00022\b\u0010\u0003\u001a\u0004\u0018\u00010\u0002H\u0016J\b\u0010\t\u001a\u00020\bH\u0016¨\u0006\f"}, m300176d2 = {"Lcom/tencent/qqmusiccommon/cgi/zipper/b;", "Lcom/tencent/qqmusiccommon/cgi/zipper/a;", "", "data", "d", "requestContent", "c", "a", "", C59898b.f201894e, "<init>", "()V", "lib_release"}, m300177k = 1, m300178mv = {1, 6, 0})
/* renamed from: com.tencent.qqmusiccommon.cgi.zipper.b */
/* loaded from: classes5.dex */
public final class C55472b implements InterfaceC55471a {
/* renamed from: b */
@NotNull
public static final C55472b f184010b = new C55472b();
private C55472b() {
}
/* renamed from: d */
private final byte[] m202365d(byte[] data2) {
int inflate;
byte[] bArr = SwordSwitches.switches24;
if (bArr != null && ((bArr[482] >> 4) & 1) > 0) {
SwordProxyResult proxyOneArg = SwordProxy.proxyOneArg(data2, this, 261461);
if (proxyOneArg.isSupported) {
return (byte[]) proxyOneArg.result;
}
}
byte[] bArr2 = null;
if (data2 == null) {
return null;
}
Inflater inflater = new Inflater();
inflater.reset();
inflater.setInput(data2);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(data2.length);
try {
} catch (IOException e) {
e.printStackTrace();
}
try {
try {
byte[] bArr3 = new byte[1024];
while (!inflater.finished() && (inflate = inflater.inflate(bArr3)) > 0) {
byteArrayOutputStream.write(bArr3, 0, inflate);
}
byte[] byteArray = byteArrayOutputStream.toByteArray();
byteArrayOutputStream.close();
bArr2 = byteArray;
} catch (Exception e2) {
e2.printStackTrace();
byteArrayOutputStream.close();
}
inflater.end();
return bArr2;
} catch (Throwable th) {
try {
byteArrayOutputStream.close();
} catch (IOException e3) {
e3.printStackTrace();
}
throw th;
}
}
@Override // com.tencent.qqmusiccommon.cgi.zipper.InterfaceC55471a
@Nullable
/* renamed from: a */
public byte[] mo202361a(@Nullable byte[] data2) {
byte[] bArr = SwordSwitches.switches24;
if (bArr != null && ((bArr[480] >> 6) & 1) > 0) {
SwordProxyResult proxyOneArg = SwordProxy.proxyOneArg(data2, this, 261447);
if (proxyOneArg.isSupported) {
return (byte[]) proxyOneArg.result;
}
}
byte[] m202365d = m202365d(data2);
return m202365d == null ? data2 : m202365d;
}
@Override // com.tencent.qqmusiccommon.cgi.zipper.InterfaceC55471a
@NotNull
/* renamed from: b */
public String mo202362b() {
return "";
}
@Override // com.tencent.qqmusiccommon.cgi.zipper.InterfaceC55471a
@Nullable
/* renamed from: c */
public byte[] mo202363c(@Nullable byte[] requestContent) {
byte[] bArr = SwordSwitches.switches24;
if (bArr != null && ((bArr[479] >> 2) & 1) > 0) {
SwordProxyResult proxyOneArg = SwordProxy.proxyOneArg(requestContent, this, 261435);
if (proxyOneArg.isSupported) {
return (byte[]) proxyOneArg.result;
}
}
byte[] m211199b = C56809a.f189692a.m211199b(requestContent);
return m211199b == null ? requestContent : m211199b;
}
}
这片代码是 mo202363c
函数所在的 C55472b
类,根据包名猜测加密应该是跟压缩相关的算法。
首先看该函数,主要操作是调用了 C56809a.f189692a.m211199b
函数,继续进入该函数的声明。
@Nullable
/* renamed from: b */
public final byte[] m211199b(@Nullable byte[] data2) {
byte[] bArr = SwordSwitches.switches24;
if (bArr != null && ((bArr[511] >> 4) & 1) > 0) {
SwordProxyResult proxyOneArg = SwordProxy.proxyOneArg(data2, this, 261693);
if (proxyOneArg.isSupported) {
return (byte[]) proxyOneArg.result;
}
}
if (data2 != null) {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(data2.length);
DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(byteArrayOutputStream);
try {
try {
deflaterOutputStream.write(data2, 0, data2.length);
deflaterOutputStream.finish();
deflaterOutputStream.flush();
byte[] byteArray = byteArrayOutputStream.toByteArray();
try {
deflaterOutputStream.close();
byteArrayOutputStream.close();
} catch (IOException unused) {
}
return byteArray;
} catch (IOException e) {
C61942e.p.m148888c("MusicPrivateEncodeUtils", "compressData Ex: " + e);
try {
deflaterOutputStream.close();
byteArrayOutputStream.close();
return null;
} catch (IOException unused2) {
return null;
}
}
} catch (Throwable th) {
try {
deflaterOutputStream.close();
byteArrayOutputStream.close();
} catch (IOException unused3) {
}
throw th;
}
}
return null;
}
上述函数找到关键字 DeflaterOutputStream,主要操作就是使用 Deflater 对数据进行压缩。
回到最开始的 m210698S
函数,在使用 DEFLATE 压缩完数据后,又将其传入了 InterfaceC55471a.INSTANCE.m202364a
方法,进入该方法查看实现
public final byte[] m202364a(@Nullable byte[] source) {
IntRange until;
IntRange until2;
int random;
byte[] bArr = SwordSwitches.switches24;
if (bArr != null && ((bArr[476] >> 4) & 1) > 0) {
SwordProxyResult proxyOneArg = SwordProxy.proxyOneArg(source, this, 261413);
if (proxyOneArg.isSupported) {
return (byte[]) proxyOneArg.result;
}
}
if (source == null) {
return null;
}
byte[] bArr2 = new byte[5];
until = RangesKt___RangesKt.until(0, 5);
Iterator<Integer> it = until.iterator();
while (it.hasNext()) {
int nextInt = ((IntIterator) it).nextInt();
until2 = RangesKt___RangesKt.until(0, 100);
random = RangesKt___RangesKt.random(until2, Random.INSTANCE);
bArr2[nextInt] = (byte) random;
}
byte[] bArr3 = new byte[source.length + 5];
System.arraycopy(bArr2, 0, bArr3, 0, 5);
System.arraycopy(source, 0, bArr3, 5, source.length);
return bArr3;
}
该函数的主要操作就是随机生成 5 字节 0-99 的随机数,然后插入到入参之前。
以上就是请求体的加密方法
响应体分析
关于响应体的解密,尝试使用 Inflater 或者去除前 5 个字节然后使用 Inflater 解压缩,均失败了,看来请求体和响应体使用的加密应该不同。
仔细观察上述的 C55472b
类,其中的 m202365d
函数是对入参使用 Inflater 类来解压缩,Inflater 通常与 Deflater(压缩器)配对使用。Deflater 用于压缩数据,而 Inflater 用于解压缩。因此这里应该是与解压相关的操作。
查看 m202365d
函数的用例,发现只有同类中的 mo202361a
函数有进行调用,继续向上追踪 mo202361a
函数的用例。
在结果中,com.tencent.qqmusicplayerprocess.network.business.C56733b.m210768a0
函数有两处调用,进入查看
public static byte[] m210768a0(byte[] bArr, int i, int i2) {
Closeable closeable;
byte[] mo202361a;
Closeable closeable2;
byte[] bArr2 = SwordSwitches.switches24;
?? r1 = 2;
r1 = 2;
byte[] bArr3 = null;
bArr3 = null;
bArr3 = null;
Closeable closeable3 = null;
bArr3 = null;
Closeable closeable4 = null;
bArr3 = null;
if (bArr2 != null && ((bArr2[508] >> 1) & 1) > 0) {
SwordProxyResult proxyMoreArgs = SwordProxy.proxyMoreArgs(new Object[]{bArr, Integer.valueOf((int) i), Integer.valueOf(i2)}, null, 261666);
if (proxyMoreArgs.isSupported) {
return (byte[]) proxyMoreArgs.result;
}
}
try {
try {
try {
try {
if (i2 == 1) {
byte[] bArr4 = new byte[bArr.length - i];
r1 = new DataInputStream(new ByteArrayInputStream(bArr));
long j = (long) i;
long skip = r1.skip(j);
if (j != skip) {
C61942e.p.m148888c("CgiRequest", "[decryptData] skip:" + ((int) i) + " actualSkip:" + skip);
}
int read = r1.read(bArr4);
i = new ByteArrayOutputStream();
if (read > 0) {
i.write(bArr4, 0, read);
bArr3 = C55472b.f184010b.mo202361a(i.toByteArray());
r1 = r1;
i = i;
}
mo202361a = bArr3;
closeable3 = r1;
closeable2 = i;
m210777m0(closeable3);
m210777m0(closeable2);
return mo202361a;
}
if (i2 == 2) {
byte[] bArr5 = new byte[bArr.length - i];
r1 = new DataInputStream(new ByteArrayInputStream(bArr));
long j2 = (long) i;
long skip2 = r1.skip(j2);
if (j2 != skip2) {
C61942e.p.m148888c("CgiRequest", "[decryptData] skip:" + ((int) i) + " actualSkip:" + skip2);
}
int read2 = r1.read(bArr5);
i = new ByteArrayOutputStream();
if (read2 > 0) {
i.write(bArr5, 0, read2);
bArr3 = C61861a.controller.brotliConfigController.mo201050a(i.toByteArray());
r1 = r1;
i = i;
}
mo202361a = bArr3;
closeable3 = r1;
closeable2 = i;
m210777m0(closeable3);
m210777m0(closeable2);
return mo202361a;
}
mo202361a = C55473c.f184011b.mo202361a(bArr);
closeable2 = null;
m210777m0(closeable3);
m210777m0(closeable2);
return mo202361a;
} catch (IOException e) {
e = e;
i = 0;
C61942e.p.m148889d("CgiRequest", "[decryptData] ", e);
m210777m0(r1);
m210777m0(i);
return bArr3;
} catch (Throwable th) {
th = th;
i = 0;
closeable4 = 2;
closeable = i;
m210777m0(closeable4);
m210777m0(closeable);
throw th;
}
} catch (IOException e2) {
e = e2;
i = 0;
r1 = 0;
C61942e.p.m148889d("CgiRequest", "[decryptData] ", e);
m210777m0(r1);
m210777m0(i);
return bArr3;
} catch (Throwable th2) {
th = th2;
closeable = null;
m210777m0(closeable4);
m210777m0(closeable);
throw th;
}
} catch (IOException e3) {
e = e3;
C61942e.p.m148889d("CgiRequest", "[decryptData] ", e);
m210777m0(r1);
m210777m0(i);
return bArr3;
}
} catch (Throwable th3) {
th = th3;
closeable4 = 2;
closeable = i;
m210777m0(closeable4);
m210777m0(closeable);
throw th;
}
}
上述函数虽然很长,简化分析就只有几个操作
-
函数接收一个字节数组 bArr、一个整数 i 和一个整数 i2 作为输入参数
-
根据 i2 的值 (1 或 2) 来决定使用不同的解密方式
-
如果 i2 == 1, 使用
C55472b.f184010b.mo202361a
方法解密 -
如果 i2 == 2, 使用
C61861a.controller.brotliConfigController.mo201050a
方法解密 -
如果 i2 不是 1 或 2, 使用
C55473c.f184011b.mo202361a
方法解密
-
-
对于 i2 为 1 或 2 的情况
-
从 bArr 中跳过前 i 个字节
-
读取剩余的字节到一个新的字节数组
-
将读取的字节写入 ByteArrayOutputStream
-
使用相应的解密方法处理这些字节
-
分析完后发现主要有三处不同的解密算法,其中 C55472b.f184010b.mo202361a
就是我们刚才分析的使用 Deflater 解压数据,第二个方法是使用 C61861a.controller.brotliConfigController.mo201050a
进行解密,其中有一个 brotli
关键词,可以大胆猜测在使用 Brotli 算法进行解压缩,最后一个 C55473c.f184011b.mo202361a
方法,进入查看一下声明。
private final byte[] m202366d(byte[] data2) {
boolean z;
GZIPInputStream gZIPInputStream;
Throwable th;
byte[] bArr = SwordSwitches.switches24;
if (bArr != null && ((bArr[483] >> 7) & 1) > 0) {
SwordProxyResult proxyOneArg = SwordProxy.proxyOneArg(data2, this, 261472);
if (proxyOneArg.isSupported) {
return (byte[]) proxyOneArg.result;
}
}
if (data2 != null) {
if (data2.length == 0) {
z = true;
} else {
z = false;
}
if (!z) {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data2);
try {
try {
gZIPInputStream = new GZIPInputStream(byteArrayInputStream);
} catch (IOException unused) {
gZIPInputStream = null;
} catch (Throwable th2) {
gZIPInputStream = null;
th = th2;
}
try {
byte[] bArr2 = new byte[1024];
while (true) {
int read = gZIPInputStream.read(bArr2);
if (read >= 0) {
byteArrayOutputStream.write(bArr2, 0, read);
} else {
byte[] byteArray = byteArrayOutputStream.toByteArray();
gZIPInputStream.close();
byteArrayInputStream.close();
byteArrayOutputStream.close();
return byteArray;
}
}
} catch (IOException unused2) {
if (gZIPInputStream != null) {
gZIPInputStream.close();
}
byteArrayInputStream.close();
byteArrayOutputStream.close();
return null;
} catch (Throwable th3) {
th = th3;
if (gZIPInputStream != null) {
try {
gZIPInputStream.close();
} catch (IOException unused3) {
throw th;
}
}
byteArrayInputStream.close();
byteArrayOutputStream.close();
throw th;
}
} catch (IOException unused4) {
return null;
}
} else {
return data2;
}
} else {
return data2;
}
}
@Override // com.tencent.qqmusiccommon.cgi.zipper.InterfaceC55471a
@Nullable
/* renamed from: a */
public byte[] mo202361a(@Nullable byte[] data2) {
byte[] bArr = SwordSwitches.switches24;
if (bArr != null && ((bArr[482] >> 7) & 1) > 0) {
SwordProxyResult proxyOneArg = SwordProxy.proxyOneArg(data2, this, 261464);
if (proxyOneArg.isSupported) {
return (byte[]) proxyOneArg.result;
}
}
return m202366d(data2);
}
看到这里发现非常眼熟 C55472b
和 C55473c
类都是 InterfaceC55471a
接口的实现类,在 C55473c
类的 mo202361a
方法中又调用了 m202366d
方法,看到 GZIPInputStream 关键字就知道这里在使用 gzip 进行解压缩。
测试
至此,三种解压算法就清晰了,这里我们可以使用 Python 编写一个类似的解密函数来进行测试
import zlib
import brotli
import gzip
def decrypt_data(bArr, i, i2):
# 跳过前 i 个字节
data_to_process = bArr[i:]
# 根据 i2 选择解密方式
if i2 == 1:
# 使用 zlib 解压缩
decrypted_data = zlib.decompress(data_to_process)
elif i2 == 2:
# 使用 Brotli 解压缩
decrypted_data = brotli.decompress(data_to_process)
else:
# 使用 gzip 解压缩
decrypted_data = gzip.decompress(data_to_process)
return decrypted_data.decode()
将抓包结果的请求体和响应体导出为.bin 二进制文件,经过测试请求体去除前 5 个随机字节后,使用 zlib 进行解压,响应体直接使用 brotli 进行解压
with open("1.bin", "rb") as f:
file_data = f.read()
result = decrypt_data(file_data, 5, 1)
print(result)
with open("2.bin", "rb") as f:
file_data = f.read()
result = decrypt_data(file_data, 0, 2)
print(result)
这里请求体和响应体的数据也是成功还原了
模拟
虽然成功解密了请求体和请求体,但是 Headers 中还存在 sign 和 mask 两个签名参数,这两个参数是调用 JNI 函数生成的,在 so 层实现的加密。
package com.tencent.qqmusic.modular.framework.encrypt.logic;
/* loaded from: classes4.dex */
public class MERJni {
/* renamed from: a */
public static void m179988a() {
System.loadLibrary("mer");
}
public static native String calc(byte[] bArr, byte[] bArr2);
}
不过由于能力有限,暂时没找到解密方法,如果感兴趣的可以尝试去逆向一下 libmer.so
虽然但是,如果你尝试在 APP 中尝试访问一个 webview 界面并抓包,会发现有大量的 musics.cfg 结尾的请求,不同之处是 params 多了 _webcgikey、_、sign 三个参数,另外请求体和响应体都是明文
其中 _webcgikey 是调用的 method 名称,如果调用了多个 module 使用 _
分隔,_
参数是请求的毫秒级时间戳,sign 参数是对请求体的加密,固定以 zzc 开头。
关于 zzc 版本的 sign 加密算法,网上也有解密方案,具体可以参考
那么 APP 的接口和 Web 接口使用相同的网关,是否通用呢?
经过对比 APP 请求体和 Web 请求体,有几点不同
其中请求体中都包含了 comm 字段,不同之处在于 APP 的请求体把 cookies 字段都写入了 comm 字段,web 接口的请求体把 cookies 都写到 headers 里了。
APP 请求体中,每个方法调用使用 module名.method名
作为 key,而 web 请求使用 req_0
开始递增作为 key,而 value 中都包含了 module
、method
、param
字段。
经过测试,将 APP 的请求体改为 Web 接口的请求格式后,也能正常发送并接收响应,至此对该 APP 的网络请求分析完毕。