← All advisories
CVE-2026-55231High · CVSS 7.2· CWE-22

Path Traversal in Vvveb Backup Tools Allows Arbitrary File Read & Delete

Vendor
givanz
Product
Vvveb
Status
Published · Jun 17 2026
Researchers
eo420
CVSS Vector
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H
Published
Jun 17 2026

Summary

Vvveb routes nearly every file path through one helper, sanitizeFileName(), which strips parent-directory sequences with a single-pass regex. That pass runs once and removes "illegal" characters in a same scan, so a forbidden character placed between two dots, for example .!., gets deleted and surrounding dots rejoin into .. afterward. Filter that's meant to kill traversal recreates it. Payload .!././.!././config/db.php becomes ../../config/db.php.

Several admin controllers trust sanitizeFileName() as a sole defense with no realpath() containment. An authenticated admin-panel user who holds backup access (default role site_admin or higher) can read and delete arbitrary files as a web-server user. Reading config/db.php discloses database credentials, reading climbs out to host files such as /etc/passwd, and deleting config/db.php pushes a site back into install mode for a full takeover. POST values reach a sink byte for byte, so a single authenticated request triggers it.

Technical Details: Root Cause

Single-pass sanitizer rejoins parent-directory sequences

sanitizeFileName() at system/functions.php:1204 tries to remove .. and forbidden characters in one preg_replace call:

// system/functions.php:1204-1219
function sanitizeFileName($file, $normalizePath = true) {
	if (! $file) { return $file; }
	$file = str_replace(chr(0), '', $file);
	$file = preg_replace('@\?.*$|\.{2,}|[^\w\-\./\\\]@' , '', $file);
	if ($normalizePath) {
		$file = str_replace(['\\', '/'], DS, $file);
	}
	return $file;
}

Alternative .{2,} removes a run of two or more consecutive dots, and alternative [^\w-./\] removes any character outside word characters, hyphen, dot, slash, and backslash. preg_replace matches against an original string and doesn't rescan its own output. So in .!., two dots aren't consecutive at scan time and .{2,} never sees them, filter removes only a middle !, and a result becomes ... Any character outside an allow-list works as a separator, for example !, |, ~, %, or a space.

Verified on PHP 8.5 with a real shipped function:

.!././.!././config/db.php                becomes  .././.././config/db.php
".!././" x15 + etc/passwd                becomes  ../ (x15) + etc/passwd
.|./.|./etc/passwd                       becomes  ../../etc/passwd

Sinks trust the sanitizer with no realpath containment

admin/controller/tools/backup.php builds a filesystem path from a sanitized value and never checks where it resolves. DIR_BACKUP equals storage/backup/ under an install root (system/core/startup.php:54).

// backup.php:352-367  download()  -- arbitrary file read
$filename = sanitizeFileName($this->request->post['file'] ?? '');
if ($filename) {
	$file = DIR_BACKUP . $filename;
	if (file_exists($file)) {
		$fp = fopen($file, 'rb');
		header('Content-Disposition: attachment; filename="' . $filename . '"');
		fpassthru($fp);            // raw bytes returned to a caller
		exit(0);
	}
}
// backup.php:305-320  delete()  -- arbitrary file delete
$file = sanitizeFileName($this->request->post['file'] ?? '');
if ($file) {
	$file = DIR_BACKUP . $file;
	if (file_exists($file)) { unlink($file); }
}

No prefix or realpath() check confines a resolved path to DIR_BACKUP, so .././.././config/db.php reads or deletes a file two levels above. Same root cause reaches media delete and rename (system/traits/media.php:215, 253), editor delete and save (admin/controller/editor/editor.php:567, 922), while restore() and nextRestore()/nextBackup() don't even call sanitizeFileName().

By contrast, code editor wraps a path with a containment check and resists this bypass, since realpath() collapses injected .. before a prefix test:

// admin/controller/editor/code.php:110
if (strncmp(realpath($file), $dir, strlen($dir)) !== 0) {
	return false;
}

Input reaches a sink unmodified, reachable by a restricted role

POST data passes through a no-op filter. system/core/request.php:58 calls filter(post, false), and a false flag makes a string branch return input untouched (an htmlspecialchars line stays commented at request.php:84), so a payload survives intact.

Authorization passes for a non-top role. permission() (system/traits/permission.php:33) builds string tools/backup/download and checks it with Role::has() (system/user/role.php:42), which turns allow rule tools/* into regex tools/.+?. Default role site_admin carries allow tools/*, so a site administrator, intended to manage one site, reads global configuration and host files well outside that remit.