WordPress Security Research Series: WordPress Request Architecture and Hooks

Welcome to Part 1 of the WordPress Security Research Beginner Series! If you haven’t had a chance, please review the series introduction blog post for more details on the goal of this series and what to expect.

Before diving into the security features of WordPress, it’s critical to understand the underlying request architecture. WordPress is a dynamic system that processes and responds to user requests in various ways, depending on the nature of the request and the context in which it’s made. By understanding this request-response pattern, we can better comprehend how plugins and themes integrate into WordPress core and how to access and trigger code that may contain vulnerabilities.

We hope that by providing this beginner series on WordPress vulnerability research, you’ll in turn take the knowledge you’ve gained and use it to participate in the Wordfence Bug Bounty Program, where you can earn up to $10,400 for each vulnerability reported, with the mission of helping make WordPress a more secure ecosystem.

Understanding Requests in WordPress

WordPress is a web application. That means, at its core, it’s built to handle HTTP requests and return responses. This interaction handles everything from rendering pages to performing administrative actions in the backend.

The Request Lifecycle

Initial Request: Everything starts when the server receives an HTTP request. This could be a simple GET request for a page or a POST request submitting form data.
WordPress Core Loading: WordPress initializes its core, loading the necessary components to handle the request, including plugin and theme files.
Routing: WordPress determines what the request is trying to access—be it a post, a page, an admin panel, or an AJAX call—and routes the request accordingly.
Hooks and Execution: This stage of the WordPress request lifecycle is where the core, along with themes and plugins, actively engage with the incoming request. WordPress core and themes predominantly use hooks — actions and filters — to alter outputs and execute operations. These hooks are predefined points in the WordPress code where plugins and themes can intervene to modify behavior or add new functionality.
Response: Finally, WordPress sends a response back to the client, which could be an HTML page, a JSON payload, or a simple status code.

The WordPress Request Lifecycle

WordPress Core Request Handling

When most requests hit a WordPress site, they are first intercepted by the index.php file in the webroot directory. This file is the controller for all front-of-site WordPress requests. It loads the wp-blog-header.php file, which in turn loads the wp-load.php file to initialize the WordPress environment and template. All WordPress requests result in wp-load.php being executed in some way or another.

WordPress Request Flow

From here, WordPress decides what to do based on the request.

Request Routing

Front-End Requests: For front-end requests, WordPress uses the Rewrite API to interpret the URL and determine which content to serve. The Rewrite API transforms user-friendly URLs into query variables that WordPress understands.

REST API Requests: These requests are routed through index.php to the REST API handler. The REST API allows external applications to interact with your WordPress site and allows functions to execute without WordPress requiring a page reload.

Admin Requests: Admin requests are directed to files within the wp-admin directory. By default, these requests go to wp-admin/index.php, which handles the administration dashboard. Files in the wp-admin directory load wp-load.php independently from the front of site, either directly, or indirectly by including a file that then loads wp-load.php. For example, AJAX requests are processed by wp-admin/admin-ajax.php which loads wp-load.php.

Each of these entry points uses hooks to allow plugins and themes to modify the request or the response.

WordPress Request Routing

Hooks and How They Relate to Requests

WordPress plugin architecture is largely based on its hook system. A hook (a.k.a. function hook) is simply a way to inject third-party functionality (like plugin code) into existing code so edits to core code do not need to be made. WordPress implements two types of hooks: actions and filters. These hooks allow WordPress plugin and theme developers to add functionality or change WordPress’ default behavior without editing WordPress core files.

Actions

Actions are hooks that WordPress core launches at specific points during execution, or when certain events occur. If you’re familiar with JavaScript, WordPress actions are similar to event listeners that you might add to elements on a webpage. Just as you can set up a listener to run a function when a user clicks a button, WordPress actions allow you to execute custom code at certain points in the WordPress request-response lifecycle, such as when a post is saved or a page is rendered. Plugins can register custom PHP functions to these hooks, which are executed when the hook is called. Here’s how actions work:

Definition: Actions are defined by the do_action() function call in the WordPress core. When the core wants to allow an action to be hooked, it calls this function.

