ISCC2025 mobile部分讲解2
ISCC2025 mobile
mobile题目就是安卓逆向,一般题目附件是apk文件。
什么是apk文件?
apk文件是Android 源文件打包后的安装包,它其实就是一个zip格式的压缩包,想要知道apk包含了什么,可以改后缀名为zip,然后用解压缩工具打开 apk 文件。

apk文件 目录:
- assets 文件夹:程序资源目录。它的作用是让开发者把一些不需要编译成Android 资源表的文件,原样放到APK里面,运行时再自己读取。比如可以放字体文件,数据库文件,json配置文件等。
- lib文件夹:so 文件存放位置。该目录存放着应用需要的 native 库文件。文件夹下有时会多一个层级,这是根据不同CPU 型号而划分的,如 ARM,ARM-v7a,x86等。armeabi-v7a基本通用所有android设备,arm64-v8a只适用于64位的android设备,x86常见用于android模拟器。
- res文件夹:就是资源文件夹,存放图片,字符串,样式等。它里面存放的所有文件都会被映射到 R 文件中,生成对应的资源 ID,便于代码中通过 ID 直接访问。比如 R.layout.activity_main。
- META-INF:保存应用的签名信息,签名信息可以验证APK文件的完整性,相当于APK的身份证(验证文件是否有被修改)。
- AndroidMainfest.xml文件:这是 Android 应用的全局配置文件,它包含了这个应用的很多配置信息,例如包名、版本号、所需权限、注册的服务等。我们可以根据这个文件了解这个apk的一些信息。在CTF题目中可以先查找 AndroidManifest.xml 文件里的 Activity 标签,一个Activity相当于一个页面,这样可以快速找到MainActivity并跳转。
- classes.dex文件:classes.dex是java源码编译后生成的java字节码文件,APK运行的主要逻辑。
- resources.arsc文件:resources.arsc是编译后的二进制资源文件,它是一个映射表,映射着资源和id,通过R文件中的id就可以找到对应的资源。
如何去逆向分析呢?
使用反编译工具 jadx或者jeb,直接将apk文件拖进去就能进行逆向分析。
邦布出击
使用jadx打开,会看到 Activity里面有MainActivity窗口和MainActivity2窗口。

双击跟进MainActivity

看到这个CHECK类,里面onclick方法就是在你点击按钮时触发的。它是取出输入框里的内容并且调用 Jformat方法。这个方法返回布尔值,如果返回true,就会出现Success!字符串,如果返回false,就会出现Wrong!那说明我们需要满足这个方法。
Jformat首先做了一个flag格式校验,如果你输入的字符串长度<7或者前五位不是”ISCC{“,或者最后一个字符不是”}”,那就会返回false。
所以,我们输入的字符串格式必须是ISCC{}包裹的。
接着往下去分析,它会调用C0532a.m54a()方法去返回一个字符串,放到strM54a这个变量里面,然后使用log打印出来。
字符串作为DES加密的明文,然后使用getiv()方法去拿到iv,key就是”WhItenet”,进行DESHelper()加密,接着把加密的结果放到strEncrypt变量里面。然后还是使用log去打印出来。

看到native,它表示这个方法不是用java实现的,而是由本地代码实现的。真正的实现一般在安卓的lib文件夹的so文件里,通过JNI导出函数实现。
最后把我们输入的字符串,花括号里面的内容,去和strEncrypt进行比较,如果一样就是true,反之false。
那我们需要去分析C0532a.m54a()方法在干什么。

这个方法是先从C0533b.m55b()拿到base64字符串,然后再取到一个字符串密钥,通过调用process方法把密钥处理成固定的16字节。然后用这个密钥构造Blowfish密钥对象,使用m57d()取到Blowfish的算法模式,初始化解密器。先对密文做Base64解码,再进行Blowfish解密,然后把解密后的字节当作UTF-8字符串返回。
接着跟进m55b()方法。

