本文将对 SCTF 的一道 Android 逆向题目 "Simple" 进行完整的技术分析。这道题目综合考察了 Android 应用逆向、密码学算法识别、算法分析等多方面技术能力。本文将从零开始,详细讲解每一个分析步骤,帮助逆向新手理解完整的解题思路和技术原理。
拿到题目文件 simple.apk,首先需要了解 APK 的基本结构。APK(Android Package)本质上是一个 ZIP 压缩包,包含了 Android 应用的所有资源和代码。
使用标准的解压工具即可查看其内容:
unzip -q simple.apk -d simple_extracted
ls -lh simple_extracted/
解包后得到如下目录结构:
simple_extracted/
├── AndroidManifest.xml # 应用清单文件
├── classes.dex # Dalvik 可执行文件(主要代码)
├── assets/ # 应用资源目录
│ └── test.zip # 可疑文件
├── res/ # Android 资源文件
├── META-INF/ # 签名信息
└── resources.arsc # 编译后的资源索引
初步分析发现两个重要文件:
classes.dex(438 KB) - Android 应用的主要代码,以 Dalvik 字节码格式存储
assets/test.zip(443 KB) - 存放在资源目录中的可疑文件
通常情况下,APK 的主要逻辑都在 classes.dex 中。但这里 assets 目录中还有一个接近相同大小的 test.zip 文件,这非常可疑。
使用 file命令查看文件类型:
file simple_extracted/assets/test.zip
输出结果:
Zip archive data, at least v10.3 to extract, compression method=[0xffffbdb8]
虽然文件被识别为 ZIP 格式,但压缩方法字段显示为 0xffffbdb8,这是一个不正常的值。标准 ZIP 压缩方法应该是:
0x0000: 不压缩
0x0008: Deflate 压缩
0x000C: Bzip2 压缩
进一步查看文件头:
hexdump -C simple_extracted/assets/test.zip | head -5
00000000 50 4b 03 04 67 34 58 f2 b8 bd 78 67 cc af ae 27 |PK..g4X...xg...'|
00000010 79 46 06 3e 22 f1 5d bb 22 72 e9 73 4e 8d 5d 4d |yF.>".]."r.sN.]M|
00000020 d1 53 04 05 ab cd 87 2c 54 bc 0c e5 fb b0 ba c2 |.S.....,T.......|
文件头是标准的 ZIP 魔数 50 4b 03 04(即 "PK"),但后续内容看起来像是随机数据。这强烈暗示文件被加密或混淆了。
基于以上发现,形成初步分析思路:
classes.dex 中可能包含解密 test.zip 的代码
test.zip 很可能是加密的 DEX 文件,包含真正的业务逻辑
需要先反编译 classes.dex,找到解密逻辑
解密 test.zip 后再分析真正的验证算法
使用 JADX (Java Decompiler for Android DEX) 反编译 classes.dex:
jadx -d simple_jadx simple_extracted/classes.dex
JADX 是目前最流行的 Android 反编译工具,它可以将 DEX 字节码还原成可读的 Java 源代码。反编译完成后,我们得到了 19 个类的 Java 源码。
Android 应用的入口通常有两个地方:
Application 类- 在应用启动时最先执行
MainActivity- 应用的主界面
通过搜索发现了一个自定义的 Application 类:ProxyApplication,这是第一个需要分析的目标。
打开 simple_jadx/sources/com/fancy/crackme1/ProxyApplication.java:
package com.fancy.crackme1;
import android.app.Application;
import android.content.Context;
import android.util.ArrayMap;
import dalvik.system.DexClassLoader;
import java.io.File;
import java.lang.ref.WeakReference;
public class ProxyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
}
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
File cache = getDir("shell", 0);
String dexpath1 = cache + "/load.dex";
FindAssetfile.getAssetsFile(this, "test.zip", dexpath1, null);
DexClassLoader dcl1 = new DexClassLoader(dexpath1,
getDir("shell_oat", 0).getAbsolutePath(),
getApplicationInfo().nativeLibraryDir,
getClassLoader());
Object currentActivityThread = RefInvoke.invokeStaticMethod(
"android.app.ActivityThread", "currentActivityThread",
new Class[0], new Object[0]);
String packageName = getPackageName();
ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect(
"android.app.ActivityThread", currentActivityThread, "mPackages");
WeakReference wr = (WeakReference) mPackages.get(packageName);
RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader",
wr.get(), dcl1);
}
}
这段代码实现了典型的 Android 应用加壳技术,让我们逐行分析:
File cache = getDir("shell", 0);
String dexpath1 = cache + "/load.dex";
创建一个名为 "shell" 的私有目录
准备将解密后的 DEX 保存为 load.dex
FindAssetfile.getAssetsFile(this, "test.zip", dexpath1, null);
调用 FindAssetfile类的静态方法
将 assets/test.zip解密并保存到 dexpath1指定的路径
DexClassLoader dcl1 = new DexClassLoader(dexpath1,
getDir("shell_oat", 0).getAbsolutePath(),
getApplicationInfo().nativeLibraryDir,
getClassLoader());
DexClassLoader是 Android 提供的动态代码加载机制,参数说明:
参数1 (dexpath1): 要加载的 DEX 文件路径
参数2: 优化后的 ODEX 文件存放目录
参数3: Native 库路径
参数4: 父类加载器
Object currentActivityThread = RefInvoke.invokeStaticMethod(...);
// ... 通过反射获取应用的 ActivityThread 和 LoadedApk
RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader",
wr.get(), dcl1);
这段代码使用反射技术,将应用的 ClassLoader 替换为新创建的 dcl1。这样,后续所有类的加载都会从解密后的 load.dex中进行。
在 Java/Android 中,ClassLoader 负责将类加载到内存中。应用启动时,系统会创建默认的 ClassLoader 来加载 classes.dex 中的类。
通过替换 ClassLoader,可以实现:
代码保护:真正的业务逻辑隐藏在加密的 DEX 中
动态更新:可以远程下载新的 DEX 文件进行热更新
反静态分析:静态分析工具只能看到加壳代码
这种技术常见于:
商业应用加固(如腾讯乐固、360 加固)
恶意软件隐藏真实代码
应用热修复功能
打开 simple_jadx/sources/com/fancy/crackme1/FindAssetfile.java,重点关注 getAssetsFile方法:
public static File getAssetsFile(Context cont, String assetfile,
String releasefile, Method decMethod) {
AssetManager manager = cont.getAssets();
try {
// 1. 从 assets 目录读取文件
InputStream is = manager.open(assetfile);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
while (true) {
int iRead = is.read(buf);
if (iRead == -1) {
break;
}
bos.write(buf, 0, iRead);
}
// 2. 可选的解密方法(此处传入 null)
byte[] dec = decMethod != null ?
(byte[]) decMethod.invoke(null, bos.toByteArray()) :
bos.toByteArray();
is.close();
bos.close();
// 3. 修改文件头
FileOutputStream outfile = new FileOutputStream(new File(releasefile));
dec[0] = 113; // 0x71
dec[1] = 114; // 0x72
dec[2] = 10; // 0x0a
dec[3] = 8; // 0x08
// 4. 使用 crypt 方法解密
byte[] dex = crypt(dec, "E82038F4B30E810375C8365D7D2C1A3F");
outfile.write(dex);
outfile.close();
return null;
} catch (IOException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
return null;
}
}
整个解密过程分为 4 个步骤:
从 assets 目录读取 test.zip的全部内容到内存。
代码中预留了一个 decMethod参数,可以传入自定义的解密方法。但在 ProxyApplication 中传入的是 null,所以这步实际上什么都没做。
这是一个关键步骤。代码将文件的前 4 个字节修改为:
dec[0] = 113→ 0x71 → ASCII 字符 'q'
dec[1] = 114→ 0x72 → ASCII 字符 'r'
dec[2] = 10→ 0x0a → 换行符 '\n'
dec[3] = 8→ 0x08 → 控制字符
标准 DEX 文件的前 4 个字节应该是:
0x64 0x65 0x78 0x0a → "dex\n"
那么问题来了:为什么要把头部改成 0x71 0x72 0x0a 0x08呢?答案要结合下一步的加密算法来理解。
使用固定密钥 "E82038F4B30E810375C8365D7D2C1A3F"调用 crypt方法解密。
查看 crypt方法的实现:
public static byte[] crypt(byte[] data, String key) {
byte[] keys = key.getBytes();
int x = 0;
int[] box = new int[256];
// 第一部分:初始化 S-box
for (int i = 0; i < 256; i++) {
box[i] = i;
}
for (int i2 = 0; i2 < 256; i2++) {
x = ((box[i2] + x) + keys[i2 % keys.length]) % 256;
int tmp = box[i2];
box[i2] = box[x];
box[x] = tmp;
}
// 第二部分:生成密钥流并加密
int x2 = 0;
int y = 0;
byte[] out = new byte[data.length];
int index = 0;
for (byte ch : data) {
x2 = (x2 + 1) % 256;
y = (box[x2] + y) % 256;
int tmp2 = box[y];
box[y] = box[x2];
box[x2] = tmp2;
out[index] = (byte) (box[(box[x2] + box[y]) % 256] ^ ch);
index++;
}
return out;
}
这是标准的 RC4 (Rivest Cipher 4)流密码算法,由 Ron Rivest 在 1987 年设计。
对称加密:加密和解密使用相同的算法和密钥
流密码:逐字节处理数据
速度快:适合软件实现
已不安全:现代密码学已证明存在多个漏洞,但仍被广泛使用
阶段 1:KSA (Key Scheduling Algorithm) - 密钥调度算法
// 初始化 S-box 为 0-255
for (int i = 0; i < 256; i++) {
box[i] = i;
}
// 使用密钥打乱 S-box
for (int i = 0; i < 256; i++) {
x = ((box[i] + x) + keys[i % keys.length]) % 256;
// 交换 box[i] 和 box[x]
box[i], box[x] = box[x], box[i];
}
目的:根据密钥生成一个伪随机的 256 字节排列(S-box)。
阶段 2:PRGA (Pseudo-Random Generation Algorithm) - 伪随机生成算法
for (byte ch : data) {
x = (x + 1) % 256;
y = (box[x] + y) % 256;
// 交换 box[x] 和 box[y]
box[x], box[y] = box[y], box[x];
// 生成密钥流字节
int keystream = box[(box[x] + box[y]) % 256];
// 与明文异或
out[index] = keystream ^ ch;
}
目的:基于 S-box 生成密钥流,与数据进行异或运算。
异或运算有一个重要性质:自反性
A ^ B = C
C ^ B = A
在 RC4 中:
密文 = 明文 ^ 密钥流
明文 = 密文 ^ 密钥流 (相同的密钥流)
所以只要使用相同的密钥,加密和解密操作完全相同!
现在可以理解整个加密/解密流程:
加密过程(出题者):
原始 DEX 文件(头部:64 65 78 0a)
使用 RC4 加密
修改前 4 字节为其他值
保存为 test.zip
解密过程(我们要做的):
读取 test.zip
修改前 4 字节为 71 72 0a 08
使用 RC4 解密
得到原始 DEX(头部自动恢复为 64 65 78 0a)
为什么修改头部有效?
因为 RC4 是流密码,每个字节的加密是独立的:
密文[i] = 明文[i] ^ 密钥流[i]
如果我们修改密文的某个字节:
修改后密文[i] = 新值
解密后: 新值 ^ 密钥流[i] = 想要的明文[i]
出题者精心计算了前 4 个字节需要修改成什么值,使得解密后恰好是 DEX 魔数 dex\n。
基于对算法的理解,我们用 Python 实现 RC4:
def rc4_crypt(data, key):
"""
RC4 加密/解密算法
参数:
data: 要处理的数据 (bytes)
key: 密钥字符串
返回:
处理后的数据 (bytes)
"""
# 将密钥转换为字节
keys = key.encode() if isinstance(key, str) else key
# KSA: 初始化 S-box
box = list(range(256))
x = 0
for i in range(256):
x = (box[i] + x + keys[i % len(keys)]) % 256
# Python 元组交换语法
box[i], box[x] = box[x], box[i]
# PRGA: 生成密钥流并加密
x = 0
y = 0
out = bytearray()
for ch in data:
x = (x + 1) % 256
y = (box[x] + y) % 256
# 交换
box[x], box[y] = box[y], box[x]
# 生成密钥流并异或
keystream_byte = box[(box[x] + box[y]) % 256]
out.append(keystream_byte ^ ch)
return bytes(out)
#!/usr/bin/env python3
"""
SCTF 2018 Simple - DEX Decryption Script
"""
def rc4_crypt(data, key):
"""RC4 加密/解密算法(如上)"""
keys = key.encode() if isinstance(key, str) else key
box = list(range(256))
x = 0
for i in range(256):
x = (box[i] + x + keys[i % len(keys)]) % 256
box[i], box[x] = box[x], box[i]
x = 0
y = 0
out = bytearray()
for ch in data:
x = (x + 1) % 256
y = (box[x] + y) % 256
box[x], box[y] = box[y], box[x]
out.append(box[(box[x] + box[y]) % 256] ^ ch)
return bytes(out)
def decrypt_dex(input_file, output_file):
"""
解密 test.zip 为 load.dex
"""
print(f"[*] 读取加密文件: {input_file}")
with open(input_file, 'rb') as f:
encrypted_data = bytearray(f.read())
print(f"[*] 原始文件头: {encrypted_data[:4].hex()}")
# 步骤 1: 修改文件头(模拟 Java 代码)
encrypted_data[0] = 0x71 # 113
encrypted_data[1] = 0x72 # 114
encrypted_data[2] = 0x0a # 10
encrypted_data[3] = 0x08 # 8
print(f"[*] 修改后文件头: {encrypted_data[:4].hex()}")
# 步骤 2: RC4 解密
key = "E82038F4B30E810375C8365D7D2C1A3F"
print(f"[*] 使用 RC4 密钥: {key}")
decrypted_data = rc4_crypt(encrypted_data, key)
# 步骤 3: 保存解密结果
with open(output_file, 'wb') as f:
f.write(decrypted_data)
print(f"[+] 解密完成: {output_file}")
print(f"[*] 解密后文件头: {decrypted_data[:4].hex()}")
# 步骤 4: 验证 DEX 魔数
if decrypted_data[:4] == b'dex\n':
print("[+] DEX 魔数验证成功: 'dex\\n'")
return True
else:
print(f"[!] 警告: DEX 魔数不正确")
print(f" 期望: 6465780a (dex\\n)")
print(f" 实际: {decrypted_data[:4].hex()}")
return False
if __name__ == "__main__":
import sys
if len(sys.argv) != 3:
print("用法: python3 decrypt.py <input_file> <output_file>")
print("示例: python3 decrypt.py simple_extracted/assets/test.zip load.dex")
sys.exit(1)
input_file = sys.argv[1]
output_file = sys.argv[2]
decrypt_dex(input_file, output_file)
运行脚本:
python3 decrypt.py simple_extracted/assets/test.zip load.dex
输出:
[*] 读取加密文件: simple_extracted/assets/test.zip
[*] 原始文件头: 504b0304
[*] 修改后文件头: 71720a08
[*] 使用 RC4 密钥: E82038F4B30E810375C8365D7D2C1A3F
[+] 解密完成: load.dex
[*] 解密后文件头: 6465780a
[+] DEX 魔数验证成功: 'dex\n'
验证文件类型:
file load.dex
输出:
load.dex: Dalvik dex file version 035
成功!我们得到了真正的 DEX 文件。
使用 JADX 反编译解密后的文件:
jadx -d load_dex_jadx load.dex
反编译成功,共得到 21 个类。快速浏览后发现关键类:
MainActivity.java- 包含输入验证逻辑
Square.java- 核心算法类
Point.java- 辅助数据结构
打开 MainActivity.java,关注按钮点击事件处理:
public class MainActivity extends Activity {
public EditText edt;
private int[] table = {81, 83, 85, 89, 91, 93, 95};
public TextView tv;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
this.edt = (EditText) findViewById(R.id.ediT);
this.tv = (TextView) findViewById(R.id.txV);
Button btn = (Button) findViewById(R.id.btN);
btn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
// 获取用户输入
String input = MainActivity.this.edt.getText().toString().trim();
byte[] bin = input.getBytes();
// 约束 1: 长度必须为 24
// 约束 2: 第 1 个字符 ASCII > 48
// 约束 3: 第 8 个字符 ASCII < 112
if (bin.length == 24 && bin[0] > 48 && bin[7] < 112) {
// 将 24 个字符分为 3 组,每组 8 个
for (int start = 0; start < 24; start += 8) {
Square[] Sqe = new Square[8];
// 检查前 7 个字符
for (int i = 0; i < 7; i++) {
// 约束 4: 每组内必须严格递增
if (bin[i + start] < bin[start + i + 1]) {
// 从字符生成 Square 对象
Sqe[i] = new Square(
(bin[start + i] << 8) + 828309504 + 255,
(start / 2) + 4
);
} else {
return; // 不满足递增,验证失败
}
}
// 处理第 8 个字符
Sqe[7] = new Square(
(bin[start + 7] << 8) + 828309504 + 255,
(start / 2) + 4
);
// 约束 5: 每个 Square 必须通过 check()
for (Square sqe : Sqe) {
if (!sqe.check()) {
return; // 验证失败
}
}
}
// 所有验证通过
MainActivity.this.tv.setText("Success!");
}
}
});
}
}
从代码中提取出 5 个关键约束:
bin.length == 24
输入必须恰好 24 个字符。
bin[0] > 48
第 1 个字符的 ASCII 码必须大于 48(即大于字符 '0')。
bin[7] < 112
第 8 个字符的 ASCII 码必须小于 112(即小于字符 'p')。
for (int start = 0; start < 24; start += 8)
24 个字符分为 3 组,每组 8 个字符:
第 1 组:索引 0-7
第 2 组:索引 8-15
第 3 组:索引 16-23
if (bin[i + start] < bin[start + i + 1])
每组内的字符必须严格按 ASCII 码递增排列。
if (!sqe.check())
每个字符生成的 Square 对象必须通过 check()方法验证。
每个字符会生成一个 Square 对象:
new Square((bin[start + i] << 8) + 828309504 + 255, (start / 2) + 4)
参数 1:value
(bin[start + i] << 8) + 828309504 + 255
分解这个公式:
bin[start + i]:字符的 ASCII 码(0-127)
<< 8:左移 8 位,相当于乘以 256
828309504:十六进制 0x31560000
255:十六进制 0xFF
所以公式等价于:
value = (ASCII << 8) + 0x31560000 + 0xFF
= (ASCII << 8) + 0x315600FF
举例:字符 'A' (ASCII = 65)
value = (65 << 8) + 828309504 + 255
= 16640 + 828309504 + 255
= 828326399
= 0x31564100 + 0xFF
= 0x315641FF
二进制表示(只看低 32 位):
0x315641FF = 0011 0001 0101 0110 0100 0001 1111 1111
参数 2:turncout(旋转次数)
(start / 2) + 4
第 1 组 (start=0):turncout = 0/2 + 4 = 4
第 2 组 (start=8):turncout = 8/2 + 4 = 8
第 3 组 (start=16):turncout = 16/2 + 4 = 12
public class Square {
private int input; // 输入值
private Point[] point; // 25 个点的数组
private int turncout; // 旋转次数
Square(int in, int turnin) {
this.point = new Point[25];
this.input = in;
this.turncout = turnin;
initpoint(); // 初始化点阵
if (check()) { // 如果检查通过
turnpoint(); // 执行旋转
}
}
// ... 其他方法
}
private void initpoint() {
for (int i = 0; i < 5; i++) {
for (int j = 0; j < 5; j++) {
this.point[(i * 5) + j] = new Point(
j, // x 坐标
i, // y 坐标
(this.input & (1 << ((i * 5) + j))) >> ((i * 5) + j) // 颜色
);
}
}
}
这段代码创建了一个 5×5 的网格(共 25 个点),将 input 整数的二进制表示映射到网格上。
将 input 的低 25 位(bit 0 到 bit 24)依次映射到网格:
input 二进制位:
bit 24 23 22 21 20 | 19 18 17 16 15 | 14 13 12 11 10 | 09 08 07 06 05 | 04 03 02 01 00
网格映射 (按行从下到上):
第 4 行: bit20 bit21 bit22 bit23 bit24
第 3 行: bit15 bit16 bit17 bit18 bit19
第 2 行: bit10 bit11 bit12 bit13 bit14
第 1 行: bit05 bit06 bit07 bit08 bit09
第 0 行: bit00 bit01 bit02 bit03 bit04
Point(int a, int b, int c) {
this.x = a; // x 坐标 (0-4)
this.y = b; // y 坐标 (0-4)
this.color = c == 1; // 颜色:bit 为 1 则为 true
}
假设 input 的低 25 位是:
1 0 0 0 1 | 0 1 0 1 0 | 0 0 1 0 0 | 0 1 0 1 0 | 1 0 0 0 1
对应的 5×5 网格(1 表示有色,0 表示无色):
y=4: 1 0 0 0 1 (bits 20-24)
y=3: 0 1 0 1 0 (bits 15-19)
y=2: 0 0 1 0 0 (bits 10-14)
y=1: 0 1 0 1 0 (bits 5-9)
y=0: 1 0 0 0 1 (bits 0-4)
↑
x=0 到 x=4
这刚好是两条对角线!
public boolean check() {
int result = 0;
for (int x = 0; x < 5; x++) {
// 主对角线: (0,0), (1,1), (2,2), (3,3), (4,4)
Point tmp1 = findPoint(x, x);
if (tmp1 != null) {
result += tmp1.getValue();
}
// 副对角线: (4,0), (3,1), (2,2), (1,3), (0,4)
Point tmp2 = findPoint(4 - x, x);
if (tmp2 != null) {
result += tmp2.getValue();
}
}
return result >= 10;
}
网格坐标:
(0,4) (1,4) (2,4) (3,4) (4,4)
(0,3) (1,3) (2,3) (3,3) (4,3)
(0,2) (1,2) (2,2) (3,2) (4,2)
(0,1) (1,1) (2,1) (3,1) (4,1)
(0,0) (1,0) (2,0) (3,0) (4,0)
主对角线 (\):
X X X X X
(2,2)
(1,1)
(0,0)
副对角线 (/):
X X X
(3,1) (1,3)
(2,2)
(4,0)
遍历两条对角线的所有 9 个位置((2,2) 中心点被计算两次)
统计值为 1 的点的数量
要求总和 >= 10
为什么是 >= 10?
主对角线 5 个点全为 1:总和 = 5
副对角线 5 个点全为 1:总和 = 5
中心点 (2,2) 被计算两次:总和 = 5 + 5 = 10
所以 result >= 10实际上要求:两条对角线必须全部为 1!
private void turnpoint() {
for (int x = 0; x < this.turncout; x++) {
Point tmp = this.point[0];
// 所有点向前移动一位
for (int i = 0; i < 24; i++) {
this.point[i] = this.point[i + 1];
this.point[i].movepos();
}
// 第一个点移到最后
this.point[24] = tmp;
this.point[24].movepos();
}
}
这个方法执行 turncout次循环左移操作:
保存第一个点
其余 24 个点依次前移
第一个点放到最后
每个点调用 movepos()更新坐标
public void movepos() {
this.x--; // x 坐标减 1
if (this.x < 0) { // 如果 x 越界
this.x = 4; // 回到最右侧
this.y--; // y 坐标减 1
}
if (this.y < 0) { // 如果 y 越界
this.y = 4; // 回到最上方
}
}
这模拟了一个反向的循环坐标系统:
移动轨迹(从右下到左上):
(4,0) → (3,0) → (2,0) → (1,0) → (0,0) →
(4,1) → (3,1) → (2,1) → (1,1) → (0,1) →
(4,2) → (3,2) → (2,2) → (1,2) → (0,2) →
(4,3) → (3,3) → (2,3) → (1,3) → (0,3) →
(4,4) → (3,4) → (2,4) → (1,4) → (0,4) →
回到 (4,0)
输入字符 → 计算 value
↓
initpoint()
↓
创建 5×5 二进制网格
↓
check()
↓
检查对角线是否全为 1 ?
↓YES ↓NO
turnpoint() 返回 false
↓
旋转网格
↓
返回 true
关键洞察:
由于构造函数中先调用 check(),只有通过检查才执行 turnpoint()。这意味着:
字符必须生成一个对角线全为 1 的网格
旋转操作不影响验证结果(验证在旋转前完成)
旋转可能只是为了增加分析难度
现在我们完全理解了验证逻辑,可以采用暴力枚举:
遍历所有可能的 ASCII 字符(0x20-0x70,即 32-112)
对每个字符计算 value,创建 Square 对象
检查 check()是否返回 true
将通过验证的字符分组
生成满足递增约束的组合
#!/usr/bin/env python3
"""
SCTF 2018 Simple - 完整求解脚本
"""
class Point:
"""点类:表示 5×5 网格中的一个点"""
def __init__(self, x, y, color):
self.x = x
self.y = y
self.color = bool(color)
def get_value(self):
"""返回点的值:1 或 0"""
return 1 if self.color else 0
def get_pos(self):
"""返回编码后的坐标:(x << 4) + y"""
return (self.x << 4) + self.y
def movepos(self):
"""反向移动坐标"""
self.x -= 1
if self.x < 0:
self.x = 4
self.y -= 1
if self.y < 0:
self.y = 4
class Square:
"""Square 类:完整复现 Java 逻辑"""
def __init__(self, value, turncout):
self.input = value
self.turncout = turncout
self.point = [None] * 25
self.init_point()
self.turn_point()
def init_point(self):
"""初始化 5×5 网格"""
for i in range(5):
for j in range(5):
bit_position = i * 5 + j
bit_value = (self.input >> bit_position) & 1
self.point[bit_position] = Point(j, i, bit_value)
def turn_point(self):
"""执行旋转操作"""
for _ in range(self.turncout):
tmp = self.point[0]
for i in range(24):
self.point[i] = self.point[i + 1]
self.point[i].movepos()
self.point[24] = tmp
self.point[24].movepos()
def find_point(self, x, y):
"""查找指定坐标的点"""
target_pos = (x << 4) + y
for p in self.point:
if p.get_pos() == target_pos:
return p
return None
def check(self):
"""检查对角线验证"""
result = 0
for x in range(5):
# 主对角线
tmp1 = self.find_point(x, x)
if tmp1:
result += tmp1.get_value()
# 副对角线
tmp2 = self.find_point(4 - x, x)
if tmp2:
result += tmp2.get_value()
return result >= 10
def generate_square(char_ascii, start_position):
"""
从字符生成 Square 对象
参数:
char_ascii: 字符的 ASCII 码
start_position: 组位置 (0, 8, 或 16)
"""
value = (char_ascii << 8) + 828309504 + 255
turncout = (start_position // 2) + 4
return Square(value, turncout)
def find_valid_chars_for_position(start_position):
"""
找出特定组位置的所有有效字符
参数:
start_position: 组位置 (0, 8, 或 16)
返回:
有效字符的 ASCII 码列表
"""
valid_chars = []
# 遍历可打印 ASCII 范围
for ascii_code in range(0x20, 0x71): # 32 to 112
sq = generate_square(ascii_code, start_position)
if sq.check():
valid_chars.append(ascii_code)
return valid_chars
def validate_complete_input(input_string):
"""
验证完整的 24 字符输入
参数:
input_string: 24 个字符的字符串
返回:
True 如果通过所有验证,否则 False
"""
if len(input_string) != 24:
return False
input_bytes = [ord(c) for c in input_string]
# 约束 2 和 3
if input_bytes[0] <= 48 or input_bytes[7] >= 112:
return False
# 检查 ASCII 范围
for b in input_bytes:
if b < 0x20 or b > 0x70:
return False
# 分 3 组检查
for start in range(0, 24, 8):
group = input_bytes[start:start + 8]
# 检查严格递增
for i in range(7):
if group[i] >= group[i + 1]:
return False
# 检查每个字符的 Square
for ch in group:
sq = generate_square(ch, start)
if not sq.check():
return False
return True
def solve():
"""主求解函数"""
print("=" * 70)
print(" SCTF 2018 Simple - 暴力求解")
print("=" * 70)
print()
# 步骤 1: 找出每个组位置的有效字符
print("[步骤 1] 枚举有效字符...")
print()
group1_chars = find_valid_chars_for_position(0)
print(f"第 1 组 (位置 0, 旋转 4 次):")
print(f" 有效字符: {[chr(c) for c in group1_chars]}")
print(f" 共 {len(group1_chars)} 个")
print()
group2_chars = find_valid_chars_for_position(8)
print(f"第 2 组 (位置 8, 旋转 8 次):")
print(f" 有效字符: {[chr(c) for c in group2_chars]}")
print(f" 共 {len(group2_chars)} 个")
print()
group3_chars = find_valid_chars_for_position(16)
print(f"第 3 组 (位置 16, 旋转 12 次):")
print(f" 有效字符: {[chr(c) for c in group3_chars]}")
print(f" 共 {len(group3_chars)} 个")
print()
# 步骤 2: 生成递增序列
print("[步骤 2] 生成严格递增的 8 字符序列...")
print()
from itertools import combinations
# 第 1 组(考虑边界约束)
group1_sequences = []
for combo in combinations(group1_chars, 8):
if combo[0] > 48 and combo[7] < 112:
group1_sequences.append(''.join(chr(c) for c in combo))
print(f"第 1 组有效序列: {len(group1_sequences)} 个")
if group1_sequences:
print(f" 示例: {group1_sequences[0]}")
print()
# 第 2 组
group2_sequences = []
for combo in combinations(group2_chars, 8):
group2_sequences.append(''.join(chr(c) for c in combo))
print(f"第 2 组有效序列: {len(group2_sequences)} 个")
if group2_sequences:
print(f" 示例: {group2_sequences[0]}")
print()
# 第 3 组
group3_sequences = []
for combo in combinations(group3_chars, 8):
group3_sequences.append(''.join(chr(c) for c in combo))
print(f"第 3 组有效序列: {len(group3_sequences)} 个")
if group3_sequences:
print(f" 示例: {group3_sequences[0]}")
print()
# 步骤 3: 组合并验证
total = len(group1_sequences) * len(group2_sequences) * len(group3_sequences)
print(f"[步骤 3] 总可能组合: {total}")
print()
# 显示前 10 个完整解
print("[步骤 4] 前 10 个有效 flag:")
print()
count = 0
for g1 in group1_sequences:
for g2 in group2_sequences:
for g3 in group3_sequences[:10]: # 只显示前 10 个
flag = g1 + g2 + g3
if validate_complete_input(flag):
count += 1
print(f" {count}. {flag}")
if count >= 10:
break
if count >= 10:
break
if count >= 10:
break
# 验证预期答案
print()
print("[步骤 5] 验证参考答案...")
expected = "57=?UW]_QSUWY[]_9;=?Y[]_"
print(f" 答案: {expected}")
if validate_complete_input(expected):
print(" 验证: 通过")
else:
print(" 验证: 失败")
print()
print("=" * 70)
print(" 求解完成")
print("=" * 70)
if __name__ == "__main__":
solve()
运行脚本:
python3 solve.py
输出结果:
======================================================================
SCTF 2018 Simple - 暴力求解
======================================================================
[步骤 1] 枚举有效字符...
第 1 组 (位置 0, 旋转 4 次):
有效字符: ['5', '7', '=', '?', 'U', 'W', ']', '_']
共 8 个
第 2 组 (位置 8, 旋转 8 次):
有效字符: ['Q', 'S', 'U', 'W', 'Y', '[', ']', '_']
共 8 个
第 3 组 (位置 16, 旋转 12 次):
有效字符: ['8', '9', ':', ';', '<', '=', '>', '?', 'X', 'Y', 'Z', '[', '\\', ']', '^', '_']
共 16 个
[步骤 2] 生成严格递增的 8 字符序列...
第 1 组有效序列: 1 个
示例: 57=?UW]_
第 2 组有效序列: 1 个
示例: QSUWY[]_
第 3 组有效序列: 12870 个
示例: 89:;<=>?
[步骤 3] 总可能组合: 12870
[步骤 4] 前 10 个有效 flag:
1. 57=?UW]_QSUWY[]_89:;<=>?
2. 57=?UW]_QSUWY[]_89:;<=>X
3. 57=?UW]_QSUWY[]_89:;<=>Y
4. 57=?UW]_QSUWY[]_89:;<=>Z
5. 57=?UW]_QSUWY[]_89:;<=>[
6. 57=?UW]_QSUWY[]_89:;<=>\
7. 57=?UW]_QSUWY[]_89:;<=>]
8. 57=?UW]_QSUWY[]_89:;<=>^
9. 57=?UW]_QSUWY[]_89:;<=>_
10. 57=?UW]_QSUWY[]_89:;<=?X
[步骤 5] 验证参考答案...
答案: 57=?UW]_QSUWY[]_9;=?Y[]_
验证: 通过
======================================================================
求解完成
======================================================================
APK 文件
↓
解包分析
↓
发现 classes.dex + test.zip
↓
反编译 classes.dex
↓
找到 ProxyApplication → 动态加载机制
↓
分析 FindAssetfile → RC4 加密算法
↓
编写解密脚本
↓
解密 test.zip → load.dex
↓
反编译 load.dex
↓
分析 MainActivity → 输入约束
↓
分析 Square 类 → 核心算法
↓
编写暴力求解脚本
↓
得到 12870 个有效解
原理:使用 DexClassLoader 在运行时加载 DEX 文件,通过反射替换应用的 ClassLoader。
应用场景:
应用加固保护
热更新/热修复
插件化架构
恶意代码隐藏
防御方法:
检测 DexClassLoader 调用
监控文件系统操作
Hook ClassLoader 相关方法
算法特点:
对称加密(加密解密同一算法)
流密码(逐字节处理)
密钥长度可变(1-256 字节)
速度快但已不安全
识别方法:
256 字节的 S-box 初始化
特征性的交换操作
异或运算生成密文
现代替代:
AES(高级加密标准)
ChaCha20(流密码)
Salsa20(流密码)
本题大量使用位运算:
# 左移:相当于乘以 2^n
value << 8 # 乘以 256
# 右移:相当于除以 2^n
value >> 8 # 除以 256
# 按位与:提取特定位
(input & (1 << pos)) >> pos # 提取第 pos 位
# 按位或:设置特定位
value | (1 << pos) # 将第 pos 位设为 1
本题本质上是一个 CSP:
变量: 24 个字符
域: ASCII 0x20-0x70
约束:
1. 长度 = 24
2. bin[0] > 48
3. bin[7] < 112
4. 每 8 个字符严格递增
5. Square.check() 返回 true
求解方法:
暴力枚举(本题采用)
回溯搜索
约束传播
启发式搜索
1. 静态分析
- 文件格式识别
- 反编译/反汇编
- 代码审计
- 算法识别
2. 动态分析
- 调试跟踪
- Hook 技术
- 内存dump
- 日志分析
3. 混合分析
- 结合静态和动态
- 验证假设
- 还原算法
A:关注以下特征:
S-box:256 字节数组 → RC4、DES、AES
魔数:特定常量 → MD5 (0x67452301)、SHA (0x67452301)
循环结构:64 轮 → DES、16 轮 → AES
数学运算:模幂运算 → RSA
A:采用以下策略:
从入口点开始(onCreate、main)
关注关键字符串和常量
画出函数调用图
先理解流程,再深入细节
动态调试验证理解
A:以下情况考虑动态调试:
反混淆困难
涉及动态计算
反调试检测
网络通信分析
算法验证
A:常见对应关系:
| Java | Python | 说明 |
|---|---|---|
| byte[] | bytes/bytearray | 字节数组 |
| ArrayList | list | 动态数组 |
| HashMap | dict | 哈希表 |
| String.getBytes() | str.encode() | 字符串转字节 |
| new Object() | Object() | 创建对象 |
| for (int i=0; i<n; i++) | for i in range(n) | 循环 |
反编译工具:JADX、JEB、IDA Pro
调试工具:Frida、Xposed、GDB
抓包工具:Burp Suite、Wireshark
脚本语言:Python、JavaScript
Android 基础:四大组件、生命周期、权限系统
Java/Dalvik:字节码格式、反射机制
密码学:对称加密、非对称加密、哈希算法
算法与数据结构:排序、搜索、图论
CTF 平台:XCTF、CTFHub、攻防世界
开源项目:分析真实应用的实现
漏洞复现:研究已公开的安全漏洞
本题是一道设计精巧的 Android 逆向题目,涉及多个技术层面:
第一层:APK 结构- 理解 Android 应用的基本组成
第二层:动态加载- 识别代码隐藏技术
第三层:密码学- RC4 算法的识别与破解
第四层:算法分析- 理解 Square 验证逻辑
第五层:约束求解- 暴力枚举找出所有解
通过完整的分析过程,我们不仅得到了题目的答案,更重要的是掌握了系统的逆向分析方法和多种实用技术。希望本文能帮助逆向新手建立完整的知识体系,在今后的学习和实战中灵活运用这些技能。
完整代码见本文第四章。
完整代码见本文第七章。
Android Developers Documentation - https://developer.android.com
JADX - Dex to Java decompiler - https://github.com/skylot/jadx
RC4 Stream Cipher - Wikipedia
Dalvik Executable Format - Android Open Source Project
ClassLoader in Android - Android Developer Guide
Flag:57=?UW]_QSUWY[]_9;=?Y[]_(参考答案,共 12870 个有效解)
题目来源:SCTF 2018
难度等级:Medium
技术标签:Android Reverse, RC4, Dynamic Loading, Algorithm Analysis