CVE-2026-94178 Subscriber to administrator through a CSV batch resume
Intrudify's autonomous pentesting engine discovered a privilege escalation vulnerability in Import and export users and customers, affecting all versions up to and including 2.5.2. The batched importer counts progress in CSV records but resumes using a physical line offset. One newline inside a quoted field desynchronises the two, and a Subscriber's own profile text is parsed as top-level CSV rows.
Summary
The plugin imports large CSV files in AJAX batches. Each batch reports how far it got, and the next batch resumes from that point. Intrudify's autonomous testing engine found that the progress counter and the resume offset do not measure the same thing.
Progress is counted in CSV records. Resumption is performed with a physical line offset. As long as no cell contains a newline the two are identical, which is why this works correctly almost all of the time. The moment one cell contains a newline they diverge, and the next batch begins parsing part-way inside a quoted field.
Technical detail
Two counters, two different units
In classes/import.php, import_users():
$manager = new SplFileObject( $file );
if( $initial_row != 0 )
$manager->seek( $initial_row );
...
while( $data = $manager->fgetcsv( $delimiter, '"', "\0" ) ):
$row++;$row increments once per CSV record returned by fgetcsv(), and $row is what the batch hands back as its resume point. SplFileObject::seek() is a physical line offset: the object is never put into READ_CSV mode, so seek() simply counts newlines.
Why a newline in a cell is legal and expected
CSV is a quoted format. A field may contain newlines, and the plugin's own exporter writes them correctly using fputcsv with the standard enclosure. Importing such a file in a single pass is handled correctly too. Only the batch resume is wrong.
WordPress core supplies the attacker-controlled newline for free. The Biographical Info profile field is stored verbatim by edit_user(), it is a plain textarea on /wp-admin/profile.php that every Subscriber can edit, and the plugin exports it as the description column.
The chain
A Subscriber pastes a block of complete, well-formed CSV records into their own Biographical Info, one per line, with administrator in the role column and a chosen password hash in the user_pass column. That is the only action the attacker performs, and it is an ordinary profile save with no crafted request and no extra capability.
In the exported file those lines sit harmlessly inside a single quoted field. The export is perfectly well-formed and completely benign when parsed in one pass. The administrator then re-imports it, which is the plugin's own backup and migration workflow and is described that way in the vendor's changelog.
If the file exceeds one batch, the second batch seeks to a physical line inside the quoted field and starts reading the attacker's lines as top-level records. Each has the right number of columns, so the header-width guard passes, and the importer treats them as normal rows.
Why the created accounts get the administrator role
This is the detail that turns a parsing bug into a privilege escalation. Role assignment is gated on the capability of whoever is running the import, and that is the administrator. So promote_users and create_users both pass, and the administrator value taken from the attacker's CSV is applied. The user_pass column is written straight into wp_users, so the account is created with a password hash the attacker chose.
The attacker logs in as a full administrator afterwards. Their own account is still a Subscriber, so nothing on their user row records what happened.
Handling the unknown column count
Column positions are fixed on every site, but the column count is not: it is the core user fields, plus role, plus one column per distinct usermeta key, which differs per installation. A row whose width does not match the header is dropped with a harmless notice.
The attacker therefore does not need to guess. The payload sweeps a range of widths with a unique username per row, repeated over several cycles. Only the matching width imports and the rest are inert, and because parsing continues line by line once the resume lands inside the block, a matching row is always reached.
Verification and controls
We ran the full chain on a default installation of 2.5.2. The observed batch sequence shows the desynchronisation directly: the first batch stops after 101 records and reports row 101, and the second batch seeks 101 physical lines, landing inside the still-open quoted field. From there, eight administrator accounts were created. The result is read from the user list and confirmed by logging in as one of them and reaching the plugin upload form, which is arbitrary PHP execution on the host.
Two negative controls isolate the trigger precisely.
First, the identical payload text saved as a single line with the newlines replaced by spaces. Same site, same export, same import, and nothing is created. Records and physical lines stay in step, so seek() is correct. The newline inside the cell is the trigger, not the payload text.
Second, the multi-line payload exactly as before, but with the site small enough that the whole file fits in one batch and seek() is never called. Nothing is created, even though the export still contains every attacker record inside the quoted field. Single-pass parsing handles it correctly. The defect is the batch resume offset, not the CSV writer and not the parser.
Impact
A Subscriber, the lowest-privileged account on a WordPress site, obtains administrator accounts with passwords they chose. From the plugin upload form that is arbitrary PHP execution, so the ceiling is full site compromise.
Two properties make this worse than the privilege level suggests. The trigger is a routine administrative action rather than something the attacker has to induce, since exporting and re-importing users is the plugin's documented backup and migration workflow. And the payload sits dormant in a profile field indefinitely, so the attacker plants it once and waits.
Attribution is also poor. The attacker's account is unchanged, the created accounts look like ordinary imported users, and the import reports large but harmless-looking ignored counts that obscure the injected rows.
Remediation
Update the plugin to 2.5.4 or later.
For maintainers, the resume point should be a byte offset taken with ftell() and restored with fseek(), rather than a line number. Alternatively, put the SplFileObject into READ_CSV mode with the same delimiter, enclosure and escape, so that seek() counts records and the two measurements agree. Rejecting records whose position was not reached by sequential parsing would also close it.
The wider lesson
There is no injection, no missing sanitisation and no missing capability check anywhere in this bug. Every individual component behaves correctly. The exporter writes valid CSV, the parser reads valid CSV, the capability gate does what it was written to do, and the width guard rejects malformed rows. The vulnerability lives entirely in the disagreement between two counters that were assumed to be interchangeable.
That assumption held for every file the developers tested, because CSV records and physical lines are identical until a cell contains a newline. This is the characteristic shape of a resumable-parse bug: correct in one pass, correct in batches too, right up until the data exercises the one case where the two units differ.
The generalisable check, for any batched or resumable parse: confirm that the unit you save as a checkpoint is the same unit you seek with. If a parser can consume a variable number of physical units per logical unit, a logical counter is not a valid seek position, and the resulting confusion is attacker-controllable wherever the input is.
Disclosure timeline
- 2026-09-18 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
- 2026-09-24 Public disclosure · CVE-2026-94178 assigned
Questions
What is CVE-2026-94178?
CVE-2026-94178 is a privilege escalation vulnerability in the Import and export users and customers WordPress plugin, versions 2.5.2 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. The batched CSV importer tracks progress in CSV records but resumes using a physical line offset, so a newline inside a quoted field desynchronises the two and lets a Subscriber have their own profile text parsed as top-level CSV rows.
How does a Subscriber escalate to administrator?
The Subscriber pastes well-formed CSV records with administrator in the role column into their own Biographical Info field, which WordPress stores verbatim. When an administrator exports users and re-imports that file, the second batch seeks into the middle of the quoted field and parses those lines as real rows, creating administrator accounts with attacker-chosen password hashes. The attacker's own account remains a Subscriber.
How do I fix CVE-2026-94178?
Update the plugin to 2.5.4 or later. The underlying fix is to track the batch resume point as a byte offset with ftell and fseek, or to put the SplFileObject into READ_CSV mode so that seek counts records rather than physical lines.