本文目标 app 使用了 白盒 AES,且进行了一定程度的 ollvm。使用 unidbg 作为主要的分析工具,配合 DFA 攻击找到了 AES的 key。
登录接口:https://capi.xxxxx.com/resource/m/user/login
版本:5001
目标参数 :sign 和 q
jadx 打开 apk。这标致 某60的加固。
拿出神器 xrt 脱壳完成之后搜索 sign ,没找到。可能是字符串做了加密。换方案,使用 hook 。
分析有包名的地方
从 com.lucky.lib.http2.AbstractLcRequest.getRequestParams 开始分析 。有几个可疑的数值,写个主动调用看看是什么。
先分析 q ,它来自 b2,b2从 c.b 的函数来。
看到这里有两个aes加密,且最后函数返回的时候,还做了 base64 的编码。把 + 替换成 - 。把 / 替换成 _ 。
继续跟进。两个都是 native 层的函数了。具体是调用了那个函数,之后通过 hook 就可以知道。
再回头分析 sign 。 7719 是我们的目标参数 sign 。那么就跟进后面的函数 r.a() 。
同样进入了 CryptoHelper
这个函数最后面调用了 md5_crypt ,也是个 native 函数。md5_crypt 第二个参数应该传入的是 int 型,有兴趣的可以打印输出一下。
到这里我们可以大概做了总结 sign 来自 md5_crypt 结果, q 值来自 aes 的结果。
分辨 hook CryptoHelper 中的几个 native 函数。看看在登录接口使用了哪个函数。
hook 之后发现 先调用了 localAESWork4Api 再调用了 md5_crypt。也就可以理解为先生成 q 再生成 sign
接下来就进入 native 层分析
so 导入 IDA 搜索 java 没有找到导出函数
翻了 davadiv 这个特征,这是 ollvm 的特征,说明 so 的代码被混淆了。不过依然是可以分析的。
首先需要找到入口函数的地址。这里肯定是就是动态注册函数了。有两种方案获取到对应的地址,第一 unidbg 第二通过 frida Hook RegisterNative 获取到对应的地址。两种都演示一下
通过 unidbg
这里运气比较好,这个SO 不用补环境就可以跑起来
注册了 4 个函数
frida Hook
同样可以看到注册了 4 个函数
使用 unidbg 进行算法分析。
前面的 unidbg 运行起来后最前面有一行日志
加载了 libandroid.so 失败,这个 so 是android 系统自带的 so 。同时它也依赖了很多其他的 so 。不好同时都导入。这里使用 unidbg 的VirtualModule 导入一个虚拟的 so。就没有这个错误日志了
主动调用算法 localAESWork4Api
IDA 分析SO 。入口在 localAESWork4Api 0x1b1cd,IDA 中 按 G 跳转。这个函数看着名字像白盒 AES
一眼看过去 就是调用了 android_native_wbaes_jni ,进入分析。
这个函数里面有很多的虚假控制流,不能按常规的直接直接分析。我们向下滑动看看有没有什么特征函数。
可以看到 PKCS5Padding wbaes_decrypt_ecb 。继续向下看看有没有加密的
找到了 wbaes_encrypt_ecb ,这个好了 ecb 不用找 iv 了。下个断点看看情况,目标地址 17BD4。
第一个参数是传入的要加密的数据,第二参数是数据的长度,第三个参数返回的数据,第四个参数是 mode =0
r1=0x10 也就是十进制的 16 ,表示一个分组的长度 。AES 一个分组长度固定是 16字节 。
再看看第一个参数,确实是我们的数据。整个数据 16 字节,填充了 07 。传入的是 lvdouzhou 长度为 6 ,16 -9 = 7 。十六进制就是 0x07。刚好印证了前面的 PKCS5 填充。
再验证一个是否是 ECB 模式。ECB 模式将明文数据分成固定大小的块,然后对每个块独立进行加密。也是因为每一块是独立加密的,所以如果有两个 16 字节的数据是相同的,加密的结果也相同。根据这一个特点,修改入参为两个 相同的16字节数据 lvdouzhoulvdouzhlvdouzhoulvdouzh。
lr 寄存器存放是函数返回的地址。在 lr 下一个断点,就可以知道这个函数返回的数据。在命令输入 blr 回车之后,按 c 继续执行。函数执完返回时,会自动触发断点。
第三个参数是返回值 也就是 mr2 的地址
读取这个地址的数据
可以看到两个 16 字节的数据都是相同的,确认是 ecb 模式啦 !
接着这个函数继续分析 ,依然是控制流平坦化,也就是 ollvm 。先跟着参数分析一下,第三个是返回值,我们选中它。按 x 查看引用。
先看第一个引用
应该是把 v29 复制给了 out 。x 查看 v29 的引用。我们往前查看引用,因为 v29 一定是在前面生成的。
找到了 aes128_enc_wb_coff ,一目了然 aes 算法。没什么说的,继续跟进。
大概浏览一下,也是做了 ollvm 混淆。但是还有很多特征的。例如看到一个 行位移。
其中的 Tboxes 通过名字猜测可能是 aes 的查表法。
AES 的某些步骤(如字节替换和列混淆)可以通过预先计算的表格来实现,从而避免在运行时进行复杂的计算。
点击跳转过去也是一个很大的数组,应该就是提前计算好的数据了。
到这里可以确定是一个白盒的 AES,使用的的 ecb 模式。
确认十轮运算的位置 。因为里面有两个 wbShiftRows 分别 hook 看那个是我们目标攻击点。
0x15AD6 0x154E8
0x15AD6 没有走。0x154E8 进入了 10 ,记得把入参限制在16 个字节内,让 AES 加密一次即可。
dfa 攻击。在第 9 轮循环注入我们的故障文。通过 Inspect 确定一下注入是否正常
可以看到只影响了一个字节的数据,达到了预期。
对比最终结果。影响了 4 个字节,达到了预期 。
批量注入故障文,获取故障结果
的到的故障文,第一行放入正确的密文
使用 phoenixAES 推到 k10
用phoenixAES库得到第10轮秘钥 869D92BBB700D0D25BD9FD3E224B5DF2。
在用 stark 推导 k00
推导出的秘钥为 644A4C64434A69566E44764D394A5570
验证一下
没问题,和正常密文一样。
重新hook一下 java层获取到实际的入参
{"blackBox":"eyJvcyI6ImFuZHJvaWQiLCJ2ZXJzaW9uIjoiMy4zLjciLCJwYWNrYWdlcyI6ImNvbS5sdWNreS5sdWNreWNsaWVudComNS4wLjAxIiwicHJvZmlsZV90aW1lIjoxNDcsImludGVydmFsX3RpbWUiOjIxNzMsInRva2VuX2lkIjoibk9cL2VhbHJjQkY2WU5wUGF0dDk4Q292T1FUYkRFUEM4NHFVM1ozSThLa2xcL1RNb1J1WUZWcGd6QVwvOGZ4T01zcHJvYXFmaEZXSWpNb2RHWENnYkw3SzFHYzBTdDY2cjFpZE5tNXFJS1Zkc2M9In0=","uniqueCode":"DU5SBJsdw1JfKSzzQ22IzTIOzNbvm6BpYQd8RFU1U0JKc2R3MUpmS1N6elEyMkl6VElPek5idm02QnBZUWQ4c2h1","regionId":"CO0001","mobile":"15712170935","countryNo":"86","validateCode":"111111","regId":"","appversion":"5001","type":1,"deviceId":"android_lucky_d169e58de60a856d","systemVersion":"29","deviceBrand":"google"},
返回值
f2947f561248ad6af3fed66d57a0421d589a5be55cb087a6d4713acfbc4d458c95b4af52a9682bae07dbde2164288106b1fad28d1ddd4215d24cb5460911c48a0b122278984d473519b59a3cc4b594e63dbd9db1df3d262bb80dcdaf6553d87c37e4b306663585e7a3030a4a01a186657729123bd72acb773f17a4567cbdb829c991f5ba5546edf952866d04b57aff503d0ff0e69370466258da89bffa296987510c12704172f9d3f276ec47556dad9c251342d87b938188ebc3489241795ae0e8cf5d3dafbebbeff75731fe42ed3452f081275c8632fe1b9a4447f5bb40c3f1fd5f0e29416f5548fc64f5e15460d58aa5fbd9de0d44edaf5e502efee22e2df8ebe38fef2d839b2c9a4c9c10433eb0f8751705162db79cf73ea6c25ce3c96df92a674c84bf65fc92073df7d305d81ab94039e8c655d9fe253147db3197def0b970ddd0744b4ef458ed9c5ac523643c276662a0a7cec3a5a28b17b7b601f9e012640b82cbbd195205c62da34d2e82632d5c2233b242c2bbf38ea17bfe68def166e850c806de0c018ce2cbcfc6a6bb05fa79a1f2c73fc309bd70bb57b48942aff1c17534ec96cddfa265e32baa759553bdf0f2f1af9ba704e52f5977a132cb157bab0700b6d61e0749fca0f5ef1bc870915de3862bb151a9b9af3b17eb3cd369109072a14f977d43fb82069b09578cb28c6e325ac12e917dcf135f89d815bd429aef6ef28bb6d7d89adeea1d4f5106b03e316b7afd934630f4138bcba2a9dfc7d79c5bdcd9a1c8e4791e698e2dde4063dff86f2a8f5e8ad9a7089bdf6121995f82e8a15896b6f883f9b41fda7a1a820074caa3b13027d7945012ba38fa4e3c97bc13ad3e6d747936475b59990c8aa2f02c20f4e2e3eaf18d0cc2339bb457db167fae462346059e4c1153d3ca59aba55108
修改 unidbg的入参
得到结果
一毛一样
自此 q 就分析出来啦 。需要注意的是,根据之前 java 层的分析,生成 q 之后要进行 base64 编码。且替换掉对应的字符串
参数 sign 来自 md5 ,前面知 md_crypt 的地址是 0x1a981
先主动调用
得到的结果为 306551304117879571918511965941451501018 长度为 39
跳转到 0x1a981 看看这个函数,也都是控制流混淆。同样的套路,先大概看下有没有什么特征函数
找到了两个 doMD5sign 和 md5 hook两个函数看看是否调用
先调用了 doMD5sign 。那先看看这个函数的参数。第一个参数是要加密的字符串。第二个参数是长度。第三个参数应该就是返回值了 。
记住 r2 的地址 0xbffff6f4,等下要用来查看返回值的。同时注意到 第三个参数是 **digest 这种的格式是二级指针的意思。 *digest 表示 digest 的地址。 *digest 本身也存放在内存的某个地址中,*digest 的地址为 **digest 。也就是说第三个参数其实是一个指针地址,如果要获取里面实际的内容。我们要把这个内容当一个地址,然后再去读取这个地址里面的内容。有点绕,后面直接看演示吧。
内存布局
在我们传入的字符串后面有加盐的操作,加了 dJLdCJiVnDvM9JUpsom9。下一个 lr 断点。c 继续执行
m0xbffff6f4
这里有个知识点就是 unidbg中的地址都是 40 开头的。还有一个就是在 md5的运算过程中数据在内存中都是用小端序的形成存放的。
4个字节的数据 0x12345678
大端序 : 12 34 56 78
小端序 : 78 56 34 12
且因为前面说过了,第三个参数是一个二级指针。所以这里的数据起始是指向原始数据的地址,这个地址转换过来就是 0x402D2000。
然后读取这个地址的数据 m0x402D2000
unidbg 运行得到的结果为 306551304117879571918511965941451501018 ,两个对比就是我们的目标结果。
但是标准的 md5 lvdouzhou 得到的值为 b2cf7dd26f44f87f74d67885a026c96c。所以里面应该还做了其他处理。在前面的hook 知道。里面还调用了一个 md5 的函数 。我们先进入 doMD5sign
可以看到的确是调用了 md5 ,但是后面还有一个可疑的 bytesToInt 函数。这里的 md5 hook之后查看返回值后,是一个标准的md5 。
代码下面还有三个 strcat ,把4 个数据进行拼接得到最终的数据。hook strcat 查看拼接的数据
这两个数据就是我们最终值的 306551304117879571918511965941451501018 的前面部分。所以只要分析清楚 bytesToInt 这里面是如何操作。就可以得到最终的sign值。这里就暂时不做分析了。因为不是本篇文章的目的。
q 来自白盒 aes,然后进行了base64编码,且对bs64结果的字符进行了替换。sign 来自 md5算法,md5的结果再进行了 bytesToInt 拼接得到最终的结果。
此次通过hook haskmap 定位了 java 的加密参数的位置。跟踪进入native 层,虽然代码被 ollvm 混淆过。通过个别函数我们也猜测到对应的算法。结合unidbg 下断点找到 DFA攻击的时间点。最终得到了 AES 的key 。总体难度适中,适合练手。到此,多谢各位大佬时间。
function
call_HashMap() {
Java.perform(
function
() {
var
hashMap = Java.use(
"java.util.HashMap"
);
hashMap.put.implementation =
function
(a, b) {
if
(a !=
null
&& a.equals(
"sign"
)) {
console.log(Java.use(
"android.util.Log"
).getStackTraceString(Java.use(
"java.lang.Throwable"
).$
new
()))
console.log(
"hashMap.put: "
, a, b);
}
return
this
.put(a, b);
}
})
}
function
call_HashMap() {
Java.perform(
function
() {
var
hashMap = Java.use(
"java.util.HashMap"
);
hashMap.put.implementation =
function
(a, b) {
if
(a !=
null
&& a.equals(
"sign"
)) {
console.log(Java.use(
"android.util.Log"
).getStackTraceString(Java.use(
"java.lang.Throwable"
).$
new
()))
console.log(
"hashMap.put: "
, a, b);
}
return
this
.put(a, b);
}
})
}
function
call_so() {
Java.perform(
function
() {
var
Class = Java.use(
'com.stub.StubApp'
);
console.log(result);
})
}
function
call_so() {
Java.perform(
function
() {
var
Class = Java.use(
'com.stub.StubApp'
);
console.log(result);
})
}
function
crypt_test() {
Java.perform(
function
() {
if
(Java.available) {
let CryptoHelper = Java.use(
"com.luckincoffee.safeboxlib.CryptoHelper"
);
CryptoHelper[
"localAESWork"
].implementation =
function
(bArr, i2, bArr2) {
console.log(`CryptoHelper.localAESWork is called: bArr=${bytesToString(bArr)}, i2=${i2}, bArr2=${bArr2}`);
let result =
this
[
"localAESWork"
](bArr, i2, bArr2);
return
result;
};
CryptoHelper[
"localAESWork4Api"
].implementation =
function
(bArr, i2) {
console.log(`CryptoHelper.localAESWork4Api is called: bArr=${bytesToString(bArr)}, i2=${i2}`);
let result =
this
[
"localAESWork4Api"
](bArr, i2);
return
result;
};
CryptoHelper[
"localConnectWork"
].implementation =
function
(bArr, bArr2) {
console.log(`CryptoHelper.localConnectWork is called: bArr=${bytesToString(bArr)}, bArr2=${bArr2}`);
let result =
this
[
"localConnectWork"
](bArr, bArr2);
return
result;
};
CryptoHelper[
"md5_crypt"
].implementation =
function
(bArr, i2) {
console.log(`CryptoHelper.md5_crypt is called: bArr=${bytesToString(bArr)}, i2=${i2}`);
let result =
this
[
"md5_crypt"
](bArr, i2);
return
result;
};
}
})
}
function
crypt_test() {
Java.perform(
function
() {
if
(Java.available) {
let CryptoHelper = Java.use(
"com.luckincoffee.safeboxlib.CryptoHelper"
);
CryptoHelper[
"localAESWork"
].implementation =
function
(bArr, i2, bArr2) {
console.log(`CryptoHelper.localAESWork is called: bArr=${bytesToString(bArr)}, i2=${i2}, bArr2=${bArr2}`);
let result =
this
[
"localAESWork"
](bArr, i2, bArr2);
return
result;
};
CryptoHelper[
"localAESWork4Api"
].implementation =
function
(bArr, i2) {
console.log(`CryptoHelper.localAESWork4Api is called: bArr=${bytesToString(bArr)}, i2=${i2}`);
let result =
this
[
"localAESWork4Api"
](bArr, i2);
return
result;
};
CryptoHelper[
"localConnectWork"
].implementation =
function
(bArr, bArr2) {
console.log(`CryptoHelper.localConnectWork is called: bArr=${bytesToString(bArr)}, bArr2=${bArr2}`);
let result =
this
[
"localConnectWork"
](bArr, bArr2);
return
result;
};
CryptoHelper[
"md5_crypt"
].implementation =
function
(bArr, i2) {
console.log(`CryptoHelper.md5_crypt is called: bArr=${bytesToString(bArr)}, i2=${i2}`);
let result =
this
[
"md5_crypt"
](bArr, i2);
return
result;
};
}
})
}
public
class
fkLucky
extends
AbstractJni {
private
AndroidEmulator emulator;
private
VM vm;
private
final
Module module;
public
fkLucky() {
emulator = AndroidEmulatorBuilder.for32Bit()
.setProcessName(
"com.lucky.luckyclient"
)
.build();
final
Memory memory = emulator.getMemory();
memory.setLibraryResolver(
new
AndroidResolver(
23
));
vm = emulator.createDalvikVM(
new
File(
"unidbg-android/src/test/java/com/com/cloudy/linglingbang/linglingbang8.2.4.apk"
));
vm.setJni(
this
);
vm.setVerbose(
true
);
DalvikModule dm = vm.loadLibrary(
new
File(
"unidbg-android/src/test/java/com/com/lucky/luckyclient/libcryptoDD5001.so"
),
true
);
module = dm.getModule();
dm.callJNI_OnLoad(emulator);
}
public
static
void
main(String[] args) {
fkLucky lucky =
new
fkLucky();
}
}
public
class
fkLucky
extends
AbstractJni {
private
AndroidEmulator emulator;
private
VM vm;
private
final
Module module;
public
fkLucky() {
emulator = AndroidEmulatorBuilder.for32Bit()
.setProcessName(
"com.lucky.luckyclient"
)
.build();
final
Memory memory = emulator.getMemory();
memory.setLibraryResolver(
new
AndroidResolver(
23
));
vm = emulator.createDalvikVM(
new
File(
"unidbg-android/src/test/java/com/com/cloudy/linglingbang/linglingbang8.2.4.apk"
));
vm.setJni(
this
);
vm.setVerbose(
true
);
DalvikModule dm = vm.loadLibrary(
new
File(
"unidbg-android/src/test/java/com/com/lucky/luckyclient/libcryptoDD5001.so"
),
true
);
module = dm.getModule();
dm.callJNI_OnLoad(emulator);
}
public
static
void
main(String[] args) {
fkLucky lucky =
new
fkLucky();
}
}
function
hook_dynamic_register_func() {
var
addrRegisterNatives =
null
;
var
symbols = Module.enumerateSymbolsSync(
"libart.so"
);
for
(
var
i = 0; i < symbols.length; i++) {
var
symbol = symbols[i];
if
(symbol.name.indexOf(
"art"
) >= 0 &&
symbol.name.indexOf(
"JNI"
) >= 0 &&
symbol.name.indexOf(
"RegisterNatives"
) >= 0 &&
symbol.name.indexOf(
"CheckJNI"
) < 0) {
addrRegisterNatives = symbol.address;
console.log(
"RegisterNatives is at "
, symbol.address, symbol.name);
break
}
}
if
(addrRegisterNatives) {
Interceptor.attach(addrRegisterNatives, {
onEnter:
function
(args) {
var
env = args[0];
var
java_class = args[1];
var
class_name = Java.vm.tryGetEnv().getClassName(java_class);
var
taget_class =
"com.luckincoffee.safeboxlib.CryptoHelper"
;
if
(class_name === taget_class) {
console.log(
"\n[RegisterNatives] method_count:"
, args[3]);
var
methods_ptr = ptr(args[2]);
var
method_count = parseInt(args[3]);
for
(
var
i = 0; i < method_count; i++) {
var
name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));
var
sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));
var
fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));
var
name = Memory.readCString(name_ptr);
var
sig = Memory.readCString(sig_ptr);
var
find_module = Process.findModuleByAddress(fnPtr_ptr);
var
offset = ptr(fnPtr_ptr).sub(find_module.base);
console.log(
'class_name:'
, class_name,
"name:"
, name,
"sig:"
, sig,
'module_name:'
, find_module.name,
"offset:"
, offset);
}
}
}
});
}
}
function
hook_dynamic_register_func() {
var
addrRegisterNatives =
null
;
var
symbols = Module.enumerateSymbolsSync(
"libart.so"
);
for
(
var
i = 0; i < symbols.length; i++) {
var
symbol = symbols[i];
if
(symbol.name.indexOf(
"art"
) >= 0 &&
symbol.name.indexOf(
"JNI"
) >= 0 &&
symbol.name.indexOf(
"RegisterNatives"
) >= 0 &&
symbol.name.indexOf(
"CheckJNI"
) < 0) {
addrRegisterNatives = symbol.address;
console.log(
"RegisterNatives is at "
, symbol.address, symbol.name);
break
}
}
if
(addrRegisterNatives) {
Interceptor.attach(addrRegisterNatives, {
onEnter:
function
(args) {
var
env = args[0];
var
java_class = args[1];
var
class_name = Java.vm.tryGetEnv().getClassName(java_class);
var
taget_class =
"com.luckincoffee.safeboxlib.CryptoHelper"
;
if
(class_name === taget_class) {
console.log(
"\n[RegisterNatives] method_count:"
, args[3]);
var
methods_ptr = ptr(args[2]);
var
method_count = parseInt(args[3]);
for
(
var
i = 0; i < method_count; i++) {
var
name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));
var
sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));
var
fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));
var
name = Memory.readCString(name_ptr);
var
sig = Memory.readCString(sig_ptr);
var
find_module = Process.findModuleByAddress(fnPtr_ptr);
var
offset = ptr(fnPtr_ptr).sub(find_module.base);
console.log(
'class_name:'
, class_name,
"name:"
, name,
"sig:"
, sig,
'module_name:'
, find_module.name,
"offset:"
, offset);
}
}
}
});
}
}
private
void
callNativeFunc() {
List<Object> args =
new
ArrayList<>(
10
);
args.add(vm.getJNIEnv());
args.add(
0
);
String par1 =
"lvdouzhou"
;
args.add(vm.addLocalObject(
new
ByteArray(vm, par1.getBytes())));
args.add(
0
);
Number retNum = module.callFunction(emulator,
0x1b1cd
,args.toArray());
ByteArray retByteArr = vm.getObject(retNum.intValue());
String retStr = Base64.getEncoder().encodeToString(retByteArr.getValue());
System.out.println(
"retBs64:"
+retStr);
}
private
void
callNativeFunc() {
List<Object> args =
new
ArrayList<>(
10
);
args.add(vm.getJNIEnv());
args.add(
0
);
String par1 =
"lvdouzhou"
;
args.add(vm.addLocalObject(
new
ByteArray(vm, par1.getBytes())));
args.add(
0
);
Number retNum = module.callFunction(emulator,
0x1b1cd
,args.toArray());
ByteArray retByteArr = vm.getObject(retNum.intValue());
String retStr = Base64.getEncoder().encodeToString(retByteArr.getValue());
System.out.println(
"retBs64:"
+retStr);
}
private
void
hookNativeFunc() {
Debugger debugger = emulator.attach();
debugger.addBreakPoint(module.base +
0x17BD4
,
new
BreakPointCallback() {
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
return
false
;
}
});
}
private
void
hookNativeFunc() {
Debugger debugger = emulator.attach();
[注意]APP应用上架合规检测服务,协助应用顺利上架!