Registration: Plugins and themes register their custom functions to hooks using the add_action() function, specifying the hook name and the function to be called.

Execution: When the do_action() call is reached during WordPress’s execution, all functions hooked to that action are executed in the order they were added.

Here is what that looks like in action:

In WordPress core code, specifically in wp-settings.php, on line 700 (at the time of this writing), the init action is called.

/**
* Fires after WordPress has finished loading but before any headers are sent.
*
* Most of WP is loaded at this stage, and the user is authenticated. WP continues
* to load on the {@see ‘init’} hook that follows (e.g. widgets), and many plugins instantiate
* themselves on it for all sorts of reasons (e.g. they need a user, a taxonomy, etc.).
*
* If you wish to plug an action once WP is loaded, use the {@see ‘wp_loaded’} hook below.
*
* @since 1.5.0
*/
do_action( ‘init’ );

Line 700 of wp-settings.php in WordPress core

In the following code example, a plugin hooks into the init action, which fires after WordPress finishes loading.

<?php
/**
* Plugin Name: Example Action Hook Plugin
* Description: A simple plugin to demonstrate the use of an action hook.
*/

// Hook into the ‘init’ action
add_action(‘init’, ‘check_custom_query’);

/**
* Check for a custom query parameter and take action.
*/
function check_custom_query() {
// Check if the ‘trigger’ query parameter is set to ‘run’
if ( isset($_GET[‘trigger’]) && $_GET[‘trigger’] === ‘run’ ) {
// Perform the action if the condition is met
run_custom_function();
}
}

/**
* A custom function that gets executed when the condition is met.
*/
function run_custom_function() {
// This could be any functionality you want to execute
// For example, logging, modifying post data, redirecting, etc.
error_log(‘Custom action triggered by the init hook.’);
}

An example WordPress plugin demonstrating the use of the init action hook to check for a specific query parameter and execute custom logic when the parameter is present.

To trigger the execution of the check_custom_query() function we simply need to send a request to a WordPress site with this plugin installed. If we pass a trigger GET parameter, we can also trigger the execution of the run_custom_function() function seen in the example above. Since the init action runs on all WordPress requests, both front-end and backend, the check_custom_query() function will run on any valid request that loads WordPress.

http://example-wordpress-site.com/?trigger=run

This example demonstrates how a plugin’s custom function (in this case run_custom_function()) is hooked to the init action, and how we can trigger this function via a simple GET request. If the run_custom_function() function failed to implement authorization checks, any unauthenticated user could execute it. If it failed to implement nonce checks, it would be susceptible to Cross-Site Request Forgery attacks.

Filters

Filters are similar to actions but are specifically used to modify data, for example, before it is sent to the database or the browser. When WordPress is about to display a title, content, or some other piece of data, it will first pass it through a filter hook if available.

Definition: Filters are defined by the apply_filters() function call in the WordPress core. This function passes data through all functions hooked to a filter, allowing them to modify the data.
Registration: Just like actions, filters are registered using the add_filter() function, which hooks a function to a specific filter.
Execution: When data is passed through the apply_filters() call, it is filtered by all hooked functions in sequence, each modifying the data before passing it onto the next.

Here’s what it looks like in action:

In WordPress core code, specifically in wp-includes/post-template.php, on line 256 (at the time of this writing), the the_content filter is called.

// In the WordPress core wp-includes/post-template.php
$content = apply_filters( ‘the_content’, $content );

<?php
/**
* Plugin Name: Content Modifier
* Description: A simple plugin to modify post content.
*/

// Function to modify post content
function modify_content( $content ) {
// Add custom text to the beginning of the content
$custom_text = ‘Notice: This content is modified by the Content Modifier plugin. ‘;
return $custom_text . $content;
}

// Add filter to modify the content of posts
add_filter( ‘the_content’, ‘modify_content’ );

In this example, the modify_content() function is hooked to the the_content filter, which is applied to the post content before it is displayed on the screen. The function prepends a custom notice to the content of every post.

Key Routes and Their Hooks

There are a number of routes that are interesting to WordPress vulnerability researchers because they trigger certain actions that plugin and theme authors often hook into with custom functions. As we discovered in previous sections, these functions may display, modify, or remove data, and they may not implement appropriate protections to prevent unauthorized users from accessing or manipulating them.

