SCTF Simple Android 逆向题目深度技术解析
前言本文将对 SCTF 的一道 Android 逆向题目 "Simple" 进行完整的技术分析。这道题目综合考察了 Android 应用逆向、密码学算法识别、算法分析等多方面技术能力。本文将从零开始, 2025-11-25 12:24:27 Author: www.freebuf.com(查看原文) 阅读量:1 收藏

前言

本文将对 SCTF 的一道 Android 逆向题目 "Simple" 进行完整的技术分析。这道题目综合考察了 Android 应用逆向、密码学算法识别、算法分析等多方面技术能力。本文将从零开始,详细讲解每一个分析步骤,帮助逆向新手理解完整的解题思路和技术原理。

一、题目初探与环境准备

1.1 题目文件分析

拿到题目文件 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            # 编译后的资源索引

1.2 关键发现

初步分析发现两个重要文件:

  1. classes.dex(438 KB) - Android 应用的主要代码,以 Dalvik 字节码格式存储

  2. assets/test.zip(443 KB) - 存放在资源目录中的可疑文件

通常情况下,APK 的主要逻辑都在 classes.dex 中。但这里 assets 目录中还有一个接近相同大小的 test.zip 文件,这非常可疑。

1.3 检查 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"),但后续内容看起来像是随机数据。这强烈暗示文件被加密或混淆了。

1.4 分析思路

基于以上发现,形成初步分析思路:

  1. classes.dex 中可能包含解密 test.zip 的代码

  2. test.zip 很可能是加密的 DEX 文件,包含真正的业务逻辑

  3. 需要先反编译 classes.dex,找到解密逻辑

  4. 解密 test.zip 后再分析真正的验证算法

二、Classes.dex 静态分析

2.1 反编译 DEX 文件

使用 JADX (Java Decompiler for Android DEX) 反编译 classes.dex:

jadx -d simple_jadx simple_extracted/classes.dex

JADX 是目前最流行的 Android 反编译工具,它可以将 DEX 字节码还原成可读的 Java 源代码。反编译完成后,我们得到了 19 个类的 Java 源码。

2.2 寻找入口点

Android 应用的入口通常有两个地方:

  1. Application 类- 在应用启动时最先执行

  2. MainActivity- 应用的主界面

通过搜索发现了一个自定义的 Application 类:ProxyApplication,这是第一个需要分析的目标。

2.3 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);
    }
}

2.4 代码分析:动态 DEX 加载

这段代码实现了典型的 Android 应用加壳技术,让我们逐行分析:

第 1-2 行:获取缓存目录

File cache = getDir("shell", 0);
String dexpath1 = cache + "/load.dex";
  • 创建一个名为 "shell" 的私有目录

  • 准备将解密后的 DEX 保存为 load.dex

第 3 行:解密资源文件

FindAssetfile.getAssetsFile(this, "test.zip", dexpath1, null);
  • 调用 FindAssetfile类的静态方法

  • assets/test.zip解密并保存到 dexpath1指定的路径

第 4-6 行:动态加载 DEX

DexClassLoader dcl1 = new DexClassLoader(dexpath1,
    getDir("shell_oat", 0).getAbsolutePath(),
    getApplicationInfo().nativeLibraryDir,
    getClassLoader());

DexClassLoader是 Android 提供的动态代码加载机制,参数说明:

  • 参数1 (dexpath1): 要加载的 DEX 文件路径

  • 参数2: 优化后的 ODEX 文件存放目录

  • 参数3: Native 库路径

  • 参数4: 父类加载器

第 7-13 行:替换 ClassLoader

Object currentActivityThread = RefInvoke.invokeStaticMethod(...);
// ... 通过反射获取应用的 ActivityThread 和 LoadedApk
RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader",
    wr.get(), dcl1);

这段代码使用反射技术,将应用的 ClassLoader 替换为新创建的 dcl1。这样,后续所有类的加载都会从解密后的 load.dex中进行。

技术原理:为什么要替换 ClassLoader?

在 Java/Android 中,ClassLoader 负责将类加载到内存中。应用启动时,系统会创建默认的 ClassLoader 来加载 classes.dex 中的类。

