0x00 前言:
好久没有做过安卓逆向了,最近重新系统地学习了安卓逆向技术。找到了一道较为典型的逆向分析题来练手,以锻炼动静态分析和动态链接库分析的基本能力。在这里记录基本的分析流程手法。
0x01 逆向分析:
一、使用 Genymotion 生成一个安卓虚拟机,把目标 Apk 拖入安装并运行:
Genymotion 是一款出色的跨平台的Android模拟器,具有容易安装和使用、运行速度快、自带 ROOT 的特点, 是Android开发、测试等相关人员的必备工具。
任意输入,返回flag错误。初步判断该软件类似于卡密软件,需要输入正确的卡密才能使用。
二、使用 Jeb 对目标 Apk 进行动静分析:
拖入 jeb 查看 Java 字节码如下:
tab 键反汇编如下:
静态分析可知:
主函数主要是获取输入框输入,判断输入字符串长度是否 > 10, 并且字符串收尾由 flag{ 和 } 组成。符合基本条件,就可以进入 check.check() 函数,对 flag{} 包裹的字符串进行检查,验证是否正确。
进入 check 函数:
可知该check函数采用 AES 对称加密对参数进行加密,然后base64编码后传入 Myjni.encode函数进行二次编码,编码后的结果与 NOYKxeJRlz65XGjgTODxUvJIBdnY8NQZNQgnoK5Mxckh3fhvJjNFWoBM8wVCdfOz
进行比较,比较相同则输入的flag正确。
其中 MyJni 是外部链接库,待会还得逆向分析 MyJni 所在的 .so 外部链接库。
基本程序逻辑已经理清楚,Ctrl + B 在check 函数中打入断点
开始 动态调试:
按基本要求输入字符串,断点成功被卡住:
断点调试直到 0000000E invoke-static Myjni->getkey()String
获取AES加密秘钥:Z29qZSUgYKMmYJ5fch9kZL==
运行到 00000096 invoke-static Base64->encodeToString([B, I)String, p0, v1
生成加密结果:uOkRlVa7zcEd9qTGJdreAw==
运行 0000009E invoke-static Myjni->encode(String)String, p0
进行二次编码,多次尝试发现,每次编码的结果都不同,而且编码后长度还变短了,有点诡异。以下是三次相同字符串(flag{xxxxxxxxxxxxxx}
)的二次编码结果:
没什么头绪,开始分析 MyJni 所在的 libHello.so, 分析 encode 函数逻辑。导出 libHello.so
放入 IDA 中进行分析:
encode 函数的汇编代码如下:
反汇编伪代码如下:
jstring __fastcall Java_com_example_myapplication_Myjni_encode(JNIEnv *env, jclass jclass, jstring jflag)
{
const char *v4; // r12
size_t i; // rbx
char v6; // al
char v7; // dl
int v8; // ecx
__m128 v10; // [rsp+0h] [rbp-78h] BYREF
__m128 v11; // [rsp+10h] [rbp-68h]
__int128 v12; // [rsp+20h] [rbp-58h]
__int128 v13; // [rsp+30h] [rbp-48h]
char v14; // [rsp+40h] [rbp-38h]
unsigned __int64 v15; // [rsp+50h] [rbp-28h]
v15 = __readfsqword(0x28u);
v4 = env->functions->GetStringUTFChars(env, jflag, 0LL);
env->functions->ReleaseStringUTFChars(env, jflag, v4);
if ( *v4 )
{
for ( i = 0LL; strlen(v4) > i; ++i )
{
v8 = v4[i];
if ( (unsigned int)(v8 - 123) > 0xFFFFFFE5 )
{
v6 = 97;
v7 = -97;
}
else
{
if ( (unsigned int)(v8 - 91) < 0xFFFFFFE6 )
continue;
v6 = 65;
v7 = -65;
}
v4[i] = v6
+ v7
+ v8
+ 17
- 26 * ((20165 * ((char)(v7 + v8) + 17) < 0) + ((unsigned int)(20165 * ((char)(v7 + v8) + 17)) >> 19));
}
}
v13 = 0LL;
v12 = 0LL;
v11 = 0LL;
v10 = 0LL;
v14 = 0;
v10 = _mm_movelh_ps((__m128)*((unsigned __int64 *)v4 + 1), (__m128)*((unsigned __int64 *)v4 + 7));
v11 = _mm_movelh_ps((__m128)*((unsigned __int64 *)v4 + 3), (__m128)*((unsigned __int64 *)v4 + 6));
v12 = *((_OWORD *)v4 + 2);
*(_QWORD *)&v13 = *((_QWORD *)v4 + 2);
*((_QWORD *)&v13 + 1) = *(_QWORD *)v4;
return env->functions->NewStringUTF(env, &v10);
}
简单分析如下:
主要分两段处理逻辑:第一段字符串替换加密,第二段字符串重组。
①、替换加密算法分析与逆向:
转成 python 代码:
after_aes_base64_value = "" # aes加密后base64编码值
ans = "" # 替换后的结果
for i in range(len(after_aes_base64_value )):
v8 = ord(after_aes_base64_value[i])
if v8 > 96:
v6 = 97
v7 = -97
else:
if v8 < 65:
ans.append(v8)
continue
v6 = 65
v7 = -65
ans.append(v6 + v7 + v8 + 17 - 26 * (20165 * ((v7 + v8 + 17) < 0) + (20165 * (v7 + v8 + 17)) >> 19))
这段代码对字符串中的字母进行了一个复杂的变换,而非字母字符保持不变。
对于字符串中的每个字符,获取字符的ASCII值(v8)。检查 v8 是否大于96(表示是小写字母)。如果v8小于65(不是大写字母或数字),直接将v8添加到ans。确定基值 v6(大写字母为65,小写字母为97)和负基值v7。使用公式计算加密值:
v6 + v7 + v8 + 17 - 26 * (20165 * ((v7 + v8 + 17) < 0) + (20165 * (v7 + v8 + 17)) >> 19)
根据以上分析写出逆向解密算法:
# 已知加密后的ans值
ans = []
# 逆向函数,计算原始字符的ASCII值
def reverse_char(encoded_char, base):
v7 = -base
v8_candidates = []
for v8 in range(base, base + 26):
# 根据加密公式计算对应的原始字符
encoded_value = base + v7 + v8 + 17 - 26 * (20165 * ((v7 + v8 + 17) < 0) + (20165 * (v7 + v8 + 17) >> 19))
if encoded_value == encoded_char:
v8_candidates.append(v8)
return v8_candidates
# 存储逆向替换字符后的字符串
after_aes_base64_value = []
# 对每个加密后的字符进行逆向计算
for char in ans:
if 97 <= char <= 122: # 小写字母
v8_candidates = reverse_char(char, 97)
elif 65 <= char <= 90: # 大写字母
v8_candidates = reverse_char(char, 65)
else:
# 非字母或数字字符直接添加
after_aes_base64_value.append(chr(char))
continue
if v8_candidates:
# 选择第一个候选值作为解密结果
after_aes_base64_value.append(chr(v8_candidates[0]))
②、字符串重组分析与逆向
在UTF-8编码下,一个字符 ‘a’ 占1个字节(8位);在UTF-16编码下,‘a’ 占2个字节(16位)。分析上述代码可知,每次合并拿 64位,也就是 64 / 8 = 8 个字符, 8个字符为一组进行重组。重组后的字符串NOYKxeJRlz65XGjgTODxUvJIBdnY8NQZNQgnoK5Mxckh3fhvJjNFWoBM8wVCdfOz
长度为64,刚好可以分为8组,重组规则如下:
编写逆向重组代码:
def reorder_string(s):
# 定义序号顺序
order = [1, 7, 3, 6, 4, 5, 2, 0]
# 每组8个字符
groups = [s[i:i+8] for i in range(0, len(s), 8)]
reordered_groups = [None] * 8
# 根据order将组放置到正确的位置
for index, position in enumerate(order):
reordered_groups[position] = groups[index]
return ''.join(reordered_groups)
s = "NOYKxeJRlz65XGjgTODxUvJIBdnY8NQZNQgnoK5Mxckh3fhvJjNFWoBM8wVCdfOz"
result = reorder_string(s)
print(result)
# 8wVCdfOzNOYKxeJRJjNFWoBMTODxUvJINQgnoK5Mxckh3fhvBdnY8NQZlz65XGjg
综上分析,我们即可写出 Myjni.encode
编码对应的解码函数,解码后再 AES 解密,即可得到正确的flag。 刚才动态分析已经获取到了秘钥为:Z29qZSUgYKMmYJ5fch9kZL==
,静态分析找到偏移量IV为:ZmxhZ2ZsYWdyZQ==
,加密方式是:AES/CBC/PKCS5Padding
。
故完整解题代码如下:
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64
def decode(s):
# 逆向重组
order = [1, 7, 3, 6, 4, 5, 2, 0]
groups = [s[i:i+8] for i in range(0, len(s), 8)]
reordered_groups = [None] * 8
for index, position in enumerate(order):
reordered_groups[position] = groups[index]
ascii_array = [ord(char) for char in ''.join(reordered_groups)]
# 存储逆向替换字符后的字符串
after_aes_base64_value = []
# 逆向替换算法
for char in ascii_array:
if 97 <= char <= 122: # 小写字母
v8_candidates = reverse_char(char, 97)
elif 65 <= char <= 90: # 大写字母
v8_candidates = reverse_char(char, 65)
else:
# 非字母或数字字符直接添加
after_aes_base64_value.append(chr(char))
continue
if v8_candidates:
# 选择第一个候选值作为解密结果
after_aes_base64_value.append(chr(v8_candidates[0]))
return aes_decrypt(''.join(after_aes_base64_value))
# 逆向函数,计算原始字符的ASCII值
def reverse_char(encoded_char, base):
v7 = -base
v8_candidates = []
for v8 in range(base, base + 26):
# 根据加密公式计算对应的原始字符
encoded_value = base + v7 + v8 + 17 - 26 * (20165 * ((v7 + v8 + 17) < 0) + (20165 * (v7 + v8 + 17) >> 19))
if encoded_value == encoded_char:
v8_candidates.append(v8)
return v8_candidates
# aes 解密
def aes_decrypt(encrypted_text):
try:
# 将密钥和IV进行编码
key = 'Z29qZSUgYKMmYJ5fch9kZL=='.encode('utf-8')
iv = 'ZmxhZ2ZsYWdyZQ=='.encode('utf-8')
encrypted_text_bytes = base64.b64decode(encrypted_text)
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted_bytes = cipher.decrypt(encrypted_text_bytes)
decrypted_text = unpad(decrypted_bytes, AES.block_size).decode('utf-8')
return decrypted_text
except (ValueError, KeyError) as e:
return f"解密失败: {str(e)}"
s = "NOYKxeJRlz65XGjgTODxUvJIBdnY8NQZNQgnoK5Mxckh3fhvJjNFWoBM8wVCdfOz"
result = decode(s)
print(f"flag{{{result}}}")
0x02 总结:
通过 APK 逆向和 so 动态链接库分析,了解应用的内部逻辑和行为。静态分析帮助我们初步理解代码结构和关键点,而动态调试则允许我们在运行时获取更详细的信息。两者结合,可以有效地进行应用的逆向工程和安全分析。