0%

DeepSeal

这题是一道 APK 逆向,题目提示是“海底的协议不会说话,但每一层都在留下签名”,所以一开始就可以往“多层变换”和“最终有一层校验”这个方向想。

拿到样本以后先做一个最粗的分类,发现它是个很小的单界面程序,Java 层类也不多,真正有用的部分只有 FlagValidator、CryptoEngine、Telemetry 和 JNI 对应的 libdeepseal.so,于是这题的主线就很清楚了:先把 Java 层的变换读明白,再去 native 里看最后怎么比对。

先看程序逻辑

程序入口很简单,界面上只有一个输入框和一个 Verify 按钮,点击以后会调用 FlagValidator.check。

1
2
3
4
5
6
7
public static boolean check(Context context, String s) {
if (!s.startsWith("ISCC{")) return false;
if (!s.endsWith("}")) return false;
s = s.substring(5, s.length() - 1);
byte[] data = CryptoEngine.transform(context, s);
return NativeBridge.verify(context, data);
}

也就是说,题目要求的 flag 格式已经直接告诉我们了,真正参与校验的其实只有中间那一段内容。

接着看 CryptoEngine.transform,这里就是第一层“协议签名”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static byte[] transform(Context context, String s) {
Telemetry t = Telemetry.load(context);
byte[] shifts = t.shifts();
byte[] mask = t.mask();
byte[] src = s.getBytes(StandardCharsets.US_ASCII);
byte[] out = new byte[src.length];
int prev = 0;
for (int i = 0; i < src.length; i++) {
int v = src[i] & 0xff;
if (32 <= v && v <= 126) {
v = (v - 32 + (shifts[i % shifts.length] & 0xff)) % 95 + 32;
}
v ^= mask[i % mask.length] & 0xff;
v += prev;
v += i * 3;
v &= 0xff;
out[i] = (byte)v;
prev = v;
}
return out;
}

这个变换可以拆成三步去理解:

先对可打印 ASCII 做一次循环位移。

再和 mask 异或。

最后把前一个输出字节和当前位置的 i * 3 混进去。

其中 shifts 和 mask 都不是写死在代码里的,而是从 assets/telemetry.bin 中读出来的。

1
2
3
4
0b04110916060d
1337c0de42a5197f
Qh2s7Ca9VxN4fKpB8J1mL+zoF0ewAyOu3DrRYSgPItU6McdkXl/WTnGEZ5ivbHjq
T8GHtJzYDRX7siXzWKLJCGlrpi1sVwnvMhZCPj3jwDWYU4Y=

前两行在 Java 层就已经用掉了:

1
2
shifts = bytes.fromhex("0b04110916060d")
mask = bytes.fromhex("1337c0de42a5197f")

到这里可以先得到一个结论:Java 层本身不负责判断对错,它只是把用户输入变成另一串字节,然后把这串字节丢给 native 层。

再看 native 层

JNI 对应的方法是 Java_cn_iscc_deepseal_NativeBridge_verify,继续往下跟,可以看到两个很关键的符号:

1
2
custom_b64_decode
apply_native_lfsr_mix

这两个名字已经把题目意思暴露得很明显了。native 会再次读取 telemetry.bin,这次用到的是后两行:

第三行是自定义的 Base64 字母表。

第四行是最终拿来比较的目标密文。

其中 apply_native_lfsr_mix 的逻辑并不长,整理以后大概是这样:

1
2
3
4
5
6
7
8
def native_mix(data):
data = bytearray(data)
s = 0x5d
for i in range(len(data)):
bit = ((s >> 3) ^ (s >> 2) ^ (s >> 5) ^ s) & 1
s = ((bit << 7) | (s >> 1)) & 0xff
data[i] ^= (i * 7 + s) & 0xff
return bytes(data)

这一层本质上还是异或混合,所以它是可逆的,而且逆操作和正向操作完全相同,重新跑一遍就能消掉这一层。

