Research/CVE-2026-87796
N-dayCVE-2026-87796CriticalPublic

CVE-2026-87796: Multi Uploader for Gravity Forms, unauthenticated RCE

Multi Uploader for Gravity Forms 1.1.9 copies a chunked upload before it validates the type. The advisory names a PHP method. The HTTP action is something else. A 200 with a WordPress theme is not the bug.

Name
CVE-2026-87796: Multi Uploader for Gravity Forms, unauthenticated RCE
Type
N-day analysis
CVE
CVE-2026-87796
CVE Risk
critical
Disclosure Status
public
Vendor
sh1zen
Affected
Multi Uploader for Gravity Forms (gf-multi-uploader), all versions through 1.1.9; plugin removed from wordpress.org, no vendor patch at publication
Published
18 Sept 2026
Updated
18 Sept 2026
Tags
n-day, wordpress, file-upload, cwe-434, gravity-forms, unauthenticated

The advisory named a method

I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-87796 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, docker-compose.override.yml. Authorized lab only. It talks to loopback.

CVE-2026-87796 (NVD, GHSA-h7vp-g8q2-89c8) is an unauthenticated arbitrary file upload in Multi Uploader for Gravity Forms 1.1.9, slug gf-multi-uploader, author sh1zen. Wordfence scored it 9.8 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). Finder: Adam Rayyan Aryasatya. CWE-434. The plugin directory listing is gone. There is no patch in the 1.1.9 tag I sat with.

The advisory names move_file. That is a private PHP method. It is not an HTTP route. If you POST action=move_file at WordPress admin-ajax.php you get the theme back, tens of kilobytes of HTML, status 200, and a very convincing sense that you have done something. You have not.

This is the map I used to get from that 200 to a witness. Isolated lab, loopback only. I am not publishing a shell. The stack is in the CVE repo so you can run it at home. The client is the repo above.

What an attacker can do

Unauthenticated multipart POST, two chunks, then GET the landed object from the plugin tmp dir. Arbitrary file write to a web-served path. That is RCE if the bytes are PHP. The lab writes a GIF89a plus a unique string, not a shell.

The lab (run this at home)

Source of truth is lab/ on GitHub. The Dockerfile pins wordpress:6.4-php8.2-apache. Compose adds mysql:8.0 and wordpress:cli. Port on loopback only. Bind 1.1.9 of the plugin next to compose as ./gf-multi-uploader (SVN tag).

Dockerfile
# Loopback lab image pin for CVE-2026-87796. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache
YAML
# CVE-2026-87796 local WordPress lab. Loopback only.
services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress
      MYSQL_ROOT_PASSWORD: root
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 15s

  wordpress:
    image: wordpress:6.4-php8.2-apache
    ports:
      - "127.0.0.1:8088:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DEBUG: "1"
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_DEBUG_LOG', '/var/log/lab/debug.log');
        define('WP_DEBUG_DISPLAY', false);
    volumes:
      - wp_data:/var/www/html
      - ./gf-multi-uploader:/var/www/html/wp-content/plugins/gf-multi-uploader:ro
    depends_on:
      db:
        condition: service_healthy

  wpcli:
    image: wordpress:cli
    user: "33:33"
    volumes:
      - wp_data:/var/www/html
      - ./gf-multi-uploader:/var/www/html/wp-content/plugins/gf-multi-uploader:ro
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress
    depends_on:
      wordpress:
        condition: service_started
    entrypoint: ["sleep", "infinity"]

volumes:
  wp_data:

docker-compose.override.yml only routes logs. It is not required to hit the sink.

YAML
# App processes should log to stdout and/or /var/log/lab.
services:
  db:
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    volumes:
      - ./logs/db:/var/log/lab
      - ./logs/db-mysql:/var/log/mysql
  wordpress:
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    volumes:
      - ./logs/wordpress:/var/log/lab
      - ./logs/wordpress-apache:/var/log/apache2

Gravity Forms is commercial. The addon will not register its nopriv hook without a GFForms / GFAddOn class. This mu-plugin stub is the fixture I used so the sink exists without buying the parent product. It also publishes a page whose body is the upload nonce, and it returns a multi-uploader field with chunk_size set so the "try activate chunking" door is already open.

