CSRF (Cross-Site Request Forgery) is a type of web application vulnerability in which an attacker tricks a user into performing an unwanted action on a site where the user is already authenticated.For WordPress sites, this vulnerability can be exploited by unauthorized changes to site settings, content publishing, or even administrative actions.
CSRF vulnerabilities in WordPress can occur when developers misuse protection mechanisms or ignore them altogether. Despite built-in tools to prevent CSRF, implementation errors can make the application vulnerable. Let’s take a closer look at the main scenarios, vulnerabilities, and their exploitation.
The Main Causes of CSRF Vulnerabilities in WordPress
If a developer does not add a wp_verify_nonce()
check to a form or AJAX request handler, an attacker can spoof the request and trick the user into performing an unwanted action. Using the same nonce for multiple actions increases the risk of an attack, as it is easier for an attacker to guess the token. Additionally, WordPress nonces are valid for 12 hours by default, which gives attackers enough time to forge a request. If only the nonce is checked but not the user’s permissions (e.g., via current_user_can()
), an attacker can force a low-level user to perform a privileged action. Lastly, failure to check the Referer
or Origin
headers may allow attacks from other sites.
Examples of CSRF Vulnerabilities in WordPress
Incorrect use of nonce:
add_action('admin_post_my_action', 'handle_my_action');
function handle_my_action() {
$user_id = intval($_POST['user_id']);
$new_role = sanitize_text_field($_POST['new_role']);
wp_update_user([
'ID' => $user_id,
'role' => $new_role,
]);
wp_redirect(admin_url());
exit;
}
An attacker can create a form that sends a request to the server on behalf of the administrator without checking the validity of the token (nonce).
An attacker can exploit the vulnerability of the SSRF and replace the HTML code of a website page:
<form method="POST" action="http://example.com/wp-admin/admin-post.php?action=my_action">
<input type="hidden" name="user_id" value="1">
<input type="hidden" name="new_role" value="administrator">
<button type="submit">Click here</button>
</form>
If an administrator logs into a malicious site and submits this form, the user’s role will be changed without permission.
Corrected example:
add_action('admin_post_my_action', 'handle_my_action');
function handle_my_action() {
if (!isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'my_action_nonce')) {
wp_die('Error: invalid token');
}
$user_id = intval($_POST['user_id']);
$new_role = sanitize_text_field($_POST['new_role']);
wp_update_user([
'ID' => $user_id,
'role' => $new_role,
]);
wp_redirect(admin_url());
exit;
}
- Checking for _wpnonce in $_POST:
if (!isset($_POST['_wpnonce']))
The line of code checks the _wpnonce field in the array of data sent by the POST method. If the field is missing, this may indicate that the form was submitted without the required token, and the code execution is interrupted.
- Token verification:
|| !wp_verify_nonce($_POST['_wpnonce'], 'my_action_nonce')
wp_verify_nonce function checks if the passed token is valid and associated with the action ‘my_action_nonce’. The token is valid only for a specific user and has a limited lifetime (default 12-24 hours).
If there is no token or the check fails, the wp_die function stops the script execution and displays an error message.
CSRF example in AJAX request
If the AJAX request handler does not check the Nonce, an attacker can trick the user into sending a malicious request.
add_action('wp_ajax_update_profile', 'update_user_profile');
function update_user_profile() {
$user_id = get_current_user_id();
$new_email = $_POST['email'];
wp_update_user(['ID' => $user_id, 'user_email' => $new_email]);
wp_send_json_success('Profile updated.');
}
The code does not check the nonce that confirms that the request is initiated from the site. This allows attackers to send a POST request on behalf of the user, even if the user is logged in, for example, through a specially crafted HTML page or a JS script.
fetch('http://example.com/wp-admin/admin-ajax.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=update_profile&email=ha**@ex*****.com'
});
Example of critical vulnerability CSRF CVE-2023-4827(link)
This vulnerability allowed exploitation of unwanted actions on behalf of the system administrator and performing actions with the WordPress file system, as a result of which the attacker could implement RCE.
Let’s look at an example of vulnerable code:
public function fsConnector()
{
if (
isset($_POST)
&& !empty($_POST)
&& !wp_verify_nonce($_POST['nonce'], 'file-manager-security-token')
) {
wp_die();
}
$uploadMaxSize = isset($this->options['njt_fs_file_manager_settings']['upload_max_size'])
&& !empty($this->options['njt_fs_file_manager_settings']['upload_max_size'])
? $this->options['njt_fs_file_manager_settings']['upload_max_size']
: 0;
}
The isset($_POST) and !empty($_POST) condition means that the nonce check is performed only if the request has POST data.
An attacker could send a GET request in which the nonce was not checked at all and perform an unprotected action.
Unlike wp_verify_nonce, the check_ajax_referer function checks for a token automatically. The function
aborts execution if the token is missing or invalid, and also allows you to avoid errors in the verification logic.
Fixed code:
public function fsConnector()
{
check_ajax_referer('file-manager-security-token', 'nonce');
$uploadMaxSize = isset($this->options['njt_fs_file_manager_settings']['upload_max_size'])
&& !empty($this->options['njt_fs_file_manager_settings']['upload_max_size'])
? $this->options['njt_fs_file_manager_settings']['upload_max_size']
: 0;
}
The special feature of the check_ajax_referer function is that the token is checked regardless of the request method. Also, if there is no token in the request, Check_ajax_referer will call wp_die() automatically, thereby eliminating the risk of an intruder accessing a non-existent index. One of the advantages of this function is that when any error is called, the token automatically stops execution. The function is designed to work with AJAX requests and the specifics of WordPress. Using check_ajax_referer in the fixed code not only fixes the current issue, but also makes the code more resilient to potential bugs.
CSRF protection features
- wp_nonce_field()
The wp_nonce_field() function in WordPress is used to create a hidden form field that contains a nonce (a numeric code used to protect against CSRF attacks). It helps with validation and verifying that the request was sent from a trusted page and has not been tampered with by an attacker.
The wp_nonce_field() function generates an HTML form that contains a hidden field . This field will contain a nonce value that will be validated when the form is submitted.
Example:
<form method="POST">
<?php wp_nonce_field('save_post_action', 'save_post_nonce'); ?>
<input type="text" name="post_title" />
<input type="submit" value="Save Post" />
</form>
This code creates a hidden field in the form that will contain a nonce that is bound to the save_post_action action. After the form with the nonce is submitted, the check_admin_referer() or check_ajax_referer() function is used to check its validity, depending on the request type:
if ( isset($_POST['save_post_nonce']) && !empty($_POST['save_post_nonce']) ) {
if ( !check_admin_referer('save_post_action', 'save_post_nonce') ) {
wp_die('Nonce verification failed');
}
}
This check will ensure that the nonce that was sent matches the expected value bound to the save_post_action action. If the check fails, execution will stop with an error.
- wp_verify_nonce()
This function checks whether the passed nonce is valid and has not expired. It should be used in request handlers for security checks.
wp_verify_nonce( $_POST['nonce_name'], 'action_name' );
Example:
if ( ! isset( $_POST['my_nonce'] ) || ! wp_verify_nonce( $_POST['my_nonce'], 'my_custom_action' ) ) {
wp_die( 'Error: invalid nonce' );
}
- check_admin_referer()
The check_admin_referer() function in WordPress is used to verify the authenticity of the request to protect against CSRF (Cross-Site Request Forgery) attacks. It checks that the request came from the same page as the form and that it contains the correct nonce. This is important for security when performing critical operations in the admin panel.
Function signature:
check_admin_referer( string $action = -1, string $query_arg = '_wpnonce' )
Parameters:
- $action (optional): A string that specifies the action the nonce is bound to. This value should match the one used when creating the nonce via wp_nonce_field(). This allows you to verify that the nonce was created for this action. If you pass -1, any nonce will be checked without being tied to a specific action.
- $query_arg (optional): This is the name of the parameter in the query that contains the nonce. By default, ‘_wpnonce’ is used. If a different name is used, that value should be specified here.
How does check_admin_referer() work?
The function performs several actions:
- Checks for the presence of a nonce in the request (usually passed via a hidden form field).
- Compares it with the value generated for the specified action ($action).
- If the nonce value is correct, the request continues.
- If nonce is missing or invalid, the function calls wp_die(), stopping script execution and preventing further operations.
Example:
if ( isset($_POST['nonce_field']) && !empty($_POST['nonce_field']) ) {
check_admin_referer('action', 'nonce_field');
}
The code checks that the nonce submitted via the form matches the nonce that was generated for the my_action action. If the check fails, execution will stop.
Embedding a nonce into a form:
<form method="POST">
<?php wp_nonce_field('action', 'nonce_field'); ?>
<input type="submit" value="Submit" />
</form>
This example creates a hidden field in the form that will contain a nonce bound to the action my_action. After submitting the form, you can use check_admin_referer(‘action’, ‘nonce_field’) to verify the authenticity of the request.
When to use check_admin_referer()?
The check_admin_referer() function should be used in scenarios where:
- To verify that the request was sent from the same page as the form (for example, when submitting data or performing operations involving changing data).
- To prevent CSRF attacks, where an attacker might attempt to send a fake request on behalf of a user.
The check_admin_referer() function is designed to protect against CSRF attacks in the WordPress admin panel by checking that the request was sent from a trusted page and contains the correct nonce. If the check fails, the request is stopped and an error message is displayed. Using this function helps to improve the security of the site and prevent fake requests from being made that could cause damage to the site or users.
Conclusion
Despite the presence of built-in protection mechanisms such as Nonce and referrer verification functions, using them incorrectly or ignoring them completely leaves the application vulnerable to attacks such as site configuration changes, privilege escalation, credential stuffing, and online store financial fraud. To prevent CSRF attacks in WordPress, it is recommended to generate and validate Nonce tokens for all critical actions, check user permissions via functions such as current_user_can(), check referrers using check_admin_referer() and wp_verify_nonce(), limit session expiration and regularly refresh tokens, and use HTTP POST methods to perform changes. Following these recommendations and regular security audits will minimize the risks associated with CSRF and increase the overall resilience of WordPress to external threats.