先定义了一个静态字符串 private static String hiddenString = “gm2yjlASyY1UZjdpAFPgDMyHdxiKJ8cc”;
第一层map,创建一个空的 HashMap,变量名叫 map
第二层map,再创建一个空的 HashMap,变量名叫 map2
把键值对放到map2里面:键:"hiddenString" 值:静态变量 hiddenString
把map2放到map里面,键:"level1" 值:map2
第三层map,又创建一个新的空 HashMap。
把map塞到map3里面:键:"level2" 值:map
对于真正返回语句
1 | return (String) ((Map) ((Map) map3.get("level2")).get("level1")).get("hiddenString"); |
先从mp3里取出键”level2”对应的值,map
然后取出键”level1”的值,map2
然后取出键”hiddenString”对应的值,就是那个base64字符串。
这个方法的作用就是返回密文字符串。
m56c()这个方法应该是返回key的,但是这里是空,不知道key的话就没办法求解flag了。而且直接去hook是不可行的,密钥是空的还会导致一个问题就是 当在程序中输入正确的flag,程序也会直接退出(抛出异常)。
m57d()方法说明了Blowfish的算法模式是ECB/PKCS5Padding
那不知道key怎么办,这样就中断了。那就回顾看看有没有哪里被遗忘的地方。
注意到Activity里面还有一个MainActivity2,看看。

在包和导包的部分,能看到 import net.sqlcipher.database.SQLiteDatabase;
这个说明程序使用的不是普通的SQLite,而是SQLCipher数据库。它是SQLite的加密版本,通常需要key才能打开。

我们能知道它调用了数据库,而且查询数据库,从数据库读出了一个字符串。
真正查询数据库的逻辑在C0534dB里面。
接着去分析C0534dB

C0534dB 是应用中负责访问 SQLCipher 数据库的核心类。其构造函数通过 C0535dH 创建数据库帮助对象,并调用getWritableDatabase(str) 打开数据库,其中参数 str 即 SQLCipher 的数据库口令。
getInfo(String str, String str2) 方法使用参数化查询语句:
1 | SELECT info FROM BBTUJIAN WHERE NAME=? AND LEVEL=? |
在表 BBTUJIAN 中按照 NAME 与 LEVEL 两个条件查找记录,并返回匹配记录第一行的 info 字段内容;若无匹配项,则返回提示字符串“全图鉴中未收录”。
接着去看C0535dH里面数据库文件名和建表语句
插一句:’\u9ca8\u7259\u5e03’这种是jadx的Unicode字符转义,想看到字符就去jadx 文件 -> 首选项 -> 反编译 把Unicode字符转义的勾选去掉。
C0535dH 是该应用的 SQLCipher 数据库帮助类,数据库文件名为 bangbu.db,版本号为 1。其 onCreate() 方法在首次创建数据库时建立表 BBTUJIAN,字段包括自增主键 ID、名称 NAME、等级 LEVEL 以及信息字段 INFO。构造函数中首先检查数据库文件是否存在,若不存在则调用 createDB() 执行初始化。

我们能注意到这里有酷似base64的字符串,绳网情报,maybe是个提示。
1 | import base64 #导入base64模块用于解密 |

这个三次解密是提示,加密数据库的key可能是需要把前三个’S’的字符串拼接出来,进行三次base64解密。而我们需要的Blowfish的key可能就是存到数据库里面了。

拿到密钥
那就可以进行数据库解密了,这个用的是SQLCipher。需要下载sqlcipher
https://github.com/sqlcipher/sqlcipher/releases/tag/v4.14.0
把加密库导出成普通的SQLite数据库
可以在sqlcipher里执行
1 | PRAGMA key = '你的密钥'; |
这几句话的意思是:
用正确密钥打开当前加密数据库
再附加一个新的空口令数据库
plain.db把当前数据库内容全部导出进去
得到一个普通明文库
plain.db

拿到一个新的数据库,然后用Navicat打开它。

就能知道密钥key了,”GhIjKlMnOpQrStUv”。
那我们就能进行解密了,这个逻辑是给出一个字符串,先进行base64解码,再进行Blowfish解密,然后用解密得到的字符串去进行DES加密,CBC模式。这个加密后的字符串与我们输入的字符串进行比较,相同就返回true。

使用在线网站进行解密 https://gchq.github.io/CyberChef/#oenc=65001

但是还缺少DES的iv,去提取so文件,放入ida里面分析。
感觉看的很眼花缭乱
1 | _QWORD *__fastcall Getiv(_QWORD *a1) |
n2_1是什么,这个时候可以稍微改改,跑一下看最后n2_1的结果。
1 |
|

