WordPress PHP Object Injection: 115 unserialize() Calls
On 12 August 2026, an advisory landed for WooCommerce Subscriptions: unauthenticated PHP object injection, escalating to remote code execution, in every version below 9.1.0. The trigger condition is the interesting part — it applies when High-Performance Order Storage is switched on.
HPOS is switched on here. So before writing anything about it, I ran the check on our own production store, and then kept going: every single unserialize() call in wp-content/, across both plugin trees, classified by whether it can be exploited at all.
115 call sites. 79 files. 21 of them carry the one-line mitigation that makes PHP object injection impossible, and 94 do not. That second number is the one people misread, so most of this post is about what it does and does not mean.
What PHP object injection actually is
The bug class has an intimidating name and a boring mechanism. PHP’s unserialize() takes a string and rebuilds it into variables — including objects. Rebuilding an object runs that class’s magic methods: __wakeup(), __destruct(), sometimes __toString().
So if an attacker can control the bytes going into unserialize(), they can name any class that happens to be loaded and hand it any property values they like. They do not get to write code. They get to pick code that already exists and choose its inputs. Chain a few of those together — a cache class whose destructor writes a file, a logger whose __toString() concatenates a path — and you have arbitrary file writes or command execution. That chain is what security researchers mean by a “gadget chain,” and the gadgets almost never live in the vulnerable plugin. They live in its dependencies, or in another plugin entirely.
Which is why this class of bug is so slippery on WordPress. A site runs 48 plugins. The vulnerable unserialize() is in one of them. The gadget that turns it into code execution is in a completely different one, shipped by a different vendor, and neither vendor can see the combination.
Since PHP 7.0 there has been a fix that takes eleven characters:
unserialize($data, ['allowed_classes' => false]);
With that flag, any object in the payload comes back as __PHP_Incomplete_Class. No constructor, no magic methods, no gadget chain. The data still deserializes; the weapon does not. It is close to a free fix, and the audit below is essentially a measurement of how many people have applied it.
First: is this store actually affected?
Three things all have to be true for the August advisory to matter to a given site. I checked each one rather than assuming, and the middle one is what settles it.

