Level 10 - Countle Secured Storage (web)

Let's look at the various web interfaces:

Initialize vault (registration at /register):

Access secrets (/login):

When we are logged in, at /profile we see:

Below the username there is a small button that can be clicked to select the colour of our username (we'll get to this later).

When we click unlock vault (/2fa):

This is basically just a countle puzzle we need to solve. The twist, however, is that we don't know what our target number is, so we don't know how to solve it!

We see Python code in the admin_bot folder in the dist files, containing the following code:

The admin bot is not exposed to the public, but only reachable from the backend golang server. It essentially waits for connections for the backend, receives a token and target, and visits the /2fa endpoint with the provided token. It then uses css classes to identify the buttons available on the 2fa page, and performs DFS to find a solution. Once a solution is found it clicks the buttons to reach the target number. It clicks number 1, then the operation, then number 2.

Note on /assets/

You can view css and js files by going to the /assets/ path.

Now let's try to understand some functions in the golang binary. Fortunately for us it isn't stripped, so we can guess their functionalities by their function names.

  • main_main: Specifies a few API endpoints and their handlers:

  1. POST /api/login - Login endpoint (no auth required)

    • Handler: off_1198A38

  2. POST /api/register - Registration endpoint (no auth required)

    • Handler: off_1198A48

  3. GET /api/me - Get current user info (requires auth)

    • Handler: off_1198A40 with auth middleware

  4. GET /api/views - Get views (requires auth)

    • Handler: off_1198A30 with auth middleware

  5. GET /api/profile/{username} - Get user profile (requires auth)

    • Handler: off_1198A28 with auth middleware

    • Path: /profile/:username (18 chars: "profile/username")

  6. POST /api/vault/unlock/request - Request vault unlock (requires auth)

    • Handler: off_1198A60 with auth middleware

    • Path: 21 chars: "vault/unlock/request"

  7. POST /api/vault/unlock/attempt - Attempt vault unlock (requires auth)

    • Handler: off_1198A58 with auth middleware

    • Path: 21 chars: "vault/unlock/attempt"

  8. POST /api/vault/check - Check vault status (requires auth)

    • Handler: off_1198A20 with auth middleware

    • Path: 12 chars: "vault/check"

    • ๐Ÿ’กThis activates the admin_bot's Python script to click the buttons to the solution in /2fa without resetting the OTP!

  • main_newOTP: Generates a new OTP using secure random

  • main_setOTP: Updates the SQL database for a particular user with the provided OTP and auth level

  • main_getOTP: Retrieves otp and auth level for a particular user from the database

  • main_handleUnlockAttempt : Calls main_getOTP to get OTP for the user. When the user sends a POST request containing their operations, it calls jro_sg_tisc_secure_vault_countle_Validate to ensure the moves are valid. If the user is currently at auth level 1 and solve the puzzle they get the secret. Otherwise if they are currently at auth level 0 and solve the puzzle a new OTP is generated using main_newOTP and the new OTP is set using main_setOTP. They are promoted to auth level 1.

  • main_handleUnlockRequest : Gets current OTP for the user using main_getOTP, handles an invalid current OTP, generates new OTP using main_newOTP, stores it using main_setOTP

  • main_genToken: Given a username, generates an authentication token for the user by running main_encrypt on the username byteslice. The first 12 bytes are a random nonce, followed by the encrypted ciphertext, followed by a 16-byte AES GCM tag.

  • main_encrypt: Uses a main_key stores in bss to perform AES GCM encryption to generate the token.

  • main_validateToken : Decodes the token, which is a hexstring, then performs AES GCM decryption using main_decrypt.

  • main_init: Initializes the global main_key using main_generateKey.

  • main_generateKey: Generates a 16-byte key using SHA1.

Let's take a closer look at main_generateKey:

  • 0x98BADCFEEFCDAB89LL and 0xC3D2E1F010325476LL are SHA1 constants

This function appears to:

  1. Get FLAG from env vars

  2. Calculate a SHA1 hash of the FLAG

  3. Uses the first 8 bytes of the SHA1 hash as a seed for rand

  4. Use rand's Int63 function 256 times

  5. Use rand's Int63 function another 16 times, each time taking the lowest 8 bits and adding it to the result array.

  6. In the end we get a 16 byte key!

This appears to not be vulnerable since we need knowledge of the entire FLAG to calculate the SHA1 and hence get the key.

However, the catch is in crypto_sha1__ptr_digest_Sum . Let's look at its pseudocode:

It actually doesn't return the hash! Rather, it grows the slice by 20 bytes (which is the size of the SHA1 digest), and appends the SHA1 digest to it. It then returns this slice.

The implication of this is that the seed used by main_generateKey will just be the first 8 bytes of the flag! Since we know that the flag format is TISC{, we just need to brute the remaining 3 bytes (24 bits) to get the correct key. In order to do so,

  1. Get a valid token (12-byte nonce || ct || 16-byte tag)

  2. Try all possible combinations of the first 8 bytes of the flag (TISC{???) and attempt to perform AES decryption.

  3. When AES decryption succeeds, we know that we have found the correct key (and retrieved the first 8 bytes of the flag).

Here's the script ChatGPT generated to achieve this, and the result when tested on remote:

Furthermore, in main_HandleRegister, I observed that when a normal user account is created, a corresponding admin account with a random password is created, and the admin account's name is admin_XXXX where XXXX is the name of the user account.

The script was then modified to also forge a valid token for the admin account of the user whose token we provided.

Now we can change our username and token in localStorage to get access to the admin account:

Now we have to look for a vulnerability that allows us to solve the countle twice. Once to get from auth level 0 to auth level 1, and another to pass auth level 1 and view the secret which contains the flag.

After playing around for awhile I tried to intercept the request and response when we tried to change the style of the username using burp suite.

Request:

Response:

After this, we would see the following element in the webpage when we try to inspect the username:

We can see that our chosen colour has been added into the <style> tag!

You may now be wondering: Can we escape the curly brace { to define CSS for other classes? The answer is YES!

Why can we just escape the curly braces?

If you looked at the JS file in http://chals.tisc25.ctf.sg:23196/assets/ProfileAvatar-CJL_IISE.js you would see the following:

Notice that the style color we sent is injected into n, and undergoes some sanitization which isn't very comprehensive at all.

If our payload was something like

we would be able to define CSS properties for other classes and comment out everything after our payload.

The next question is: What can we do with this CSS injection primitive?

Well, our ultimate goal is to be able to solve the countle puzzle on the admin account twice. In order to do this, we must be able to either:

a. Leak the target number, or

b. Leak the path to obtaining the target number

It turns out that step b is more feasible. If you recall, the admin bot performs DFS to find a sequence of operations to solve the puzzle, then clicks the buttons to solve the puzzle. It finds these buttons by their CSS classes. As such, if we can modify the buttons' CSS such that we can somehow determine the order of buttons clicks, we can simply re-enact the clicks to solve the puzzle.

Finding the right thing to inject is non-trivial, however. Eventually after trying to click the buttons myself and looking at http://chals.tisc25.ctf.sg:23196/assets/countle-qAA7LAPw.js, I realised that whenever a button is clicked, it momentarily gets a new CSS class called number-card--anim-popout. If we did something like:

we observe that it fails, presumably because external fetches aren't allowed.

However, if we were to try something like:

And we try clicking it ourselves on the 2fa page, it actually works! We would see a corresponding profile view on the account specified in the URL, complete with timestamps:

๐Ÿ˜‚ Now I encountered another problem: When I tried to do this for mathematical operator buttons, only the first click would send a request. Subsequent requests wouldn't get sent. This is most likely due to the browser caching the CSS, resulting in subsequent fetches not occurring.

I couldn't find a way to defeat this phenomenon. However, every time the 2fa endpoint is accessed (even by the bot), the number buttons are shuffled. This means that the bot may be able to find different distinct solutions. In this case, even if we are unsure which operations are used for some lines, we can make an educated guess by obtaining multiple distinct solutions which sum up to the same target.

Now to formulate our entire payload let's look at the CSS of the different buttons the bot may click (I also added one line of equations because the bot may use an intermediate number:

We can split these into the following groups:

  1. The 7 initial number cards. These all have the number-card--filled class initially, and also have the number-card--X-chars class depending on their number of characters. These are shuffled on each attempt to access /2fa.

  2. Mathematical operators. These are always in the order + - x รท and have the number-card--operator class

  3. Intermediate results. In the above example my first line result was 10. It has the classes number-card number-card--filled number-card--2-chars

We already know that we want each button to map to a different profile. If we can do this, then we can look across our different profiles to see the time each profile was viewed. By sorting those timings, we can then re-construct the bot's button clicks.

However, although this is possible for (2) and (3), it is not possible for (1). It is impossible (to my knowledge) to use CSS selectors to select between

because no CSS selector works on the content, and their classes are exactly the same.

Hence my approach was to create the following accounts:

This approach allowed me to track all button presses, except for

a) repeated mathematical operator presses, and

b) differentiating between 1-digit initial numbers and differentiating between 2-digit initial numbers

Final CSS payload

Now I wanted to write a script which would:

  1. Send a POST request to http://chals.tisc25.ctf.sg:23196/api/vault/check which activates the bot to click the buttons without changing the OTP

  2. Checks the aforementioned user accounts to check for the timestamps of profile views right after the bot was activated

  3. Sort the timestamps and produce a chronological display of buttons clicked

  4. Display the equations obtained in a mathematically friendly manner. (for ease of more LLM processing)

These 4 steps would be repeated to get a set of about 3 distinct equations for the same OTP target.

I got ChatGPT and Claude to do steps 1-3 for me and did step 4 myself.

Final Python script to do steps 1-4

The output looks something like this:

As you can see, occasionally the last click doesn't get registered. I just re-run the script until it works, and get multiple sets of valid equations. Valid equation sets would look something like:

I then threw this to Claude to solve for r5, assuming r5 is the same for all equation sets. (If it failed I might have had to write this myself ...)

Fortunately, Claude was able to consistently solve these equations. For the above case for example, it gave me this solve script:

Claude's solve script (Python)

The result of running the script was something like:

This allowed me to easily solve the OTPs twice in a row and get the admin secret ๐Ÿ˜„

Last updated