Cool Template Revenge
[Cloudfest, 2026]
Cool Templates Revenge Writeup
Overview
The challenge is a WordPress site with a custom plugin that appends a footer.
The intended defense is a blacklist around a user-controlled function call, but
the real vulnerability is worse: the plugin lets the client call arbitrary PHP
built-ins, including unserialize().
Target code from the plugin:
if (isset($_REQUEST['template']) && isset($_REQUEST['content'])) {
$template = strtolower($_REQUEST['template']);
$content = wp_unslash(urldecode(base64_decode($_REQUEST['content'])));
if(preg_match('/^[a-zA-Z0-9]+$/', $template) && !in_array($template, $blacklist)) {
$footer = $template($content);
echo $footer;
}
}
So the request controls:
template: the function name to callcontent: the argument, afterbase64_decode()
Because unserialize is not blacklisted, this becomes unauthenticated PHP
object injection.
Bug
The core issue is:
$footer = $template($content);
echo $footer;
with template=unserialize.
That means the application effectively does:
echo unserialize($attacker_controlled_data);
This is enough for a POP chain in WordPress core.
Why the blacklist is irrelevant
The plugin tries to block dangerous functions like system, exec,
file_get_contents, etc. But once unserialize() is reachable, the blacklist
no longer matters. We can deserialize a chain of existing WordPress objects
that eventually reaches a dangerous sink inside core code.
Relevant WordPress core gadgets
The target runs WordPress 6.9.1. That matters because 6.9.2 added a fix
specifically blocking one of the gadgets:
WP_HTML_Tag_Processor::__wakeup()was added in6.9.2WP_Block_Patterns_Registry::get_content()was hardened in6.9.2
The chain used here is:
WP_HTML_Tag_Processor::__toString()WP_HTML_Tag_Processor::get_updated_html()WP_HTML_Tag_Processor::class_name_updates_to_attributes_updates()WP_Block_List::offsetGet()WP_Block::__construct()WP_Block_Patterns_Registry::get_registered()WP_Block_Patterns_Registry::get_content()include $patterns[$pattern_name]['filePath']
That last step is the sink.
In 6.9.1, the vulnerable sink is:
if ( ! isset( $patterns[ $pattern_name ]['content'] ) && isset( $patterns[ $pattern_name ]['filePath'] ) ) {
ob_start();
include $patterns[ $pattern_name ]['filePath'];
$patterns[ $pattern_name ]['content'] = ob_get_clean();
unset( $patterns[ $pattern_name ]['filePath'] );
}
So if we control registered_patterns, we control an include.
Triggering the chain
We need an object whose string conversion is triggered automatically, because the plugin does:
echo $footer;
If unserialize() returns an object, echo invokes __toString().
The entry gadget is WP_HTML_Tag_Processor::__toString():
public function __toString(): string {
return $this->get_updated_html();
}
Inside get_updated_html(), if classname_updates is non-empty, it calls
class_name_updates_to_attributes_updates(). That method accesses:
$this->attributes['class']
If attributes is not a normal array but a WP_Block_List, then
offsetGet('class') runs.
WP_Block_List::offsetGet() constructs a WP_Block using attacker-controlled
data:
$block = new WP_Block( $block, $this->available_context, $this->registry );
Then WP_Block::__construct() calls:
$this->block_type = $registry->get_registered( $this->name );
So if registry is actually a WP_Block_Patterns_Registry instance, we pivot
into get_registered(), then get_content(), then the controlled include.
Why direct file inclusion is not enough
If filePath is just /flag-...txt, the file gets included inside
ob_start() and stored into a pattern buffer. That does not necessarily show
up directly in the response in a useful way.
So the intended move is to use the include sink for code execution, not plain read.
Turning include into code execution
I used a standard php://filter include chain to generate a PHP payload at
include time.
First I verified code execution with:
<?php die("PWNED"); ?>
Then I used:
<?php die(shell_exec('ls -la /')); ?>
That listed the root directory and revealed the real flag filename:
/flag-adgwDT374Jcp5tINNZeU.txt
Finally I used:
<?php die(file_get_contents('/flag-adgwDT374Jcp5tINNZeU.txt')); ?>
and got the flag.
Payload structure
The serialized top-level object was a WP_HTML_Tag_Processor with these
important properties:
html = "foobar"classname_updates = [1]attributes = WP_Block_List
The WP_Block_List contained:
blocks['class'] = ['blockName' => 'test', 'innerBlocks' => [], 'innerHTML' => '', 'innerContent' => []]registry = WP_Block_Patterns_Registry
The WP_Block_Patterns_Registry contained:
registered_patterns['test']['filePath'] = <php://filter chain>registered_patterns['test']['name'] = 'test'
Then the whole serialized blob was base64-encoded and sent as content, with
template=unserialize.
Exploit flow
- Send
template=unserialize - Base64-decode attacker data
unserialize()returnsWP_HTML_Tag_Processor- Plugin does
echo $footer WP_HTML_Tag_Processor::__toString()executes- Internal property access pivots through
WP_Block_ListandWP_Block WP_Block_Patterns_Registry::get_content()performs controlledinclude- Included
php://filterpayload executes attacker PHP - Enumerate
/, then read the real flag file
Flag
CTF{revenge_this_cool_template_1337_78e1068b}
Takeaway
The service was "fixed" by blacklisting obvious dangerous functions, but the
real primitive was still arbitrary function invocation. Once unserialize() is
reachable on attacker-controlled input, WordPress core provides the rest. The
blacklist did not address the actual trust boundary at all.