Route
Triggers These Hooks
With this Authentication/Authorization

/ (Frontend)
template_redirect, wp_head, wp_footer
None (public)

/wp-admin/ (Admin Dashboard)
admin_init, admin_menu, admin_footer, admin_action_{action}
Generally subscriber-level or higher role.
EXCEPTION: Sending an unauthenticated request to /wp-admin/admin-ajax.php or /wp-admin/admin-post.php will trigger the admin_init action.

/wp-admin/admin-ajax.php (AJAX Endpoint)
wp_ajax_{action}, wp_ajax_nopriv_{action}
wp_ajax_{action} requires login and can be triggered by users with a subscriber-level or higher role with access to /wp-admin.
wp_ajax_nopriv_{action} can be accessed by unauthenticated users.

/wp-admin/admin-post.php (Form Submission)
admin_post_{action}, admin_post_nopriv_{action}
None (public) for nopriv otherwise subscriber-level or higher role.

User Profile/Update (wp-admin/profile.php)
profile_update, wp_update_user, personal_options_update, edit_user_profile_update
Generally subscriber-level or higher role

Options Update
update_option
Authentication/Authorization depends, because the update_option action is tied to when the update_option() function is called by the plugin/theme developer. If, for example, update_option() is called in a function hooked to a wp_ajax_nopriv_{action}, then the required Authentication/Authorization would be “None (public)”.

Any Page Load
init
None (public)

/wp-json/ (REST API)
rest_api_init
None (public) by default. Permissions can be controlled by permission_callback.

/wp-login.php (Login Page)
wp_login, login_form
None (public)

/wp-signup.php (Multisite Registration)
signup_header
None (public)

/wp-cron.php (WP-Cron)
wp_scheduled_delete, wp_scheduled_auto_draft_delete
None (public)

/xmlrpc.php (XML-RPC)
xmlrpc_call
Depends on method

WordPress Action and Filter Hooks Relevant to Vulnerability Research

Now that you’re familiar with routes and which hooks they trigger, let’s review the most important actions and filters for vulnerability research:

Actions

init: Fires after WordPress has finished loading but before any headers are sent. This is an early stage in the WordPress loading process, making it a critical point for initializing plugin or theme functionalities.
admin_init: Triggers before any other hook when a user accesses the admin area (/wp-admin). Despite the name, this action can be triggered by unauthenticated users.
wp_ajax_{action}: A core part of the admin-side AJAX functionality. It’s used to handle authenticated AJAX requests.
wp_ajax_nopriv_{action}: Similar to wp_ajax_{action}, but specifically for handling unauthenticated AJAX requests. This hook is crucial for AJAX functionality available to public users.
admin_post and admin_post_nopriv: These hooks are used for handling form submissions in the WordPress admin area for authenticated (admin_post) and unauthenticated (admin_post_nopriv) users.
admin_action_{action}: Triggered in response to admin actions. It’s a versatile hook for custom admin functionalities and can be accessed via both GET and POST requests.
profile_update: Fires when a user’s profile is updated. It’s an important hook for executing actions post-user update.
wp_update_user: Similar to profile_update, this action occurs when a user’s data is updated. It is crucial for managing user information.
personal_options_update: This hook is triggered when a user updates their own profile in the WordPress admin area. It allows developers to execute custom code when a user updates their personal settings.
edit_user_profile_update: This hook is triggered when an admin or another user with the appropriate permissions updates another user’s profile. It allows developers to execute custom code during the profile update process for other users. It’s almost always used with personal_options_update.

Filters

the_content: Filters the post content before it’s sent to the browser. If improperly sanitized, it can lead to XSS attacks.
the_title: Similar to the_content, this filter can be a vector for XSS if the title output is not properly escaped.
user_has_cap: Filters a user’s capabilities and can be used to alter permissions, potentially leading to privilege escalation.
authenticate: Filters the authentication process. If overridden or improperly extended, it can lead to authentication bypass vulnerabilities.