另一边的 custom_b64_decode 也没有做特别复杂的事,只是把标准 Base64 的字母表替换成了 telemetry.bin 第三行那一套字符。也就是说,只要我们先按这套字母表把第四行解出来,再把 native 的混合逆掉,就能拿到 Java 层输出的真实目标值。

所以整个校验链可以画成这样:

1
2
3
4
5
用户输入
-> 去掉 ISCC{ 和 }
-> Java transform
-> native LFSR/XOR mix
-> 和 telemetry.bin 中的目标字节比较

现在程序怎么验已经完全清楚了,剩下的事情就是倒着推回去。

逆向求解

第一步先实现自定义 Base64 解码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def custom_b64_decode(s, alphabet):
rev = {c: i for i, c in enumerate(alphabet)}
out = bytearray()
bits = 0
val = 0
for ch in s:
if ch == "=":
break
val = (val << 6) | rev[ch]
bits += 6
if bits >= 8:
bits -= 8
out.append((val >> bits) & 0xff)
return bytes(out)

第二步把 native 的混合层逆掉。因为这里是异或,所以直接再跑一遍同样的 native_mix 即可。

第三步再逆 Java 层。Java 的最后一步用到了“前一个输出字节”,所以逆的时候也必须按顺序逐字节恢复:

1
2
3
4
5
6
7
8
9
10
def inverse_java_transform(out, shifts, mask):
inp = bytearray(len(out))
prev = 0
for i, ob in enumerate(out):
v = (ob - prev - i * 3) & 0xff
v ^= mask[i % len(mask)]
v = ((v - 32 - shifts[i % len(shifts)]) % 95) + 32
inp[i] = v
prev = ob
return bytes(inp)

把这几步拼起来,完整脚本如下:

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
from pathlib import Path

lines = Path("telemetry.bin").read_text().strip().splitlines()

shifts = bytes.fromhex(lines[0])
mask = bytes.fromhex(lines[1])
alphabet = lines[2]
target_enc = lines[3]

def custom_b64_decode(s, alphabet):
rev = {c: i for i, c in enumerate(alphabet)}
out = bytearray()
bits = 0
val = 0
for ch in s:
if ch == "=":
break
val = (val << 6) | rev[ch]
bits += 6
if bits >= 8:
bits -= 8
out.append((val >> bits) & 0xff)
return bytes(out)

def native_mix(data):
data = bytearray(data)
s = 0x5d
for i in range(len(data)):
bit = ((s >> 3) ^ (s >> 2) ^ (s >> 5) ^ s) & 1
s = ((bit << 7) | (s >> 1)) & 0xff
data[i] ^= (i * 7 + s) & 0xff
return bytes(data)

def inverse_java_transform(out, shifts, mask):
inp = bytearray(len(out))
prev = 0
for i, ob in enumerate(out):
v = (ob - prev - i * 3) & 0xff
v ^= mask[i % len(mask)]
v = ((v - 32 - shifts[i % len(shifts)]) % 95) + 32
inp[i] = v
prev = ob
return bytes(inp)

decoded = custom_b64_decode(target_enc, alphabet)
after_native = native_mix(decoded)
inner = inverse_java_transform(after_native, shifts, mask).decode()

print("ISCC{" + inner + "}")

跑一下就能得到中间内容:

1
abyssal_vows_bind_the_last_protocol

外面补回固定前后缀以后,最终 flag 就是:

1
ISCC{abyssal_vows_bind_the_last_protocol}

总结一下

这题其实没有特别重的混淆,难点更多在于题目想让我们意识到“每一层都留了签名”这句话不是修辞,而是在直说程序结构。

Java 层留下的是 shifts 和 mask 这一层签名。

telemetry.bin 继续留下了自定义 Base64 字母表和目标密文。

native 最后再补一层 LFSR/XOR 混合,形成最终比较值。

所以只要顺着这条链把每一层都拆开,再按相反顺序推回去,flag 就自然出来了。

最后答案:

1
ISCC{abyssal_vows_bind_the_last_protocol}
-------------本文结束感谢您的阅读-------------