ISCC 练武校级赛和擂台部分 WP,主要是 Mobile 和 Misc

练武

Misc

书法大师

下载附件,图片属性内找到字符串L9k8JhGfDsA,binwalk 发现一个压缩包(其实一开始的附件里有一堆压缩包,但只有 1-50 或 45 才有用),拿字符串解密得到 message45.txt,内容如下

1
右女 丙色 太歌 从少 生乙 女下 没个 没医 石成 男真 那比 市尖 丙比 成慢 作〇 切厂 石站 马〇 片摔 尖趣 耳回 石乙 女群 工睡

猜测是和笔画数有关,两个一组,写个脚本用cnchar-data查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import requests
from urllib import parse
import json

url = "https://unpkg.com/cnchar-data@1.1.0/draw/"
output = ''
data = "右女 丙色 太歌 从少 生乙 女下 没个 没医 石成 男真 那比 市尖 丙比 成慢 作〇 切厂 石站 马〇 片摔 尖趣 耳回 石乙 女群 工睡"
def get_strokes(x):
try:
res = requests.get(url + parse.quote(x) + ".json", timeout=10)
res.raise_for_status()
j = json.loads(res.text)['strokes']
return str(hex(len(j))[2:])
except:
print(f"Error fetching strokes for character '{x}'")
return '00' # 没查到就返回00

for char in data:
if char == ' ':
output += ' '
else:
output += get_strokes(char)
print(output)

print(output)

解出来再用CyberChefFrom HexFrom Base64

1
ISCC{0W7UNzAgCh}

反方向的钟

下载附件,打开 txt 发现第二个字符不太对,疑似 Unicode 零宽字符加密

用自己的 unicodetool.html 工具解密,或 Github 有个项目叫 Hidden Word

1
iscc2025O3vv

查看图片,时间指向 4 点 5 分,数字 8 推测为日期,图片名为 202502

那时间就是 202502080405,wav 不知道是啥

然后 😡 然后发现跟这些没关系,拿 txt 里的 base64 解一下再 xor 那个文本就出来了

20250505191424-2025-05-05-19-14-24

Mobile

简单配一下环境

下载adb并配置 Path,然后用 Android Studio 自带模拟器开个虚拟机

下载frida-server,需要的架构可以用 adb 命令查看

1
adb shell getprop ro.product.cpu.abi

我的架构是x86_64,那就下载frida-server-XX.XX.XX-android-x86_64.xz并解压

运行以下命令

1
2
3
4
5
6
adb push .\frida-server-android-x86_64 /data/local/tmp
adb shell
su
cd /data/local/tmp
chmod +x frida-server-android-x86_64
./frida-server-android-x86_64

然后在新终端设置端口转发并查看进程

1
2
3
4
adb forward tcp:27042 tcp:27042
adb forward tcp:27043 tcp:27043

frida-ps -U

正常输出进程列表说明已配置完成,接下来可以用 python 的 frida 库,也可以用frida -U com.android.xxx -l payload.js这样的方式进行注入

三进制战争

jadx 反编译,查看逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public final boolean CHECK1(String str1) {
if (str1.length() <= 13) {
return false;
}
String substring = str1.substring(0, 5);
Intrinsics.checkNotNullExpressionValue(substring, "substring(...)");
if (Intrinsics.areEqual(substring, "ISCC{") && str1.charAt(str1.length() - 1) == '}') {
String stringFromJN1 = stringFromJN1();
String substring2 = str1.substring(11, str1.length() - 1);
Intrinsics.checkNotNullExpressionValue(substring2, "substring(...)");
String stringFromJNl = stringFromJNl(stringFromJN1, substring2);
String substring3 = str1.substring(5, 11);
Intrinsics.checkNotNullExpressionValue(substring3, "substring(...)");
if (Intrinsics.areEqual(substring3, stringFromJNI("2vW&77")) && CHECK2(stringFromJNl)) {
return true;
}
}
return false;
}

private final boolean CHECK2(String str1) {
return Intrinsics.areEqual(str1, "011102001221020101000020001220010221");
}

static {
System.loadLibrary("mobile02");
}

满足CHECK1CHECK2即可,具体函数实现可在 native 层的libmobile02.so找到

编写hook1.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import frida

def on_message(message, data):
if message['type'] == 'send':
print("*****[frida hook]***** : {0}".format(message['payload']))
else:
print("*****[frida hook]***** : " + str(message))

