Contao is vulnerable to remote code execution in template closures
Description
Contao is an Open Source CMS. From version 4.0.0 to before 4.13.57, before 5.3.42, and before 5.6.5, back end users with precise control over the contents of template closures can execute arbitrary PHP functions that do not have required parameters. This issue has been patched in versions 4.13.57, 5.3.42, and 5.6.5. A workaround for this issue involves manually patching the Contao\Template::once() method.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Contao CMS allows authenticated back end users with template closure control to execute arbitrary PHP functions without required parameters, patched in versions 4.13.57, 5.3.42, and 5.6.5.
The vulnerability resides in the Template::once() method of Contao's template system. The original implementation allowed a user-supplied closure to be invoked repeatedly and without proper parameter validation, enabling arbitrary PHP function execution when the attacker controls the closure contents. The fix, visible in the commit diffs, changes the logic to execute the callback only once and store its result, preventing re-invocation and ensuring the callback is nullified after execution [1][3].
Exploitation requires an authenticated back end user with the ability to control template closure contents, such as through template editing or other privileged actions. The attacker can then supply a callable that invokes any PHP function that does not require parameters, as the callback is invoked without argument validation [1].
Successful exploitation leads to arbitrary PHP code execution within the context of the Contao application. This can result in full compromise of the CMS installation, including data exfiltration, modification, and potential lateral movement within the hosting environment [1].
Patches have been released in Contao versions 4.13.57, 5.3.42, and 5.6.5. For users unable to upgrade immediately, a workaround involves manually patching the Template::once() method as described in the advisory [1][3].
AI Insight generated on May 19, 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.57 | 4.13.57 |
contao/core-bundlePackagist | >= 5.0.0-RC1, < 5.3.42 | 5.3.42 |
contao/core-bundlePackagist | >= 5.4.0-RC1, < 5.6.5 | 5.6.5 |
Affected products
2- Range: >=4.0.0, <4.13.57 || >=5.0.0, <5.3.42 || >=5.4.0, <5.6.5
- contao/contaov5Range: >= 4.0.0, < 4.13.57
Patches
33 files changed · +31 −5
core-bundle/contao/library/Contao/Template.php+7 −4 modified@@ -221,13 +221,16 @@ public function __isset($strKey) */ public static function once(callable $callback) { - return static function () use (&$callback) { - if (\is_callable($callback)) + $result = null; + + return static function () use (&$callback, &$result) { + if ($callback !== null) { - $callback = $callback(); + $result = $callback(); + $callback = null; } - return $callback; + return $result; }; }
core-bundle/tests/Contao/TemplateTest.php+20 −0 modified@@ -25,6 +25,7 @@ use Contao\CoreBundle\Tests\TestCase; use Contao\FrontendTemplate; use Contao\System; +use Contao\Template; use Nelmio\SecurityBundle\ContentSecurityPolicy\DirectiveSet; use Nelmio\SecurityBundle\ContentSecurityPolicy\PolicyManager; use PHPUnit\Framework\Attributes\DataProvider; @@ -569,4 +570,23 @@ public function testExtractsStyleAttributesForCsp(): void $this->assertSame(\sprintf("style-src 'self' 'unsafe-hashes' '%s-%s'", $algorithm, $expectedHash), $response->headers->get('Content-Security-Policy')); } + + public function testOnceHelperExecutesCodeOnce(): void + { + $invocationCount = 0; + + $expensiveFunction = static function () use (&$invocationCount) { + ++$invocationCount; + + return false; + }; + + $template = new FrontendTemplate(); + $template->hasFoo = Template::once($expensiveFunction); + + $this->assertFalse($template->hasFoo, 'first call'); + $this->assertFalse($template->hasFoo, 'second call'); + + $this->assertSame(1, $invocationCount); + } }
core-bundle/tests/Twig/Interop/ContextFactoryTest.php+4 −1 modified@@ -38,7 +38,8 @@ public function testCreateContextFromTemplate(): void 'lazy2' => static fn (int $n = 0): string => "evaluated: $n", 'lazy3' => static fn (): array => [1, 2], 'lazy4' => $closure(...), - 'value' => 'strtolower', // do not confuse with callable + 'value' => 'strtolower', // do not confuse with callable, + 'has_foo' => Template::once(static fn (): bool => false), ]; $template = $this->createMock(Template::class); @@ -58,6 +59,7 @@ public function testCreateContextFromTemplate(): void lazy3: {{ lazy3.invoke()|join('|') }} lazy4: {{ lazy4 }} value: {{ value }} + has_foo? {% if has_foo.invoke() %}yes{% else %}no{% endif %}. TEMPLATE; @@ -72,6 +74,7 @@ public function testCreateContextFromTemplate(): void lazy3: 1|2 lazy4: evaluated Closure value: strtolower + has_foo? no. OUTPUT;
3 files changed · +31 −5
core-bundle/contao/library/Contao/Template.php+7 −4 modified@@ -220,13 +220,16 @@ public function __isset($strKey) */ public static function once(callable $callback) { - return static function () use (&$callback) { - if (\is_callable($callback)) + $result = null; + + return static function () use (&$callback, &$result) { + if ($callback !== null) { - $callback = $callback(); + $result = $callback(); + $callback = null; } - return $callback; + return $result; }; }
core-bundle/tests/Contao/TemplateTest.php+20 −0 modified@@ -25,6 +25,7 @@ use Contao\CoreBundle\Tests\TestCase; use Contao\FrontendTemplate; use Contao\System; +use Contao\Template; use Nelmio\SecurityBundle\ContentSecurityPolicy\DirectiveSet; use Nelmio\SecurityBundle\ContentSecurityPolicy\PolicyManager; use Psr\Log\LoggerInterface; @@ -570,4 +571,23 @@ public function testExtractsStyleAttributesForCsp(): void $this->assertSame(\sprintf("style-src 'self' 'unsafe-hashes' '%s-%s'", $algorithm, $expectedHash), $response->headers->get('Content-Security-Policy')); } + + public function testOnceHelperExecutesCodeOnce(): void + { + $invocationCount = 0; + + $expensiveFunction = static function () use (&$invocationCount) { + ++$invocationCount; + + return false; + }; + + $template = new FrontendTemplate(); + $template->hasFoo = Template::once($expensiveFunction); + + $this->assertFalse($template->hasFoo, 'first call'); + $this->assertFalse($template->hasFoo, 'second call'); + + $this->assertSame(1, $invocationCount); + } }
core-bundle/tests/Twig/Interop/ContextFactoryTest.php+4 −1 modified@@ -41,7 +41,8 @@ public function testCreateContextFromTemplate(): void 'lazy2' => static fn (int $n = 0): string => "evaluated: $n", 'lazy3' => static fn (): array => [1, 2], 'lazy4' => $closure(...), - 'value' => 'strtolower', // do not confuse with callable + 'value' => 'strtolower', // do not confuse with callable, + 'has_foo' => Template::once(static fn (): bool => false), ]; $template = $this->createMock(Template::class); @@ -61,6 +62,7 @@ public function testCreateContextFromTemplate(): void lazy3: {{ lazy3.invoke()|join('|') }} lazy4: {{ lazy4 }} value: {{ value }} + has_foo? {% if has_foo.invoke() %}yes{% else %}no{% endif %}. TEMPLATE; @@ -75,6 +77,7 @@ public function testCreateContextFromTemplate(): void lazy3: 1|2 lazy4: evaluated Closure value: strtolower + has_foo? no. OUTPUT;
2 files changed · +27 −4
core-bundle/src/Resources/contao/library/Contao/Template.php+7 −4 modified@@ -207,14 +207,17 @@ public function __isset($strKey) */ public static function once(callable $callback) { - return static function () use (&$callback) + $result = null; + + return static function () use (&$callback, &$result) { - if (\is_callable($callback)) + if ($callback !== null) { - $callback = $callback(); + $result = $callback(); + $callback = null; } - return $callback; + return $result; }; }
core-bundle/tests/Contao/TemplateTest.php+20 −0 modified@@ -20,6 +20,7 @@ use Contao\CoreBundle\Tests\TestCase; use Contao\FrontendTemplate; use Contao\System; +use Contao\Template; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Asset\Packages; use Symfony\Component\Filesystem\Filesystem; @@ -457,4 +458,23 @@ public function provideBuffer(): \Generator '{{}}<script>[{][}]</script>{{}}<script>[{][}]</script>{{}}', ]; } + + public function testOnceHelperExecutesCodeOnce(): void + { + $invocationCount = 0; + + $expensiveFunction = static function () use (&$invocationCount) { + ++$invocationCount; + + return false; + }; + + $template = new FrontendTemplate(); + $template->hasFoo = Template::once($expensiveFunction); + + $this->assertFalse($template->hasFoo, 'first call'); + $this->assertFalse($template->hasFoo, 'second call'); + + $this->assertSame(1, $invocationCount); + } }
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-98vj-mm79-v77rghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-65960ghsaADVISORY
- contao.org/en/security-advisories/remote-code-execution-in-template-closuresghsax_refsource_MISCWEB
- github.com/contao/contao/commit/577d7fdd5b1ca84f65f034ff556865422f0a3bd1ghsaWEB
- github.com/contao/contao/commit/676f0855d39007ac9a0dbe7ae6a7414cba2312a5ghsaWEB
- github.com/contao/contao/commit/ebf84c90e5679a67060f396b924ce4a3c3f206b3ghsaWEB
- github.com/contao/contao/security/advisories/GHSA-98vj-mm79-v77rghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.