前言
本篇文章主要是对这个APP 的白盒AES 使用 DFA 攻击最终获取到 key 和 iv的整个流程的分析记录。在论坛上有很多大佬都对这个app进行过分析了。但是我发现挺多细节的流程,大佬都懒得写。对于我这种野生码仔来说,复现案例的时候就经常找不着北,根据正态分布原理来看,牛逼的大佬终究是少数,大部分人还是处于普通水平,我就是那普通的一员,在自己稍微有点摸到点逆向的门槛的时候,也希望分享点经验让大家能少走弯路就少走点吧。于是就出现了本篇文章,我想尽可能的展现出每个步骤。至少在复现的时候,无法跟进下去的时候。知道自己是哪个地方的知识点缺失。去补足之后,继续跟进。
目标基本信息
版本:8.2.4
是否有壳:有
目标接口:登录接口
参数:sd
so : 32位
资料下载地址
链接:https://pan.quark.cn/s/08601adeb0f0 提取码:35HF
确定参数
基操抓包,简单明了就一个 sd 。
Java 层
嗯?这硬-邦-邦-的ke?又给app上套啊。现在不带套都不安全了!!问题我就想不戴套看看里面有什么好东西啊!
脱壳
基于寒冰大佬开源的 FART 修改的,很多加固厂商都对FART的特征进行检测。所以我自己改了 fart 的特征编译了一个sailfish的 FART10,还可以指定app进行脱壳,也可以指定黑名单,黑名单上面的类就不主动调用,用起来目前还是很丝滑。我把它命名成 XRT10(狗头保命) 。哈哈哈!! 再次感谢寒冰大佬。这个app要脱壳挺久的,虽然我定制的rom过滤掉了很多包,但是也是脱了十几分钟,可能是 saifish 不太行?先不管了,能用就行。
目标登录页面
搜一下在哪里。然后把 dump 下来的 codeitem 回填到 dex 中
回填codeitem
导入回填好的 comp.dex 然后搜索 "sd"
红框内的都是 app 中的 sd 。第一个一看就是个定义变量的,直接跟进。再搜索一波。没搜到,好吧!
那就跟进下面的。
其实就在刚才那个值下面。然后我们看到了 encrypte 。搞逆向的都知道吧,找到 encrypt 字眼,就说明没找错位置。
继续跟进
好嘛,也没啥弯弯绕绕,直接干进 native 层了。so 名字也知道了。
那就继续盘它咯 。hook 一下获取参数
1
2
3
4
5
6
7
8
9
10
11
function
call_java() {
Java.perform(
function
() {
let CheckCodeUtils = Java.use(
"com.cloudy.linglingbang.model.request.retrofit2.CheckCodeUtils"
);
CheckCodeUtils[
"encrypt"
].implementation =
function
(str, i) {
console.log(`CheckCodeUtils.encrypt is called: str=${str}, i=${i}`);
let result =
this
[
"encrypt"
](str, i);
console.log(`CheckCodeUtils.encrypt result=${result}`);
return
result;
};
})
}
Native 层
Uinidbg 补环境
补环境的话,真没啥好说的,就缺啥补啥吧。慢慢的就熟练了
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
@Override
public
DvmObject<?> callStaticObjectMethod(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {
switch
(signature) {
case
"android/app/ActivityThread->currentActivityThread()Landroid/app/ActivityThread;"
:
return
vm.resolveClass(
"android/app/ActivityThread"
).newObject(
null
);
}
return
super
.callStaticObjectMethod(vm, dvmClass, signature, varArg);
}
@Override
public
DvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {
switch
(signature) {
case
"android/app/ActivityThread->getSystemContext()Landroid/app/ContextImpl;"
:
return
vm.resolveClass(
"android/app/ContextImpl"
).newObject(
null
);
case
"android/app/ContextImpl->getPackageManager()Landroid/content/pm/PackageManager;"
:
return
vm.resolveClass(
"android/content/pm/PackageManager"
).newObject(
null
);
}
return
super
.callObjectMethod(vm, dvmObject, signature, varArg);
}
@Override
public
DvmObject<?> getStaticObjectField(BaseVM vm, DvmClass dvmClass, String signature) {
switch
(signature) {
case
"android/os/Build->MODEL:Ljava/lang/String;"
:
return
new
StringObject(vm,
"Pixel 4 XL"
);
case
"android/os/Build->MANUFACTURER:Ljava/lang/String;"
: {
return
new
StringObject(vm,
"Google"
);
}
case
"android/os/Build$VERSION->SDK:Ljava/lang/String;"
: {
return
new
StringObject(vm,
"29"
);
}
}
return
super
.getStaticObjectField(vm, dvmClass, signature);
}
再来个主动调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void call_checkcode() {
List<Object> args =
new
ArrayList<>();
args.add(vm.getJNIEnv());
args.add(0);
args.add(vm.addLocalObject(
new
StringObject(vm,
"mobile=157696969691&password=996007icu&client_id=2019041810222516127&client_secret=c5ad2a4290faa3df39683865c2e10310&state=eu4acofTmb&response_type=token"
)));
args.add(2);
args.add(vm.addLocalObject(
new
StringObject(vm,
"1709100421650"
)));
Number retNum = module.callFunction(emulator, 0x13480 + 1, args.toArray());
String ret = vm.getObject(retNum.intValue()).getValue().toString();
System.out.println(
"结果->"
+ ret);
}
加密函数分析与定位
接下来的函数无法跟进,需要到汇编看看是怎么跳转的。
Hook 一看。咦?R12 再哪呢?可能是我汇编知识掌握不够,确实不知道哪个是R12。
那只能换种方式咯。通过代码输出寄存器地址。
1
2
3
4
5
6
7
8
9
debugger.addBreakPoint(module.base +
0x5AFC
+
1
,
new
BreakPointCallback() {
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
Arm32RegisterContext context1 = emulator.getContext();
long
r12 = context1.getR12Long();
System.out.println(
"r12 "
+r12);
return
false
;
}
});
得到 1073762617 这个是个十进制的地址。转换成 16 进制就是
其中 5139 就是偏移地址,IDA 中按 G 跳转。
Nice! 一次就中!
Hook 查看一下 入参
1
2
3
4
5
6
7
8
9
10
debugger.addBreakPoint(module.base +
0x5138
+
1
,
new
BreakPointCallback() {
RegisterContext context = emulator.getContext();
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
return
false
;
}
});
第二个参数是我们的入参(这里的入参被我修改掉了,参数与之前不同请忽略,下面会讲怎么修改的)
入参的数据太长了。不利于我们后续的分析,所以这里我们 hook 它然后修改它的数据,放入一个新的 16字节的数据。在最开始进入函数的位置就修改它的入参。
1
2
3
4
5
6
7
8
9
10
11
12
13
debugger.addBreakPoint(module.base +
0x5CF4
+
1
,
new
BreakPointCallback() {
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
String fakeInput =
"0123456789abcdef"
;
MemoryBlock fakeInputBlock = emulator.getMemory().malloc(fakeInput.length(),
true
);
fakeInputBlock.getPointer().write(fakeInput.getBytes(StandardCharsets.UTF_8));
emulator.getBackend().reg_write(ArmConst.UC_ARM_REG_R0, fakeInputBlock.getPointer().peer);
return
true
;
}
});
重新 hook 0x5138 看入参是否修改成功。
注意! 0x538这个地方的函数,参数 par2 我们要重点关注,AES加密完成之后,indata 里面就是加密好的数据。因为我们在前面修改了入参为 0123456789abcdef ,所以我们在这个函数加密完成之后,要专门去拦截看看 AES 加密的结果。DFA 攻击的结果,也要通过这个参数去获取。
通过分析可以知道,在这里开始处理明文。AES 第一步就是要把明文进行处理。
Hook 看看进入的数据和出来的数据
1
2
3
debugger.addBreakPoint(module.base +
0x4A10
+
1
);
debugger.addBreakPoint(module.base +
0x4A36
+
1
);
mr1 为入参没问题,mr2 位处理后的数据,刚进入函数是 0 也是正确的。注意要记住 r2 的地址 0xbffff468 ,之后的 AES 运算是读取这个地址的数据,DFA 故障注入也是通过这个地址。
按 c 让程序继续执行到函数返回处的断点。读取 m0xbffff468
经过对比,确实是明文处理之后的数据。之后就是要找到 DFA 攻击点了。为什么内从中明文转换的数据会增加 0 我目前也不太清楚,如果有人知道可以在评论区告诉我。
寻找 DFA 攻击点
DFA 攻击原理就是要倒数后两轮的列混淆之前注入故障文,然后得到有故障文的加密结果。不断循环这个步骤得到几十上百个故障的加密结果。最终通过 phoneixAES 计算出第10轮秘钥。再使用 stark 通过轮秘钥推到出 k0 秘钥。根据 AES 特性,K00 就是我们的最终目标主密钥。
所以这里我们就是要找到第 9 轮加密开始的地方
分析代码之后我们看到了这个特征 9 ,在代码的最下面也看到了 10 这个特征,到10 这里应该就是最后一轮运算,也就是没有列混淆的最后一轮。
这里就 hook i==9 这里,看调用了多少次。
1
2
3
4
5
6
7
8
9
10
debugger.addBreakPoint(module.base +
0x5194
+
1
,
new
BreakPointCallback() {
int
count =
0
;
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
count++;
System.out.println(
" 5194 count "
+ count);
return
true
;
}
});
虽然是运行了 10 ,但也是我们的目标地址 。只要在第9轮注入故障文即可。
DFA 攻击
通过前面我们知道了明文地址是 0xbffff468 ,所以只要修改这个地址上的数据来注入故障文。
修改内存地址中 state 数据
修改传入的 state 明文 ,明文在内存中 0xbffff468 ,所以我们这里直接修改对应内存地址中的数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public
void
dfaAttack() {
emulator.attach().addBreakPoint(module.base +
0x5194
,
new
BreakPointCallback() {
int
round =
0
;
UnidbgPointer statePointer = emulator.getMemory().pointer(0xbffff468L );
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
round +=
1
;
if
(round %
9
==
0
) {
System.out.println(
"inject error "
);
statePointer.setByte(randInt(
0
,
15
), (
byte
) randInt(
0
,
15
));
}
return
true
;
}
});
}
public
static
int
randInt(
int
min,
int
max) {
Random rand =
new
Random();
return
rand.nextInt((max - min) +
1
) + min;
}
确认修改之后数据是否正确。首先获取正常加密之后的密文。注意,不能使用整个so运行完成之后的结果。因为后面还对这个数据做了其他处理。这里我们直接 hook WBACRAES_EncryptOneBlock 这个函数的返回结果。函数的返回地址一般都存放在LR 寄存器中,直接 LR 就能在函数返回之前查看加密结果。
0xbffff468L 是要被处理的数据的地址,我们在 LR 返回的时候通过 Inspector 输出对应加密结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
debugger.addBreakPoint(module.base +
0x5138
+
1
,
new
BreakPointCallback() {
RegisterContext context = emulator.getContext();
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
Arm32RegisterContext context1 = emulator.getContext();
Pointer r1prt = context1.getR1Pointer();
System.out.println(
"r1str->"
+ r1prt .getString(
0
));
Pointer r2ptr = context1.getR2Pointer();
emulator.attach().addBreakPoint(context.getLRPointer().peer,
new
BreakPointCallback() {
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
Inspector.inspect(r2ptr.getByteArray(
0
,
16
),
"AES finish ->"
);
return
true
;
}
});
return
true
;
}
});
得到 AES 加密结果
5039f8f250d9a10688806593cefc4f80
然后注入故障密文,再输出注入故障文之后的加密结果。这里我们只在第一个字节注入故障文,以确定注入时机是否选择正确。 0xbffff468L 就是AESMatrix 的 indata_state 的地址 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public
void
dfaAttack() {
emulator.attach().addBreakPoint(module.base +
0x5194
,
new
BreakPointCallback() {
int
round =
0
;
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
round +=
1
;
if
(round %
9
==
0
) {
UnidbgPointer statePointer = emulator.getMemory().pointer(0xbffff468L);
int
randnum = randInt(
0
,
255
);
long
injIndex = randInt(
0
,
15
);
statePointer.setByte(
0
,(
byte
) randnum );
}
return
true
;
}
});
}
public
static
int
randInt(
int
min,
int
max) {
Random rand =
new
Random();
return
rand.nextInt((max - min) +
1
) + min;
}
对比两次的结果。发现的确符合 DFA 故障注入之后的特征。在最后一次列混淆注入故障密文,只会影响最终结果的 4 字节,且影响的位置出于0 7 10 13 字节的位置上 。
获取大量故障文加密结果
在确定注入位置正确后,就需要获取大量的注入故障文之后的加密结果以反推第10轮秘钥。根据 DFA 原理,我们在地 0 - 3 字节,分别注入 5次故障,一共 生成 20 个故障文。理论上就可以通过 phoenixAES 推到出 k10 也就是第 10 轮秘钥了。
剩下就是苦力活了,各种复制粘贴结果
1
2
3
4
5
# 注意,不是同时写下面的代码。每次用一个就行了,这里只是列举出来
statePointer.setByte(
0
,(
byte
) randnum );
statePointer.setByte(
1
,(
byte
) randnum );
statePointer.setByte(
2
,(
byte
) randnum );
statePointer.setByte(
3
,(
byte
) randnum );
还有一种写法是在明文矩阵中 0 - 15 字节位置随机注入随机注入
1
statePointer.setByte(randInt(
0
,
15
),(
byte
) randnum )
具体用那种写法,就看你实际测试那种效果好了。这里我用了第一种。
最终得到一批故障文,用下面的格式放入一个文件中。第一行放入正常的密文。后面放故障文
然后推导密文
1
2
3
import
phoenixAES
phoenixAES.crack_file(r
".\dfa32new.log"
,[],
True
,
False
,verbose
=
2
)
Nice! 一次就中!得到了 k10 8A6E30D74045AE83634D6ECDE1516CA1
推导 k00 也就是主密钥 。得到 主密钥 F6F472F595B511EA9237685B35A8F866
寻找 IV
CBC 模式需要使用一个 IV ,且在最开始的时候需要与处理过的明文进行异或操作。异或操作是可以做逆运算的。
也就是 5^6 = 3 反过来,我们可以知道 5^3 = 6 ,这里假设 6 就是我们的目标 iv 。通过这一层操作我们可以在真正进行加密操作前把查看一下传入的数据。把传入加密的数据和我们的原始明文再做一个异或。理论上就可以得到 iv了。
结合 hook 结果我们可以在在进入 WBACRAES_EncryptOneBlock 函数的时候,传入的还是没有处理过的明文数据。
我们前面 DFA 的注入时机选择在这里
所以最有可能进行了 iv 异或的操作位置就在 CSecFunctProvider::PrepareAESMatrix 。直接 hook 这个函数,在函数返回的时候查看第 3 个参数的结果。在 DFA 的时候已经知道了 第三个参数的地址是 0xbffff468 。
1
2
3
4
5
6
7
8
9
10
11
12
debugger.addBreakPoint(module.base +
0x4A10
+
1
,
new
BreakPointCallback() {
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
emulator.attach().addBreakPoint(emulator.getContext().getLRPointer().peer,
new
BreakPointCallback() {
@Override
public
boolean
onHit(Emulator<?> emulator,
long
address) {
return
false
;
}
});
return
false
;
}
});
先看看入参 mr0 没问题
按 c 继续执行到函数返回的地方,然后读取 0xbffff468 的数据,理论上就是明文和 iv异或之后的数据 。
嗯?? 似乎没有变化?
所以 明文^iv=明文 。这个时候就想起了一个异或的定律,任何数与0异或都等于 0 。其实在 DFA的公式推导中就使用了这个神奇的定律
掏出计算器验证一下,是了!! 数学就是这么神奇!!amazing!!
所以我们这里就可以推到出 iv = 00000000000000000000000000000000
至此我们 key=F6F472F595B511EA9237685B35A8F866 iv= 00000000000000000000000000000000
还差一个 padding
Padding
在IDA中我们向前查看代码可以看到在 CWAESCipher::WBACRAES128_EncryptCBC 中有写入padding 的操作
没什么说的,hook 查看返回值
可以看到我们传入的数据被拼接上了很多数据 。
注意看这里还 7 个空位。按 c 继续执行。再查看这个数据。
看到 结尾增加了 07 ,没跑了 pkcs7 。
验证
加密解密都没有问题
再 unidbg 的生成的数据对比一下 。除了开头的 M 其他都一样了。这个M就是个手动拼接上去的字符 。
MD5
寻找padding的时候我们传入原文,发现原文最后面拼接了一个checkcode
长度是 32 ,第一反应可能是 MD5。对前面字符串的一个签名 。试一下看,试试又不会怀孕。
下面的是 unidbg 产生的,上面是手工生成的,发现就是替换了位置而已。
解密函数
通过 unidbg 主动调用传入,我们刚才的加密结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public
void
decrtpy(String str) {
List<Object> list =
new
ArrayList<>(
5
);
list.add(vm.getJNIEnv());
list.add(
0
);
list.add(vm.addLocalObject(
new
StringObject(vm, str)));
Number number = module.callFunction(emulator,
0x160B4
+
1
, list.toArray());
String result = vm.getObject(number.intValue()).getValue().toString();
System.out.println(
"decrypt:"
+ result);
}
String ret =
"MC2Flb7PBa8OyZYNpAQPQsaGqIfEAfDTdqS+7RDWEJ5mLKMZug4QMfcP1YhKf+0uMnmG5/f4n0gM6gmr4vYkcxbhA7UahLBDfkPF8Ye1G8fZaQ2Jrm9AvO05+6Zj8H10AHyzMnDlaiUDEw0H51sfeLdsInQCa4Y8Y3Og3Aj5wxlbNbutZ6aj+gyA1ONsOxIBsC1gVg5zsCwxUKHQdijg4SzmukFJJqN0gsYiZNRfoVVmkKdZmOxOmn12mi5WR0Jgc0kdUFP8wvWoFvhHPhevkCktRg6tzsdV62EVxVBMLaC+EpWQFjUlKt5mjRhn/Tibr+oerqasrAHYWeyh4Aclv4HTUiHPYf6VPB0sG2P2yRLgBrkJSEuNC3dAF/MS+/vJcJuKgpdE9jHvVBeCXEWOKgA=="
;
decrtpy(ret);
没什么问题,到此就都搞定啦!!
总结
这次案例中我们通过抓包定位到加密参数 sd 。追踪到 native 层的入口为 checkcode 。运用 unidbg 补环境,下断点分析出这是个白盒 AES 。运用 DFA 攻击得到了 AES 的 iv 和 key。这个 app 很适合入门 DFA ,难度适中。需要用到脱壳,抓包,参数定位,native层算法分析,DFA 等各项逆向技能。能够从头跟到尾,相信你对逆向的理解就更上一层楼。
整个过程涉及很多步骤,很难一一写清楚,加上我写文章的能力也一般。有什么不足的欢迎大家在评论区探讨。多谢各位大佬的时间。
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)