通过替换 ClassLoader,可以实现:

  1. 代码保护:真正的业务逻辑隐藏在加密的 DEX 中

  2. 动态更新:可以远程下载新的 DEX 文件进行热更新

  3. 反静态分析:静态分析工具只能看到加壳代码

这种技术常见于:

  • 商业应用加固(如腾讯乐固、360 加固)

  • 恶意软件隐藏真实代码

  • 应用热修复功能

三、解密逻辑深度分析

3.1 FindAssetfile 类分析

打开 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;
    }
}

3.2 解密流程分析

整个解密过程分为 4 个步骤:

步骤 1:读取加密文件

从 assets 目录读取 test.zip的全部内容到内存。

步骤 2:可选解密

代码中预留了一个 decMethod参数,可以传入自定义的解密方法。但在 ProxyApplication 中传入的是 null,所以这步实际上什么都没做。

步骤 3:修改文件头

这是一个关键步骤。代码将文件的前 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呢?答案要结合下一步的加密算法来理解。

步骤 4:调用 crypt 方法

使用固定密钥 "E82038F4B30E810375C8365D7D2C1A3F"调用 crypt方法解密。

3.3 Crypt 方法:RC4 算法识别

查看 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;
}

3.4 RC4 算法原理详解

这是标准的 RC4 (Rivest Cipher 4)流密码算法,由 Ron Rivest 在 1987 年设计。

RC4 特点:

  1. 对称加密:加密和解密使用相同的算法和密钥

  2. 流密码:逐字节处理数据

  3. 速度快:适合软件实现

  4. 已不安全:现代密码学已证明存在多个漏洞,但仍被广泛使用

算法分为两个阶段:

阶段 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 生成密钥流,与数据进行异或运算。

为什么 RC4 加密和解密是同一个算法?

异或运算有一个重要性质:自反性

A ^ B = C
C ^ B = A

在 RC4 中:

密文 = 明文 ^ 密钥流
明文 = 密文 ^ 密钥流  (相同的密钥流)

所以只要使用相同的密钥,加密和解密操作完全相同!

3.5 解密逻辑完整理解

现在可以理解整个加密/解密流程:

加密过程(出题者):

  1. 原始 DEX 文件(头部:64 65 78 0a

  2. 使用 RC4 加密

  3. 修改前 4 字节为其他值

  4. 保存为 test.zip

解密过程(我们要做的):

  1. 读取 test.zip

  2. 修改前 4 字节为 71 72 0a 08

  3. 使用 RC4 解密

  4. 得到原始 DEX(头部自动恢复为 64 65 78 0a

为什么修改头部有效?

因为 RC4 是流密码,每个字节的加密是独立的:

密文[i] = 明文[i] ^ 密钥流[i]

如果我们修改密文的某个字节:

修改后密文[i] = 新值
解密后: 新值 ^ 密钥流[i] = 想要的明文[i]

出题者精心计算了前 4 个字节需要修改成什么值,使得解密后恰好是 DEX 魔数 dex\n

四、编写解密脚本

4.1 Python 实现 RC4

基于对算法的理解,我们用 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)

4.2 完整解密脚本

#!/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)

4.3 执行解密

运行脚本:

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 文件。

五、Load.dex 算法分析

5.1 反编译 load.dex

使用 JADX 反编译解密后的文件:

jadx -d load_dex_jadx load.dex

反编译成功,共得到 21 个类。快速浏览后发现关键类:

  • MainActivity.java- 包含输入验证逻辑

  • Square.java- 核心算法类

  • Point.java- 辅助数据结构

5.2 MainActivity 输入验证分析

打开 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.3 输入约束总结

从代码中提取出 5 个关键约束:

约束 1:长度限制

bin.length == 24

输入必须恰好 24 个字符。

约束 2:首字符限制

bin[0] > 48

第 1 个字符的 ASCII 码必须大于 48(即大于字符 '0')。

约束 3:第 8 字符限制

bin[7] < 112

第 8 个字符的 ASCII 码必须小于 112(即小于字符 'p')。

约束 4:分组与递增

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 码递增排列。

约束 5:Square 验证

if (!sqe.check())

每个字符生成的 Square 对象必须通过 check()方法验证。

5.4 Square 生成公式分析

每个字符会生成一个 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

六、Square 类算法深度剖析

6.1 Square 类整体结构

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(); // 执行旋转
        }
    }

    // ... 其他方法
}

