Introduction
A form plugin running on more than 5 million websites shipped a safe deserialization helper, then kept calling the unsafe one in a code path that accepts unauthenticated submissions. That gap is CVE-2026-103039, a PHP object injection flaw in Gravity Forms that lets an unauthenticated attacker plant a live PHP object into post metadata, with conditional paths toward remote code execution depending on what else is installed.
Gravity Forms, developed by Rocketgenius, Inc., is a commercial form management system for WordPress used to collect, manage, and share business data. The vendor states the product powers more than 5 million websites worldwide. Because it processes attacker-reachable input by design, a deserialization defect in its submission handling matters across a lot of sites.
Technical Information
The weakness is CWE-502, Deserialization of Untrusted Data. WordPress core exposes a global function, maybe_unserialize(). If a string looks serialized, that function hands it to PHP's unserialize() with no class restriction. A string shaped like O:8:"SomeClass":... therefore becomes a real object, and that object's magic methods (__unserialize() or __wakeup()) can run later.
In version 3.1.1, Gravity Forms added a safer static method with an almost identical name:
GFCommon::maybe_unserialize( $data, $allowed_classes = false )
Its documentation describes unserializing data without instantiating PHP objects by default. The false default is forwarded as allowed_classes, so PHP turns any embedded object into an inert __PHP_Incomplete_Class instead of the attacker's chosen class.
The defect is that GFFormsModel::create_post() still called the unsafe WordPress global when handling a Post Custom Field with the List input type. Wordfence notes that when a content template is enabled, the submitted row reaches that call unchanged. The result is an object injection primitive reachable with no authentication and no user interaction, consistent with the CVSS 9.8 vector.
What the primitive does and does not give you
The injection point lets an attacker place a live PHP object into post meta. On its own it does not establish code execution. Gravity Forms contains no applicable gadget chain for this issue. The advisory documents two routes that can escalate the primitive:
- An additional plugin or theme supplies a usable property oriented programming (POP) chain. OWASP explains that PHP object injection becomes actionable when available classes and magic methods provide useful behavior, and that those classes must be declared or autoloadable at deserialization time.
- The custom field is mapped to the protected
_wp_page_templatemeta key. In that case WordPress itself supplies the chain:validate_file()accepts non-scalars,WP_Theme::__toString()resolves an attacker-controlled path, and on hosts with a readable PEAR installation andregister_argc_argvenabled this reaches remote code execution.
The 9.8 rating describes severe potential impact, not an observed compromise of every affected site. NVD displays Wordfence's score and has not supplied its own independent assessment.
Attack flow
Based on the briefing, an unauthenticated attacker would:
- Identify a Gravity Forms installation at or below version 3.1.2.
- Locate a form containing a Post Custom Field with the List input type and a content template enabled.
- Extract the form submission nonce (
_gform_submit_nonce_<form_id>). - Submit a serialized PHP object as the List field value, which reaches the unrestricted
maybe_unserialize()call increate_post()and is instantiated into post meta. - Depending on available gadget chains or the
_wp_page_templatemapping and host configuration, achieve conditional code execution.
Proof of Concept
A public proof-of-concept exists in the GitHub repository noxcrypt-x/CVE-2026-103039, created on October 31, 2026, written in Python by the handle "NXCRYPT". The repository contains a single working PoC script, CVE-2026-103039.py, plus a README.md. It is reviewable but not a self-contained end-to-end RCE exploit: the optional PHP payload generator gen_payload.php is left out on purpose (confirmed by .gitignore and the README), so the operator must supply a serialized PHP gadget-chain payload via --payload or --payload-file. The author claims no live target validation.
From the script source, it implements:
- Version detection via
wp-content/plugins/gravityforms/readme.txt(parsingStable tag:) with a JS?ver=fallback, comparing againstVULNERABLE_MAX = (3, 1, 2). - Form and field auto-discovery by regexing
gform_<id>,data-formid, andfield_<form>_<field>/gfield--type-<t>from candidate pages (/,/contact/,/contact-us/,/?gf_page=preview&id=N). - Nonce extraction of
_gform_submit_nonce_<form_id>. - Payload submission as a form POST, setting both
input_<field_id>andinput_<field_id>[0][0]to the serialized payload. - Optional CAPTCHA handling (manual answer, cached replay, pytesseract OCR, wordlist brute, or 2Captcha API).
- RCE verification by scanning responses and follow-up post URLs for markers
uid=\d+,gid=\d+, and the sentinelGF_CVE_2026_103039_OK. - Multi-target threading, field scanning (
--scan-fields), and JSON output.
The submission wiring in the script is:
data = { "gform_submit": str(form_id), f"is_submit_{form_id}": "1", f"_gform_submit_nonce_{form_id}": nonce, } data[f"input_{field_id}"] = payload data[f"input_{field_id}[0][0]"] = payload
Representative usage from the script's own documentation:
# Version detect python CVE-2026-103039.py -u https://target --detect # Auto-detect form/field IDs python CVE-2026-103039.py -u https://target --auto-detect # Submit a (caller-supplied) serialized payload and verify python CVE-2026-103039.py -u https://target \ --form-id 1 --field-id 2 \ --payload "O:13:\"WP_HTML_Token\":..." \ --form-page https://target/contact/ --verify # Scan all fields of a form python CVE-2026-103039.py -u https://target --form-id 1 --scan-fields # Mass scan python CVE-2026-103039.py -l targets.txt --detect --threads 20 -o results.json
This is a genuine, publicly available PoC and scanner that automates target identification and delivery of the object-injection vector, which lowers the bar for exploitation. But because the gadget-chain payload generator is withheld and no live RCE run is demonstrated, full weaponized unauthenticated RCE still requires the operator to supply a working serialized PHP object gadget chain, and depends on a suitable gadget being present on the target.
Patch Information
The fixed version is Gravity Forms 3.1.3, released October 1, 2026. Wordfence, the CNA, lists 3.1.3 as the patched version and advises updating to 3.1.3 or a newer patched release. The vendor's changelog entry for 3.1.3 only states "Added security enhancements" and does not name the CVE. A third-party mirror of the plugin source (codewurker/gravityforms, commit 32ea71f, "Updates to 3.1.3") shows the exact change. One upstream system listed 3.2.1 as the fix; 3.2.1 is a later release that also covers this bug, but the change first appears in 3.1.3.
The core fix in forms_model.php is a single line:
@@ -5164,7 +5164,7 @@ public static function create_post( $form, &$lead ) { switch ( RGFormsModel::get_input_type( $custom_field ) ) { case 'list': - $value = maybe_unserialize( $value ); + $value = GFCommon::maybe_unserialize( $value ); if ( is_array( $value ) ) {
Normal List data is a serialized array of rows and columns and still decodes correctly, but a serialized object can no longer be turned into a live instance. Because no attacker-controlled object is created, nothing usable reaches post meta, which also blocks the _wp_page_template to WP_Theme::__toString() chain described in the advisory.
The 3.1.3 diff also replaces the global maybe_unserialize() with GFCommon::maybe_unserialize() at several other call sites that read stored List data, closing a second path where a stored payload could come back to life when an entry is read later:
includes/webapi/webapi.php:maybe_json_encode_list_fields(), for List field values in the v1 REST API.includes/webapi/v2/includes/controllers/class-gf-rest-controller.php: bothmaybe_json_encode_list_fields()andprepare_entry_for_response(), for List fields in v2 REST responses.gravityforms.php: decoding the add-ons API response body ininstall_addon_translations().settings.php: decoding the plugin-info response ininstall_recaptcha_addon().
For example, in the v2 REST controller:
- $entry[ $field->id ] = maybe_unserialize( $entry[ $field->id ] ); + $entry[ $field->id ] = GFCommon::maybe_unserialize( $entry[ $field->id ] );
The same release also adds GF_Field_List::sanitize_list_value(), which runs wp_kses() over every string cell before List rows are serialized in get_value_save_input(), and moves several field types to a stricter get_entry_allowed_html() policy. Those changes appear to address a separate List-field stored XSS fixed in the same version, not the object injection.
The reviewer lesson is direct: adding a safe helper secures nothing until every old call site is switched over. The hardened function had existed since 3.1.1, and the bug survived because one create_post() branch still used the WordPress global. A quick way to check a patched install is to grep for remaining direct calls to maybe_unserialize( or unserialize( on user-influenced data.
Affected Systems and Versions
- Affected product: Gravity Forms for WordPress (Rocketgenius, Inc.).
- Vulnerable versions: all versions up to and including 3.1.2.
- Vulnerable configuration: a Post Custom Field using the List input type with a content template enabled, so the submitted row reaches the unrestricted
maybe_unserialize()call verbatim. - Not remediated: versions 3.1.1 and 3.1.2, despite the hardened
GFCommon::maybe_unserialize()helper existing since 3.1.1. - First patched version: 3.1.3 (released October 1, 2026). Later releases such as 3.2.1 also contain the fix.
- Conditional RCE prerequisites: a POP gadget chain from another installed plugin or theme, or the
_wp_page_templatemeta-key mapping on a pre-fix WordPress with a readable PEAR install andregister_argc_argvenabled.
Vendor Security History
In July 2025, Gravity Forms issued a security incident notice stating that limited manually downloaded packages were maliciously modified: 2.9.11.1, downloaded on July 9 or 10, and 2.9.12, downloaded on July 10. Automatic updates to 2.9.12 through the WordPress dashboard and packages downloaded on other dates were not affected. The modifications attempted to block updates, contact an external server for additional payloads, and potentially add an administrative account. The vendor responded by releasing 2.9.13, scanning other downloadable packages, updating service credentials, auditing administrative accounts, cycling passwords, and working to disable associated infrastructure.
That incident concerns compromised software distribution, while CVE-2026-103039 concerns processing an attacker submission through vulnerable application code. They are distinct and should not be treated as the same campaign.
References
- NVD: CVE-2026-103039
- CVE Record: CVE-2026-103039
- Wordfence advisory: Gravity Forms <= 3.1.2 Unauthenticated PHP Object Injection via Post Custom Field
- Patchstack: Deserialization of untrusted data in Gravity Forms
- Censys advisory: Gravity Forms PHP Object Injection
- IONIX threat center entry
- Gravity Forms Security Whitepaper
- Gravity Forms Changelog
- How to Manually Update Gravity Forms
- The Importance of Updates
- Gravity Forms July 2025 Security Incident Notice
- About Gravity Forms
- PHP Manual: unserialize
- OWASP: PHP Object Injection
- WordPress advisory GHSA-7hp8-65ch-5whp (page-template resolution RCE)
- codewurker/gravityforms commit 32ea71f (Updates to 3.1.3)
- GravityKit docs: GFCommon class
- Public PoC repository: noxcrypt-x/CVE-2026-103039
- PoC script: CVE-2026-103039.py
- PoC README
- PoC repository metadata



