Skip to content

Commit decf36d

Browse files
committed
feat: add bsides ctf writeup
1 parent a1b3f3d commit decf36d

File tree

9 files changed

+15472
-0
lines changed

9 files changed

+15472
-0
lines changed

content/blog/okay-buddy.md

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
---
2+
title: 'Okay Buddy skateboarding dog writeup'
3+
date: 2025-09-28T20:20:26.000+10:00
4+
slug: okay-buddy-2025
5+
description: Writeup from B sides Canberra 2025's CTF hosted by skateboarding dog for 'okay buddy'
6+
image: "/uploads/github-macos-malware.png"
7+
keywords:
8+
- bsides canberra 2025
9+
- skateboard dog 2025 okay buddy writeup
10+
- bsides skateboard dog ctf writeup
11+
- adam kostarelas
12+
- blog
13+
author: Adam Kostarelas
14+
tags:
15+
- tech
16+
math: false
17+
toc: false
18+
19+
---
20+
21+
22+
23+
24+
![CTF splashscreen](/uploads/bsidesc2025/ctfsplash.png)
25+
26+
I've only done a handful of CTF challenges before and found that I've gravitated towards forensics, OSINT and some cryptography.
27+
This challenge was outside of my comfort zone, but I wanted to persist and try something new.
28+
29+
It's fun when there's a theme to a CTF, and skateboarding dog did an amazing job with the 2D pixelart and game.
30+
31+
## Skateboarding dog CTF writeup
32+
33+
### Okay Buddy
34+
35+
There's a flag in the tree, and Buddy is so close to help us grab it.
36+
When chatting to Buddy something seems wrong..
37+
38+
![Never ending Buddy conversation](/uploads/bsidesc2025/buddy.gif)
39+
40+
There are always 4 options to choose from and it's not super obvious what replies are correct, with 'Interesting, Right, Yeah and I see'.
41+
42+
Time to inspect the code and try to find why we're having such nice weather.
43+
![Never ending Buddy conversation](/uploads/bsidesc2025/inspection.png)
44+
45+
The source code reveals that the correct sequence of responses will unlock the encrypted flag.
46+
Trying to brueforce it won't work, as there are `4^30` possibilities.
47+
48+
Two important constants are defined:
49+
50+
`DATA` is a huge base64 blob, which we can later use to XOR against a “state” buffer and
51+
`ENCRYPTED_FLAG` is the AES-GCM cipher text of the flag.
52+
53+
54+
Even though it looks like a silly conversation, the game is secretly adding digits for every response being 30 numbers long.
55+
56+
The game starts with a state that’s all zeroes and every time you make a choice, the game combines that block with the current state using XOR. After 30 moves if your state is all ones buddy gets the flag, if not you need to listen more closely..
57+
58+
To try and solve this, as we can't bruteforce, theres another way using math. understood this like sudoku, if a 'number' doesn't fit in a square, stop and try something else.
59+
60+
61+
| Sudoku ✍️ | XOR Puzzle chatting with Buddy 🐶 |
62+
| --------------------------------- | ------------------- |
63+
| Sudoku cell (to be filled) | Buddy Yapping |
64+
| Candidate number (1–9) | Response, I see etc. |
65+
| Sudoku rule check (row/col/box) | XOR lock check |
66+
| Wrong number, erase & backtrack | Response fails |
67+
| Number fits, continue |Response passes |
68+
| You win the Sudoku puzzle | Flag taken from the tree |
69+
70+
71+
``` javascript
72+
Sudoku Buddy Conversation
73+
74+
Step 1: Start Step 1: Yap 1
75+
. . . | . . . | . . . ├
76+
. . . | . . . | . . . ├─ Interesting (1) → "You know, trees like that one..." ✅ Continue
77+
. . . | . . . | . . . ├─ Right (2) → "Oh okay then." ❌ Prune
78+
------+-------+------- ├─ Yeah (3) → "Hmm, not quite." ❌ Prune
79+
. . . | . . . | . . . └─ I see (4) → "Wrong choice!" ❌ Prune
80+
. . . | . . . | . . .
81+
. . . | . . . | . . .
82+
------+-------+-------
83+
. . . | . . . | . . .
84+
. . . | . . . | . . .
85+
. . . | . . . | . . .
86+
87+
--------------------------------------------------------------
88+
89+
Step 2: Step 2: Yap 2
90+
Place "5" in Cell (1,1)
91+
5 . . | . . . | . . . ├─ Interesting (1) → "No, not helpful." ❌ Prune
92+
. . . | . . . | . . . ├─ Right (2) → "Hmm, not quite." ❌ Prune
93+
. . . | . . . | . . . ├─ Yeah (3) → "If your park has a bunch of lollipop trees..." ✅ Continue
94+
------+-------+------- └─ I see (4) → "Wrong path!" ❌ Prune
95+
. . . | . . . | . . .
96+
. . . | . . . | . . .
97+
. . . | . . . | . . .
98+
------+-------+-------
99+
. . . | . . . | . . .
100+
. . . | . . . | . . .
101+
. . . | . . . | . . .
102+
103+
--------------------------------------------------------------
104+
105+
Step 3: Step 3: Yap 3
106+
Place "3" in Cell (1,2)
107+
5 3 . | . . . | . . . ├─ Interesting (1) → "Not quite." ❌ Prune
108+
. . . | . . . | . . . ├─ Right (2) → "Wrong again." ❌ Prune
109+
. . . | . . . | . . . ├─ Yeah (3) → "Trees like those are actually doing more harm..." ✅ Continue
110+
------+-------+------- └─ I see (4) → "That’s not it." ❌ Prune
111+
. . . | . . . | . . .
112+
. . . | . . . | . . .
113+
. . . | . . . | . . .
114+
------+-------+-------
115+
. . . | . . . | . . .
116+
. . . | . . . | . . .
117+
. . . | . . . | . . .
118+
119+
--------------------------------------------------------------
120+
121+
Step N: Completed Grid Step N: Yap Flag
122+
5 3 4 | 6 7 8 | 9 1 2 ├─"Oh right, the flag in the tree. Let me grab that for you..."
123+
6 7 2 | 1 9 5 | 3 4 8 └─ skbdg{flag}
124+
1 9 8 | 3 4 2 | 5 6 7
125+
------+-------+-------
126+
8 5 9 | 7 6 1 | 4 2 3
127+
4 2 6 | 8 5 3 | 7 9 1
128+
7 1 3 | 9 2 4 | 8 5 6
129+
------+-------+-------
130+
9 6 1 | 5 3 7 | 2 8 4
131+
2 8 7 | 4 1 9 | 6 3 5
132+
3 4 5 | 2 8 6 | 1 7 9
133+
134+
135+
```
136+
137+
I used **AI** to help build a script to run the XOR and eventually decrypt the flag.
138+
139+
```
140+
(env) ➜ okay buddy python3 aisolver.py
141+
Loaded DATA: total bytes=3600, groups=30
142+
Simulating known choices...
143+
SUCCESS: final state is all 0xFF (condition satisfied).
144+
Derived AES key (SHA-256 of choices string): 5ff0f7de257c5e9f327acbc77a227e9e45953855fa5cbf3c9f6fa247db4f03ab
145+
--- DECRYPTED FLAG ---
146+
skbdg{the_flag_was_stuck_in_the_linear_algebranch!}
147+
----------------------
148+
```
149+
150+
[Python Script for your inspection](https://github.com/adamxweb/adamxweb.github.io/uploads/bsidesc2025/okay%20buddy/aisolver.py "AI generated script on Github")
151+
152+
By writing a little script to explore the possibilities, we unlocked the secret responses that the game wants to get the flag.
153+
154+
### Flag found!
155+
156+
`skbdg{the_flag_was_stuck_in_the_linear_algebranch!}`
157+
158+
The script solves and decrypts the flag, but that's no fun. We can also go through the options to see the satisfaction of Buddy giving us the flag too.. (for validation reasons)
159+
![Buddy gave in and gave us the flag](/uploads/bsidesc2025/flag.png)
160+
161+
162+
### Technical info
163+
164+
Even though the puzzle looked like we had to find the right sequence based on how Buddy was feeling, it was really a math puzzle in disguise.
165+
166+
XOR-based state machines are essentially linear algebra problems in disguise. When AES keys are derived directly from deterministic state (responses to buddy), reverse-engineering the selection logic allows us to reconstruct the exact key.
167+
168+
Combining static analysis with scripting makes it possible to recover the flag without ever playing through the game.
169+
170+
The important function to know when chatting to Buddy is:
171+
``` javascript
172+
handleYapResponse(t, e) {
173+
this.choices.push(t);
174+
const i = 30 * (4 * e + t),
175+
n = DATA.subarray(i, i + 30);
176+
for (let r = 0; r < 30; r++) this.st[r] ^= n[r];
177+
return this.st.every((t => 255 == t))
178+
}
179+
180+
```
181+
182+
As this checks if your conversation choices are correct at each step.\
183+
The script simulates `this.st` starting at zero, and XORs the 30-byte block `DATA.subarray(30*(4*g + choice) , 30*(4*g + choice)+30)` for each group `g (0..29)`.
184+
After XORing, the function checks if every byte in `this.st` equals 255.
185+
186+
`DATA` = all candidate numbers and rules encoded.\
187+
`st` = the current partially filled grid state.\
188+
`XOR step` = checking your number fits all rules for that cell.\
189+
`every == 255` = the grid is valid for now.
190+
191+
Similar to above, it solves like:
192+
``` javascript
193+
Initial state:
194+
st = [0, 0, 0, ..., 0] (30 bytes)
195+
196+
Step 1 (Yap choices):
197+
├─ Interesting
198+
XOR DATA[0:30] → st updated
199+
│ st != all 255 -> pass ✅ Continue
200+
201+
├─ Right
202+
XOR DATA[1*30:1*30+30] → st updated
203+
│ st != all 255 -> fail ❌ Prune
204+
205+
├─ Yeah
206+
XOR DATA[2*30:2*30+30] → st updated
207+
│ st != all 255 -> fail ❌ Prune
208+
209+
└─ I see
210+
XOR DATA[3*30:3*30+30] → st updated
211+
st != all 255 -> fail ❌ Prune
212+
```
213+
214+
This was a personal achievement to understand the problem and to solve it, and a bonus to be the first!
215+
![First blood](/uploads/bsidesc2025/firstblood.png)
216+
217+
Looking forward to participating again next year!
566 KB
Loading
94 KB
Loading
59.6 KB
Loading
37.1 KB
Loading
295 KB
Loading
190 KB
Loading
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
# buddy_solver.py
3+
# Reproduces the decrypt from the JS for the Buddy NPC CTF challenge.
4+
5+
import base64
6+
import hashlib
7+
from Crypto.Cipher import AES # pip install pycryptodome
8+
from typing import List
9+
10+
# ----------------------------
11+
# Paste the blobs exactly as in the JS:
12+
# ----------------------------
13+
14+
DATA_B64 = (
15+
"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"
16+
)
17+
18+
ENCRYPTED_FLAG_B64 = (
19+
"lYLlXdDqUMRvPPrzUcCt4/cGgXVmZsHB1L5bg1Vrylnx8G2rvIxhrfIWuRx2KPC28TW1LlMqwchfbootLTNwnaW6jw=="
20+
)
21+
22+
# ----------------------------
23+
# Known solution choices (0-based mapping in original JS):
24+
# Original mapping in early writeup: 0=Interesting,1=Right,2=Yeah,3=I see
25+
# The sequence we found (0..3) is:
26+
KNOWN_CHOICES = [1,0,1,1,3,0,2,3,3,2,2,0,2,1,3,2,1,1,2,0,2,3,1,1,3,3,3,0,0,3]
27+
# ----------------------------
28+
29+
def load_data():
30+
data = base64.b64decode(DATA_B64)
31+
enc = base64.b64decode(ENCRYPTED_FLAG_B64)
32+
if len(data) % 30 != 0:
33+
raise ValueError("DATA length is not a multiple of 30!")
34+
groups = len(data) // 30 // 4 # should be 30 groups
35+
# Alternate calculation: there are 4 choices per group, and group size 30 bytes
36+
assert (len(data) // 30) % 4 == 0
37+
n_groups = (len(data) // 30) // 4
38+
print(f"Loaded DATA: total bytes={len(data)}, groups={n_groups}")
39+
# Build a structure: data_groups[g][choice] => bytes
40+
data_groups = []
41+
for g in range(n_groups):
42+
choices = []
43+
for c in range(4):
44+
start = 30 * (4 * g + c)
45+
choices.append(data[start:start+30])
46+
data_groups.append(choices)
47+
return data_groups, enc
48+
49+
def simulate_choices_and_check(data_groups: List[List[bytes]], choices: List[int]) -> bytes:
50+
"""
51+
Simulate the XOR accumulation described in handleYapResponse.
52+
Start with st = 30 zero bytes. For each group g, XOR the chosen 30-byte slice.
53+
Return the final st as bytes.
54+
"""
55+
if len(choices) != len(data_groups):
56+
raise ValueError("choices length mismatch")
57+
st = bytearray(30)
58+
for g, c in enumerate(choices):
59+
block = data_groups[g][c]
60+
for i in range(30):
61+
st[i] ^= block[i]
62+
return bytes(st)
63+
64+
def derive_key_from_choices(choices: List[int]) -> bytes:
65+
# JS uses choices.toString() which produces a comma-separated string with no spaces:
66+
# e.g. "1,0,1,..."
67+
s = ",".join(str(x) for x in choices)
68+
h = hashlib.sha256(s.encode()).digest()
69+
return h
70+
71+
def aesgcm_decrypt(key: bytes, ciphertext_and_tag: bytes) -> bytes:
72+
# From the JS: IV = 12 zero bytes; AES-GCM; ciphertext includes tag at end (16 bytes)
73+
iv = bytes([0]*12)
74+
if len(ciphertext_and_tag) < 16:
75+
raise ValueError("ciphertext too short")
76+
ct = ciphertext_and_tag[:-16]
77+
tag = ciphertext_and_tag[-16:]
78+
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
79+
plaintext = cipher.decrypt_and_verify(ct, tag)
80+
return plaintext
81+
82+
def main():
83+
data_groups, enc = load_data()
84+
print("Simulating known choices...")
85+
final_st = simulate_choices_and_check(data_groups, KNOWN_CHOICES)
86+
if final_st == bytes([0xFF]*30):
87+
print("SUCCESS: final state is all 0xFF (condition satisfied).")
88+
else:
89+
print("FAIL: final state is not all 0xFF — here's the final state (hex):")
90+
print(final_st.hex())
91+
# still continue to try decrypt (may fail)
92+
key = derive_key_from_choices(KNOWN_CHOICES)
93+
print("Derived AES key (SHA-256 of choices string):", key.hex())
94+
try:
95+
plaintext = aesgcm_decrypt(key, enc)
96+
print("\n--- DECRYPTED FLAG ---")
97+
print(plaintext.decode())
98+
print("----------------------")
99+
except Exception as e:
100+
print("Decryption failed:", e)
101+
102+
if __name__ == "__main__":
103+
main()

0 commit comments

Comments
 (0)