Know that this is not an exhaustive list and vulnerabilities can be introduced in custom plugin and theme functions that are hooked to other actions and filters. Such plugins and themes can even include standalone PHP files that do not load WordPress, which often result in vulnerabilities such as the File Manager vulnerability that put hundreds of thousands of sites at risk.

Examples

Let’s take a look at some examples of how some of these actions might be hooked by plugin code and how a vulnerability might be introduced.

Example 1: wp_ajax_{action} Hook in a File Upload Plugin

Scenario: A plugin that allows users to upload files from the frontend might use the wp_ajax_{action} hook to handle the upload process. The plugin registers a function to this hook that processes the file upload.

function handle_file_upload() {
if (isset($_FILES[‘file’])) {
$file = $_FILES[‘file’];

// Vulnerability: The function does not properly check the file extension and MIME type.
// An attacker could upload a malicious PHP file as a subscriber-level user’.
// Another issue is the lack of directory traversal protection.

// Example of insufficient validation:
if ($file[‘size’] > 1000000) {
echo ‘Error: File size is too large.’;
wp_die();
}

// Assume the file is moved to a directory accessible via the web
move_uploaded_file($file[‘tmp_name’], ‘/wp-content/uploads/’ . $file[‘name’]);

echo ‘File uploaded successfully!’;
} else {
echo ‘No file uploaded.’;
}
wp_die(); // Required to terminate and return a response
}

add_action(‘wp_ajax_upload_file’, ‘handle_file_upload’);

To access this function, a POST request would be made to /wp-admin/admin-ajax.php with the action parameter set to upload_file.

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: example-wordpress-site.com
Content-Type: multipart/form-data; boundary=—-WebKitFormBoundaryePkpFF7tjBAqx29L
Content-Length: …

——WebKitFormBoundaryePkpFF7tjBAqx29L
Content-Disposition: form-data; name=”action”

upload_file
——WebKitFormBoundaryePkpFF7tjBAqx29L
Content-Disposition: form-data; name=”file”; filename=”malicious.php”
Content-Type: application/x-php

——WebKitFormBoundaryePkpFF7tjBAqx29L–

💡 Real-World Example

Check out this real-world example of an Arbitrary File Upload Vulnerability discovered in the WEmanage App Worker WordPress plugin that earned a $657 bounty.

Example 2: admin_init Hook in a Plugin with a PHP File Inclusion

Scenario: A WordPress plugin intends to allow administrators to include various PHP templates. However, It uses the admin_init hook and includes a PHP file based on a user-supplied input, leading to a potential LFI vulnerability that is exploitable by subscriber-level users.

function plugin_template_includer_init() {
// Vulnerability: Including a PHP file based on user input without proper validation
// This could lead to Local File Inclusion (LFI) if an attacker manipulates the ‘template’ parameter.
if (isset($_GET[‘template’])) {
$template = $_GET[‘template’];

// Example of a vulnerable include statement
// An attacker could potentially execute arbitrary PHP code.
include(‘/path/to/templates/’ . $template . ‘.php’);
}
}

// Hooking into ‘admin_init’
add_action(‘admin_init’, ‘plugin_template_includer_init’);

As noted in the Key Routes and Their Hooks section, we can access this function via a simple GET request to the /wp-admin/ endpoint.

http://example-wordpress-site.com/wp-admin/?page=some_plugin_page&log_file=../../../../malicious

💡 Real-World Example

Check out this real-world example of a Local File Inclusion Vulnerability discovered in the MasterStudy LMS WordPress plugin that earned a $937 bounty

Other Examples: Tracing a Request to a Hook

Let’s trace a typical request to see where vulnerabilities might be introduced:

Frontend Page Load: A user visits the homepage (/).

WordPress loads and parses the request.
The template_redirect action is triggered.
A plugin hooks into template_redirect to add custom redirection logic but fails to validate the URL properly, leading to an open redirect vulnerability.

Admin Area: A user makes a request to /wp-admin/.

The admin_init action is triggered.
A theme’s functions.php file hooks into admin_init to add custom settings, but does not check user capabilities, allowing any logged-in user to change settings meant for admins only.

AJAX Request: A user submits a form that triggers an AJAX call to admin-ajax.php.