jscode = """
Java.perform(function x() {
Java.choose('com.example.mobile02.MainActivity', {
onMatch: function (instance) {
console.log(instance.stringFromJN1());
},
onComplete: function () {
console.log('Done');
}
});
});
"""
a.get_usb_device(-1).attach('mobile02')
process = frid
script = process.create_script(jscode)
script.on('message', on_message)
print('[*] HOOK')
script.load()

输出如下

1
2
3
[*] HOOK
c292ed6911b76747c8e620bda5c1f18a
Done

stringFromJN1c292ed6911b76747c8e620bda5c1f18a

stringFromJNl = stringFromJNl(stringFromJN1, substring2)substring2为 flag 后半部分

substring3stringFromJNI("2vW&77")相等

编写hook2.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import frida

def on_message(message, data):
if message['type'] == 'send':
print("*****[frida hook]***** : {0}".format(message['payload']))
else:
print("*****[frida hook]***** : " + str(message))

jscode = """
Java.perform(function x() {
Java.choose('com.example.mobile02.MainActivity', {
onMatch: function (instance) {
console.log(instance.stringFromJNI("2vW&77"));
},
onComplete: function () {
console.log('Done');
}
});
});
"""
process = frida.get_usb_device(-1).attach('mobile02')
script = process.create_script(jscode)
script.on('message', on_message)
print('[*] HOOK')
script.load()

结果为

1
2
3
[*] HOOK
'sc2Rd%2S
Done

substring3就是'sc2Rd%2S,继续往下看知道stringFromJNl等于011102001221020101000020001220010221,简单 hook 一下调用stringFromJNl(),观察发现 substring2 只有 6 个字符,写脚本逐字符爆破,再拼上substring3就是完整 flag 了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
function hook() {
Java.perform(function () {
const MainActivity = Java.use("com.example.mobile02.MainActivity");
const enc = "011102001221020101000020001220010221";

MainActivity["CHECK1"].implementation = function (str1) {
console.log(`MainActivity.CHECK1 is called: str1=${str1}`);
let res = this["CHECK1"]("ISCC{6xY*08S4t(5U}");
console.log(`MainActivity.CHECK1 res=${res}`);
return res;
};

MainActivity["stringFromJNl"].implementation = function (key, input) {
console.log(
`MainActivity.stringFromJNl is called: key=${key}, input=${input}`
);
let res = this["stringFromJNl"](key, input);
console.log(`MainActivity.stringFromJNl res=${res}`);
let correctInput = input.split("");
for (let pos = 0; pos < input.length; pos++) {
// 遍历所有可打印 ASCII 字符(32-126) ISCC的flag比较奇怪
for (let charCode = 32; charCode <= 126; charCode++) {
let char = String.fromCharCode(charCode);
input = correctInput.join("");
let modifiedInput =
input.substring(0, pos) +
char +
input.substring(pos + 1);
try {
let testres = this["stringFromJNl"](key, modifiedInput);
if (testres === enc) {
console.log(modifiedInput);
}
const testIndex = 6 * pos,
encIndex = 6 * pos + 6;
if (
testIndex < testres.length &&
encIndex < enc.length
) {
if (
testres.substring(testIndex, encIndex) ===
enc.substring(testIndex, encIndex)
) {
console.log(
`Match at pos=${pos}, char='${char}'`
);
correctInput[pos] = char;
}
}
} catch (e) {
console.log(
`Error with char '${char}' at pos ${pos}: ${e}`
);
}
}
}
return res;
};

let Intrinsics = Java.use("kotlin.jvm.internal.Intrinsics");
Intrinsics["areEqual"].overload(
"java.lang.Object",
"java.lang.Object"
).implementation = function (obj, obj2) {
console.log(
`Intrinsics.areEqual is called: obj=${obj}, obj2=${obj2}`
);
let res = this["areEqual"](obj, obj2);
console.log(`Intrinsics.areEqual res=${res}`);
return res;
};
});
}

setImmediate(hook);

