VYPR
Moderate severityNVD Advisory· Published Nov 25, 2025· Updated Nov 25, 2025

Contao is vulnerable to remote code execution in template closures

CVE-2025-65960

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.

PackageAffected versionsPatched versions
contao/core-bundlePackagist
>= 4.0.0, < 4.13.574.13.57
contao/core-bundlePackagist
>= 5.0.0-RC1, < 5.3.425.3.42
contao/core-bundlePackagist
>= 5.4.0-RC1, < 5.6.55.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/contaov5
    Range: >= 4.0.0, < 4.13.57

Patches

3
ebf84c90e567

Merge commit from fork

https://github.com/contao/contaoM. VondanoNov 25, 2025via ghsa
3 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;
     
    
676f0855d390

Merge commit from fork

https://github.com/contao/contaoLeo FeyerNov 25, 2025via ghsa
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;
     
    
577d7fdd5b1c

Merge commit from fork

https://github.com/contao/contaoM. VondanoNov 25, 2025via ghsa
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
                 '&#123;&#123;&#125;&#125;<script>[{][}]</script>&#123;&#123;&#125;&#125;<script>[{][}]</script>&#123;&#123;&#125;&#125;',
             ];
         }
    +
    +    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

News mentions

0

No linked articles in our index yet.