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.
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.
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.
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 <@INCLUDE *malicious PHP file content here*@> ------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 intoadmin_init
to add custom settings, but does not check user capabilities, allowing any logged-in user to change settings meant for admins only.
- The
- 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 routes the request to the appropriate handler based on the
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.
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!
Comments
2:09 pm
Dear Wordfence, What a fabulous introduction to the mechanics of WordPress ! Very simple to read and to understand. Not sure that I have the skills to do much, but this sort of initiative should encourage many others to assist. Thanks
9:29 am
Thanks for the kind words Mike!
12:19 am
Hello Alex,
Feeling great that I have finally found a proper guide to pentest plugins, Thank you for this blog, appreciate it!!!
9:50 am
You're welcome Dhairya!
1:53 am
Came from discord , thank you for this great guide !!
12:53 pm
You're welcome, Youcef!