Remote command execution through file upload in contao/core-bundle
Description
Contao is an Open Source CMS. In affected versions a back end user with access to the file manager can upload malicious files and execute them on the server. Users are advised to update to Contao 4.13.49, 5.3.15 or 5.4.3. Users unable to update are advised to configure their web server so it does not execute PHP files and other scripts in the Contao file upload directory.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Contao CMS authenticated remote code execution via file upload due to incomplete extension validation.
Vulnerability
Overview
Contao CMS suffers from an authenticated remote code execution vulnerability in its file manager component. The root cause is an incomplete file extension validation mechanism. The original code used strtolower(substr($file['name'], strrpos($file['name'], '.') + 1)) which fails to properly handle files with multiple extensions or special characters, allowing a malicious file to bypass the intended extension checks. This is fixed by using Path::getExtension($file['name'], true) from Symfony Filesystem, which correctly extracts the real extension [2][4].
Exploitation
An attacker with back-end user credentials and file manager access can upload a malicious file, for example a PHP file crafted to bypass the extension filter. The attacker does not need any special privileges beyond the standard file manager role. The upload directory is configured on the web server, and if the server executes PHP files in that directory, the attacker's code is executed on the server [1].
Impact
Successful exploitation leads to arbitrary code execution on the web server, potentially allowing the attacker to fully compromise the application and its data, including reading, modifying, or deleting files and database records, and possibly pivoting to other systems.
Mitigation
The vulnerability affects Contao versions prior to 4.13.49, 5.3.15, and 5.4.3. Users are strongly advised to update to these patched versions. For those unable to update, a workaround is to configure the web server to not execute PHP or other scripts in the Contao file upload directory [1].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
contao/core-bundlePackagist | >= 4.0.0, < 4.13.49 | 4.13.49 |
contao/core-bundlePackagist | >= 5.0.0, < 5.3.15 | 5.3.15 |
contao/core-bundlePackagist | >= 5.4.0, < 5.4.3 | 5.4.3 |
Affected products
2- contao/contaov5Range: >=4.0.0, < 4.13.49
Patches
34 files changed · +16 −4
core-bundle/src/Resources/contao/classes/FileUpload.php+2 −1 modified@@ -10,6 +10,7 @@ namespace Contao; +use Symfony\Component\Filesystem\Path; use Symfony\Component\HttpFoundation\File\UploadedFile; /** @@ -144,7 +145,7 @@ public function uploadTo($strTarget) } else { - $strExtension = strtolower(substr($file['name'], strrpos($file['name'], '.') + 1)); + $strExtension = Path::getExtension($file['name'], true); // Image is too big if (\in_array($strExtension, array('gif', 'jpg', 'jpeg', 'png', 'webp', 'avif', 'heic', 'jxl')) && System::getContainer()->getParameter('contao.image.reject_large_uploads'))
core-bundle/src/Resources/contao/dca/tl_files.php+2 −1 modified@@ -25,6 +25,7 @@ use Contao\StringUtil; use Contao\System; use Contao\Validator; +use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; $GLOBALS['TL_DCA']['tl_files'] = array @@ -496,7 +497,7 @@ public function adjustPalettes(DataContainer $dc) } // Only show the important part fields for images - if ($blnIsFolder || !in_array(strtolower(substr($dc->id, strrpos($dc->id, '.') + 1)), System::getContainer()->getParameter('contao.image.valid_extensions'))) + if ($blnIsFolder || !in_array(Path::getExtension($dc->id, true), System::getContainer()->getParameter('contao.image.valid_extensions'))) { PaletteManipulator::create() ->removeField(array('importantPartX', 'importantPartY', 'importantPartWidth', 'importantPartHeight'))
core-bundle/src/Resources/contao/drivers/DC_Folder.php+9 −1 modified@@ -890,7 +890,7 @@ public function copy($source=null, $destination=null) { $count = 1; $new = $destination; - $ext = strtolower(substr($destination, strrpos($destination, '.') + 1)); + $ext = Path::getExtension($destination, true); // Add a suffix if the file exists while (file_exists($this->strRootDir . '/' . $new) && $count < 12) @@ -2304,6 +2304,14 @@ protected function save($varValue) } } + // Check the full path to see if the file extension has changed, because if + // $this->strExtension is empty, a new extension could otherwise be added to + // $varValue and change the file type! + if (Path::getExtension($varValue . $this->strExtension) !== Path::getExtension($this->varValue . $this->strExtension)) + { + throw new \Exception($GLOBALS['TL_LANG']['ERR']['invalidName']); + } + // The target exists if (strcasecmp($this->strPath . '/' . $this->varValue . $this->strExtension, $this->strPath . '/' . $varValue . $this->strExtension) !== 0 && file_exists($this->strRootDir . '/' . $this->strPath . '/' . $varValue . $this->strExtension)) {
core-bundle/src/Resources/contao/library/Contao/GdImage.php+3 −1 modified@@ -10,6 +10,8 @@ namespace Contao; +use Symfony\Component\Filesystem\Path; + trigger_deprecation('contao/core-bundle', '4.3', 'Using the "Contao\GdImage" class has been deprecated and will no longer work in Contao 5.0. Use the Imagine library instead.'); /** @@ -157,7 +159,7 @@ public function setResource($gdResource) public function saveToFile($path) { $arrGdInfo = gd_info(); - $extension = strtolower(substr($path, strrpos($path, '.') + 1)); + $extension = Path::getExtension($path, true); // Fallback to PNG if GIF ist not supported if ($extension == 'gif' && !$arrGdInfo['GIF Create Support'])
3 files changed · +13 −3
core-bundle/contao/classes/FileUpload.php+2 −1 modified@@ -10,6 +10,7 @@ namespace Contao; +use Symfony\Component\Filesystem\Path; use Symfony\Component\HttpFoundation\File\UploadedFile; /** @@ -144,7 +145,7 @@ public function uploadTo($strTarget) } else { - $strExtension = strtolower(substr($file['name'], strrpos($file['name'], '.') + 1)); + $strExtension = Path::getExtension($file['name'], true); // Image is too big if (\in_array($strExtension, array('gif', 'jpg', 'jpeg', 'png', 'webp', 'avif', 'heic', 'jxl')) && Config::get('imageWidth') && Config::get('imageHeight') && System::getContainer()->getParameter('contao.image.reject_large_uploads'))
core-bundle/contao/dca/tl_files.php+2 −1 modified@@ -27,6 +27,7 @@ use Contao\StringUtil; use Contao\System; use Contao\Validator; +use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; $GLOBALS['TL_DCA']['tl_files'] = array @@ -480,7 +481,7 @@ public function adjustPalettes(string $strPalette, DataContainer $dc) } // Only show the important part fields for images - if ($blnIsFolder || !in_array(strtolower(substr($dc->id, strrpos($dc->id, '.') + 1)), System::getContainer()->getParameter('contao.image.valid_extensions'))) + if ($blnIsFolder || !in_array(Path::getExtension($dc->id, true), System::getContainer()->getParameter('contao.image.valid_extensions'))) { $strPalette = PaletteManipulator::create() ->removeField(array('importantPartX', 'importantPartY', 'importantPartWidth', 'importantPartHeight'))
core-bundle/contao/drivers/DC_Folder.php+9 −1 modified@@ -906,7 +906,7 @@ public function copy($source=null, $destination=null) { $count = 1; $new = $destination; - $ext = strtolower(substr($destination, strrpos($destination, '.') + 1)); + $ext = Path::getExtension($destination, true); if ($ext === 'twig') { @@ -2243,6 +2243,14 @@ protected function save($varValue) } } + // Check the full path to see if the file extension has changed, because if + // $this->strExtension is empty, a new extension could otherwise be added to + // $varValue and change the file type! + if (Path::getExtension($varValue . $this->strExtension) !== Path::getExtension($this->varValue . $this->strExtension)) + { + throw new \Exception($GLOBALS['TL_LANG']['ERR']['invalidName']); + } + // The target exists if (strcasecmp($this->strPath . '/' . $this->varValue . $this->strExtension, $this->strPath . '/' . $varValue . $this->strExtension) !== 0 && file_exists($this->strRootDir . '/' . $this->strPath . '/' . $varValue . $this->strExtension)) {
3 files changed · +13 −3
core-bundle/contao/classes/FileUpload.php+2 −1 modified@@ -10,6 +10,7 @@ namespace Contao; +use Symfony\Component\Filesystem\Path; use Symfony\Component\HttpFoundation\File\UploadedFile; /** @@ -144,7 +145,7 @@ public function uploadTo($strTarget) } else { - $strExtension = strtolower(substr($file['name'], strrpos($file['name'], '.') + 1)); + $strExtension = Path::getExtension($file['name'], true); // Image is too big if (\in_array($strExtension, array('gif', 'jpg', 'jpeg', 'png', 'webp', 'avif', 'heic', 'jxl')) && Config::get('imageWidth') && Config::get('imageHeight') && System::getContainer()->getParameter('contao.image.reject_large_uploads'))
core-bundle/contao/dca/tl_files.php+2 −1 modified@@ -27,6 +27,7 @@ use Contao\StringUtil; use Contao\System; use Contao\Validator; +use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; $GLOBALS['TL_DCA']['tl_files'] = array @@ -480,7 +481,7 @@ public function adjustPalettes(string $strPalette, DataContainer $dc) } // Only show the important part fields for images - if ($blnIsFolder || !in_array(strtolower(substr($dc->id, strrpos($dc->id, '.') + 1)), System::getContainer()->getParameter('contao.image.valid_extensions'))) + if ($blnIsFolder || !in_array(Path::getExtension($dc->id, true), System::getContainer()->getParameter('contao.image.valid_extensions'))) { $strPalette = PaletteManipulator::create() ->removeField(array('importantPartX', 'importantPartY', 'importantPartWidth', 'importantPartHeight'))
core-bundle/contao/drivers/DC_Folder.php+9 −1 modified@@ -922,7 +922,7 @@ public function copy($source=null, $destination=null) { $count = 1; $new = $destination; - $ext = strtolower(substr($destination, strrpos($destination, '.') + 1)); + $ext = Path::getExtension($destination, true); if ($ext === 'twig') { @@ -2279,6 +2279,14 @@ protected function save($varValue) } } + // Check the full path to see if the file extension has changed, because if + // $this->strExtension is empty, a new extension could otherwise be added to + // $varValue and change the file type! + if (Path::getExtension($varValue . $this->strExtension) !== Path::getExtension($this->varValue . $this->strExtension)) + { + throw new \Exception($GLOBALS['TL_LANG']['ERR']['invalidName']); + } + // The target exists if (strcasecmp($this->strPath . '/' . $this->varValue . $this->strExtension, $this->strPath . '/' . $varValue . $this->strExtension) !== 0 && file_exists($this->strRootDir . '/' . $this->strPath . '/' . $varValue . $this->strExtension)) {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-vm6r-j788-hjh5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-45398ghsaADVISORY
- contao.org/en/security-advisories/remote-command-execution-through-file-uploadsghsax_refsource_MISCWEB
- github.com/contao/contao/commit/9445d509f12a7f1b68a4794dcc5e3e459b363ebbghsaWEB
- github.com/contao/contao/commit/a7e39f96ac8fdc281f7caaa96e01deb0e24ac7d3ghsaWEB
- github.com/contao/contao/commit/f3db59ffe5a6c0e1f705b3230ebd5ff16865280eghsaWEB
- github.com/contao/contao/security/advisories/GHSA-vm6r-j788-hjh5ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.