Most people picture a website attacker as someone outside trying to get in. A large share of real WordPress compromises start from inside, with an account the site handed out for free. If your site lets visitors register, and most that sell courses, memberships or downloads do, then “authenticated attackers only” does not mean what it sounds like. A vulnerability disclosed in September 2026 in a WordPress e-learning plugin is a thorough demonstration: a subscriber account, obtained by filling in the site’s own registration form, was enough to run commands on the server.

What PHP object injection is

PHP can turn a data structure into a piece of text and turn it back again. The process is called serialization, and WordPress relies on it constantly: whenever an option or a piece of user data is an array rather than a simple value, WordPress converts it to text before storing it in the database and converts it back when it reads it.

The stored text describes what it holds, including how long each piece is. A short string of four characters is stored with a note saying it is four characters long. Reading it back is a matter of following those notes.

Turning text back into data is where the danger is, because the text does not only describe simple values. It can describe objects, and rebuilding an object means PHP creates a real instance of a real class. Classes come with methods that run automatically at particular moments, most usefully for an attacker the one that runs when an object is destroyed at the end of a request. So feeding attacker-written text to PHP’s unserialize() does not just produce data. It can cause code to execute.

The attacker does not have to supply the class. They use one that is already on the site, and this is the part that surprises people: it does not have to be a class the plugin uses, or a class anyone knows is there. Plugins routinely bundle third-party libraries, and every bundled library adds classes an attacker can pick from. The technique of stringing together classes that are already present until one of them does something useful, such as writing a file, is called a POP chain.

What went wrong in this case

Three ordinary-looking decisions combined, which is what makes it worth reading about rather than simply patching.

A nonce was mistaken for a permission check. The affected feature saves withdrawal account details, which is an instructor function. Its handler verified a nonce and nothing else. A nonce answers “did this request come from our own page”, not “is this person allowed”. The value in question is printed on every front-end page of the site, so any logged-in user had one. The sibling function that actually performs a withdrawal did check that the user was an instructor. This one did not, and that omission was the door.

A sanitising function was used in the wrong place. Before storing the submitted values, the code passed them through esc_sql(). That function is correct and necessary when you are building a database query by hand. It was being used on data destined for WordPress’s own user-data storage, which builds its own queries.

The consequence is subtle and rather elegant. Internally, esc_sql() replaces every percent sign with a long placeholder token, so that percent signs are not later mistaken for formatting instructions. WordPress strips those placeholders out again immediately before a query runs. Here, the value was serialized with the placeholder in place, so the stored text recorded the inflated length, and then the placeholder was stripped out on the way into the database. What landed in the table was a piece of text claiming to be seventy-one characters long while actually containing six.

The attacker controlled what came next. When that text is read back, PHP trusts the length and reads seventy-one characters, running off the end of the real value and into whatever follows it. What followed it was another field whose name the attacker had chosen freely, because the code took the field names straight from the submitted form without constraining them. The over-read lands in the middle of attacker-written text, and PHP carries on reading it as if it were legitimate.

From there the chain is mechanical. The injected text names a class, the plugin’s own loader resolves that name to a file inside the plugin and loads it, that file makes a bundled third-party library available, and an object from that library is created. When PHP tidies that object away at the end of the request, the library’s cleanup routine writes a file, and both the filename and the contents were chosen by the attacker. The file is a PHP script in the uploads folder. Requesting it runs commands on the server, which is the same destination as the upload attacks we wrote about earlier, reached by an entirely different road.

The trigger is undramatic. Sending the same request twice is enough, because saving the value a second time makes WordPress read the corrupted one first to see whether anything changed. Opening the withdrawal page also does it.

The specific plugin

The vulnerability is CVE-2026-78175, scored 8.8, in Tutor LMS, an e-learning plugin active on more than 100,000 sites. All versions up to and including 4.0.7 are affected and 4.0.8 fixes it. Exploitation requires the plugin’s monetization feature to be switched on.

If you run Tutor LMS, update it. If you run courses on a site somebody else built for you, this is worth a specific question to them rather than an assumption.

The fix is a good one and worth noting for its shape: it adds the missing instructor check, stops using esc_sql() on that data, restricts the field names to a list the plugin defines rather than whatever was submitted, and validates the withdrawal method before processing anything. Any one of the first three would have prevented the attack. Shipping all four is how it should be done.

Why a free account matters more than it looks

This is the part that generalises, and it applies to your site whether or not you have ever heard of this plugin.

A great many WordPress vulnerabilities are described as requiring an authenticated user with subscriber access. That phrasing sounds like a meaningful barrier and frequently is not. On any site with open registration, a subscriber account is available to anyone who can complete a form, in seconds, as many times as they like. For a site selling courses or memberships, open registration is not a misconfiguration, it is the business. The practical severity of a subscriber-level flaw on such a site is the same as an unauthenticated one.

So the question worth asking of your own site is not whether registration is enabled, but whether it needs to be:

  1. Check whether anyone can register. In WordPress this is a single setting, Anyone can register, under General settings. A surprising number of sites have it on because a plugin turned it on years ago, or because it shipped that way, and nobody has ever used it.
  2. If you do not need it, turn it off. If a plugin needs it, make sure you know which one and why.
  3. Check the default role for new accounts is Subscriber and not something more generous. Contributor and Author are meaningfully more powerful.
  4. Look at the accounts you already have. Sites with open registration accumulate spam accounts, and a long list nobody reads is a good place to hide.
  5. Read plugin advisories with this in mind. When you see subscriber-level access described as a limitation, translate it against your own site. On a shop with guest checkout only, it is a real limitation. On a membership site, it is not one at all.

If you want to know whether it already happened

The end of this particular chain is a PHP file written into the uploads folder, which is the most checkable outcome in WordPress security. Nothing legitimate writes a program file into a folder meant for images and documents. Over SSH, listing PHP files under wp-content/uploads should return nothing at all, and anything it does return needs explaining before anything else happens. The same check, and what to do if it finds something, is covered in our article on backdoors uploaded through plugin flaws.

Blocking PHP execution inside the uploads directory is worth doing on any site regardless. It does not fix the underlying flaw, but it turns a successful write into a harmless file. If we host your site and you would like that rule added, open a ticket.

The wider point

Two lessons come out of this that are worth more than the specific patch.

The first is that a check is only as good as the question it answers. A nonce proves a request came from your own pages. It says nothing about who sent it. We saw the opposite failure a week earlier, where a plugin switched the nonce check off entirely while the permission checks kept working. Both are needed, and neither substitutes for the other.

The second is that safety functions are not universally safe. Escaping intended for one context can be actively harmful in another, and here a function whose entire purpose is to make data safe was the direct cause of the corruption that made the attack possible. That is a warning for anyone writing code for a site, and a reason for everyone else to prefer plugins that are actively maintained and widely reviewed, since this sort of thing is found by people looking hard rather than by accident. Our piece on the security measures that actually matter sets out the rest.

Copyright Havenswift Hosting 2007-2026. All rights reserved.