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加密算法,网上也有解密方案,具体可以参考 https://jixun.uk/posts/2024/qqmusic-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的网络请求分析完毕。
没有回复内容