Aliyunctf 2026 re

license [not solved]

解到最后一层RSA 不会密码 原来是维纳攻击 忘记了…

pixelflow

用Il2cppdumper dump出dump.cs和一些数据

image-20260131230209976

导入到IDA打开的GameAssembly.dll恢复一些函数名和符号

同时也在Assembly-Csharp.dll发现了check指向了GameAssembly的地址

在stringliteral.json里看到了flag头

1
2
3
4
{
  "value": "alictf{",
  "address": "0xEBDF38"
},

Controller__Check_d__18__MoveNext

1
2
3
4
5
6
这里能看到依次调用 Method_Controller__Check_b__18_0__ / b__18_1__ / b__18_2__ / b__18_3__,其中 b__18_2 被 await3次(对应K0运行3 次)
前后缀/长度校验:Controller.<<Check>b__18_0>d::MoveNext 在 0x1801D63F0
这里直接调用 System_String__StartsWith / EndsWith,并引用 StringLiteral_1026(alictf{)和 StringLiteral_4628(}),再 Substring + Length==16
上传输入到 TexF:Controller.<<Check>b__18_1>d::MoveNext 在 0x1801D6710
K0的Dispatch:Controller.<<Check>b__18_2>d::MoveNext 在 0x1801D6AE0,里面是 UnityEngine_ComputeShader__Dispatch(Shader0, K0, 1,1,1)
K1 的 Dispatch + 清空 SharedState:Controller.<<Check>b__18_3>d::MoveNext 在 0x1801D6D40,先 ComputeBuffer.SetData 清 0,再 Dispatch(Shader0, K1, 1,1,1)

可以得出

Check(string input) 为 async,做了:

  • 前缀/后缀校验(alictf{ 和 })
  • 中间长度 16
  • 使用 Resources.Load 读取两张纹理 ciTex / coTex
  • 用 compute shader 的三个 kernel(K0/K1/K2)
  • K0 执行 3 次
  • K1 判断对错(错则 SharedState=1)

纹理与资源位置,可以用AssetRipper提取所有的资源文件

ciTex.png 是 16x1 RGB,R 通道数据:[233,142,138,138,183,231,201,224,184,151,183,75,59,33,211,124]

coTex.png 是 13x1 RGBA,像素(R,G,B,A):[(0,1,0,42),(0,2,0,0),(1,0,2,0),(2,0,1,0),(3,0,2,0), (4,0,0,7),(6,0,2,0),(7,2,0,0),(6,1,0,0),(5,2,2,1), (8,2,0,16),(9,0,0,247),(10,0,0,0)]

可以读出来看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from PIL import Image

def dump_ci(path):
 im = Image.open(path).convert("RGB")
 w,h = im.size
 px = im.load()
 return [px[x,0][0] for x in range(w)]

def dump_co(path):
 im = Image.open(path).convert("RGBA")
 w,h = im.size
 px = im.load()
 return [px[x,0] for x in range(w)]

print(dump_ci("game_Data/test2/Assemblies/Assets/Texture2D/ciTex.png"))
print(dump_co("game_Data/test2/Assemblies/Assets/Texture2D/coTex.png"))

game_Data/sharedassets*.assets.resS 或 game_Data/resources.assets.resS里有dxbc资源文件,做法是扫这些 .resS 文件里的 DXBC 头,然后按 DXBC 结构的长度切块保存,所以第一个切下来的块就命名成 dxbc_0.bin。

之后用HLSLDecompiler去反编译Dxbc为HLSL

image-20260201160308647

但是出了点小问题,只出了asm不过也够用给AI就能梭出来解密脚本了

K0 的核心行为:读取 coTex 作为程序,对输入 16 字节执行一个 VM;其中 K0 连续执行 3 次。

推导出的等效算法(核心变换 F)

1
2
3
4
5
6
7
8
9
s = 42
for i in 0..15:
  x = input[i]
  x ^= s
  x = rol8(x, i&7)
  x = (x * 7) & 0xFF
  x = (x + i) & 0xFF
  output[i] = x
  s = (s + x) & 0xFF