6.2 initpoint() - 初始化网格

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 构造函数:

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

这刚好是两条对角线!

6.3 check() - 对角线验证

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)

验证逻辑:

  1. 遍历两条对角线的所有 9 个位置((2,2) 中心点被计算两次)

  2. 统计值为 1 的点的数量

  3. 要求总和 >= 10

为什么是 >= 10?

  • 主对角线 5 个点全为 1:总和 = 5

  • 副对角线 5 个点全为 1:总和 = 5

  • 中心点 (2,2) 被计算两次:总和 = 5 + 5 = 10

所以 result >= 10实际上要求:两条对角线必须全部为 1

6.4 turnpoint() - 网格旋转

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次循环左移操作:

  1. 保存第一个点

  2. 其余 24 个点依次前移

  3. 第一个点放到最后

  4. 每个点调用 movepos()更新坐标

Point.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)

6.5 算法整体流程总结

输入字符 → 计算 value
            ↓
        initpoint()
            ↓
    创建 5×5 二进制网格
            ↓
        check()
            ↓
    检查对角线是否全为 1 ?
     ↓YES            ↓NO
turnpoint()      返回 false
     ↓
  旋转网格
     ↓
返回 true

关键洞察:

由于构造函数中先调用 check(),只有通过检查才执行 turnpoint()。这意味着:

  1. 字符必须生成一个对角线全为 1 的网格

  2. 旋转操作不影响验证结果(验证在旋转前完成)

  3. 旋转可能只是为了增加分析难度

七、暴力求解实现

7.1 解题思路

现在我们完全理解了验证逻辑,可以采用暴力枚举:

  1. 遍历所有可能的 ASCII 字符(0x20-0x70,即 32-112)

  2. 对每个字符计算 value,创建 Square 对象

  3. 检查 check()是否返回 true

  4. 将通过验证的字符分组

  5. 生成满足递增约束的组合

7.2 Python 完整实现

#!/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()

7.3 执行求解

运行脚本:

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[]_
  验证: 通过

======================================================================
 求解完成
======================================================================

八、解题总结与技术要点

8.1 解题流程回顾

APK 文件
  ↓
解包分析
  ↓
发现 classes.dex + test.zip
  ↓
反编译 classes.dex
  ↓
找到 ProxyApplication → 动态加载机制
  ↓
分析 FindAssetfile → RC4 加密算法
  ↓
编写解密脚本
  ↓
解密 test.zip → load.dex
  ↓
反编译 load.dex
  ↓
分析 MainActivity → 输入约束
  ↓
分析 Square 类 → 核心算法
  ↓
编写暴力求解脚本
  ↓
得到 12870 个有效解

8.2 核心技术点总结

技术点 1:Android 动态加载

原理:使用 DexClassLoader 在运行时加载 DEX 文件,通过反射替换应用的 ClassLoader。

应用场景:

  • 应用加固保护

  • 热更新/热修复

  • 插件化架构

  • 恶意代码隐藏

防御方法:

  • 检测 DexClassLoader 调用

  • 监控文件系统操作

  • Hook ClassLoader 相关方法

技术点 2:RC4 流密码

算法特点:

  • 对称加密(加密解密同一算法)

  • 流密码(逐字节处理)

  • 密钥长度可变(1-256 字节)

  • 速度快但已不安全

识别方法:

  1. 256 字节的 S-box 初始化

  2. 特征性的交换操作

  3. 异或运算生成密文

现代替代:

  • AES(高级加密标准)

  • ChaCha20(流密码)

  • Salsa20(流密码)

技术点 3:二进制位运算

本题大量使用位运算:

# 左移:相当于乘以 2^n
value << 8  # 乘以 256

# 右移:相当于除以 2^n
value >> 8  # 除以 256

# 按位与:提取特定位
(input & (1 << pos)) >> pos  # 提取第 pos 位

# 按位或:设置特定位
value | (1 << pos)  # 将第 pos 位设为 1

技术点 4:约束满足问题 (CSP)