PHP
<?php
/**
 * Lab fixture for gf-multi-uploader 1.1.9 (CVE-2026-87796).
 * Gravity Forms is commercial; a tiny stub lets the addon register nopriv AJAX.
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

$mu_dir = WP_CONTENT_DIR . '/mu-plugins';
if ( ! is_dir( $mu_dir ) ) {
	wp_mkdir_p( $mu_dir );
}
$stub = <<<'PHP'
<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}
if ( ! defined( 'RG_CURRENT_PAGE' ) ) {
	define( 'RG_CURRENT_PAGE', basename( $_SERVER['PHP_SELF'] ?? '' ) );
}
if ( ! class_exists( 'GFForms' ) ) {
	class GFForms {
		public static function include_addon_framework() {}
	}
}
if ( ! class_exists( 'GFAddOn' ) ) {
	class GFAddOn {
		public static function register( $class ) {
			add_action(
				'init',
				static function () use ( $class ) {
					if ( ! class_exists( $class ) ) {
						return;
					}
					$inst = $class::get_instance();
					if ( method_exists( $inst, 'pre_init' ) ) {
						$inst->pre_init();
					}
					if ( method_exists( $inst, 'init' ) ) {
						$inst->init();
					}
				},
				5
			);
		}
		public function init_ajax() {}
		public function init_admin() {}
		public function init_frontend() {}
		public function pre_init() {}
		public function get_plugin_settings() {
			return array();
		}
		public function get_slug() {
			return 'gf_multiuploader';
		}
		public function meets_minimum_requirements() {
			return array( 'meets_requirements' => true );
		}
		public function is_gravityforms_supported() {
			return true;
		}
	}
}
if ( ! class_exists( 'RGFormsModel' ) ) {
	class GFMU_Lab_Field {
		public $type = 'multi-uploader';
		public function get_gfmu_field_settings() {
			return array(
				'filters'            => array( 'files' => '' ),
				'max_file_size'      => '10mb',
				'max_files'          => 10,
				'save_to_meta'       => false,
				'rename_file_status' => false,
				'chunk_size'         => '2mb',
			);
		}
	}
	class RGFormsModel {
		public static function get_field( $form_id, $field_id ) {
			return new GFMU_Lab_Field();
		}
	}
}
add_action(
	'plugins_loaded',
	static function () {
		do_action( 'gform_loaded' );
	},
	20
);
PHP;
file_put_contents( $mu_dir . '/gf-lab-stub.php', $stub );

wp_set_current_user( 0 );
$nonce = wp_create_nonce( 'gfmu-upload-nonce' );
$slug  = 'gfmu-lab-nonce';
$body  = 'GFMU_NONCE=' . $nonce;
$existing = get_page_by_path( $slug, OBJECT, 'page' );
if ( $existing ) {
	wp_update_post(
		array(
			'ID'           => (int) $existing->ID,
			'post_content' => $body,
			'post_status'  => 'publish',
		)
	);
	$post_id = (int) $existing->ID;
} else {
	$post_id = wp_insert_post(
		array(
			'post_type'    => 'page',
			'post_status'  => 'publish',
			'post_title'   => 'gfmu lab nonce',
			'post_name'    => $slug,
			'post_content' => $body,
		),
		true
	);
	if ( is_wp_error( $post_id ) ) {
		WP_CLI::error( $post_id->get_error_message() );
	}
}

WP_CLI::success( 'gf stub + nonce page id=' . (int) $post_id . ' GFMU_NONCE=' . $nonce );

Bring-up from the CVE repo lab/:

Plain text
git clone https://github.com/abraxas/CVE-2026-87796
cd CVE-2026-87796/lab
svn export https://plugins.svn.wordpress.org/gf-multi-uploader/tags/1.1.9 gf-multi-uploader
docker compose up -d --force-recreate
docker compose exec -T wpcli wp core install \
  --url=http://127.0.0.1:8088 \
  --title='CVE-2026-87796 Lab' \
  --admin_user=admin \
  --admin_password=labadmin \
  --admin_email=lab@localhost.invalid \
  --skip-email
docker compose exec -T wpcli wp plugin activate gf-multi-uploader
docker compose cp seed.php wpcli:/tmp/seed.php
docker compose exec -T wpcli wp eval-file /tmp/seed.php
python3 ../CVE-2026-87796-Abraxas-Labs.py

The seed is the fixture above; it is not in lab/ on GitHub. Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.

What the tree actually registers

Plupload talks to WordPress through a nopriv AJAX hook. The addon registers it in GFMUAddon::init_ajax with add_action:

PHP
add_action('wp_ajax_nopriv_gfmu-plupload-submit', array($this->pluploaderHandler, 'plupload_ajax_submit'));
add_action('wp_ajax_gfmu-plupload-submit', array($this->pluploaderHandler, 'plupload_ajax_submit'));

So the HTTP shape is POST to admin-ajax.php with action=gfmu-plupload-submit. Unauthenticated. The handler is GFMUHandlePluploader::plupload_ajax_submit. It checks a nonce for action gfmu-upload-nonce in $_REQUEST['nonce'], builds a GFMU_FileUploader, and calls handleUpload into the directory wp_upload_dir() plus the plugin's tmp name gfmu-uploads-tmp.

There is a comment on that handler that I kept rereading:

Script will not accept any .js or .php ot .html extensions regardless of validation settings.

That is a comment. Comments are not a control. The .php / .html / .js claim sits above a function that, on the chunked path, copies first.

Two paths, one of them copies first

handleUpload branches on $_REQUEST['chunks']. That is Plupload's chunked protocol: chunks is the count, chunk is the zero-based index.

Non-chunked (chunks missing or 1) calls validateUploadedFile before it picks a destination. Extension and mime get a vote while the bytes are still in PHP's $_FILES temp file.

Chunked (chunks > 1) appends parts into {name}.part with fopen ab, and on the last part it does this:

PHP
if ($this->move_file($tmp_chunk_file_path, $target)) {
    if (($validate_result = $this->validateUploadedFile($name, $target)) !== true) {
        @unlink($tmp_chunk_file_path);
        @unlink($target);
        return $validate_result;
    }
    return array(
        'result'   => 'success',
        'file_uid' => $this->uuid,
        'success'  => array("file_id" => $file_info['file_name'])
    );
}

move_file is copy then unlink of the source. The file is already at $target under the upload tmp dir, under the name the client sent, before validation runs. Validation may then delete it. Validation may also decide the bytes are fine.

validateUploadedFile checks the filename extension against allowedExtensions, then asks wp_check_filetype_and_ext about the contents, with get_allowed_mime_types() as the allow-list. Later it will trust WordPress's type against that global list. A body that starts GIF89a can be a GIF as far as finfo and WordPress are concerned, while the name on disk is still .php. The copy already used the name.

That is the CVE. Not "uploads are hard." Order.

The 200 that was a homepage

The first client I pointed at this was polite. It used the names in the CVE paragraph. Function names in an advisory are PHP methods unless a hook says otherwise. move_file is not action=. You get 200. You get the theme. You get nothing in the plugin tmp dir.

A few other ways to lose without learning anything:

  • admin-ajax.php body 0. Wrong action, or the addon never booted. Gravity Forms is a commercial dependency; if GFForms is not there, the nopriv hook is not there.
  • Server error. plus a nonce complaint. You reached the handler. You did not reach handleUpload. See verify_nonce against gfmu-upload-nonce.
  • try activate chunking. No field metadata, enable_chunked stays false, and the class compares a 10mb sizeLimit against PHP's upload_max_filesize / post_max_size before it writes. That error is a door, not the sink.
  • A GET. The hook is POST. Multipart or it returns "Not a multipart request."
  • An allowed jpg that lands. That is the product working. This CVE is the arbitrary-type path.

The tell, once the router actually matches: the response shrinks. Theme HTML is tens of kilobytes. The JSON I wanted was tens of bytes. {"success":true} on the first chunk, then {"result":"success",...} on the last. If you are still reading a DOCTYPE, you are still lost.

What I actually did

Treat the on-disk product as the spec. Wordfence already pointed at the lines. I read them in order. Proof of concept: CVE-2026-87796-Abraxas-Labs.py.

Nonce like a visitor. The handler will not talk without gfmu-upload-nonce. Hard-coding a nonce from a gist is how you get Server error. forever. I pulled it the way an unauthenticated visitor would: whatever the rendered form or a public REST payload already prints. Same motion on a real site.

Field ids so chunking is on. pluploader_server_settings returns empty unless currentFormID and currentFieldID are present and the field type is multi-uploader. Empty settings means default enable_chunked = false, and you die on the size check. A field with chunk_size set skips that check. Then chunks=2 is what takes the copy-first branch.

Two POSTs, then a GET. Chunk 0 writes the part file and returns success: true. The last chunk is the one that calls move_file. Then GET the landed object from the plugin tmp dir and look for a unique string in the body. I used an echo of a witness token, not a reverse shell, not an outbound connect. If the string is in the file, the sink wrote attacker-controlled bytes to a path the web server will serve. That is the proof. Anything else is theatre.

Last lab run, trimmed:

Plain text
nonce GET status=200
chunk 0 status=200 len=16
{"success":true}
chunk 1 status=200
{"result":"success","file_uid":"poc_witness","success":{"file_id":"poc_witness-1.php"}}
get status=200
GIF89a
POC_WITNESS_87796
SUCCESS CVE-2026-87796

Small JSON. Then the file. Then the string. That is the whole argument. The client that produced it is on GitHub.

Wrong turns: admin-ajax body 0 (wrong action, or Gravity Forms never booted so the nopriv hook is missing); Server error. plus a nonce complaint; GET; an allowed jpg that lands (the product working).

What this is not

It is not Gravity Forms the commercial plugin. That is a different product with a different CVE (Wordfence has a 2026 write-up on the first-party uploader). This is the third-party field that hangs off it. The directory listing is gone; Wordfence still has this record as unpatched through 1.1.9. If you still have the zip on a site, remove it.

I am not going to print a multipart recipe you can paste at someone else's admin-ajax.php. The call chain and the order bug are the useful part. If you own the box, run the proof of concept against loopback.

Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like the plugin was fine.

References