High-Performance Order Storage is on: woocommerce_custom_orders_table_enabled reads yes, there are 836 rows in wp9x_wc_orders, and zero legacy shop_order posts. The store has fully migrated. That is the precondition the advisory names.
WooCommerce Subscriptions is not installed. Zero plugin directories match *subscription*, against a control of five directories matching *woocommerce* — so the search works, and the absence is real rather than a broken glob.
There is a trap in that check worth naming, because I nearly walked into it. Grepping the plugin tree for the class name WC_Subscriptions returns five files. That reads instantly as “we do have Subscriptions.” All five belong to WooCommerce Payments, which ships an optional integration that checks whether Subscriptions exists before doing anything. A grep finds candidates. Only the surrounding code classifies them.
Verdict: the dangerous setting is on, the vulnerable code is absent, and only the second fact spares us. That is a thin kind of safe — it is luck about which plugins we happened to install, not a property of the site. Which is what pushed the audit wider.
Every unserialize() call on the site
I swept wp-content/ for raw unserialize( call sites — excluding WordPress’s own maybe_unserialize() wrapper, which is a different question — and classified each one by whether allowed_classes appears in the call.

| Measurement | Result |
|---|---|
Raw unserialize( call sites in wp-content/ | 115 |
| Distinct files containing one | 79 |
Call sites passing allowed_classes | 21 (18.3%) |
| Call sites with no class restriction | 94 (81.7%) |
maybe_unserialize( call sites (separate question) | 108 |
Call sites taking $_POST/$_GET/$_REQUEST/$_COOKIE on the same line | 0 |
Same sweep, wp-includes/ and wp-admin/ | 14 and 1 |
Two controls ran inside the same command, because a sweep that returns a tidy number is worth less than a sweep that proves it can find things. A nonsense needle returned 0 files; the string function returned 16,968. And because a one-line grep would miss a call formatted across two lines, I re-ran it with a two-line window: still 21. No multi-line calls are hiding the flag.
Where the calls live

| Plugin | Hardened | Unrestricted | Total |
|---|---|---|---|
| wordfence | 1 | 27 | 28 |
| w3-total-cache | 5 | 19 | 24 |
| bbpress | 0 | 20 | 20 |
| MxChat (all add-ons) | 12 | 0 | 12 |
| seo-by-rank-math | 2 | 4 | 6 |
| wpforms-lite | 0 | 5 | 5 |
| woocommerce | 1 | 3 | 4 |
| woocommerce-payments | 0 | 3 | 3 |
| imagify | 0 | 3 | 3 |
| google-listings-and-ads | 0 | 3 | 3 |
What 94 is not
It is not 94 vulnerabilities. It is not 94 bugs, 94 findings, or 94 anything you should file a ticket about. Publishing it as a vulnerability count would be the same error as reading a user-agent string as proof of who sent a request.
An unserialize() call is only dangerous when an attacker can influence its input. Reading the actual code around these matches, most of them cannot be reached that way at all:
- bbPress accounts for 20 of the 94, and every one is in a forum importer. Files like
includes/admin/converters/vBulletin.phpunserialize a password blob out of a legacy forum database that an administrator explicitly pointed the importer at. To exploit it you would need to already be an admin, and to already control the database you are importing from. At that point you have easier options. - Wordfence’s 27 and W3 Total Cache’s 19 are overwhelmingly cache and config reads — data the plugin itself serialized and wrote moments earlier.
- Zero call sites anywhere in
wp-content/pass a superglobal intounserialize()on the same line. That is the crude version of the dangerous pattern, and nobody here is doing it.
So what is the number good for? It measures a habit. Every one of those 94 call sites is one refactor away from being reachable — the day someone routes a webhook payload, an import file, or a REST body into a function that already contained an unrestricted unserialize(), the vulnerability appears with no line of dangerous-looking code being written. The flag costs nothing and removes the whole category in advance. 18.3% adoption across a mature plugin ecosystem is the finding.
The 108 calls I left out, and why they are worse
The sweep above deliberately excluded maybe_unserialize(), WordPress’s own wrapper, on the grounds that it is a different question. It is — and having gone and read it, it is a more uncomfortable one.
Here is the entire function, from core 7.0.4:
function maybe_unserialize( $data ) {
if ( is_serialized( $data ) ) {
return @unserialize( trim( $data ) );
}
return $data;
}
No allowed_classes. And that is not an oversight in one function — the string allowed_classes appears zero times in the whole of wp-includes/ and wp-admin/. Core does not use the flag anywhere.
The guard that is there, is_serialized(), is easy to mistake for a safety check. It is a format check. It answers “does this string look like PHP serialized data?” — and O:8:"stdClass":1:{...} is a perfectly well-formed serialized string, so it sails through and gets rebuilt into an object with its magic methods intact. Checking the format of a payload tells you nothing about whether you want to instantiate what is inside it.
So the 108 wrapper call sites in wp-content/ are not a safer category than the 94. They are the same exposure with the unsafe part moved into core, where a plugin author reading their own code sees a tidy WordPress function rather than a raw unserialize(). Every option read through get_option() and every post meta read goes through this path.
To be fair to core: it cannot simply add the flag. Plugins have stored real objects in options and post meta for fifteen years, and restricting classes in maybe_unserialize() would break them on the next update. That is a genuine backwards-compatibility trap, not negligence — but it does mean the mitigation is a per-plugin decision, forever, and only 18.3% of the call sites on this site have made it.
What our own code does
MxChat’s twelve call sites all pass allowed_classes, and I want to be precise about why that is less impressive than the chart makes it look. They are all the same call, doing the same job:
? unserialize($row->embedding_vector, ['allowed_classes' => false])
Those are embedding vectors — arrays of floats the plugin serialized itself with maybe_serialize() before writing them to its own table, then reads back to compare against a query. The data never leaves the site, and it is an array of numbers, so restricting classes costs literally nothing. This is one codebase with one reason to call unserialize(), not a security program. Wordfence has 28 call sites because it does 28 different things.
The honest version of the comparison: a plugin with a narrow reason to deserialize should have 100% coverage, and any that does not is leaving a free mitigation on the table.
The 60-second audit for your own site
Run this over SSH from your WordPress root. It is read-only.
# every raw unserialize() call site under wp-content
grep -rn --include=*.php -E '(^|[^_a-zA-Z])unserialize\s*\(' wp-content/ | wc -l
# how many of them restrict classes
grep -rn --include=*.php -E '(^|[^_a-zA-Z])unserialize\s*\(' wp-content/ \
| grep -c 'allowed_classes'
# the genuinely alarming pattern: user input straight into unserialize()
grep -rn --include=*.php -E '(^|[^_a-zA-Z])unserialize\s*\(.*\$_(POST|GET|REQUEST|COOKIE)' wp-content/
# controls — prove the search works before believing a zero
grep -rl --include=*.php 'ZZQQNONSENSE' wp-content/ | wc -l # expect 0
grep -rl --include=*.php 'function ' wp-content/ | wc -l # expect thousands
Read the third command’s output first. If it returns anything, stop and look at it properly — that is the pattern the August advisories are about. If it returns nothing, you are in the same position we are: no obvious hole, and a large pile of call sites that are fine today because of how they happen to be called.
Then check the boring thing that actually protects you:
wp plugin list --update=available --format=table
wp core check-update
Both August advisories were fixed by an update that was already available. The exploit path only stays open on sites that do not install it — which, as we found out the hard way, is not the same thing as sites that have updates turned off.
Why chatbot plugins sit in this blast radius
AI chat plugins are unusually exposed to this bug class, and it is worth saying so plainly given what we sell. They store conversation state, transcripts, per-session context, tool-call results and settings blobs — all structured data, all of which has to be written somewhere and read back. Serialization is the path of least resistance for every one of those, and each one touches data that originated, at least partly, with a visitor.
If you are evaluating any chatbot plugin, that is a fair thing to ask its vendor about: what does it serialize, where does it store it, and does it restrict classes when it reads it back. It is a question with a checkable answer. Ours is above — twelve call sites, all restricted, all reading vectors the plugin wrote itself. You can verify it with the same grep on your own install of MxChat rather than taking our word for it.
Limits of this audit
One site, 48 active plugins, one afternoon. The sweep finds call sites, not exploitability — establishing that would mean tracing every input to every one of the 94, which is a week of work and would still only describe this install. Multi-line calls were checked with a two-line window and none were found, but a call split across three lines would have escaped. And maybe_unserialize() — 108 more call sites — is a related question this audit deliberately did not open, because WordPress’s wrapper has its own behaviour worth a separate look.
The second August advisory, against Ajax Search Lite, is reported in aggregator roundups as CVE-2026-28139 with a CVSS of 9.8. I could not confirm it in a primary vulnerability database — the object-injection entry I can find for that plugin is an older one — so this post rests on the WooCommerce Subscriptions advisory, which I did verify. That plugin is not installed here either.
Questions people actually ask about this
Does a security plugin protect me from object injection? Partially, and not in the way you would hope. A firewall can pattern-match serialized payloads arriving in requests, which catches unsophisticated attempts. It cannot see the gadget chain, because the chain is assembled from classes already loaded on your site — nothing malicious crosses the wire except a string that looks like ordinary serialized data. Patching remains the control that works.
Should I turn off High-Performance Order Storage? No. HPOS was the trigger condition for one advisory in one plugin; it is not itself the vulnerability, and turning it off on a store with 836 migrated orders trades a hypothetical for a real migration risk. Update the plugin instead.
Is maybe_unserialize() safe to use in my own plugin? It is safe for data you wrote yourself and are confident nobody else can write. It is not a sanitiser. If the value could have originated with a visitor, a webhook, an import file or a REST body, call unserialize() directly with ['allowed_classes' => false] — or store JSON instead, which has no object-instantiation semantics at all.
How do I know if I was already hit? You largely cannot from the outside, which is the unpleasant part. Object injection leaves no distinctive log line — the request looks like a normal POST. What you can do is check file modification times under wp-content/ against your deployment history, and take the update.
If you want the same treatment applied to other corners of a real WordPress install, we have done it for REST API routes and for stored API keys. Same method, same production site, same willingness to publish the number when it is unflattering.
Related: we also ran the two published prompt-injection tests against this plugin’s own chat path — what a study of 17 chatbot plugins found, and where ours lands.