本帖最后由 red1y 于 2022-9-21 22:26 编辑
xx度灰app加密算法分析还原
本文包括
一. 修改测试
- 重新打包签名生成可调试版本后运行闪退
- 使用mt管理修改dex文件重新编译后正常运行,访问相关资源会提示正版维护信息
- 使用mt管理器重新签名后运行闪退
二、Java层静态分析
-
定位启动Activity
<activity android:name="com.tencent.mm.ui.LaunchActivity" android:screenOrientation="1" android:theme="@style/AppTransparentTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
-
分析onCreate 函数
LauncheActivity 继承自BaseActivity ,BaseActivity 中调用了几个函数,经分析确定了w5() 为关键函数:
@Override // android.support.v7.app.AppCompatActivity
protected void onCreate(@nullable Bundle arg2) {
this.Z5();
super.onCreate(arg2);
try {
this.a = android.databinding.f.l(this.O5(), this.Q5());
this.V5();
com.tencent.mm.base.f.c().a(this);
this.W5(); // 关键函数
this.U5();
this.b6();
}
catch(Exception v2) {
v2.printStackTrace();
}
}
-
跟踪w5()
在前面测试过程中有一个信息是:修改了签名后的app并不会直接闪退,而是在申请完相关使用权限后才会退出,如果没有通过权限的申请,会自动正常退出,而不是闪退;在w5() 中找到了相关权限申请的函数T6()
@Override // com.tencent.mm.base.BaseActivity
public void W5() {
/* other code */
int v0_1 = this.checkSha1(this) ? 1 : 2;
com.tencent.mm.network.d.h = this.D6(this) + ":" + v0_1;
org.greenrobot.eventbus.c.f().t(this);
this.I = new LaunchModel(this);
this.z6();
this.T6(); // 在T6中进行权限申请
String v0_2 = i1.k().E();
if(!TextUtils.isEmpty(v0_2)) {
com.tencent.mm.l.j.d().v(((UserInfoBean)JSON.parseObject(v0_2, UserInfoBean.class)));
}
}
在T6() 中如果没有赋予应用相关权限,则会结束应用,否则进入B6()
public void T6() {
/* other code */
// if no permission, return and eixt
LaunchActivity.this.B6();
跟踪B6() 后续的一系列函数调用,最终定位到一个向服务器发送请求的函数
public void q(String arg6) {
d.D1().N4(arg6);
d.D1().d4("http://xxxx/.../xxx", d.D1().x1(), new b("/api/xxx/xxx") {
}
}
开启Fiddler 抓报后,发现应用自启动到闪退没有发送任何请求,d4() 函数中在发送请求前进行了一系列的数据操作
public void d4(String arg2, HttpParams arg3, com.tencent.mm.network.b arg4) {
((PostRequest)((PostRequest)((PostRequest)((PostRequest)((PostRequest)OkGo.post(arg2).tag(arg4.b())).upJson(this.s2(arg3).toJSONString())).headers("token", i1.k().w())).cacheKey(this.C0(arg4.a()))).cacheMode(CacheMode.FIRST_CACHE_THEN_REQUEST)).execute(arg4);
}
upJson() 参数即为上传的数据,经过了s2() 的处理,跟进s2() ,最终数据的加密封装在com.szcx.lib.encrypt.c.k() 中进行
public String k(String arg5) throws JSONException {
String v5 = this.e(arg5);
JSONObject v2 = new JSONObject();
v2.put("timestamp", "1663503240");
v2.put("_ver", "v1");
v2.put("data", v5);
v2.put("sign", this.j(a.e("_ver=v1&data=" + v5 + "×tamp=" + "1663503240" + this.e)));
return v2.toString();
}
经过对正常app运行时的抓包比较,此处的参数与实际一致,在e() 中队数据进行了加密,最终调用native 函数进行加密
public String f(String arg1, String arg2) {
return EncryptUtil.encrypt(arg1, arg2);
}
public static native String encrypt(String arg0, String arg1) {
}
同时在代码中发现了多个密钥,包括但不限于,第一个base64 编码的密钥在跟踪流程中传递给了native 函数
BwcnBzRjN2U/MmZhYjRmND4xPjI+NWQwZWU0YmI2MWQ3YjAzKw8cEywsIS4BIg==
81d7beac44a86f4337f534ec9332837
三、Java层动态跟踪、Hook分析
-
将前面重新打包签名生成的可调试的apk 安装到手机上,为防止应用直接闪退,拒绝其相关权限的申请,同时在程序判断权限申请结果处下断,动态修改权限申请的结果,使后续流程继续下去
-
调试跟踪函数,最终定位发现程序在加载上述native 加密so 库时闪退
.method static constructor <clinit>()V
.registers 1
00000000 const-string v0, "sojm"
00000004 invoke-static System->loadLibrary(String)V, v0
0000000A return-void
.end method
-
同时在上面的跟踪过程中还可以得到程序生成的一系列请求参数,包含了大量系统、设备信息,但没有hash 相关的参数
-
使用 frida hook encrypt 函数,主动调用其多次加密相同数据,可以发现每次得到的结果都不同,应该使用了某种随机量
四、so层静态分析
-
使用ida pro 分析sojm 库,通过观察函数名可以得到其是通过静态注册的,这里的四个参数也符合常规的jni 函数
// JNIEnv* env
// jclass _clazz
// jstring a3
// jstring a4
int __fastcall Java_com_qq_lib_EncryptUtil_encrypt(int a1, int a2, int a3, int a4)
{
int v8; // r4
int v10[4]; // [sp+4h] [bp-2Ch] BYREF
int v11; // [sp+14h] [bp-1Ch]
v8 = cgo_wait_runtime_init_done();
v10[3] = a4; // jstring
v10[2] = a3; // jstring
v10[1] = a2; // _clazz
v10[0] = a1; // env
v11 = 0;
crosscall2(cgoexp_17c794619cba_Java_com_qq_lib_EncryptUtil_encrypt, v10, 20, v8);
cgo_release_context(v8);
return v11; // jstring
}
-
但是后续的操作就不太常规了,可以看出它把参数依次赋给了一个数组;同时调用了crosscall2 ,其参数为:
- 一个函数地址
- 参数数组
- 应该是参数数组的长度,size
init 函数的返回值
值得注意的是,v10 明明只有四个元素,但是传入的参数size却是20 = 5 * 4 ,同时v11 被置0 后又没有显式的赋值,最终却被返回,猜测应该是在cgoexp_17c794619cba_Java_com_qq_lib_EncryptUtil_encrypt 中被赋值了
-
进入cgoexp_17c794619cba_Java_com_qq_lib_EncryptUtil_encrypt 后发现参数个数很奇怪,而且sub_BC3C4658 传入了很多重复的参数;
int __fastcall cgoexp_17c794619cba_Java_com_qq_lib_EncryptUtil_encrypt(int a1, int a2, int a3, int a4, int a5, int a6)
{
int v6; // r10
int v7; // lr
int v9; // [sp+14h] [bp-4h]
int v10; // [sp+14h] [bp-4h]
while ( (unsigned int)&a5 <= *(_DWORD *)(v6 + 8) )
sub_BC360D10();
sub_BC3C4658( // 通过这个函数可以推测a6为前面传入的数组地址
a6,
*(_DWORD *)a6,
*(_DWORD *)(a6 + 4),
*(_DWORD *)(a6 + 8),
v7,
*(_DWORD *)a6,
*(_DWORD *)(a6 + 4),
*(_DWORD *)(a6 + 8),
*(_DWORD *)(a6 + 12),
v9);
// sub_BC3C4658函数没有返回值,局部变量v10也没有被显式地赋值
*(_DWORD *)(a6 + 16) = v10; // 在这里对a6[4],也就是上面v11的地址处进行了赋值
sub_BC301DF8();
return sub_BC2FBDAC();
}
-
通过上面的观察分析,可以察觉到这不是常规的函数调用约定,而且肯定不是fastcall ;注意到函数中出现了cgo 字样,且在该so 库的函数表中也有大量的cgo 函数
-
分析:
- 这个
so 库的调用约定与常规的不同,很可能是全部通过栈进行的,包括参数的传递以及返回值的传递
golang 是可以和c 进行交叉调用的,而且可以编译成so 库
- 这个so库的核心加密部分应该是由
golang 编写的,C 接口函数就起到个连接、转发的作用
-
定位关键加密函数
虽然看起来有点奇怪,但是这并不妨碍定位到关键函数,跟进上面的sub_BC3C4658() 函数:
int __fastcall sub_BC3C4658(int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10)
{
int v10; // r10
int v11; // lr
int result; // r0
int v13; // [sp+Ch] [bp-2Ch]
_DWORD *v14; // [sp+10h] [bp-28h]
int v15; // [sp+10h] [bp-28h]
int v16; // [sp+14h] [bp-24h]
int v17; // [sp+20h] [bp-18h]
int v18; // [sp+24h] [bp-14h]
int v19[2]; // [sp+30h] [bp-8h] BYREF
while ( (unsigned int)&a5 <= *(_DWORD *)(v10 + 8) )
sub_BC360D10();
v19[0] = a6;
sub_BC3BED84();
v19[1] = v13;
sub_BC3BED84();
sub_BC3C0D0C(v13, (int)v14, v16, v16, v11, (int)v19, v13, (int)v14, v16, v13, v14, v16, v17, v18);
sub_BC3BEC54();
result = v15;
a10 = v15;
return result;
}
跟进sub_BC3C0D0C() ,发现了package_name ,pakcage_hash 字样,且进行了大量函数调用,将动态调试的目光先锁定在它身上
int __fastcall sub_BC3C0D0C(int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, _DWORD *a11, int a12, int a13, int a14)
{
while ( (unsigned int)&a5 <= *(_DWORD *)(v14 + 8) )
sub_BC360D10();
v34 = v15;
a13 = 0;
a14 = 0;
v72 = &off_BC3DFD34;
sub_BC3C42D8();
v38 = a11;
sub_BC3C21F8();
v16 = v46;
if ( v61 )
{
a13 = 0;
a14 = 0;
result = sub_BC3C0CDC();
}
else
{
v68 = v49;
v69 = v57;
sub_BC304C38();
v71 = v38;
sub_BC305928();
if ( dword_BC47A460 )
{
sub_BC36294C(dword_BC47A460, v17, v71, &unk_BC3D2F68);
v18 = v19;
}
else
{
v18 = v71;
*v71 = &unk_BC3D2F68;
}
v50 = (int)v18;
sub_BC3A70F8();
if ( v55 )
{
v20 = a9;
v21 = a8;
v22 = a7;
}
else
{
v23 = v65;
do
*v23++ = 0;
while ( (int)v23 <= (int)&v65[15] );
qmemcpy(v65, "__package_name__", sizeof(v65));
sub_BC34E438((int)&v65[15], (int)v23, 0, 95, v15, 0, (unsigned __int8 *)v65, 16, (int)&unk_BC3CF818, v50);
v67 = v47;
v64 = v51;
v24 = v65;
do
*v24++ = 0;
while ( (int)v24 <= (int)&v65[15] );
qmemcpy(v65, "__package_hash__", sizeof(v65));
sub_BC34E438(v47, (int)v24, v51, 0, v35, 0, (unsigned __int8 *)v65, 16, v47, v51);
v66 = v48;
v63 = v52;
sub_BC3C4318();
sub_BC34E438(0, v25, v26, v27, v36, 0, v39, v41, v48, v52);
sub_BC301F40();
v28 = (unsigned __int8 *)*v71;
v70 = v42;
v40 = v28;
v43 = v67;
sub_BC309480();
*v53 = &unk_BC3D2A70;
if ( dword_BC47A460 )
sub_BC36294C(v53 + 1, v29, v53 + 1, v70);
else
v53[1] = v70;
sub_BC3C440C();
sub_BC34E438(0, v30, v31, v32, v37, 0, v40, v43, v64, (int)v53);
sub_BC3C094C();
sub_BC301F40();
v70 = v44;
v45 = v66;
sub_BC309480();
*v54 = &unk_BC3D2A70;
if ( dword_BC47A460 )
sub_BC36294C(v54, dword_BC47A460, v54 + 1, v70);
else
v54[1] = v70;
sub_BC3AEB0C();
v22 = v45;
v21 = v63;
v20 = (int)v54;
}
if ( v16 )
{
a13 = 0;
a14 = 0;
}
else
{
sub_BC3C05EC(v56, v22, v21, v20, v34, v22, v21, v20, v69, v58, v59, v68, v55, v56, v59, 0);
a13 = v60;
a14 = v62;
}
result = sub_BC3C0CDC();
}
return result;
}
五、so层动态调试、分析调用约定
-
在jni 接口处下断,符合fastcall 调用约定
-
传递给cgo 的参数
-
进入cgo 函数,首先观察栈平衡循环
结束后
-
观察从哪里取得参数
-
在下一个函数调用前下断,观察参数传递
-
f8步过,观察栈变化以及从哪里取的返回值
-
总结得出函数调用:参数完全通过栈传递,返回值存储在参数往下的地址中
六、so层加密算法还原
-
前置工作分析:可以跟踪调试sub_BC3C0D0C 函数,发现这里只是进行了一些参数以及其他操作,真正的加密处理函数在这个函数的结尾处调用,即:sub_C2DC05EC
-
需要说明的是,这个函数中对java 层传入的key 进行了base64 解码,并得到两个密钥:
key1 : 4c7e?2fab4f4>1>2>5d0ee4bb61d7b03
key2 : mIZUjjghGd
-
在sub_C2DC05EC 处下断,分析参数
-
首先对传入的两个key 进行了异或得到一个新key
-
再对key 进行了两次转换,得到
第一次得到
第二次得到,此时密钥已经成为一个不可读的字节序列,这也是最终加密算法使用的密钥
-
之后生成了一个长度为0x10 的随机串,这是最终加密算法使用的初始向量
-
之后传入密钥,调用一个函数后返回了一个全局地址和一个指针
其中指针的内容是
到这里的话,因为前面已经猜测这是一个golang 编写的so 库,此时基本可以确定这是使用的go 的crypto/cipher 加密库了;
通过查看go 加密的源码,能发现其newCipher 最终会生成两个长度为0x3C 即60 的密钥,分别用来加密和解密;
又由于是对称加密,因此使用的是用一个密钥,这里生成的两个密钥刚好是逆序的关系,可能是因为方便实现的原因
-
说明:这里usb断了一次连接,因此下面一些参数的地址可能和上面不同
-
接着,调用了一个保存在寄存器的地址,并将明文和前面密钥生成的结构当作参数传了进去
-
函数返回后,那片内存空间里已经由全0填充为了字节序列,可以确定其为加密函数
-
最后,又对加密生成的序列进行了一次字母表映射,字母表为16进制的16个字符
-
最终得到的密文为
-
算法还原
-
找到最后一步密钥转换后生成的字节序列,这就是真正的加密密钥
-
确定加密算法,根据几个特征,推测应该是一种有初始向量的流加密,最终确定为CFB模式的aes加密
- go的加密库
- 有初始向量的加密算法
- 没有padding操作
-
之后,首先用go还原一下,验证加密算法无误
-
之后用python重现,这里要注意的是默认的python和go的CFB加密结果是不同的,需要在python加密中设置以下属性:
-
最后验证python和go的加密结果一致即可
七、Java层签名算法分析还原
-
接下来就是java层sign字段的生成了,这个比较简单,它依次调用了两个哈希算法
-
sha256:根据post参数的格式及各个参数生成输入,经过sha256得到一个十六进制字符串
public static String e(String arg2) {
try {
MessageDigest v0 = MessageDigest.getInstance("SHA-256");
v0.update(arg2.getBytes("UTF-8"));
return a.b(v0.digest()); // 将字节数组转换为16进制字符串
}
catch(NoSuchAlgorithmException v2_1) {
v2_1.printStackTrace();
return "";
}
catch(UnsupportedEncodingException v2) {
v2.printStackTrace();
return "";
}
}
-
md5:将上面的到的sha256字符串经过md5变换得到最终的sign值
public static String b(String arg2) {
try { // a() 将字节数组转换为16进制字符串
return c.a(MessageDigest.getInstance("MD5").digest(arg2.getBytes("UTF-8")));
}
catch(Exception v2) {
v2.printStackTrace();
return "";
}
}
八、发包验证算法正确性
-
用python实现它的数据加解密以及签名、封装过程,生成请求数据,并向其服务器的一个接口发起请求验证:
-
服务器正常返回数据,解密得到数据:
|