1. 项目概述与核心思路拆解
最近在ISCC-2025的Mobile赛道上,遇到了一道融合了经典加密算法与安卓逆向的综合题目,它从基础的XXTEA算法入手,逐步过渡到经过深度魔改的AES加密,对逆向分析者的算法识别、代码还原和动态调试能力提出了不小的挑战。这道题的核心价值在于,它模拟了真实移动安全评估中常见的场景:开发者为了保护核心业务逻辑或关键数据,往往会采用自定义或修改过的加密算法来增加逆向难度。对于从事安卓安全研究、应用审计或CTF比赛的同行来说,掌握这类复合型加密算法的分析流程,是一项非常实用的技能。
这道题适合有一定安卓逆向基础,熟悉Jadx、Frida等基础工具,并对常见加密算法(如TEA系列、AES)有初步了解的开发者或安全爱好者。通过完整的解题过程,你不仅能巩固静态分析与动态调试的技巧,更能深入理解如何从零开始,一步步还原一个被混淆和修改过的加密黑盒。整个过程就像在解一个复杂的密码锁,你需要先找到锁眼(算法入口),识别锁芯的结构(算法类型),最后才能配出正确的钥匙(解密密钥)。
2. 环境准备与工具链选择
工欲善其事,必先利其器。面对一个混合了多种加密的安卓APK,一套高效、稳定的工具链是成功的一半。以下是我在实际解题和日常审计中反复验证过的组合,兼顾了效率与深度。
2.1 核心静态分析工具
静态分析是我们的“地图”,用于快速了解应用结构和定位关键代码。
- Jadx-GUI :这是我们的主力反编译器。它的优势在于能将Dex文件快速、可读地反编译成Java代码,对于分析算法逻辑和程序流程至关重要。我通常使用其“搜索”功能全局查找关键词,如“encrypt”、“decrypt”、“AES”、“TEA”等。
- Android Killer / APKTool :用于APK的解包和重打包。当我们需要修改Smali代码进行插桩、绕过验证或者简单Patch时,就离不开它们。Android Killer集成了APKTool、签名等工具,对新手更友好。
- IDA Pro / Ghidra :当关键逻辑被下沉到Native层(.so文件)时,就必须请出这些强大的反汇编工具。对于魔改的AES算法,其核心的S盒、列混合等操作很可能在C/C++层实现,需要用它们进行逆向。
2.2 核心动态调试工具
动态调试是我们的“探针”,用于在运行时观察数据流和验证猜想。
- Frida :动态插桩的瑞士军刀。我们可以编写JavaScript脚本,在目标应用运行时挂钩(Hook)关键函数,打印出函数的输入参数、返回值以及内部变量的值。这对于快速验证加密函数的输入输出、获取中间计算值(如轮密钥)有无可替代的优势。
- Objection :基于Frida的命令行工具,可以快速完成内存搜索、绕过SSL Pinning等常见任务,作为Frida的补充非常高效。
- 一台Root过的安卓真机或模拟器 :这是动态调试的基础。我强烈推荐使用真机,稳定性远胜模拟器。如果使用模拟器,推荐 夜神模拟器(Android 7/9) ,其对Frida的支持相对较好。 注意 :题目本身或日常分析中,绝对不应涉及任何关于绕过网络限制的工具或方法,所有分析均在合法授权的设备或模拟环境中进行。
2.3 辅助与效率工具
- Charles / Burp Suite :用于抓取应用网络流量,有时加密后的数据会直接通过网络传输,抓包可以为我们提供密文样本。
- Python + 相关密码学库(pycryptodome) :用于快速验证算法。当我们推测出算法和密钥后,可以立刻写一个Python脚本进行加解密验证,这比在手机或Java环境中测试要快得多。
- 文本编辑器/IDE :如VS Code,用于编写和修改Frida脚本、Python脚本。
提示:工具版本尽量保持较新,但不必追求最新。一个稳定、熟悉的工具组合比频繁更换新工具更能提升效率。建议提前配置好Frida服务端与客户端的连接,避免在分析时手忙脚乱。
3. 初步侦察与入口点定位
拿到APK后,不要急于深入细节。先进行一遍快速的“体检”,了解其整体情况。
3.1 基础信息收集
首先使用
apktool d
或直接使用Jadx打开APK。关注以下几点:
-
AndroidManifest.xml
:查看主Activity(程序入口)、使用的权限、是否有Native库声明(
android:hasCode="true"或特定的<library>标签)。 -
资源文件
:有时密钥或关键字符串会硬编码在
strings.xml或res目录下的其他文件中。 -
Lib目录
:查看
lib/下有哪些架构的.so文件,这能提示我们加密逻辑是否在Native层。
3.2 搜索加密相关关键词
在Jadx中打开全局搜索(通常快捷键是Ctrl+Shift+F)。我们根据题目标题和常见模式,优先搜索以下关键词:
-
算法名
:
TEA,XXTEA,AES,DES,RSA,MD5,SHA -
功能名
:
encrypt,decrypt,cipher,Crypto,SecretKey,KeyGenerator -
常量特征
:
0x9E3779B9(TEA系列算法的黄金比例常数),AES/ECB/PKCS5Padding(AES算法模式字符串)。 -
类名
:查看是否有包含
Crypto,Encrypt,Decrypt,Security,Cipher等字眼的类。
在这个题目中,搜索“XXTEA”或“0x9E3779B9”很可能直接定位到第一层加密的逻辑。而“AES”相关的搜索可能会指向另一个类或方法,这提示我们加密是分阶段的。
3.3 定位用户交互点
找到程序的主Activity,特别是用户输入(如Flag)和点击“验证”按钮的事件处理方法。这个方法的末尾,通常会调用核心的校验或加密函数。这是我们分析逻辑流的起点。例如,找到
onClick
监听器,跟踪其调用的
checkFlag()
或
verify()
方法。
4. 第一层:XXTEA算法识别与逆向
假设我们通过搜索,找到了一个名为
XXTEAUtils.encrypt
的方法。XXTEA是TEA系列算法的一个变种,比原始TEA更安全。
4.1 XXTEA算法原理快速回顾
理解算法才能逆向它。XXTEA的核心是对一个32位整数数组进行多轮迭代运算。其加密和解密过程使用相同的轮函数,但顺序相反。几个关键特征:
-
数据分组
:将明文填充并分割成多个32位无符号整数(
uint32_t)。 - 密钥 :是一个128位的密钥,同样表示为4个32位整数数组。
-
魔数
:
0x9E3779B9,这是一个由黄金分割率推导出的常数,在每轮运算中都会使用。 - 轮函数 :包含移位、异或、加法等操作,将数据与密钥进行多轮混合。
在Java代码中,你可能会看到类似这样的结构:
public static byte[] encrypt(byte[] data, byte[] key) {
// 1. 将data字节数组转换为int数组v
int[] v = bytesToInts(data, true); // true表示小端序
// 2. 将key字节数组转换为int数组k
int[] k = bytesToInts(key, true);
// 3. 核心加密循环
int n = v.length - 1;
int rounds = 6 + 52 / (n + 1);
long sum = 0;
int z = v[n];
int y = v[0];
int delta = 0x9E3779B9; // 魔数
for (int i = 0; i < rounds; i++) {
sum += delta;
int e = (int)((sum >> 2) & 3);
for (int p = 0; p < n; p++) {
y = v[p + 1];
// MX宏定义的运算,包含移位、异或、加法
v[p] += ((z >> 5 ^ y << 2) + (y >> 3 ^ z << 4)) ^ ((sum ^ y) + (k[(p & 3) ^ e] ^ z));
z = v[p];
}
y = v[0];
// 最后一轮的MX运算
v[n] += ((z >> 5 ^ y << 2) + (y >> 3 ^ z << 4)) ^ ((sum ^ y) + (k[(n & 3) ^ e] ^ z));
z = v[n];
}
// 4. 将int数组v转换回字节数组
return intsToBytes(v, true);
}
看到这样的结构,结合常数
0x9E3779B9
,基本可以确定是XXTEA。
4.2 静态分析与密钥获取
我们的目标是找到解密逻辑。通常,题目会提供加密函数,我们需要逆向写出解密函数,或者更简单——直接获取加密时使用的密钥。
-
查找密钥
:在
encrypt方法附近,查找密钥的来源。它可能是:- 硬编码在代码中的字节数组。
-
从资源文件(如
strings.xml)中读取。 - 通过某个简单变换(如字符串反转、Base64解码)生成。
- 在Jadx中,可以右键点击密钥变量,选择“查找用例”,追踪其赋值源头。
-
分析数据流
:找到调用
XXTEAUtils.encrypt的地方,看它的输入(明文)是什么,输出(密文)又传递到哪里去了。输出很可能作为下一层加密(魔改AES)的输入。
4.3 使用Frida进行动态验证
静态分析可能因为混淆而不够清晰。此时,Frida就派上用场了。我们可以写一个脚本,Hook这个加密函数。
Java.perform(function() {
var XXTEAUtils = Java.use('com.example.challenge.XXTEAUtils');
XXTEAUtils.encrypt.overload('[B', '[B').implementation = function(data, key) {
console.log(\"[*] XXTEA.encrypt called!\");
console.log(\"Data (hex): \" + bytesToHex(data));
console.log(\"Key (hex): \" + bytesToHex(key));
var result = this.encrypt(data, key);
console.log(\"Result (hex): \" + bytesToHex(result));
return result;
};
// 辅助函数:字节数组转十六进制字符串
function bytesToHex(bytes) {
var arr = [];
for (var i = 0; i < bytes.length; i++) {
arr.push(('0' + (bytes[i] & 0xFF).toString(16)).slice(-2));
}
return arr.join('');
}
});
运行这个脚本,当应用执行加密时,我们就能在控制台看到实时的输入、输出和密钥。这能100%确认我们的静态分析结果,并拿到确切的密钥值。
5. 第二层:魔改AES的深度剖析
通过了XXTEA层,我们得到的输出成为了魔改AES的输入。这是本题的难点所在。“魔改”意味着标准AES的某些组成部分被修改了,常见的魔改点包括:
5.1 AES标准流程与常见魔改点
标准AES-128加密流程(以ECB模式为例)包括:
- 密钥扩展 :将初始密钥扩展成11组轮密钥(Round Key)。
- 初始轮 :明文与第0轮轮密钥进行异或(AddRoundKey)。
-
重复9轮标准轮函数
,每轮包含:
- 字节替换(SubBytes) :通过S盒进行非线性替换。
- 行移位(ShiftRows) :对状态矩阵的行进行循环移位。
- 列混合(MixColumns) :对状态矩阵的列进行线性变换。
- 轮密钥加(AddRoundKey) :与当前轮密钥异或。
- 最终轮 :执行字节替换、行移位、轮密钥加( 不进行列混合 )。
魔改通常发生在:
- 自定义S盒 :开发者替换了标准的AES S盒,这是最隐蔽的魔改。逆向时必须找到这个新的替换表。
-
修改列混合矩阵
:改变了
MixColumns所用的固定矩阵。 - 修改密钥扩展算法 :让轮密钥的生成方式变得非标准。
- 增加/减少加密轮数 。
- 改变操作顺序 。
5.2 定位与逆向魔改AES逻辑
-
寻找入口
:在Jadx中跟踪XXTEA加密结果的流向,找到下一个处理它的方法,类名可能包含
AES、MyCipher、CustomCrypto等。 -
区分Java与Native
:如果这个类的方法体非常简单,只是用
native关键字声明,或者直接调用了System.loadLibrary加载的库函数,那么核心逻辑就在.so文件里。反之,则可能在Java层。 -
静态分析Java层魔改
:如果在Java层,我们需要仔细阅读代码,对比标准AES实现。重点关注:
-
S盒数组
:查找名为
SBOX、sBox的静态字节数组。用Python打印出其值,与标准AES S盒对比。如果不一致,就是魔改点。 -
列混合矩阵
:查找名为
MIX_COLUMNS_MATRIX的矩阵。 -
密钥扩展函数
:查看
KeySchedule或expandKey方法。 -
轮函数
:查看
encryptBlock或cipher方法。
-
S盒数组
:查找名为
-
静态分析Native层魔改
:如果在Native层(
.so),就需要用IDA Pro或Ghidra打开对应的库文件。-
导出函数
:在导出函数表中查找
Java_开头的函数(JNI函数),或者直接搜索AES、encrypt等字符串。 - 定位S盒 :在IDA中,S盒通常是一个大小为256的静态字节数组。可以通过查找交叉引用来定位使用它的函数。魔改的S盒值会直接硬编码在数据段。
-
分析算法逻辑
:对照标准AES的伪代码,分析反汇编出来的函数流程。寻找
SubBytes、ShiftRows、MixColumns、AddRoundKey对应的代码块。注意识别魔改部分。
-
导出函数
:在导出函数表中查找
5.3 动态提取关键数据(S盒、密钥)
对于魔改AES,尤其是S盒被改的情况,动态提取往往比静态分析更直接。我们可以用Frida Hook Native层函数。
假设我们通过分析,找到了Native层的加密函数
native_encrypt
。我们可以编写Frida脚本读取其内存中的S盒和轮密钥。
// 假设我们已知S盒在内存中的地址是 0xcf2d4000(通过IDA静态分析得到)
var sbox_addr = ptr(\"0xcf2d4000\");
// 读取256字节的S盒
var sbox = Memory.readByteArray(sbox_addr, 256);
console.log(\"[*] Dumping Custom S-Box:\");
console.log(hexdump(sbox, { offset: 0, length: 256, header: true, ansi: false }));
// Hook native加密函数,打印输入输出
Interceptor.attach(Module.findExportByName(\"libnative-lib.so\", \"native_encrypt\"), {
onEnter: function(args) {
// args[0]: JNIEnv, args[1]: jclass, args[2]: jbyteArray input, args[3]: jbyteArray key...
console.log(\"[*] native_encrypt called\");
// 这里需要根据函数签名,正确读取Java传入的字节数组
// 通常使用JNI API如 GetByteArrayElements
},
onLeave: function(retval) {
console.log(\"[*] native_encrypt returned\");
}
});
通过动态Hook,我们可以直接拿到运行时确切的S盒内容、输入的明文/密文以及密钥,为后续编写解密脚本提供准确数据。
6. 完整解密流程串联与脚本编写
现在,我们手头应该有了以下信息:
- XXTEA的密钥(Key_xxtea)。
- 魔改AES的密钥(Key_aes)和自定义S盒(Custom_Sbox)。
-
整个加密流程:
Flag -> XXTEA加密 -> 中间数据 -> 魔改AES加密 -> 最终密文。
题目通常会给出一个“最终密文”,我们需要逆向这个过程得到Flag。
6.1 解密步骤设计
解密流程是加密的逆序:
-
逆向魔改AES
:使用我们提取出的
Custom_Sbox和Key_aes,编写一个解密函数。这需要根据魔改的具体内容来实现:-
如果只改了S盒,那么解密时需要对应的逆S盒(Inv_Sbox)。可以通过遍历
Custom_Sbox计算出来:Inv_Sbox[Custom_Sbox[i]] = i。 - 如果改了列混合矩阵,则需要求出该矩阵的逆矩阵,用于解密时的逆列混合。
- 密钥扩展算法如果被改,解密时也需要对应的逆密钥扩展,或者直接使用加密时生成的轮密钥(顺序反向使用)。
-
如果只改了S盒,那么解密时需要对应的逆S盒(Inv_Sbox)。可以通过遍历
-
逆向XXTEA
:XXTEA的解密算法是其加密算法的逆过程。标准XXTEA解密代码网上很多,我们需要确保使用的
delta常数(0x9E3779B9)和轮数与加密端一致。
6.2 Python解密脚本示例
以下是一个高度简化的示例框架,展示了如何串联两个解密过程:
import struct
# 假设我们通过分析得到的常量
DELTA = 0x9E3779B9
ROUNDS = 32 # XXTEA轮数,根据实际分析确定
# 1. 自定义的AES解密函数 (这里仅为框架,需填充具体魔改逻辑)
def custom_aes_decrypt(ciphertext, key, custom_sbox):
"""
魔改AES解密
:param ciphertext: 字节数组,最终密文
:param key: 字节数组,AES密钥
:param custom_sbox: 字节数组,自定义的256字节S盒
:return: 字节数组,AES解密后的中间数据
"""
# TODO: 实现基于custom_sbox和key的AES解密逻辑
# 包括:生成逆S盒、逆列混合、轮密钥逆序使用等
# 这里需要你根据静态/动态分析出的具体魔改方式编写
intermediate_data = b'' # 假设这是解密结果
print(f\"[+] AES Decrypted: {intermediate_data.hex()}\")
return intermediate_data
# 2. 标准XXTEA解密函数
def xxtea_decrypt(data, key):
"""
XXTEA解密
:param data: 字节数组
:param key: 字节数组(16字节)
:return: 字节数组
"""
def _to_ints(v):
n = len(v)
ints = []
for i in range(0, n, 4):
ints.append(struct.unpack(\"<I\", v[i:i+4])[0]) # 小端序
return ints
def _to_bytes(ints):
data = b''
for i in ints:
data += struct.pack(\"<I\", i & 0xffffffff) # 小端序
return data
v = _to_ints(data)
k = _to_ints(key)
n = len(v)
rounds = 6 + 52 // n
sum_ = (DELTA * rounds) & 0xffffffff
y = v[0]
for _ in range(rounds):
e = (sum_ >> 2) & 3
for p in range(n-1, 0, -1):
z = v[p-1]
v[p] -= ((z >> 5 ^ y << 2) + (y >> 3 ^ z << 4)) ^ ((sum_ ^ y) + (k[(p & 3) ^ e] ^ z))
v[p] &= 0xffffffff
y = v[p]
z = v[n-1]
v[0] -= ((z >> 5 ^ y << 2) + (y >> 3 ^ z << 4)) ^ ((sum_ ^ y) + (k[(0 & 3) ^ e] ^ z))
v[0] &= 0xffffffff
y = v[0]
sum_ = (sum_ - DELTA) & 0xffffffff
plain_bytes = _to_bytes(v)
# 去除可能的填充(根据实际情况,可能是PKCS5/7等)
# padding_len = plain_bytes[-1]
# plain_bytes = plain_bytes[:-padding_len]
return plain_bytes
# 3. 主解密流程
def main():
# 这些值需要从题目或你的分析中获取
final_ciphertext = bytes.fromhex(\"这里是题目给的最终密文十六进制字符串\")
aes_key = bytes.fromhex(\"你分析出的AES密钥\")
xxtea_key = bytes.fromhex(\"你分析出的XXTEA密钥\")
custom_sbox = bytes.fromhex(\"你dump出的256字节自定义S盒十六进制字符串\")
print(\"[*] Starting decryption...\")
# 第一步:魔改AES解密
intermediate_data = custom_aes_decrypt(final_ciphertext, aes_key, custom_sbox)
# 第二步:XXTEA解密
flag_bytes = xxtea_decrypt(intermediate_data, xxtea_key)
# 第三步:输出Flag
try:
flag = flag_bytes.decode('utf-8')
print(f\"[+] Flag found: {flag}\")
except UnicodeDecodeError:
print(f\"[+] Raw flag bytes (hex): {flag_bytes.hex()}\")
print(\"Flag might not be a simple string, check the bytes.\")
if __name__ == \"__main__\":
main()
7. 常见问题与实战调试技巧
在实际操作中,你几乎一定会遇到各种问题。下面是我踩过坑后总结的一些经验。
7.1 静态分析中的障碍与应对
-
问题:代码混淆严重,类名方法名无意义。
-
技巧
:不要纠结于名称。关注方法体的逻辑和常量。搜索特征常量(如
0x9E3779B9,0x63(标准S盒第一个值))来定位关键函数。使用“字符串搜索”功能,查找可能泄露信息的日志字符串。
-
技巧
:不要纠结于名称。关注方法体的逻辑和常量。搜索特征常量(如
-
问题:算法逻辑被分散到多个类或方法中。
- 技巧 :在Jadx中,对关键变量或参数使用“查找用例”功能,追踪其在整个项目中的传递路径,绘制出简化的调用图。
7.2 动态调试中的陷阱与解决
-
问题:Frida脚本注入失败或应用崩溃。
-
检查点
:
- Frida-server版本与桌面客户端版本是否匹配。
-
应用是否有反调试或反注入检测?可以尝试使用
objection的android disable命令禁用一些常见检测,或者使用frida -U -f com.package.name --no-pause在应用启动前注入。 -
Hook的函数签名是否完全正确?重载(overload)是否选对?使用
Java.choose或枚举类的方法来确认。
-
检查点
:
-
问题:Native层Hook时,函数地址找不到。
-
技巧
:不要只依赖导出函数名。使用
Module.enumerateImports()和Module.enumerateExports()列出所有导入导出函数。对于未导出的函数,可以通过特征码(字节序列)在内存中搜索。或者,先Hook一个已知的JNI函数(如Java_com_example_MainActivity_encrypt),然后在其内部回溯调用栈,找到真正的算法函数。
-
技巧
:不要只依赖导出函数名。使用
7.3 算法还原与验证阶段的难题
-
问题:自己实现的解密脚本输出乱码,无法验证。
-
排查步骤
:
-
字节序
:这是最常见的问题!Java/Android默认是
大端序(Big-Endian)
,而很多C语言实现和标准算法描述使用
小端序(Little-Endian)
。在
bytesToInts和intsToBytes转换时,必须确认与目标代码保持一致。用Frida Hook打印出中间整数数组的值,与你脚本中转换后的值对比。 - 填充模式 :AES是块加密,需要对明文进行填充。常见的PKCS5/PKCS7填充在解密后需要去除。确认题目使用的填充方式,并在解密后正确去除填充。
- 魔改点遗漏 :是否只改了S盒,但忽略了密钥扩展?是否轮数不对?用Frida在Native层加密函数的入口和出口打印状态矩阵(State)的中间值,与你本地算法实现的每一步输出进行比对,定位第一个出现差异的步骤。
- 密钥错误 :双重确认获取的密钥是否正确。是否在传递过程中经过了编码(如Base64)或二次转换?
-
字节序
:这是最常见的问题!Java/Android默认是
大端序(Big-Endian)
,而很多C语言实现和标准算法描述使用
小端序(Little-Endian)
。在
-
排查步骤
:
7.4 效率提升心得
- 先动后静 :对于复杂算法,有时直接动态Hook获取输入输出和中间值,比完全静态分析更快。用已知的明文去触发加密,观察输出,可以快速验证对算法结构的猜想。
- 单元测试思维 :将大问题分解。先单独验证XXTEA的解密是否正确(用题目可能提供的中间数据或自己加密一个测试数据)。再单独验证魔改AES的解密。最后再串联。
- 善用搜索引擎和社区 :标准算法(如XXTEA)的实现代码网上很多。但 切勿直接复制 ,一定要理解其代码逻辑,并适配题目可能存在的细微改动(如轮数、字节序)。
逆向工程就像侦探破案,需要耐心、细致的观察和逻辑推理。这道从XXTEA到魔改AES的题目,完美地串联了移动逆向的多个核心技能点。当你成功提取出Flag的那一刻,所有的调试和排查都会变得值得。最重要的是,通过这个过程积累的经验,能让你在面对真实世界中更加复杂的保护方案时,拥有清晰的破解思路和扎实的动手能力。
1001

被折叠的 条评论
为什么被折叠?