这说明了它是在从0到10里面找质数,并且把最后一个质数保存到n2_1里面。
n2_1是7。
然后是凯撒移位,对当前字母n66做一个向后移n2_1位的变换。
也就是说剩下的是凯撒移位+7。
它是对字符串进行凯撒移位,然后取前8字节,因为DES分组长度是8字节,iv也必须是8字节。
1 | def caesar_shift_char(c, shift): |
然后在线解密一下,flag就出来了。

方法二

能注意到,它在进行加密后会使用日志打印出来最后加密的结果,那么我们把key填入后,就能使用frida hook去读取这个加密结果,根本不用去看iv。
那需要先填入key,推荐使用MT管理器,比较方便。Android Killer也可以修改,但是修改后还需要进行签名。
使用MT管理器去把密钥key填充一下,搜索com.example.mobile01.b类来定位

填入密钥 GhIjKlMnOpQrStUv ,然后去保存。
那接着就要进行frida hook了
Log.d(“DEBUG_RES”, “加密结果 res: “ + strEncrypt);
调用的时候实际传入的两个参数是tag = “DEBUG_RES”和msg = "加密结果 res: " + strEncrypt 拼接后的最终字符串。
在java里,他们的类型都是java.lang.String
1 | Java.perform(function () { |
然后使用frida 去运行这个脚本。
frida
frida是一个动态插桩工具,程序正常运行时,函数会一个个被调用,frida的作用就是让你在这些函数执行的瞬间:看它收到什么参数,返回了什么结果,修改参数,修改返回值,强制执行某段代码,调用App里的Java/native函数等。
首先要在电脑端安装Frida。
1 | pip install frida-tools |
然后是安装ADB。
如果你安装了 Android Studio,一般已经有 adb。
或者是不想安装Android Studio的,可以去安装Android SDK Platform-Tools。
打开Google 官方页面:
https://developer.android.com/tools/releases/platform-tools
找到:
1 | Download SDK Platform-Tools for Windows |
下载后会得到类似:
1 | platform-tools-latest-windows.zip |
解压后里面应该有:
1 | adb.exe |
需要把adb的路径配置环境变量。
这样就能使用adb了。
手机上打开USB调试
手机上依次打开:
1 | 设置 -> 关于手机 -> 连续点击“版本号”7次 |
然后返回:
1 | 设置 -> 系统 -> 开发者选项 |
打开:
1 | USB 调试 |
不同手机路径可能略有区别,也可能在:
1 | 设置 -> 更多设置 -> 开发者选项 |
连接手机测试
电脑连接手机后执行:
1 | adb devices |
第一次会显示:
1 | List of devices attached |
这时看手机屏幕,会弹出:
1 | 是否允许 USB 调试? |
勾选允许,然后点确定。
再执行:
1 | adb devices |
正常应该是:
1 | List of devices attached |
看到 device 就说明 ADB 安装和连接都成功了。
Android端安装frida-server
首先要看安卓设备(真机或模拟器)的架构,然后去下载对应的frida-server
查看CPU架构
1 | adb shell getprop ro.product.cpu.abi |
对应下载:
1 | arm64-v8a -> frida-server-xxx-android-arm64.xz |
Frida 的 Android server 要从 Frida GitHub releases 下载,并且最好和电脑端 Frida 版本一致
https://github.com/frida/frida/releases?utm_source=chatgpt.com
查看电脑端版本:
1 | frida --version |
例如显示:
1 | 17.3.2 |
那你就下载:
1 | frida-server-17.3.2-android-arm64.xz |
解压 frida-server
下载后一般是 .xz 文件,例如:
1 | frida-server-17.3.2-android-arm64.xz |
用 7-Zip 解压,得到:
1 | frida-server-17.3.2-android-arm64 |
建议改名成:
1 | frida-server |
然后去推送到手机
1 | adb push frida-server /data/local/tmp/ |
给执行权限
1 | adb shell |
启动
1 | /data/local/tmp/frida-server & |
推到手机后,之后只需要下面的指令就能执行了
1 | adb shell |

检查连接是否成功
1 | frida-ps -U |
如果能列出手机上的进程,就说明成功了。
-U表示USB设备。
1 | frida-ps -Uai |
这个命令还会列出手机上应用包名。
包名是android应用的唯一标识名,可以理解成app的身份证号。
默认常见情况,进程名和包名一样。
但如果app有多进程,那就可能进程名和包名不一样。
Frida 常用启动命令
1 | frida -U -p PID -l 脚本.js |
运行一下,结果就是这个。

flag就是 ISCC{OVDM2e3LJh60/IsC+I+uyhmpF4IuoRbj}
Blowfish
Blowfish加密算法是一种对称分组加密算法。
它每次加密处理的分组长度是64位(也就是8字节),密钥长度是可变长度,大约可以是4字节到56字节,也就是1-14个32位的密钥。
这个加密算法和DES一样,使用的是Feistel来进行分组加密的。
Feistel(费斯妥)是一种分组密码的设计结构。
它的核心思路是:
把明文分成左右两半,记作 L 和 R
每一轮用右半部分 R 和子密钥做一个轮函数 F
然后把结果和左半部分 L 做异或
左右交换,进入下一轮
加密流程图如下:

在正式加密前,Blowfish需要进行密钥扩展。
1.数据分组:先将64位的明文分组拆成左右两部分,记为L0和R0,每部分各32位。
2.子密钥:算法使用一个预先生成的 P盒(P盒包含18个32位的值P1, P2, …… P18)和四个 S 盒(每个 S 盒包含 256 个 32 比特的值)。
这个P盒就是使用常量Π的小数部分,将其转换成16进制。
关于如何计算Π后面的小数位以及将小数位转为十六进制存储到p[0]—p[18]就不多讲了,都是计算好的。
S盒跟P盒一样也是Π的小数位组成。
要进行数据加密的话,需要生成子密钥,就是将我们的key和P盒进行异或。
我们的密钥key是1-14个32位数字,k1-k14来表示。k1^P1=K1,k2^P2 = K2 …… k14^P14 = K14,因为我们的密钥总共14个,所以要循环异或
k1^P15 = K15,k2^P16 = K16。原来的可变密钥是14个,异或后是16个,将异或的结果作为新的子密钥K数组 K1-K16去加密。这就是密钥扩展。
Blowfish的核心加密过程包含 16 轮完全相同的操作,利用了著名的 Feistel 网络结构:
比如说当前是第i轮
左侧异或 : 将左侧 32 比特数据 Li-1 与当前的 K 阵列子密钥 Ki 进行异或(XOR)操作。
$$
L’{i} = L{i-1} \oplus K_i
$$
计算 F 函数: F 函数是 Blowfish 的“核心混淆”部分。它接受步骤 1 异或后的新左侧数作为输入,并产生一个 32 比特的输出。异或后的结果兵分两路,一路向下走,另一路进入F函数。
拆分输入: 将 32 比特的 L’i 拆分为四个 8 比特的片段,分别记为 a, b, c, d。
S 盒查找与组合:
使用 a 从 S-box 0 查找值,记为 S0[a]。
使用 b 从 S-box 1 查找值,记为 S1[b]。
使用 c 从 S-box 2 查找值,记为 S2[c]。
使用 d 从 S-box 3 查找值,记为 S3[d]。
也就是将a,b,c,d作为索引,分别去取S盒的内容。
混合操作: 按照以下公式进行组合(注意符号,田表示模2的32次方加法,其实就是普通的加法,但如果超过了32位能表示的最大值,就会舍弃进位。
$$
F(L’{i}) = ((S_0[a] \boxplus S_1[b]) \oplus S_2[c]) \boxplus S_3[d]
$$
右侧异或 F 函数输出: 将计算出来的 F 函数的结果与右半部分Ri-1进行异或。
$$
R’{i} = R_{i-1} \oplus F(L’{i})
$$
左右交换(除16轮): 在第 1 到 15 轮结束时,交换左右两部分。
$$
L_i = R’{i}
$$
$$
R_i = L’_{i}
$$
注意:第 16 轮结束时不交换,保持
$$
L_{16}=L’{16} 和 R{16}=R’_{16}
$$
16轮操作完成之后,还会再进行两次最后的异或操作
1.左侧会与子密钥K18异或:将第16轮后的L16与K18异或
2.右侧会与子密钥K17异或:将第16轮后的R16与K17异或
最后将L和R合并成64位的密文C。
加密流程如下所示:
叽米是梦的开场白
首先使用jadx打开,找到MainActivity去定位关键逻辑。
分析java层
这个是有两个输入框的,第一个输入框是要输入验证码,第二个是输入flag。它是将用户输入的验证码通过调用native方法BdCheck进行处理,然后对它的返回值分别进行m54B处理和m55c处理,m54b的作用是从字符串末尾往前遍历,只保留非数字字符。m55c的作用是从字符串中提取所有数字字符。然后将用户输入的验证码与m54B处理后的结果进行比较,如果一样的话就把m55c处理的结果保存为token。

然后需要发送广播 com.example.mobile04.GET_DEX
广播里要带上token,它收到广播后会去校验token,如果token正确才会触发之后的逻辑。

这里说一下安卓里的广播。
在 Android 里,广播 Broadcast 可以理解成一种“发消息通知”的机制。
类似校园广播,比如说学校广播下午五点放假,所有听广播的人都能知道这个消息,然后收到并且去执行对应的行为逻辑,放学了就该背起小书包回家啦。
Android 里也是类似:
1 | 某个 App 或系统发出广播 |
Android 里接收广播的类叫:BroadcastReceiver。只要收到指定广播,就会自动进入:onReceive()
广播又靠什么区分呢?是Intent里面的action。
这个里面注册了下面这样的广播

意思就是TriggerReceiver 只接收 action 为 com.example.mobile04.GET_DEX 的广播
所以当有人发送:
1 | com.example.mobile04.GET_DEX |
这个广播时,TriggerReceiver.onReceive() 就会被执行。
接着看下面的逻辑,这是动态dex释放,它会循环七次,每次从native层取一段加密的dex,然后通过本地广播发送给DataReceiver,DataReceiver收到后会调用decryptSegment去把每一段解密,然后拼接。

这里是从so文件加载的dex文件,我们可以直接去看so文件的逻辑,ida打开进行分析。

双击后把这个dex 全部 dump出来。
然后放到jadx里面进行分析

这个是3DES加密,key放在了native层,需要去看Sunday.so文件

使用CyberChef去解密

这其实就是前半部分的flag了
往下看看就知道下面那个输入框,要输入flag,它将输入的flag去调用checkFlagAsync这个方法。

这个方法是flag格式校验,长度必须大于13,ISCC{}包裹。
提交flag之前,还必须先通过前面的广播流程生成decrypted.dex文件,生成了之后才会进行下一步的操作。

把flag分成两部分,第二部分可能走两个路径,EvilService.checkFFlag2()或者C0533a.m60a()。应该去看m60a,下面必须这两个都是true才能验证成功。

这个调用了native层的check函数,导出Monday.so文件去看看。
这个函数整体是native层收到一个java传来的byte数组,然后先做三次反hook/反调试检测,如果环境安全,就把这个byte[]的每个字节做一次固定变换。

1 | *(v9 + i) = ((((*(v9 + i) >> 6) | (4 * *(v9 + i))) ^ 0xEE) >> 3) |
把*(v9 + i)看成当前字节x,等价于下面的
1 | x = (((x >> 6) | (x << 2)) ^ 0xEE) >> 3 |
1 | t = ((x >> 6) | (x << 2)) ^ 0xEE; |
第一步是x循环左移了2位,然后异或 0xEE,看作t
第二步就是 t 进行循环右移3位的操作。
这部分就是解密的操作。
1 | def decode_enreal_file(input_path, output_path): |
解密出来的文件放到ida里面继续分析

但是这里要注意 小端序。
IDA 显示的是整数:
1 | 0x76B94F6B4F4B28EA |
它在内存里的字节顺序是反过来的:
1 | EA 28 4B 4F 6B 4F B9 76 |
所以真正的目标密文字节是:
1 | EA284B4F6B4FB976 |
可以使用在线网站去解密

第二部分的flag知道了,那就拼接一下,套个ISCC{}就行。
也可以套用脚本
1 | from Crypto.Cipher import DES3 |



