Skip to content

Commit 6bac23a

Browse files
authored
Create index.md
1 parent a3dd3f8 commit 6bac23a

File tree

1 file changed

+322
-0
lines changed
  • content/writeups/GoogleCTF2025/postviewer

1 file changed

+322
-0
lines changed
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
+++
2+
title = "POSTVIEWER"
3+
date = 2025-08-11
4+
authors = ["Sanat Garyali"]
5+
+++
6+
7+
# POSTVIEWER v5² WRITEUP
8+
---
9+
10+
## Introduction
11+
12+
This year’s **Postviewer** in Google CTF was a SafeContentFrame (SCF) playground ,a client-side puzzle where files render on a sandbox origin instead of your app’s origin. Only **2 solves**. It’s intentionally evil: the fix is simple, the race is not.
13+
14+
15+
---
16+
17+
18+
19+
### 1) Upload & store (IndexedDB)
20+
21+
Files (cached or non-cached) are stored client-side with metadata:
22+
23+
```js
24+
async addFile({ id, file, cached, isPublic}) {
25+
const db = await this.dbPromise;
26+
const tx = db.transaction(["files", "info"], "readwrite");
27+
const filesdb = tx.objectStore("files");
28+
const infodb = tx.objectStore("info");
29+
30+
const req = filesdb.put({ id, file, cached, isPublic });
31+
return new Promise((resolve) => {
32+
req.onsuccess = () => {
33+
const fileInfo = { id, name: file.name, cached, isPublic, date: Date.now() };
34+
const req2 = infodb.put(fileInfo);
35+
req2.onsuccess = () => resolve(fileInfo);
36+
};
37+
});
38+
}
39+
```
40+
<figure style="text-align:center; margin:1.25rem 0;">
41+
<img src="mainpage.png" style="max-width:100%; height:auto;">
42+
</figure>
43+
44+
45+
### 2) Render pipeline (SCF URL + shim)
46+
47+
Selecting a file (click or `#<N>` deep-link) kicks off the SCF flow. The app computes a hash from product + salt + origin, base36’d and fixed-width, to build the **shim URL**:
48+
49+
```js
50+
async function calculateHash(...parts) {
51+
const encoder = new TextEncoder();
52+
const newParts = [];
53+
for (let i = 0; i < parts.length; i++) {
54+
const part = parts[i];
55+
newParts.push(typeof part === "string" ? encoder.encode(part).buffer : part);
56+
if (i < parts.length - 1) newParts.push(encoder.encode("$@#|").buffer);
57+
}
58+
const buffer = concatBuffers(...newParts);
59+
const hash = await crypto.subtle.digest("SHA-256", buffer);
60+
return arrayToBase36(new Uint8Array(hash)).padStart(50, "0").slice(0, 50);
61+
}
62+
63+
const product = "google-ctf";
64+
const hash = await calculateHash(product, salt, window.origin);
65+
const url = `https://${hash}-h748636364.scf.usercontent.goog/${product}/shim.html`;
66+
```
67+
68+
Then the parent creates/points the iframe and **attaches `onload` before the load completes**:
69+
70+
```js
71+
safeFrameIframe.addEventListener("load", safeFrameLoaded, true);
72+
safeFrameIframe.src = url; // shim.html on SCF
73+
```
74+
75+
### 3) Parent ⇄ Shim handshake (normal)
76+
77+
On **LOAD(shim)** the parent posts the file over:
78+
79+
```js
80+
safeFrameIframe.contentWindow.postMessage(
81+
{ body, mimeType, salt },
82+
url.origin,
83+
[messageChannel.port2]
84+
);
85+
```
86+
87+
Shim verifies **origin+salt** and replies with **"Reloading iframe"**; parent removes the handler and shim swaps itself for the blob file:
88+
89+
```js
90+
if (e.data.message == "Reloading iframe") {
91+
safeFrameIframe.removeEventListener("load", safeFrameLoaded, true);
92+
resolve();
93+
}
94+
```
95+
96+
### 4) Cached vs Non-cached salts
97+
98+
* **Cached**: `salt = encode(file.name)` (deterministic per filename).
99+
* **Non-cached**: `salt = getRandom(5)` → 5 draws of `Math.random()` → base36 chunk per draw → concatenated.
100+
101+
```js
102+
function getRandom(n) {
103+
return Array.from(Array(n), Math.random)
104+
.map(e => e.toString(36).slice(2))
105+
.join('');
106+
}
107+
108+
async function renderFile({ id, cached, file }, safeFrameIframe) {
109+
let salt;
110+
const encoder = new TextEncoder();
111+
if (cached) {
112+
salt = encoder.encode(id).buffer; // filename-based
113+
} else {
114+
const rand = getRandom(5); // 5 chunks from Math.random()
115+
mathRandomInvocations.push(rand);
116+
salt = encoder.encode(rand).buffer;
117+
}
118+
return window.safeFrameRender({
119+
body: await file.arrayBuffer(), mimeType: file.type, salt, cached
120+
}, safeFrameIframe);
121+
}
122+
```
123+
124+
### 5) `#<N>` deep-link
125+
126+
The app can auto-open a file at load if the URL ends in `#<index>`.
127+
128+
---
129+
130+
## Bot behavior (context)
131+
132+
1. Bot opens the app (localhost), adds a **non-cached** file containing the **plaintext flag**.
133+
2. Bot visits our supplied URL (our exploit page).
134+
3. After \~5 minutes, the bot closes the browser.
135+
136+
So when our exploit runs, the **flag file already exists** in IndexedDB, and its **salt path is driven by `Math.random()`**.
137+
138+
---
139+
140+
<figure style="text-align:center; margin:1.25rem 0;">
141+
<img src="mainbot.png" style="max-width:100%; height:auto;">
142+
</figure>
143+
144+
## The Race Condition
145+
146+
> TL;DR: We want the **parent** to send `{…, salt}` **twice**; the second send must land while **our leaker** is already the iframe document, and **before** the parent processes the shim’s ACK (which would remove the handler).
147+
148+
### Normal cycle (why leakage *doesn’t* happen by default)
149+
150+
```
151+
APP: attach onload → iframe.src = SHIM
152+
SHIM loads → LOAD fires → APP → SHIM: {body, mimeType, salt}
153+
SHIM verifies → posts ACK → APP removes onload
154+
SHIM replaces itself with blob(file) (file now runs) → but APP won’t send again
155+
```
156+
157+
**Why no leak:** the send happens before our doc ever runs; after ACK, onload is gone.
158+
159+
### Exploit vision
160+
161+
We need **one more LOAD** to be **handled before** the ACK. That makes APP re-run the onload handler and resend the salt into **whatever** is currently inside the iframe → **our leaker**.
162+
163+
### Two-cycle plan (Shim₁ + Shim₂)
164+
165+
**Cycle A (cached, Shim₁ → Navigator)**
166+
167+
* Share **cached Navigator** (a file that self-navigates).
168+
* Shim₁ verifies → posts ACK → (parent not stalled yet) processes it immediately → removes *that cycle’s* onload.
169+
* Shim₁ swaps to **Navigator**, which starts **renavigating** (e.g., via `setTimeout(()=>location=blob(...),150)`). Each navigation will produce a **future iframe LOAD** — these are our “extra tickets.” They exist independent of the cached-cycle handler (which is already removed).
170+
171+
**Cycle B (non-cached, Shim₂ → Leaker)**
172+
173+
* Start a new cycle by sharing **non-cached Leaker**. Fresh **onload handler attached** for this cycle.
174+
* On **LOAD(Shim₂)**, APP sends `{…, salt_real}` (first send; verification).
175+
* **Freeze the parent** (CPU-burn gadget) so **ACK from Shim₂ is queued but unprocessed**. Meanwhile:
176+
177+
* Shim₂ swaps to **Leaker** (our script with `onmessage=e=>leak(e.data.salt)`).
178+
* Separately, a **Navigator-driven navigation** completes, which will result in a **new LOAD** destined for the parent.
179+
* **Unfreeze** the parent → queued tasks drain. With tuning:
180+
181+
1. **LOAD** (from Navigator) fires **before** the **ACK**.
182+
2. APP’s onload handler runs **again** and **re-sends `{…, salt_real}`**, now into **Leaker**, which grabs and exfiltrates it.
183+
3. Only after that does the **ACK** run and remove the handler. Race won.
184+
185+
### Why stalling works
186+
187+
* The iframe lives in a separate process; it can continue completing navigations while the parent is blocked.
188+
* We use the built-in slow gadgets:
189+
190+
```js
191+
// provided by the challenge (intended/unintended gadgets)
192+
window.onmessage = async function(e){
193+
if(e.data.type == 'share'){
194+
// can loop absurd amounts: {file:{length:1e8}}
195+
for (var i = 0; i < e.data.files.length; i++) { /* burn */ }
196+
}
197+
if(e.data.slow){
198+
for (i = e.data.slow; i--; );
199+
}
200+
}
201+
```
202+
203+
### Navigator & Leaker
204+
205+
```html
206+
<!-- Navigator (cached) → keeps re-navigating -->
207+
<script>
208+
setTimeout(() => {
209+
location = URL.createObjectURL(
210+
new Blob([document.documentElement.innerHTML], {type: 'text/html'})
211+
);
212+
}, 150);
213+
</script>
214+
```
215+
216+
```html
217+
<!-- Leaker (non-cached) → catch the resend -->
218+
<script>
219+
onmessage = e => leak(e.data.salt);
220+
</script>
221+
```
222+
223+
### handler timing
224+
225+
* **Loads only help if the onload handler is attached at that moment.**
226+
* Cached cycle: ACK processed immediately → handler gone → Navigator’s initial loads are ignored by the parent (but they still *happen*, which is important).
227+
* Non-cached cycle: fresh handler attached; we **delay** its ACK; now a Navigator load fires while handler is **still attached** → second send happens.
228+
229+
---
230+
231+
## After the leak: PRNG → XSS pivot → flag
232+
233+
Once we have **one** non-cached salt, the rest is deterministic engineering.
234+
235+
### 1) Split the salt into 5 base36 chunks
236+
237+
The non-cached salt is the concatenation of 5 `Math.random()` outputs in base36. E.g.
238+
239+
```js
240+
salt = r1.toString(36).slice(2)
241+
+ r2.toString(36).slice(2)
242+
+ r3.toString(36).slice(2)
243+
+ r4.toString(36).slice(2)
244+
+ r5.toString(36).slice(2);
245+
```
246+
247+
Chunk lengths vary (\~10–12 chars). Recovering them is a small search:
248+
249+
* Try plausible 5-way splits (start with 11/11/11/11/11, adjust ±1 where needed).
250+
* Convert base36 chunk → float in `[0,1)` approximation.
251+
* Check if a consistent JS-PRNG progression fits those 5 outputs (don’t over-explain engine specifics; we just verify consistency and move forward).
252+
253+
Outcome: **five consecutive PRNG outputs** recovered.
254+
255+
### 2) Recover PRNG state & predict forward
256+
257+
With several consecutive outputs, you can reconstruct the PRNG’s state and produce **future** `Math.random()` values on demand. End product: a `nextRandom()` you control.
258+
259+
### 3) Hunt a **short** predicted salt (< 51 chars total)
260+
261+
We need a predicted concatenation `S* = s1+s2+s3+s4+s5` whose **total length < 51**. Iterate `nextRandom()` 5-at-a-time until such a concatenation appears.
262+
263+
### 4) Craft a **cached XSS** pinned to that salt
264+
265+
* Create a **cached** file whose **filename = `S*`** and whose **content hash is shorter than the filename length** so the app uses **filename-as-salt** (not the hash).
266+
* Rendering this cached file now fixes the SCF origin to the one derived from `S*` - same origin your future non-cached flag render will use when the PRNG lands on `S*`.
267+
268+
### 5) Keep a handle to the SCF doc
269+
270+
The app hides the SCF iframe behind a `shadowRoot`. From your XSS, **open** or **navigate** to that exact SCF URL and **store a reference** so you can still talk to it later:
271+
272+
```js
273+
const scfUrl = makeScfUrlFromSalt(Sstar);
274+
const w = window.open(scfUrl, "scfS"); // or keep a ref after location.replace
275+
// later: w.document.body.textContent → same-origin DOM read
276+
```
277+
278+
### 6) Burn salts until the next non-cached = `S*`
279+
280+
Each non-cached render consumes **5** PRNG draws. Force the app to render dummy non-cached files to advance in steps of 5 until the predictor says the **next** salt will be `S*`.
281+
282+
### 7) Render the **flag** file
283+
284+
Trigger the app to render the real flag file. Its non-cached salt equals `S*` now → it loads on **the same SCF origin** as your cached XSS payload.
285+
286+
### 8) Same-origin = victory
287+
288+
289+
290+
## End-to-end playbook-recapped
291+
292+
1. Share cached **Navigator**; let it start looping navigations (extra LOADs exist now).
293+
2. Start non-cached **Leaker**; fresh onload attached; first send verifies.
294+
3. **Stall** parent; ACK delayed; iframe swaps to **Leaker**.
295+
4. **Unstall**; a queued **LOAD** fires **before** ACK → parent re-sends salt → **Leaker** leaks.
296+
5. **Split** leaked salt → recover PRNG → **predict** forward.
297+
6. Find **short** predicted salt `<51` → craft **cached XSS** with `filename=S*` and body-hash shorter than filename.
298+
7. **Store handle** to `SCF(S*)` document.
299+
8. **Burn** PRNG outputs (dummy non-cached renders) until next non-cached salt = `S*`.
300+
9. Render **flag** → now on same SCF origin as our XSS → **read & exfil**.
301+
302+
303+
304+
## Environment
305+
306+
I was using Arch which gave me 2 separate headaches:
307+
308+
* **Networking:** bot can’t hit private LAN IPs
309+
* **Dependencies:** Arch + pip + sagemath created conflict hell.
310+
311+
Fix: spin a tiny **Ubuntu 22.04** VM on Goggle Cloud with a **public IP**, open HTTP.
312+
313+
<figure style="text-align:center; margin:1.25rem 0;">
314+
<img src="server.png" style="max-width:100%; height:auto;">
315+
</figure>
316+
317+
### Flag:
318+
319+
<figure style="text-align:center; margin:1.25rem 0;">
320+
<img src="mainflag.png" style="max-width:100%; height:auto;">
321+
</figure>
322+
```

0 commit comments

Comments
 (0)