本题本质上是一个 CSP:

变量: 24 个字符
域: ASCII 0x20-0x70
约束:
  1. 长度 = 24
  2. bin[0] > 48
  3. bin[7] < 112
  4. 每 8 个字符严格递增
  5. Square.check() 返回 true

求解方法:

  • 暴力枚举(本题采用)

  • 回溯搜索

  • 约束传播

  • 启发式搜索

技术点 5:逆向分析方法论

1. 静态分析
   - 文件格式识别
   - 反编译/反汇编
   - 代码审计
   - 算法识别

2. 动态分析
   - 调试跟踪
   - Hook 技术
   - 内存dump
   - 日志分析

3. 混合分析
   - 结合静态和动态
   - 验证假设
   - 还原算法

8.3 新手常见问题解答

Q1: 如何识别加密算法?

A:关注以下特征:

  1. S-box:256 字节数组 → RC4、DES、AES

  2. 魔数:特定常量 → MD5 (0x67452301)、SHA (0x67452301)

  3. 循环结构:64 轮 → DES、16 轮 → AES

  4. 数学运算:模幂运算 → RSA

Q2: 反编译的代码看不懂怎么办?

A:采用以下策略:

  1. 从入口点开始(onCreate、main)

  2. 关注关键字符串和常量

  3. 画出函数调用图

  4. 先理解流程,再深入细节

  5. 动态调试验证理解

Q3: 如何判断是否需要动态调试?

A:以下情况考虑动态调试:

  1. 反混淆困难

  2. 涉及动态计算

  3. 反调试检测

  4. 网络通信分析

  5. 算法验证

Q4: Python 和 Java 代码如何对应?

A:常见对应关系:

JavaPython说明
byte[]bytes/bytearray字节数组
ArrayListlist动态数组
HashMapdict哈希表
String.getBytes()str.encode()字符串转字节
new Object()Object()创建对象
for (int i=0; i<n; i++)for i in range(n)循环

8.4 进阶学习建议

工具掌握

  1. 反编译工具:JADX、JEB、IDA Pro

  2. 调试工具:Frida、Xposed、GDB

  3. 抓包工具:Burp Suite、Wireshark

  4. 脚本语言:Python、JavaScript

知识储备

  1. Android 基础:四大组件、生命周期、权限系统

  2. Java/Dalvik:字节码格式、反射机制

  3. 密码学:对称加密、非对称加密、哈希算法

  4. 算法与数据结构:排序、搜索、图论

实战练习

  1. CTF 平台:XCTF、CTFHub、攻防世界

  2. 开源项目:分析真实应用的实现

  3. 漏洞复现:研究已公开的安全漏洞

九、总结

本题是一道设计精巧的 Android 逆向题目,涉及多个技术层面:

  1. 第一层:APK 结构- 理解 Android 应用的基本组成

  2. 第二层:动态加载- 识别代码隐藏技术

  3. 第三层:密码学- RC4 算法的识别与破解

  4. 第四层:算法分析- 理解 Square 验证逻辑

  5. 第五层:约束求解- 暴力枚举找出所有解

通过完整的分析过程,我们不仅得到了题目的答案,更重要的是掌握了系统的逆向分析方法和多种实用技术。希望本文能帮助逆向新手建立完整的知识体系,在今后的学习和实战中灵活运用这些技能。

附录:完整代码

解密脚本 (decrypt.py)

完整代码见本文第四章。

求解脚本 (solve.py)

完整代码见本文第七章。

参考资料

  1. Android Developers Documentation - https://developer.android.com

  2. JADX - Dex to Java decompiler - https://github.com/skylot/jadx

  3. RC4 Stream Cipher - Wikipedia

  4. Dalvik Executable Format - Android Open Source Project

  5. ClassLoader in Android - Android Developer Guide


Flag:57=?UW]_QSUWY[]_9;=?Y[]_(参考答案,共 12870 个有效解)

题目来源:SCTF 2018

难度等级:Medium

技术标签:Android Reverse, RC4, Dynamic Loading, Algorithm Analysis


文章来源: https://www.freebuf.com/articles/others-articles/458989.html
如有侵权请联系:admin#unsafe.sh