K1 的判断逻辑:

1
2
3
CiTex[i] 与 WorkTex[i] 比较:
CiTex[i] == (WorkTex[i] + i) & 0xFF
任一不符 → SharedState=1 → 判错。

因此需满足:

WorkTex[i] = (CiTex[i] - i) & 0xFF,而 WorkTex 是 F^3(输入)。

反推输入(3 次逆变换)逆变换(F 的逆):

  • 逆乘法:inv7 = 183,因为 7*183 ≡ 1 (mod 256)
  • 逆旋转:ror8(x, i&7)

exp

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
def rol8(x, n):
 n &= 7
 return ((x << n) | (x >> (8-n))) & 0xFF

def ror8(x, n):
 n &= 7
 return ((x >> n) | (x << (8-n))) & 0xFF

inv7 = 183

def F(inp):
 s = 42
 out = []
 for i,x in enumerate(inp):
     x ^= s
     x = rol8(x, i&7)
     x = (x * 7) & 0xFF
     x = (x + i) & 0xFF
     out.append(x)
     s = (s + x) & 0xFF
 return out

def Finv(out):
 s = 42
 inp = []
 for i,y in enumerate(out):
     x = (y - i) & 0xFF
     x = (x * inv7) & 0xFF
     x = ror8(x, i&7)
     x ^= s
     inp.append(x)
     s = (s + y) & 0xFF
 return inp

ci = [233,142,138,138,183,231,201,224,184,151,183,75,59,33,211,124]
W = [(b - i) & 0xFF for i,b in enumerate(ci)]

v = W
for _ in range(3):  # inverse of 3 rounds
 v = Finv(v)

print(v)
print(bytes(v))

运行结果:[53, 104, 97, 100, 101, 114, 86, 77, 95, 82, 101, 112, 51, 97, 116, 33]  

最终 flag:alictf{5haderVM_Rep3at!}

Thief

