If you run Gravity Forms, make sure it’s on 3.1.3 or later. The current version is 3.2.1. Then check whether any form creates posts using a List-type custom field. On October 30, three vulnerabilities in Gravity Forms were published, all open to visitors with no login. The worst, CVE-2026-103039, is scored 9.8: an anonymous form submission can place a PHP object into post meta. On the wrong form and server setup, that becomes code running on your server. The fixes shipped in September and on October 1, and no attacks have been reported. A site that updates regularly is already covered. The one to worry about is a site whose license lapsed and quietly stopped getting updates.
At a glance
| CVEs | CVE-2026-103039, CVE-2026-97645, CVE-2026-90437 |
|---|---|
| Plugin | Gravity Forms, a commercial form plugin (not on WordPress.org, so there’s no public install count) |
| Type | Unauthenticated PHP object injection (103039), unauthenticated stored XSS aimed at administrators (97645, 90437) |
| Severity | CVSS 9.8 (103039), 7.2 (97645), 7.2 (90437) |
| Affected | 3.1.2 and earlier (103039, 97645); 3.1.1 and earlier (90437) |
| Fixed | 3.1.3, released October 1, 2026. Latest is 3.2.1 (October 28) |
| Status | No exploitation reported. CISA’s enrichment says “none,” but marks all three as automatable |
What’s wrong
CVE-2026-103039: one call that didn’t get the fix
Gravity Forms can create a WordPress post from a form submission, using Post Fields. One of those is the Post Custom Field, which saves a submitted value as post meta. It can take several input types, including List, which lets a visitor add rows of values. It can also use a content template, which shapes the value before it’s saved.
PHP’s unserialize() turns a stored string back into data, and it can also create objects of any class the application has loaded. That’s why it’s dangerous on untrusted input: an attacker can choose the class and fill in its properties, and PHP will run that class’s magic methods when the object is used. WordPress’s maybe_unserialize() is a thin wrapper around it with no class restriction.
The Gravity Forms team knew this. In 3.1.1 they added their own GFCommon::maybe_unserialize(), which passes allowed_classes => false so no objects can be created. But GFFormsModel::create_post() still called the WordPress version. On a List-type custom field with a content template enabled, the submitted row reached that call as sent. The result:
- An anonymous visitor submits the form with a serialized PHP object in the List field.
- Gravity Forms unserializes it and saves the resulting object as post meta.
- Anything that later reads that meta gets the object back.
Gravity Forms itself contains no class an attacker can use for damage (a “gadget chain”). So on most sites this needs another plugin or theme to supply one. Reported by Tin Pham (TF1T), Hao Ngo and Trong Pham.
When WordPress supplies the chain
The record describes one case where no other plugin is needed: the custom field is mapped to _wp_page_template, the meta key that decides which template file a page uses. WordPress expects a string there. Handed an object, core’s validate_file() doesn’t object, because it returns “valid” for anything that isn’t a scalar. I checked this in the 7.1.4 source, and it’s still there. According to the record, WP_Theme::__toString() then resolves a path the attacker controls, which gives them a choice of file for WordPress to load as the template.
Including a chosen file only becomes code execution if there’s a useful file to include. The record names two server conditions: a readable PEAR install and register_argc_argv turned on. That combination is a long-known trick: pearcmd.php reads its arguments from the query string, and can be made to write a file. All of it has to line up, which is a good reason to check your server settings even after you patch.
CVE-2026-97645 and CVE-2026-90437: traps for administrators
Both XSS bugs store a payload in a form entry and wait for an administrator to open it.
- CVE-2026-90437 (fixed after 3.1.1). Post Excerpt, Post Custom Field and rich-text Paragraph fields allow some HTML. An entity-encoded payload in a
data-dialog-confirmattribute passeswp_kses_post()when saved, then the plugin’s admin JavaScript hands the decoded value toinsertAdjacentHTML(). Reported by daroo. - CVE-2026-97645 (fixed in 3.1.3). Through a List field. The administrator has to click the injected link on the entry detail screen and confirm a dialog, which fires a callback the attacker chose. Reported by Supakiad S. (m3ez).
Are you exposed?
For the 9.8, the condition is specific: a form with a Post Custom Field set to the List input type, with a content template enabled. To shortlist forms, search the form definitions:
wp db query "SELECT form_id FROM $(wp db prefix)gf_form_meta WHERE display_meta LIKE '%post_custom_field%' AND display_meta LIKE '%\"inputType\":\"list\"%'"
This can match a form where the List field is a different field, so open each result and look at its Post Custom Fields. Check three things: the input type, whether Create content template is on, and the custom field name. A form mapped to _wp_page_template has no good reason to exist, and needs a close look.
A site with no such form isn’t exposed to this bug. The XSS bugs are broader: any public form on a vulnerable version can receive those entries, so update regardless.
What to do
1. Update
wp plugin list --name=gravityforms --fields=name,status,version,update
Anything below 3.1.3 is vulnerable. Go to the latest available version, currently 3.2.1. Updates come through your Gravity Forms license, so if it has expired, the dashboard may show nothing at all. An inactive license on a live site looks exactly like “up to date.” Check the version number itself, and download the update from your account if you need to.
2. If you can’t update today
Remove the List-type Post Custom Field from any form that has one, or take the form off public pages. That handles the 9.8, but not the XSS bugs, so don’t open entries from public forms until you’ve updated.
3. Check your PHP settings
wp eval 'var_dump( ini_get( "register_argc_argv" ) );'
find / -name pearcmd.php 2>/dev/null
Run these on the web server, because the command line can have its own PHP configuration. The php.ini-production file sets register_argc_argv to Off, but PHP’s built-in default is On, so a server with no php.ini, including some container images, has it on. Set it to Off in your web PHP configuration, and leave PEAR off web servers that don’t need it. That closes this route and others like it, since this technique isn’t specific to Gravity Forms.
4. Check whether you were hit
Nobody has reported attacks, but all three records are now public. If you had an exposed form on a vulnerable version, look:
- Page templates:
_wp_page_templateshould holddefaultor a plain file name. This finds anything that looks serialized:wp db query "SELECT post_id, meta_value FROM $(wp db prefix)postmeta WHERE meta_key = '_wp_page_template' AND meta_value REGEXP '^[aOC]:[0-9]+:'" - Objects in post meta: run the same search with no
meta_keyfilter, looking for values that start withO:. Plugins sometimes store objects legitimately, so judge each one, but look hard at recent posts created by your forms. - Entries: search recent entries in the List and rich-text fields for serialized strings,
data-dialog-attributes and encoded markup. - Files: new PHP files in
uploads/,/tmpor anywhere writable. - Users: administrators you didn’t create, which is what the XSS bugs are usually for.
- Logs: query strings containing
pearcmd, and POST requests to pages that hold your forms.
If you find evidence, treat the site as compromised: restore from a clean backup, reset every password, log out all sessions and rotate the salts. The remote-access audit covers the places attackers leave a way back in.
Where sources disagree
- Severity. 9.8 is the worst-case score, and it marks attack complexity as low. In practice, the code-execution path needs a specific form, a helpful gadget chain or the
_wp_page_templatemapping, and particular server settings. Treat the number as “update now,” not “every site is at risk of takeover.” - Automatable. CISA’s enrichment says these can be automated, which is true once an attacker has found a suitable form. Finding one is the slow part.
- Vendor disclosure. The Gravity Forms changelog says only “Added security enhancements” for 3.1.1, 3.1.2 and 3.1.3. The CVEs came a month after the last fix, so there was nothing in the changelog to warn you to hurry. Wordfence lists 3.1.3 as the patched version for CVE-2026-103039. The record for CVE-2026-90437 implies 3.1.2 for that bug.
The bigger lesson
A hardened function doesn’t help the calls that don’t use it. Gravity Forms got this right: they saw the risk and wrote a safe wrapper. Then one call to the unsafe version stayed in the code. When you fix a dangerous pattern, the job isn’t finished when the safe helper exists. It’s finished when a search for the old call comes back empty, and when something stops it coming back. For your own code, that means a grep, and a lint rule if you have one:
grep -rnE "(^|[^:_a-zA-Z])(maybe_)?unserialize\(" wp-content/themes wp-content/plugins --include=*.php
The XSS bug is a lesson you may have seen here last week. As with the WordPress 7.1.4 Interactivity API bugs, a filter approved the value when it was saved, and something later turned it into markup. And like the ACF Extended form bugs, these all start at a form, the one place that accepts input from the whole internet.
Last, a form plugin that creates posts from anonymous submissions is a powerful feature. If you don’t use Post Fields, your forms can’t reach this code path at all. Plugins earn their place feature by feature, and so do the options inside them.
Sources
- CVE.org: CVE-2026-103039
- CVE.org: CVE-2026-97645
- CVE.org: CVE-2026-90437
- Wordfence: Gravity Forms ≤ 3.1.2, unauthenticated PHP object injection via Post Custom Field
- Wordfence: Gravity Forms ≤ 3.1.2, unauthenticated stored XSS via List field
- Wordfence: Gravity Forms ≤ 3.1.1, unauthenticated stored XSS via post and rich-text fields
- Gravity Forms changelog
- PHP manual: register_argc_argv