WordPress routes the request to the appropriate handler based on the action parameter.
A plugin handles the request but does not verify the nonce, making it susceptible to CSRF attacks.

WordPress Plugin and Theme Loading and Request Mechanics

While plugins and themes commonly hook into various WordPress actions and filters to implement their functionality, they can also directly handle requests and their parameters. Let’s review how plugins and themes are loaded, and how they can perform actions based on user-supplied request parameters.

Plugin Loading

All plugins have a main PHP file and it can be identified by a header comment that typically looks something like this:

<?php
/**
* Plugin Name: My Custom Plugin
* Plugin URI: https://example.com/my-custom-plugin
* Description: This is a custom plugin to enhance WordPress functionality.
* Version: 1.0
* Author: Your Name`
* Author URI: https://example.com
* License: GPLv2 or later
* Text Domain: my-custom-plugin
*/

When a WordPress plugin is activated, it’s registered within the WordPress database, and its main PHP file gets loaded on every subsequent request to the WordPress site. This behavior is crucial to understand because any code placed directly in the main plugin file executes during each page load, regardless of the context or the user’s intention.

For instance, consider a plugin with the following code in its main PHP file:

if ( isset($_GET[‘delete’]) && $_GET[‘delete’] == ‘all_posts’ ) {
// A function that deletes all posts
delete_all_posts();
}

This code checks for a specific $_GET parameter and triggers the delete_all_posts() function accordingly.

http://example-wordpress-site.com/?delete=all_posts

Without proper validation and security checks, this code would introduce a vulnerability that allows an attacker to delete all posts without any authentication.

Theme Loading

WordPress themes control the presentation and layout of content on a website. Unlike plugins, which often add specific functionalities, themes primarily determine the visual aspects and user experience. However, while not as common as plugins, our experience shows that themes do implement user-controllable functionality on occasion.

WordPress uses a template hierarchy to decide which file in the theme should be used to display a certain type of content. For example:

single.php is used to display single/individual posts for all post types.
page.php is used for individual/static pages.
archive.php is used to display a list of posts grouped by category, tag, date, author, or custom taxonomy. It provides a way to present archived content.

WordPress template hierarchy visualization. Image Source: https://developer.wordpress.org/files/2014/10/Screenshot-2019-01-23-00.20.04.png

Each of these template files can contain PHP code and HTML to render the website’s content. Like plugins, the PHP code within these theme files is executed on each relevant page load.

As mentioned earlier, themes may also directly handle user requests. This can happen in various theme template files or even in the functions.php file, which acts like a plugin and is automatically loaded by WordPress. For example, a theme might include code in functions.php to handle custom query parameters for theme-specific functionalities.

if (isset($_GET[‘theme_action’])) {
// Perform some theme-specific action based on the query parameter
}

Similar to the previous example that allows attackers to delete all posts, if the condition is met in the above example and appropriate validation and security checks are not in place, an attacker could leverage this code to perform an action that may not have been intended for unauthenticated or lower-level users.

Conclusion and Next Steps

In Part 1 of this beginner series on WordPress vulnerability research, we delved into the WordPress request and response mechanism, uncovering the important role of hooks – both actions and filters – in how plugins and themes integrate with WordPress core. We examined common hooks and their related requests, providing examples of their usage and how they can be triggered.

Understanding the request architecture in WordPress is a fundamental step in identifying and testing the exploitation of potential vulnerabilities. It enables us to discern how plugins and themes interact with the core system and how their functions, accessible through HTTP requests, might present security risks. This knowledge is vital for testing and assessing these functions for vulnerabilities in practical scenarios.

In Part 2 of this series, we will delve deeper into each type of request and dissect the common security issues associated with them. A particular focus will be on the WordPress Security API, examining its effective use and common misuses by plugin and theme authors.

We encourage you to get started with a local WordPress installation and apply this knowledge with existing plugins and themes. Stay tuned for the next part in our beginner vulnerability research series, and don’t forget to register as a researcher for our Bug Bounty Program and join our Discord to engage with the Wordfence Threat Intelligence team and other security researchers!

The post WordPress Security Research Series: WordPress Request Architecture and Hooks appeared first on Wordfence.

Leave a Comment