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/passwdSinks 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.