APK 源码本身没什么有效信息,真正逻辑藏在 app/src/main/res/mipmap-hdpi/*.webp(实际上是 .class)

这些字节码会扫描上级目录 .java 文件,每 3 个为一组打包,加密后发到 localhost:8889(pcap 里就是这些 payload)。

每个 batch payload 结构:

  • \x89ali 魔数
  • batch index (LE int)
  • RSA 加密后的 8 字节 key
  • 文件数、文件名长度、文件名(异或0xE9)、offsets
  • 加密数据 blob

压缩算法是自定义 LZRR

加密算法有两种:

  • algo3(奇数 batch):不依赖 key
  • algo2(偶数 batch):是线性仿射 keystream XOR,依赖 key

解析 pcap 拿密文stream0/1/2 都在 dump.pcapng,dump.pcapng 里有程序发往 127.0.0.1:8889 的 TCP 流。每个流的 payload 结构是:

1
2
3
4
5
6
[0x89 'a' 'l' 'i']        // magic
[batchIndex LE int]
[rsaEncryptedKey 256 bytes]
[fileCount LE int]
[fileNameLen LE int][fileNameXorE9][offset LE int] * N
[encrypted data blob] // 这部分就是 streamX_encdata.bin

解析了 payload 头后,把最后的加密 blob单独保存为:

  • stream0_encdata.bin(batch1)
  • stream1_encdata.bin(batch2)
  • stream2_encdata.bin(batch3)

解 batch1/3(algo3)algo3 不依赖 key → 直接用 Runner.encrypt 解出密文 → LZRR 解压 → 得到Image1Part1/3/5/6.java

来自隐藏字节码 i.l.l1I 的私有方法 Il1(byte[],byte[],int)。

在 javap -c -private i.l.l1I 里可以看到:

  • 方法开头 lookupswitch 对 batch % 2 分支
  • 分支里有两段超长 Base64 字符串
  • Base64 解出来的字节数组会传给 Runner.encrypt

为了区分叫它们:

  • algo2:用于偶数 batch(batch2)
  • algo3:用于奇数 batch(batch1/3)

javap -classpath extracted_classes -c -private i.l.l1I | sed -n ‘356,520p’

  • algo3:不同 key 结果相同 → 不依赖 key
  • algo2:输出随 key 变化 → 依赖 key

LZRR 解压器

i.l.Il1.Il1(byte[]) 生成的压缩数据(每个 .java 文件都会先被压缩成 LZRR),也就是从 stream*_dec.bin 切出来的那一段段文件压缩块

它解的是LZRR 压缩块→ 输出原始 .java 文件内容

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
# lzrr_decompress.py
def rle_decode(data):
out = bytearray()
i = 0
while i < len(data):
b = data[i]
if b == 0xFF:
if data[i+1] == 0xFF:
out.append(0xFF); i += 2
else:
count = data[i+1] + 4
val = data[i+2]
out.extend([val]*count); i += 3
else:
out.append(b); i += 1
return bytes(out)

class BitReader:
def __init__(self, data):
self.data = data; self.bitpos = 0
def read_bit(self):
b = self.data[self.bitpos>>3]
bit = (b >> (7-(self.bitpos & 7))) & 1
self.bitpos += 1
return bit
def read_bits(self, n):
v = 0
for _ in range(n): v = (v<<1)|self.read_bit()
return v

def lzrr_decompress(blob):
magic = int.from_bytes(blob[0:4],'big')
assert magic == 0x4c5a5252
flags = int.from_bytes(blob[6:8],'big')
orig_len = int.from_bytes(blob[8:12],'big')
data = blob[16:]
if flags == 15:
data = rle_decode(data)
br = BitReader(data)
out = bytearray()
while len(out) < orig_len:
if br.read_bit() == 0:
out.append(br.read_bits(8))
else:
dist = br.read_bits(8) if br.read_bit()==0 else br.read_bits(16)
if br.read_bit()==0:
l2 = br.read_bits(3)
else:
l2 = br.read_bits(6)+8 if br.read_bit()==0 else br.read_bits(8)
length = l2 + 3
start = len(out)-dist
for i in range(length):
out.append(out[start+i])
return bytes(out)

batch2 的加密算法(algo2)需要 key。但 key 在 RSA 公钥里,静态解不出来。于是用已知明文(ParticlePhysicsSimulator.java 源码)先压缩成 LZRR,再和密文 XOR 得出 keystream,最终反推出 key

调用原始压缩器, 直接调用 i.l.Il1.Il1(byte[]):

1
2
3
4
5
6
7
8
9
10
11
// CompressIl1.java
import java.nio.file.*;
import i.l.Il1;

public class CompressIl1 {
public static void main(String[] args) throws Exception {
byte[] data = Files.readAllBytes(Paths.get(args[0]));
byte[] out = Il1.Il1(data);
Files.write(Paths.get(args[1]), out);
}
}

解 batch2(algo2)algo2 依赖 key。RSA 私钥不知道,因此不能直接还原 key。但 batch2 里的第一个文件是 ParticlePhysicsSimulator.java,它是已知明文

于是:

  • 用同样的 LZRR 压缩器生成压缩后明文
  • keystream = ciphertext XOR plaintext
  • 发现 keystream 对 key 是线性仿射 → 用 GF(2) 高斯消元解出 8 字节 key
  • 得到 key:a91b1bb4e8978bda

生成 keystream

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
// DumpKS.java
import java.nio.file.*;
import i.l.Runner;

public class DumpKS {
public static void main(String[] args) throws Exception {
byte[] algo = Files.readAllBytes(Paths.get(args[0]));
int n = Integer.parseInt(args[1]);
byte[] key = hexToBytes(args[2]);
byte[] data = new byte[n]; // 全零
byte[] out = Runner.encrypt(algo, data, key);
StringBuilder sb = new StringBuilder();
for (byte b : out) sb.append(String.format("%02x", b));
System.out.print(sb.toString());
}
static byte[] hexToBytes(String h){
byte[] out=new byte[h.length()/2];
for(int i=0;i<out.length;i++){
int hi=Character.digit(h.charAt(i*2),16);
int lo=Character.digit(h.charAt(i*2+1),16);
out[i]=(byte)((hi<<4)|lo);
}
return out;
}
}

用 GF(2) 解 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
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
import subprocess
from pathlib import Path

algo = 'l1I_decoded_0.bin'
L = 256

cipher = Path('stream1_encdata.bin').read_bytes()
plain = Path('pps.lzrr').read_bytes()
obs = bytes(c^p for c,p in zip(cipher[:len(plain)], plain))[:L]

def ks(keyhex, n=L):
out = subprocess.check_output(
['java','-classpath','extracted_classes:.','DumpKS',algo,str(n),keyhex],
text=True).strip()
return bytes.fromhex(out)

s0 = ks('0000000000000000')

basis_bits = []
for i in range(64):
kb = [0]*8; kb[i//8] = 1 << (i%8)
keyhex = ''.join(f'{b:02x}' for b in kb)
s = ks(keyhex)
b = bytes(a^b for a,b in zip(s,s0))
bits = 0
for idx, byte in enumerate(b):
for bit in range(8):
if byte & (1<<bit):
bits |= 1 << (idx*8+bit)
basis_bits.append(bits)

trg = bytes(a^b for a,b in zip(obs, s0))
rows = []
for bit_idx in range(L*8):
coeff = 0
for i in range(64):
if (basis_bits[i] >> bit_idx) & 1:
coeff |= 1<<i
rhs = (trg[bit_idx//8] >> (bit_idx%8)) & 1
if coeff: rows.append([coeff, rhs])

# 高斯消元
row=0; where=[-1]*64
for col in range(64):
pivot = next((r for r in range(row,len(rows)) if (rows[r][0]>>col)&1), None)
if pivot is None: continue
rows[row], rows[pivot] = rows[pivot], rows[row]
where[col]=row
for r in range(len(rows)):
if r!=row and ((rows[r][0]>>col)&1):
rows[r][0]^=rows[row][0]; rows[r][1]^=rows[row][1]
row+=1

key_bits=[0]*64
for col in range(64):
if where[col]!=-1:
key_bits[col]=rows[where[col]][1]

key_bytes=[0]*8
for i,b in enumerate(key_bits):
if b: key_bytes[i//8]|=1<<(i%8)
keyhex=''.join(f'{b:02x}' for b in key_bytes)
print(keyhex) # a91b1bb4e8978bda

用 key 解密 batch2,解压出 Image1Part2/4.java如下所示的到,啊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.io.FileOutputStream;
import java.util.Base64;

public class Image1Part2 {
private static final String IMAGE_DATA = "......;

public static void main(String[] args) throws Exception {
byte[] imageBytes = Base64.getDecoder().decode(IMAGE_DATA);
try (FileOutputStream fos = new FileOutputStream("flag.jpg")) {
fos.write(imageBytes);
}
System.out.println("Image saved to flag.jpg");
}
}

从 6 个 Java 文件提取 base64 JPEG,拼接得到完整 flag.jpg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import base64, re
from pathlib import Path
from PIL import Image

# 提取各 part 的 base64
for i in range(1,7):
text = Path(f'extracted_files/flagImage/Image1Part{i}.java').read_text()
b64 = re.search(r'IMAGE_DATA\\s*=\\s*\"([^\"]+)\"', text).group(1)
Path(f'flag_images/part{i}.jpg').write_bytes(base64.b64decode(b64))

# 拼接成完整 flag
parts=[Image.open(f'flag_images/part{i}.jpg') for i in range(1,7)]
w=sum(p.width for p in parts); h=max(p.height for p in parts)
out=Image.new('RGB',(w,h))
x=0
for p in parts:
out.paste(p,(x,0)); x+=p.width
out.save('flag_images/flag.jpg')