四、 DES 加密验证 ------ 动态 DEX + DES ECB
题目信息
|----------|-------------------------------|
| 题目名称 | DES加密验证 |
| 题目分类 | REVERSE |
| 题目难度 | 初级 |
| 题目分值 | 350 |
| 附件 | CrackMe_2_5.zip (Android APK) |
| 安装命令 | adb install -t xx.apk |
题目展示

图 4-1 DES 加密验证 题目页面
题目分析
这道题是四道里结构最复杂的一题,但每一层都不难。整体可以拆成 Java 层动态 DEX 加载 + JNI 反射桥接 + Native 层 DES ECB 加密三层结构。下面分层来看。
▶ 1. APK 结构
解压 APK 后核心文件如下:
classes2.dex # Material Design 库
classes3.dex # 主逻辑 (com.cr.crackme2.MainActivity)
classes4.dex # DataBinding 生成类
assets/
classes3.dex # 动态加载的 DEX (com.cr.test.wide)
myde.bin # 另一个版本的 DEX (也包含 wide 类)
my2.dex # 大型第三方库 DEX
lib/x86/libcrackme2.so # Native 库
注意 assets 目录下还藏了一个 classes3.dex ------ 这是真正包含验证逻辑的 DEX,但它在 APK 安装后并不会被默认加载,而是由 MainActivity 在运行时通过反射动态加载。

图 4-2 jadx 打开 APK 分析主逻辑
▶ 2. 动态 DEX 加载机制
com.cr.crackme2.MainActivity 在启动时会执行一套典型的 DEX 动态加载流程:
• 从 assets/classes3.dex 读取 DEX 文件字节流;
• 保存为内部存储中的 mydex.dex;
• 通过反射获取 ActivityThread.currentActivityThread();
• 拿到 mPackages 中的 mClassLoader;
• 创建自定义 DexClassLoader 加载 mydex.dex;
• 从加载的 DEX 中反射获取 com.cr.test.wide 类;
• 调用 wide.init() 完成初始化。
用户输入 flag 后点击 "开始验证" 按钮,触发的是 wide.verify() 方法 ------ 注意,这个方法在动态加载的 DEX 里,不在主 DEX 中。
▶ 3. 反射桥接 callNativeMethod
反汇编 assets/classes3.dex 中的 wide.verify(),可以看到它先取输入框文本,然后调用一个 callNativeMethod 把验证工作转发出去:
invoke-virtual {v0,v5}, findViewById # 获取输入框
invoke-virtual {v0}, getText # 获取文本
invoke-virtual {v0}, toString # 转字符串
invoke-direct {v0,v5}, callNativeMethod # 调用 native 验证
move-result v2
if-eqz v2, FAIL # v2==0 -> 失败
const-string v4, "恭喜,这是..." # 成功分支
invoke-static {v0,v6,v3}, make # 构建显示内容
invoke-virtual {v0}, show
return-void
# 失败分支
const-string v4, "flag..." # 失败提示
callNativeMethod 自己又是一层反射,最终调到 MainActivity.verifyFlag 这个 native 方法:
java
Class<?> clazz = Class.forName("com.cr.crackme2.MainActivity");
Method method = clazz.getMethod("verifyFlag", String.class);
Object result = method.invoke(null, userInput);
return ((Boolean) result).booleanValue();
也就是说,验证逻辑最终落到 lib/x86/libcrackme2.so 中的 verifyFlag 函数(地址 0x24140)。
▶ 4. Native 层 DES 分析
反汇编 verifyFlag,整体流程是标准的 "加密 + hex 化 + 比较" 三段式:
java
GetStringUTFChars(env, input, &isCopy) # 获取 UTF-8 输入
strlen(input) # 计算长度
malloc(length) # 分配缓冲区
des_ecb_encrypt(input, length, key, out) # DES ECB 加密
bytesToHex(out, hexStr) # 转十六进制字符串
# 将 hexStr 与预期值比较...
DES 密钥的定位有点绕。汇编里有这么一条:
java
lea -0x47d7b(%ebx), %esi # 密钥地址
其中 %ebx 是 GOT 基址 0x56e04,密钥偏移算出来是 0xf089。读 .rodata 偏移 0xf089 处的 8 字节,得到 3132333435363738 ------ ASCII 字符串 "12345678"。这就是 DES 密钥,硬编码在 .so 里。
▶ 5. Flag 提取
既然算法是 DES ECB + hex 比较,那么 .rodata 中一定藏着 hex 编码后的预期密文。在 .rodata 偏移 0xea05 处确实发现了一段连续的 hex 字符:

图 4-3 .rodata 偏移 0xea05 处的 hex 数据
这段 hex 直接解码就能拿到 Flag ------ 因为 verifyFlag 函数里 hex 化后的预期比较值就是 Flag 本身经过 DES 加密后的产物,但出题人图省事直接把 Flag 用 hex 编码塞进了 .rodata,根本没用 DES 真的算一遍。
用随波逐流或 Python 脚本解码都可以:

图 4-4 * 随波逐流解码得到* Flag
python
# 直接从 .so 文件中提取 flag
with open('lib/x86/libcrackme2.so', 'rb') as f:
f.seek(0xea05) # .rodata 偏移
hex_chars = b'0123456789abcdef'
result = b''
while True:
b = f.read(1)
if not b or b[0] not in hex_chars:
break
result += b
flag = bytes.fromhex(result.decode())
# 去除 PKCS7 填充
pad = flag[-1]
if pad <= 16:
flag = flag[:-pad]
print(flag.decode())
Flag
|-------------------------------------------------------------------------------------|
| FLAG flag{b527e2621131134ec22251cfbca75e8c9f5ae4f41371871fd55911927f66a1b4} |
技术总结
这道题在四道里考察面最广,但每一层都是套路,分层击破即可:
| 层级 | 技术点 |
|---|---|
| Java 层 | 动态 DEX 加载(DexClassLoader + 反射 ActivityThread) |
| JNI 层 | Native 方法声明 verifyFlag,反射桥接 callNativeMethod |
| Native 层 | DES ECB 加密(密钥硬编码 "12345678")+ hex 编码比较 |
| 数据存储 | Flag 以 hex 编码形式直接存于 .rodata 段,未真正使用 DES 加密结果 |
题目最有意思的地方在于:虽然出题人搭了三层结构(动态加载 + 反射 + DES),但 Flag 本身是用 hex 编码直接写死在 .rodata 里的,所以只要会 strings + 偏移定位就能秒出答案,DES 部分反而成了摆设。这种 "看起来很复杂、其实有捷径" 的设计在 CTF 里挺常见的。