爆破出来是P2q&3R,然后会发现MainActivity.CHECK1结果还是false,原因是他本地 CHECK 有问题,检查的长度不对,直接交就行,flag 为ISCC{'sc2Rd%2SP2q&3R}

Encode

下载附件发现无法安装,改后缀为 zip 再拿 WinRAR 一键修复~~(又不是不行)~~

改回 apk 再安装,jadx 打开,关键部分如下

1
2
3
4
5
6
7
8
9
10
11
12
private native boolean nativeCheckFormat(String str);
private native boolean nativeCheckLast(String str);
static {
System.loadLibrary("encode");
}
public boolean Jformat(String str) {
int lastIndexOf = str.lastIndexOf("_");
if (lastIndexOf == -1 || str.length() < 10 || !str.startsWith("ISCC{") || !str.endsWith("}")) {
return false;
}
return nativeCheckLast(str.substring(lastIndexOf + 1, str.length() - 1)) && nativeCheckFormat(str);
}

导出libencode.so用 IDA 打开分析逻辑,里面有个简单 base64。在函数名列表查找关键字encode
20250507220915-2025-05-07-22-09-16

Java_com_example_encode_MainActivity_nativeCheckFormat()内可找到一个 128 位数据xmmword_14540,右键改属性为 Data,解出来是bVpbW0pdTRwcXXAA,还有一行数据LOBYTE(v3) = (*(_DWORD *)v9 ^ 0x75686F7D | *(_DWORD *)(v9 + 3) ^ 0x45437575) == 0;可提取出十六进制数据\x7D\x6F\x68\x75\x75\x75\x43\x45

encode_front_part(front_part) == stru_14540,查看encode_front_part(),核心代码如下

1
2
for ( i = 0LL; i != v3; ++i )
std::string::push_back(&v9, (char)v2[i] ^ 0x2Fu);

可以看到每个字符异或了2f,可以拿 CyberChef 解出前半 flag,多了一个/是为了凑 3 字节而填充的数据
20250507222623-2025-05-07-22-26-23

encode_last_part对应后半,编码逻辑就是每个字符加 3 再对整个字符串反转,解密只需要反转再减 3,完整 flag 脚本如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import base64
import struct

FRONT_CIPHER = "bVpbW0pdTRwcXXAA"
CHECK1 = 0x75686F7D
CHECK2 = 0x45437575

def decode_front():
raw = base64.b64decode(FRONT_CIPHER + "==")
plain = bytes(b ^ 0x2F for b in raw)
tmp = plain[:-2]
return tmp.decode()

def decode_last():
N = 7
arr1 = struct.pack("<I", CHECK1)
arr2 = struct.pack("<I", CHECK2)
arr = bytearray(N)
arr[0:4] = arr1
arr[3:7] = arr2[:4]

suffix_chars = [chr((arr[N - 1 - i] - 3) & 0xFF) for i in range(N)]
return "".join(suffix_chars)

def encode_last(s: str) -> bytes:
arr = [(ord(c) + 3) & 0xFF for c in s]
arr.reverse()
return bytes(arr)

if __name__ == "__main__":
front = decode_front()
last = decode_last()
flag = f"ISCC{{{front}_{last}}}"
print("front part:", front)
print("last part :", last)
print("flag :", flag)

输出

1
2
3
front part: Butterb33r
last part : B@rrelz
flag : ISCC{Butterb33r_B@rrelz}

睡蕉小猴

打的时候没真机,模拟器奇奇怪怪的很多问题

jadx 反编译查看源代码,模拟器安装 apk 打开是一段重复动画

在类 p000.Shui18 下有个函数getFlag(),对ENCRYPTED_FLAG直接 base64 解码得前半 flag

1
ISCC{3m#Qls

继续翻看资源文件,发现一个可疑的res/raw/key18.txt

1
enkeyishere

MainActivity下检查 flag 的函数为CHECK(),满足需求即为正确 flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private boolean check(String str) {
if (str.length() < 13 || !str.substring(0, 5).equals("ISCC{") || !str.substring(str.length() - 1).equals("}")) {
return false;
}
return Jformat(str.substring(11, str.length() - 1), getSnowkey());
}
private boolean Jformat(String str, String str2) {
try {
return ((Boolean) new DexClassLoader(getDir("dex", 0).getAbsolutePath() + "/fox", getCodeCacheDir().getAbsolutePath(), null, getClassLoader()).loadClass("com.example.mobile03.fox").getMethod("isFlag", Context.class, String.class).invoke(null, this, str)).booleanValue();
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

首先 hook 一下getSnowkey()拿到 key

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import frida

def on_message(message, data):
if message['type'] == 'send':
print("*****[frida hook]***** : {0}".format(message['payload']))
else:
print("*****[frida hook]***** : " + str(message))

jscode = """
Java.perform(function x() {
Java.choose('com.example.mobile03.MainActivity', {
onMatch: function (instance) {
console.log(instance.getSnowkey());
},
onComplete: function () {
console.log('Done');
}
});
});
"""
process = frida.get_usb_device(-1).attach('mobile03')
script = process.create_script(jscode)
script.on('message', on_message)
print('[*] HOOK')
script.load()

key 为342150。再审计Jformat(),调用了类com.example.mobile03.Fox18里的方法isFlag()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Fox18 {
private static final String TARGET = "42f793efdf4dbfaf82556b9a562473a84a9806a81267b8b47c5e06308fc765ba825818e2ccf2793477b75805be6c45e8ee4df9849516ed0af1a4eebebd497fb8";

public static boolean isFlag(Context context, String str) {
try {
PublicKey publicKey = (PublicKey) new DexClassLoader(context.getDir("dex", 0).getAbsolutePath() + "/rabbit", context.getCodeCacheDir().getAbsolutePath(), null, Fox18.class.getClassLoader()).loadClass("com.example.mobile03.rabbit").getMethod("getPublicKey", Context.class).invoke(null, context);
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(1, publicKey);
return TARGET.equalsIgnoreCase(bytesToHex(cipher.doFinal(str.getBytes())));
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

public static String getPrivateKey(Context context) {
try {
return (String) new DexClassLoader(context.getDir("dex", 0).getAbsolutePath() + "/rabbit", context.getCodeCacheDir().getAbsolutePath(), null, Fox18.class.getClassLoader()).loadClass("com.example.mobile03.rabbit").getMethod("getPrivateKey", Context.class).invoke(null, context);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

private static String bytesToHex(byte[] bArr) {
StringBuilder sb = new StringBuilder();
for (byte b : bArr) {
String hexString = Integer.toHexString(b & UByte.MAX_VALUE);
if (hexString.length() == 1) {
sb.append('0');
}
sb.append(hexString);
}
return sb.toString().toLowerCase();
}
}

一路追踪 RSA 私钥fox->rabbit->pig-swan

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class swan {
private static native String genPvKey(String str);
private static native String getPvKey();

static {
System.loadLibrary("swan");
}

public static String getPrivateKey() {
if (m55a("1235") == 1) {
return getPvKey();
}
return genPvKey(m56b());
}

public static int m55a(String str) {
if (str.length() % 2 == 0) {
return 1;
}
return 0;
}

public static String m56b() {
return "demo";
}
}

导出libswan.so,IDA 打开并分析,需要调用getPvKey()得到私钥数据

翻看资源文件,在res/raw/key18.txt可以找到提示enkeyishere,下方有空白字符隐写,尝试了一下不是whitespace语言,那大概率就是 snow 隐写。前面已经通过getSnowkey()拿到密码,直接解密

1
.\SNOW.EXE -p 342150 key18.txt > out

后续未复现,但大致思路是调用getPvKey后解出私钥,获取flag

Pwn

连上环境已是拼尽全力 😭

第一题看了似乎是泄露 canary+libc

另外第一题没给 libc

擂台

Misc

蛇壳下的秘密

下载附件,是个使用 pyinstaller 打包的贪吃蛇小游戏,先pyinstxtractor解包得到50个附件生成.pyc

拿本地的uncompyle6无法反编译这个 pyc,拿 pycdc 也不行,于是先玩贪吃蛇游戏,途中出现了两次弹窗,一次提示继续玩,一次提示行百里者半九十,一直玩到 300 多分不再出现提示

发现同目录下生成了game_log.zip,解压需要密码

直接使用 010Editor 打开 pyc 分析可读部分,发现在game_log字样前有个Welcome,测试了一下正是压缩包密码,解压得到game_log.txt

里面有两个可疑字符串

1
2
ISCC{eWVhcgo=}
ISCC{U2FsdGVkX1+L/wKmHIDfApCg80p+D+QrET/NmTD7QNeRSGbAkJFM}

推测是寻找密码解后面的密文,eWVhcgo=解 base64 得到year,联想目前出现的字符串和 Hint,排列组合各种密码和加密方式,最后发现是 RC4.密码为serpentyearISCC
20250507225510-2025-05-07-22-55-10

flag 为

1
ISCC{ISCC蛇年大吉_0US1HH}

Mobile

whereisflag

可找到密文iB3A7kSISR,替换表直接上 frida

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function hook() {
Java.perform(function () {
let a = Java.use("com.example.whereisflag.a");
a["compute"].implementation = function (str) {
console.log(`a.compute is called: str=${str}`);
let result = this["compute"](
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
);
console.log(`a.compute result=${result}`);
return result;
};
});
}
setImmediate(hook);

得到替换表后解密

1
2
3
4
5
from string import printable
get="baZEYXVLIUTQPOGNMFRCAeKJDB10zyxwvutsrqponmlkjihSgfdcHW98765432"[::-1]
enc="iB3A7kSISR"
for i in enc[::-1]:
print(printable[get.index(i)],end="")

得到 flag

1
ISCC{HeRei5F1Ag}