Malam itu, ruang kerja Devan di sebuah kafe kopi di Yogyakarta terasa lebih gelap dari biasanya. Laptop terbuka di depannya menampilkan halaman Wizer training—platform latihan keamanan siber yang sedang ramai dibicarakan di komunitas developer lokal. Myesha, kakaknya yang bekerja sebagai security engineer, duduk di seberangnya dengan secangkir kopi yang sudah dingin, sementara Jovian, adik bungsu mereka, sedang scroll dokumentasi di HP-nya.
“Jadi kalian serius mau coba CTF challenge ini?” tanya Myesha sambil melirik layar Devan.
“Iya, kakak,” jawab Devan dengan nada yang campur antara percaya diri dan gugup. “Aku pengen belajar gimana cara orang-orang berpikir tentang security. Di kuliah cuma diajar teori doang—ini praktik nyata.”
Jovian, yang baru lulus dari bootcamp coding, membagikan apa yang dia ketahui. “Dari yang aku baca, CTF itu seperti puzzle security. Kamu harus menemukan vulnerability, exploit, dan dapat flag.”
Myesha tersenyum. “Kalian siap? Karena apa yang kalian pelajari malam ini bakal mengubah cara kalian nulis kode selamanya. Nggak akan pernah sama lagi setelah kalian tahu betapa mudahnya hal-hal bisa jebol.”
Devan tidak tahu betapa dalamnya kata-kata kakaknya itu sampai mereka benar-benar mulai.
Bab 1: Authorization Bypass—Ketika Penjaga Pintu Lupa Tugasnya
Challenge pertama yang mereka hadapi adalah Challenge #64, sebuah aplikasi profile viewer yang kelihatannya sederhana. Devan membaca objektifnya: “Dapatkan profil admin beserta secret code mereka untuk menang flag.”
Myesha membuka source code-nya di layar besar. “Oke, baca dulu semuanya dari atas.”
// index.js
const express = require("express");
const path = require("path");
const dotenv = require("dotenv");
dotenv.config();
const app = express();
const PORT = process.env.PORT || 4000;
// In-memory "database"
const users = {
"1": { id: "1", username: "dev_player", role: "employee", managerId: "2" },
"2": { id: "2", username: "team_manager", role: "manager", managerId: null },
"3": { id: "3", username: "hr_manager", role: "manager", managerId: null },
"999": { id: "999", username: "super_admin", role: "admin", managerId: null,
secretFlag: process.env.SUPER_ADMIN_SECRET }
};
// Fake auth: semua orang login sebagai employee "1"
app.use((req, res, next) => {
req.user = users["1"];
next();
});
// Profile endpoint
app.get("/profile", (req, res) => {
const userIdParam = req.query.userId;
if (!userIdParam) {
return res.status(400).json({ error: "Missing userId" });
}
const requestedIds = Array.isArray(userIdParam) ? userIdParam : [userIdParam];
let validatedIds = [];
try {
for (const rawId of requestedIds) {
const id = String(rawId);
// Only digits allowed
if (!/^d+$/.test(id)) { // <-- bug ada di sini
throw new Error("Invalid ID format");
}
const isSelf = id === req.user.id;
const isManager = id === req.user.managerId;
if (!isSelf && !isManager && id !== "999") {
return res.status(403).json({ error: "Forbidden profile " + id });
}
// Extra protection for admin 999: requires secret code
if (id === "999") {
const providedCode = req.query.superAdminCode;
const expectedCode = process.env.superAdminCode;
if (!expectedCode) {
return res.status(403).json({ error: "Super admin code not configured" });
}
if (!providedCode || providedCode !== expectedCode) {
return res.status(403).json({ error: "Invalid super admin code" });
}
}
validatedIds.push(id);
}
} catch (err) {
console.error("[WARN] Validation failed:", err.message);
validatedIds = requestedIds.map((x) => String(x)); // <-- unsafe fallback!
}
const profiles = validatedIds
.map((id) => users[id])
.filter(Boolean)
.map((u) => ({
id: u.id,
username: u.username,
role: u.role,
managerId: u.managerId,
secretFlag: u.secretFlag || null // <-- secret ikut terekspos
}));
res.json({ requestedIds, profiles });
});
app.listen(PORT, () => console.log(`CTF server listening on port ${PORT}`));
“Kedengarannya simpel,” kata Jovian sambil menyisir kodenya baris per baris.
Tapi Myesha justru senyum tipis. “Perhatikan tiga hal: regex di baris validation, isi catch block, dan field yang ikut ter-return di akhir.”
Mereka perlu akses user ID 999 yang merupakan super admin. Tapi ada password gate yang menghalangi. Sekilas, logika seperti ini kelihatan solid banget.
Myesha membungkuk ke depan. “Kalian tahu sesuatu tentang try-catch block dalam konteks security?”
Mereka perhatikan ulang kodenya. Ada validation di dalam try block:
try {
for (const rawId of requestedIds) {
const id = String(rawId);
// Only digits allowed
if (!/^d+$/.test(id)) {
throw new Error("Invalid ID format");
}
// Authorization checks...
if (id === "999") {
// Admin protection...
}
validatedIds.push(id);
}
} catch (err) {
// Unsafe fallback!
validatedIds = requestedIds.map((x) => String(x));
}
Devan tiba-tiba menyadari sesuatu. “Tunggu—ada bug di regex ini. Harusnya ^\d+$ pakai backslash, tapi dia pakai ^d+$ tanpa backslash. Cuma karakter d biasa, bukan digit escape sequence!”
“Terus?” desak Myesha.
“Jadi… kalau kita kirim userId=abc, regex bakal throw error. Lalu catch block bakal jalan, dan itu… itu bakal set validatedIds ke raw request IDs tanpa validation lagi!”
Devan langsung mengetik payload: userId=abc&userId=999
“Jadi yang terjadi adalah: abc gagal validation, lempar exception, catch block ambil alih dan simpan semua IDs asli tanpa jalanin kode proteksi admin lagi!”
Mereka coba di live challenge:
/profile?userId=abc&userId=999
Response-nya datang:
{
"profiles": [{
"id": "999",
"username": "super_admin",
"secretFlag": "If you got this code you must be the super admin ;-)"
}]
}
Jovian membelalakkan mata. “Itu… kelewat mudah.”
“Bukan mudah,” koreksi Myesha dengan serius. “Itu adalah bukti bahwa tidak ada yang benar-benar tak tertembus. Exception handling yang dirancang untuk ‘safety’ malah buka celah. Developer yang nulis ini PIKIR mereka aman karena ada try-catch. Tapi mereka tidak sadar bahwa catch block mereka lebih permissive daripada alur normalnya.”
Devan menulis di buku catatannya:
Pelajaran Pertama: Exception Handler Bisa Jadi Vulnerability
Kalau exception ditangani dengan fallback yang lebih permissive daripada alur normal, itu bisa jadi security hole. Fail open dalam konteks security adalah pola yang berbahaya. Harus selalu fail closed—kalau ada yang salah, tolak akses, jangan lanjutkan.
Bab 2: Remote Code Execution—Ketika Deserialisasi Berubah Jadi Mimpi Buruk
Challenge #62 menampilkan sesuatu yang jauh lebih menyeramkan. Myesha buka source code-nya:
// index.js
import express from 'express';
import bodyParser from 'body-parser';
import { config } from 'dotenv';
import { getFlag } from './flag.js';
config();
const app = express();
app.use(bodyParser.json());
const userDatabase = {
admin: { username: 'admin', role: 'admin' },
user: { username: 'user', role: 'user' },
};
// Deserialize user session data
function deserializeSession(serializedData) {
return eval(`(${serializedData})`); // <-- pintu masuk utama
}
app.post('/login', (req, res) => {
const { sessionData } = req.body;
if (!sessionData) {
return res.status(400).send('Session data is required.');
}
try {
const session = deserializeSession(sessionData);
if (session && userDatabase[session.username]) {
const user = userDatabase[session.username];
// Coba blokir flag dari bocor lewat response
if (String(JSON.stringify(user)).includes(getFlag())) {
return res.status(400).send('Insecure session data detected.');
}
res.send(`Welcome ${user.username}! Your role is: ${user.role}`);
RevertDefaultUsersChanges();
} else {
res.status(401).send('Invalid session data.');
}
} catch (err) {
res.status(500).send('An error occurred while processing the session data.');
}
});
app.post('/submit_flag', (req, res) => {
const { flag } = req.body;
if (flag === getFlag()) {
res.send(`Flag ${flag} submitted successfully!`);
} else {
res.status(400).send('Invalid flag.');
}
});
const RevertDefaultUsersChanges = () => {
userDatabase.admin = { username: 'admin', role: 'admin' };
userDatabase.user = { username: 'user', role: 'user' };
};
app.listen(process.env.PORT);
Jovian langsung tahu begitu matanya sampai di deserializeSession. “Ini… ini bukan sekadar vulnerability biasa. Ini adalah vulnerability yang ditulis pakai spidol merah gede-gede.”
“Coba lihat lebih dalam,” kata Myesha. “Vulnerability-nya bukan cuma eval. Yang menarik adalah security check di tengah sana. Mereka sudah sadar ada risiko bocor—tapi cara mereka cegahnya punya lubang sendiri.”
Mereka baca anti-leak protection-nya:
if (String(JSON.stringify(user)).includes(getFlag())) {
return res.status(400).send('Insecure session data detected.');
}
res.send(`Welcome ${user.username}! Your role is: ${user.role}`);
“Developer di sini coba mencegah flag bocor dengan periksa apakah stringified user object berisi flag,” jelas Myesha. “Tapi ada kelemahan halus. String interpolation lewat template literal menggunakan jalur coercion yang berbeda dari JSON.stringify.”
Devan tiba-tiba melihatnya. “Jadi… kalau kita buat object dengan custom method toString()…”
userDatabase.admin.username = {
toString: () => getFlag(), // Mengembalikan flag saat dipakai di template literal
toJSON: () => "admin" // Mengembalikan "admin" saat JSON.stringify dipanggil
}
“Saat JSON.stringify() dipanggil untuk cek, dia panggil toJSON() yang kembalikan string aman. Tapi saat username di-interpolate ke template literal Welcome ${user.username}!, dia panggil toString() yang kembalikan flag penuh!”
Mereka coba payload-nya:
{
"sessionData": "(()=>{userDatabase.admin.username={toString:()=>getFlag(),toJSON:()=>\"admin\"};return {username:\"admin\"};})()"
}
Hasilnya:
Welcome Wizer{You_F0und_Th3_S3cr3t_Fl4gxxx7884}! Your role is: admin
Jovian benar-benar terpukul. “Itu… itu exploit yang sangat elegan sekaligus sangat menakutkan. Pakai pemahaman mendalam tentang JavaScript object coercion untuk bypass security check.”
“Tepat,” kata Myesha dengan nada serius. “Inilah kenapa eval() benar-benar dilarang dalam kode production. Tidak ada mitigasi yang cukup untuk menambalnya. Dan ini juga menunjukkan kenapa security check berbasis string itu rapuh. Setiap asumsi bisa saja salah.”
Devan mendokumentasikan:
Pelajaran Kedua: Coercion Attack pada Security Check
Object method seperti
toString(),toJSON(), danvalueOf()memungkinkan perilaku coercion yang custom. Security check yang bergantung pada satu jalur coercion bisa disiasati dengan menggunakan jalur coercion yang berbeda. Selalu asumsikan bahwa attacker bisa memanipulasi perilaku object.
Bab 3: Session Token Bocor—Ketika Pesan Error Berbisik Rahasia
Challenge #55 menampilkan sesuatu yang lebih subtle. Myesha paste source code-nya:
// index.js
import dotenv from "dotenv";
dotenv.config();
import express from "express";
import crypto from "crypto";
const app = express();
app.use(express.json());
// XOR key diturunkan dari string yang sudah diketahui
const XOR_KEY = crypto.createHash("md5").update("svc-telemetry").digest("hex").slice(0, 12);
function xorEncode(str, key) {
return Buffer.from(
[...str].map((char, i) =>
String.fromCharCode(char.charCodeAt(0) ^ key.charCodeAt(i % key.length))
).join("")
).toString("base64");
}
// Session token svc dibuat dari timestamp saat server start
const coreCredString = `svc:${Math.floor(Date.now() / 1000)}`;
const staticSessionToken = Buffer.from(coreCredString).toString("base64");
const sessionStore = {
"alice": crypto.randomUUID(),
"bob": crypto.randomUUID(),
"svc": staticSessionToken, // <-- ini yang kita incar
"svc_legacy": Buffer.from(process.env.SVC_LEGACY_SESSION).toString("base64"),
"sysadmin": Buffer.from(process.env.SYSADMIN_SESSION).toString("base64"),
"qa_bot": crypto.randomUUID(),
"proxy": crypto.randomUUID()
};
const users = {
"svc": { role: "elevated", info: "Internal reports available." },
"sysadmin": { role: "admin", info: "Admin console temporarily locked." },
// ... user lainnya
};
// Trace logger — dipanggil saat user TIDAK ditemukan
function traceLookup(username) {
const entry = { query: username, timestamp: Date.now() };
// Kalau username mengandung "svc", bocorkan coreCredString yang ter-encode
if (username.includes("svc") || username.includes("%00")) {
entry.context = "fallback-user-lookup";
entry.note = `tracer:${xorEncode(coreCredString, XOR_KEY)}`; // <-- bocor di sini
}
return entry;
}
app.get("/api/profile", (req, res) => {
const username = req.query.user;
const user = users[username];
if (!user) {
// User tidak ada → kirim metadata debug yang berisi trace
return res.status(404).json({
error: "User not found",
metadata: traceLookup(username) // <-- leak terjadi lewat sini
});
}
res.json({ status: "ok", user: username, info: user.info });
});
// Endpoint yang perlu kita akses
app.post("/api/internal-report", (req, res) => {
const sessionId = req.body.xSessionId;
if (sessionId && sessionId === sessionStore.svc) {
return res.json({
status: "success",
report: {
metrics: { notes: { internal_summary: process.env.LAST_SUMMARY } }
}
});
}
return res.status(403).json({ error: "Access denied." });
});
app.listen(process.env.PORT);
“Sekilas seperti aplikasi biasa,” kata Devan, matanya menyapu kode dari atas ke bawah. “Tapi ada yang aneh…”
Myesha menunjuk kode di dalam fungsi traceLookup():
if (username.includes("svc") || username.includes("%00")) {
entry.context = "fallback-user-lookup";
entry.note = `tracer:${xorEncode(coreCredString, XOR_KEY)}`;
}
“Seseorang nulis obfuscation logic untuk ‘lindungi’ data sensitif. Tapi lihat—di-encode, bukan di-encrypt,” jelas Myesha. “Dan key-nya diturunkan dari string svc-telemetry yang static dan sudah ter-hardcode di source code. Encoding itu reversible kalau kunci-nya bisa diturunkan ulang.”
“Key ini… deterministic,” kata Devan. “Siapa pun bisa hitung ulang. Itu bukan rahasia.”
Mereka kirim request ke user yang tidak ada tapi namanya mengandung “svc”:
GET /api/profile?user=svcx
Error response datang kembali dengan metadata berisi nilai tracer yang di-XOR-encode. Mereka hitung hash MD5 dari “svc-telemetry”, ambil 12 karakter hex pertama, dan decode nilai XOR-nya. Hasilnya:
svc:1778253036
Itu adalah session token yang mereka butuhkan untuk akses /api/internal-report.
Jovian menggeleng. “Jadi… vulnerability chain-nya adalah: error message bocor → credential yang di-obfuscate tapi bisa di-reverse → credential itu bisa dipakai untuk akses nyata.”
“Tepat,” kata Myesha. “Ini adalah contoh sempurna dari kegagalan defense-in-depth. SETIAP LAPISAN bisa ditembus:
- Error message tidak boleh bocorkan data sensitif
- Obfuscation bukan pengganti encryption
- Key tidak boleh bisa diturunkan dari string yang sudah diketahui”
Devan mencatat:
Pelajaran Ketiga: Error Message adalah Attack Surface
Informasi debug dan error trace yang ditujukan untuk developer bisa jadi tambang emas buat attacker. Jangan pernah expose internal state, credential hint, atau encoded secret dalam error response. Bahkan data yang “di-obfuscate” pun bisa di-reverse-engineer kalau key-nya bisa diturunkan.
Bab 4: DOM Clobbering dan CSP Bypass—Ketika HTML Attribute Jadi Senjata
Challenge #54 giliran Jovian yang baca source code-nya keras-keras:
// index.js
import express from 'express';
import { v4 as uuidv4 } from 'uuid';
const app = express();
const nonce = uuidv4(); // di-generate SEKALI saat server start, tidak berubah per request
app.get('/comment', (req, res) => {
const rawComment = req.query.comment || 'No comment provided';
res.setHeader(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${nonce}'; object-src 'none';`
);
// Escape output di server-side
const escapedComment = rawComment
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
res.send(`
<!DOCTYPE html>
<html>
<body>
<h1>User Comment</h1>
<!-- Output yang sudah di-escape — aman -->
<div id="comment">${escapedComment}</div>
<div id="debug-zone"></div>
<script nonce="${nonce}">
window.addEventListener('DOMContentLoaded', () => {
// Baca raw comment langsung dari URL — TIDAK di-escape
const commentParam = new URLSearchParams(window.location.search).get('comment');
if (commentParam) {
const decoded = decodeURIComponent(commentParam);
// Hanya inject kalau ada tag <form> ... </form>
if (decoded.includes('<form') && decoded.includes('</form>')) {
const debugZone = document.getElementById('debug-zone');
debugZone.innerHTML = decoded; // <-- unsafe sink!
// Eksekusi script dari debug-zone kalau isDebugMode aktif
if (window.isDebugMode) {
const scripts = debugZone.querySelectorAll('script');
scripts.forEach(script => {
if (script.nonce === '${nonce}') {
const newScript = document.createElement('script');
newScript.textContent = script.textContent;
newScript.nonce = script.nonce;
document.body.appendChild(newScript);
}
});
}
}
}
});
</script>
</body>
</html>
`);
});
app.listen(3000);
Hening sebentar. Jovian yang pertama angkat bicara. “Jadi server-side sudah di-escape dengan benar. Tapi client-side ambil ulang parameter dari URL dan inject ke innerHTML tanpa escape sama sekali?”
“Tepat,” kata Myesha. “Dan sekarang lihat dua kondisi yang melindungi eksekusi script-nya: window.isDebugMode harus true, dan nonce harus cocok.”
“Jadi kalau window.isDebugMode bernilai true, dia execute script dengan nonce yang cocok.”
Devan menangkap sesuatu. “Nonce itu… bukan rahasia. Itu ada di HTML source. Dan…”
“Dan window.isDebugMode bisa di-set dengan inject element form yang ID-nya cocok,” lengkap Myesha dengan senyum puas.
<form id=isDebugMode></form>
“Ini yang disebut DOM clobbering. Ketika kamu buat HTML element dengan atribut id, itu otomatis ter-set sebagai property di object window.”
Payload mereka:
<form id=isDebugMode></form>
<form>
<script nonce="THE_NONCE_FROM_PAGE">alert("Wizer")</script>
</form>
Dan script-nya jalan dengan mulus, bypass CSP sekaligus.
“Jadi,” ringkas Myesha, “CSP kasih lapisan, tapi bukan perlindungan penuh. DOM clobbering bypass client-side guard. Nonce yang terlihat di HTML source menghapus nilai rahasianya. Security theater nyata.”
Devan mendokumentasikan:
Pelajaran Keempat: DOM Clobbering dan Batas CSP
HTML element dengan atribut
idotomatis jadi window property lewat DOM clobbering. Security check yang bergantung pada global state bisa dimanipulasi lewat HTML injection. Nonce CSP juga bukan rahasia kalau terlihat di HTML source—itu hanya cegah injeksi script eksternal, bukan inline injection yang dikontrol attacker.
Bab 5: SQL Injection Lewat WAF Bypass—Ketika Blacklist Bertemu Kenyataan
Challenge #53. Devan buka source code-nya, baca sebentar, lalu langsung mengernyit:
// index.js
import express from 'express';
import bodyParser from 'body-parser';
import { seedDatabase } from './seed.mjs';
import { config } from 'dotenv';
config();
const app = express();
app.use(bodyParser.json());
// In-app WAF — mencoba blokir SQL injection
function isMaliciousInput(input) {
const wafRegex = new RegExp(
[
'(\\b(SELECT|UNION|INSERT|DELETE|UPDATE|DROP|SCRIPT|ALERT|ONERROR|ONLOAD)\\b',
'|["();<>\\s]',
'|--',
'|\\b(AND|OR)\\b(?!/\\*\\*\\/))'
].join(''),
'i'
);
return wafRegex.test(input);
}
app.post('/login', (req, res) => {
const userInput = req.body.username;
if (typeof userInput !== 'string') {
return res.status(400).json({ error: "Username must be a string" });
}
// WAF check
if (isMaliciousInput(userInput)) {
return res.status(403).json({ message: "❌ Blocked suspicious input by WAF" });
}
seedDatabase((db) => {
// Query langsung pakai string interpolation — tidak pakai parameterized query
const query = `SELECT * FROM users WHERE username = '${userInput}'`;
db.all(query, [], (err, rows) => {
if (err) return res.status(500).json({ error: "Database error" });
if (rows.length > 0) {
res.json({ users: rows });
} else {
res.json({ message: "No user found" });
}
db.close();
});
});
});
app.listen(process.env.PORT);
“WAF yang blokir keyword dan karakter khusus,” amati Jovian.
Tapi query yang digunakan di bawahnya adalah:
const query = `SELECT * FROM users WHERE username = '${userInput}'`;
“String interpolation. Tidak ada parameterized query,” geleng Devan.
“WAF di sini blokir keyword SQL yang jelas, tapi SQL punya banyak cara untuk ekspresikan logika yang sama,” jelas Myesha. “Single quote masih diizinkan. Syntax komentar SQL tertentu juga masih lolos.”
Mereka sadar ada banyak hal yang tidak diblokir WAF:
- Operator
LIKE(tidak diblokir) - String literal pakai single quote (diizinkan)
- SQL comment dengan
/*(tidak diblokir)
Payload:
{
"username": "'LIKE'%'/*"
}
Query akhirnya jadi:
SELECT * FROM users WHERE username = ''LIKE'%'/*'
“String kosong LIKE pattern % mengevaluasi true,” jelas Myesha. “Syntax komentar /* makan tanda kutip di belakangnya. Jadi query kembalikan semua baris.”
Response-nya mencakup semua user dengan bcrypt hash mereka.
“Perlindungan berbasis WAF itu cacat secara fundamental,” simpul Myesha. “Pendekatan blacklist secara inheren tidak lengkap. SQL adalah turing-complete—ada cara tak terbatas untuk ekspresikan statement yang sama. Satu-satunya pertahanan yang benar-benar bekerja adalah parameterized query.”
Devan mencatat:
Pelajaran Kelima: Blacklist WAF Tidak Cukup
Security lewat blacklist adalah perlombaan senjata yang pembela tidak bisa menang. Attacker hanya butuh satu celah; pembela harus blokir semua kemungkinan. Hanya whitelist dan parameterized query yang kasih perlindungan nyata. WAF bisa membantu, tapi bukan pengganti secure coding practice.
Bab 6: CSP Nonce yang Tidak Lagi Aman
Challenge #48. Ini yang terakhir. Myesha buka source code-nya yang terlihat paling pendek dan paling bersih di antara semua challenge:
// index.js
import express, { urlencoded } from "express";
import { randomBytes } from "crypto";
const app = express();
app.use(urlencoded({ extended: true }));
app.get("/", (req, res) => {
// Nonce di-generate fresh setiap request — terlihat aman
const nonce = randomBytes(16).toString("base64");
const userInput = req.query.input || "";
res.setHeader(
"Content-Security-Policy",
`default-src 'none'; script-src 'nonce-${nonce}'`
);
// userInput langsung di-interpolate ke HTML — tanpa escaping sama sekali
res.send(`
<!DOCTYPE html>
<html lang="en">
<head><title>XSS Mitigation Demo</title></head>
<body>
<h1>XSS Mitigation Demo</h1>
<form action="/" method="GET">
<input type="text" name="input" placeholder="Enter text" />
<button type="submit">Submit</button>
</form>
<p>Echo: ${userInput}</p>
<script nonce="${nonce}" src="/script.js"></script>
</body>
</html>
`);
});
app.get("/script.js", (req, res) => {
res.setHeader("Content-Type", "application/javascript");
res.send(`console.log("Secure script running");`);
});
app.listen(3000);
“Kodenya kelihatan paling rapi,” kata Jovian. “Nonce di-generate fresh tiap request, CSP-nya ketat. Di mana masalahnya?”
Myesha tersenyum. “Justru di bagian yang paling simpel. Lihat baris Echo: ${userInput} — tidak ada escaping sama sekali.”
“Jadi kalau attacker inject HTML, dia bisa keluar dari tag paragraph,” kata Jovian.
Myesha mengangguk. “Tapi script injection harusnya gagal karena CSP kan?”
“Kecuali…” Devan menyadari. “Kecuali attacker bisa dapat nonce dari halaman dan masukkan di payload.”
“Tepat. Nonce bukan rahasia. Terlihat jelas di HTML source. Jadi attacker ambil nonce dari halaman yang di-generate, inject script dengan nonce yang sama, dan CSP izinkan karena nonce cocok.”
</p><script nonce="THE_NONCE_FROM_PAGE">alert("Wizer")</script><p>
“Desain CSP asumsikan nonce adalah rahasia yang hanya server yang tahu. Tapi kalau attacker bisa pengaruhi HTML dan lihat nonce yang di-generate—yang keduanya benar di skenario ini—nonce bukan lagi perlindungan yang efektif,” simpul Myesha.
Devan mendokumentasikan:
Pelajaran Keenam: Nonce Saja Tidak Cukup
Nonce CSP adalah tool yang kuat, tapi hanya efektif kalau:
- Nonce tidak terekspos di HTML yang di-generate (tapi ini sering diperlukan)
- Attacker tidak bisa kontrol HTML injection point
- Output encoding adalah lapisan terpisah
Nonce cegah script eksternal, tapi tidak cegah inline injection kalau attacker bisa hasilkan konten dengan nonce yang tepat.
Bab 7: Malam Terobosan
Sudah jam 2 pagi. Ketiga saudara duduk di kafe yang masih buka, dikelilingi cangkir kopi kosong dan kertas penuh catatan. Devan benar-benar exhausted—dia sudah selesaikan semua challenge, dokumentasikan masing-masing, dan mulai melihat pola yang menghubungkan semuanya.
“Kakak,” tanya Devan dengan suara pelan. “Setiap vulnerability ini… seperti developer yang sudah berusaha keras tapi tetap terlewat sesuatu. Bukan karena mereka tidak peduli.”
Myesha mengangguk pelan. “Tepat. Dan itu yang justru lebih menakutkan. Setiap developer yang buat challenge ini mungkin sudah habiskan berjam-jam mikirin security. Tapi mereka tetap miss sesuatu. Dan itulah intinya—security tidak pernah ‘selesai’. Itu proses yang terus berjalan.”
“Jadi…” Jovian berpikir keras. “Kita tidak bisa pernah benar-benar aman?”
“Kita bisa cukup aman untuk konteks yang spesifik,” koreksi Myesha. “Tapi ‘perfect security’ itu tidak ada. Yang bisa kita lakukan adalah:
- Pahami pola umum dan jebakan yang sering terjadi
- Terapkan defense-in-depth—beberapa lapisan sekaligus
- Fail closed, bukan fail open
- Terus belajar dan tetap paranoid”
Devan menulis di buku catatannya:
Security Principles dari Lima CTF Challenge:
1. Authorization: Fail closed. Exception handler bukan fitur security.
2. Code Execution: Jangan pernah eval(). Obfuscation bukan pengganti encryption.
3. Information Disclosure: Minimalisir error message. Perlakukan error sebagai attack surface.
4. Client-Side Security: DOM clobbering, CSP bypass. Jangan pernah andalkan gate di sisi client.
5. SQL Injection: Parameterized query. Whitelist, bukan blacklist.
6. Nonce/Secret: Data yang terlihat bukan rahasia. Nonce membantu, tapi tidak cukup sendirian.
Meta-pattern: Security by obscurity bukan security. Defense-in-depth adalah keharusan.
Bab 8: Ketika Pelajaran Jadi Nyata
Tiga minggu kemudian, Devan wawancara untuk internship di sebuah security startup. Si pewawancara bertanya, “Ceritakan vulnerability paling menarik yang pernah kamu temukan atau pelajari.”
Devan cerita tentang lima challenge itu—bukan cuma teknisnya, tapi bagaimana masing-masing mengajarkan sesuatu yang fundamental tentang cara manusia berpikir dalam konteks security. Pewawancara terkesan bukan karena Devan bisa selesaikan technical puzzle, tapi karena dia benar-benar memahami mindset di balik setiap kelemahan.
“Itulah yang membuat security engineer yang baik,” kata pewawancara. “Bukan cuma bisa temukan bug, tapi paham kenapa bug itu ada dan bagaimana mencegahnya ke depan.”
Devan dapat internship-nya. Yang lebih penting, dia bawa pulang sesuatu dari sana—perspektif baru yang tidak akan hilang. Setiap kali dia nulis kode sekarang, ada suara kecil yang tanya: “Bagaimana ini bisa di-exploit? Asumsi apa yang bisa salah?”
Myesha, ketika Devan cerita soal hasilnya, hanya tersenyum. “Sekarang kamu paham. Security itu mindset, bukan fitur.”
Jovian, yang masih berjuang dengan fundamentals, bertanya, “Segitunya? Harus selalu paranoid?”
“Iya,” jawab Devan. “Tapi seiring waktu, paranoia itu jadi kebiasaan. Dan kebiasaan itu bikin kode kamu lebih baik. Bukan cuma lebih aman, tapi lebih thoughtful secara keseluruhan.”
Epilog: Pelajaran yang Tidak Ikut Pergi
Enam bulan kemudian, dalam proyek untuk klien, Devan melihat kode yang pakai try-catch untuk “handle error dengan elegan.” Setelah dia gali lebih dalam, dia sadar bahwa catch block itu sebenarnya lebih permissive dari jalur normalnya—persis pola yang sama seperti Challenge #64.
Dia flag untuk code review. Rekan kerjanya—developer senior—awalnya defensif. “Ini sudah ditest, jalan baik-baik saja.” Tapi Devan tunjukkan exploit path-nya, dan tiba-tiba si developer itu terdiam.
“Wow. Saya sudah nulis kode 10 tahun dan tidak pernah sadar ini bisa jadi masalah,” akuinya.
“Nggak apa-apa,” kata Devan santai. “Security memang jarang diajarkan di kampus. Harus belajar dari pengalaman atau latihan aktif seperti CTF.”
Cerita itu menyebar di tim. Tiba-tiba, orang mulai anggap security lebih serius. Dan semuanya bermula dari lima challenge yang Devan selesaikan pada malam yang dingin di kafe di Yogyakarta.
Myesha, saat dengar soal dampaknya, bilang ke Devan waktu makan malam: “Ini keajaiban dari belajar. Kamu tidak cuma belajar untuk diri sendiri. Kamu belajar, dan tanpa disadari kamu menularkan itu ke orang-orang di sekitarmu.”
Devan merenungkan perjalanannya—dari gugup di hadapan challenge pertama, sampai sekarang jadi orang yang mentor rekan kerja. Dari yang pikir security adalah urusan orang lain, sampai paham bahwa itu tanggung jawab setiap orang yang nulis kode.
“Hal yang indah tentang pembelajaran adalah tidak ada siapapun yang bisa mengambilnya dari kamu.” — B.B. King
Tapi buat Devan, hal yang paling indah bukan cuma belajarnya. Itu adalah malam-malam yang dihabiskan bareng Myesha dan Jovian, berbagi momen “aha!” di kafe, dan perlahan-lahan berubah dari programmer yang penasaran menjadi engineer yang sadar akan keamanan.
Dan semuanya bermula dari lima challenge yang kelihatannya sederhana—masing-masing mengajarkan kebenaran yang fundamental tentang security, yang tidak akan terlihat sampai kamu benar-benar mau duduk, mikir, dan mencoba.
Lampiran: Quick Reference Setiap Challenge
Challenge #64: Authorization Bypass via Exception Fallback
Pattern: Try-catch yang tangani exception dengan fallback yang permissive Pelajaran: Fail open dalam exception handling adalah vulnerability kritis Fix: Fail closed—kembalikan error, jangan lanjutkan dengan fallback
Challenge #62: RCE via eval() dan Coercion Bypass
Pattern: eval() deserialize untrusted data + security check yang lemah
Pelajaran: Object coercion method bisa bypass security check berbasis string
Fix: Pakai JSON.parse(), validasi schema, jangan pernah eval()
Challenge #55: Session Token Bocor via Error Message
Pattern: Error message bocorkan credential yang di-obfuscate tapi bisa di-reverse Pelajaran: Encoding bukan encryption; error handling adalah attack surface Fix: Minimalisir error message; pakai encryption yang proper kalau harus sembunyikan data
Challenge #54: DOM Clobbering + CSP Bypass
Pattern: HTML injection + DOM property pollution + client-side security gate Pelajaran: Client-side security hanya defense-in-depth; jangan andalkan sendirian Fix: Output encoding di server-side, hindari client-side gate, gabungkan CSP dengan input validation
Challenge #48: CSP Nonce Reuse
Pattern: Nonce terlihat di HTML + HTML injection = CSP bypass Pelajaran: Data yang terlihat bukan rahasia; nonce membantu tapi tidak cukup Fix: Output encoding + CSP + input validation; lapisan keamanan di server-side
Challenge #53: SQL Injection via WAF Bypass
Pattern: Blacklist WAF + SQL string interpolation Pelajaran: Pendekatan blacklist secara inheren tidak lengkap; SQL punya variasi tak terbatas Fix: Parameterized query, pendekatan whitelist, hindari string interpolation
Penutup
Security bukan tujuan akhir. Itu perjalanan yang tidak pernah benar-benar selesai. Lima challenge yang Devan selesaikan mewakili sebagian kecil dari lanskap kerentanan yang ada. Tapi itu bagian yang krusial—masing-masing mengajarkan pola yang berulang di aplikasi-aplikasi nyata tak terhitung jumlahnya.
Dan yang paling kuat dari belajar adalah itu tidak hanya mengubah Devan. Ia menyebar. Dalam code review, dalam percakapan, dalam keputusan arsitektur. Security culture tumbuh dari individu-individu yang mau mengambil tanggung jawab untuk benar-benar memahami.
Itulah kemenangan nyata dari CTF challenge—bukan flagnya, tapi perubahan cara pikir yang melekat dan bertahan lama.