CVE-2026-12227 — How a Validate-Then-Mutate Bug Turns Into Unauthenticated LFI in Visual Composer
Overview Field Value CVE ID CVE-2026-12227 CVSS 3.1 9.8 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H CWE CWE-98 (Improper Control of Filename for Include/Require in PHP) Affected Visual Composer Websi
Overview
| Field | Value |
|---|---|
| CVE ID | CVE-2026-12227 |
| CVSS 3.1 | 9.8 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
|
| CWE | CWE-98 (Improper Control of Filename for Include/Require in PHP) |
| Affected | Visual Composer Website Builder WordPress plugin ≤ 45.16.0 |
| Fixed in | 45.16.1 |
| Vulnerable file | visualcomposer/Modules/Editors/Settings/PageTemplatesController.php |
| Vulnerable function | viewPageTemplate() |
This one boils down to a single ordering mistake: the input is validated, and then mutated afterward. No authentication is required, and a single HTTP request is enough to make the server include an arbitrary file — which is why it lands at a 9.8.
Where the bug lives
Visual Composer hooks into WordPress's template_include filter to decide which template file to render for custom-layout pages. That filter is a core WordPress extension point that runs on every single front-end request, regardless of whether the visitor is logged in.
// somewhere in PageTemplatesController.php
add_filter('template_include', [$this, 'viewPageTemplate'], 11);
Priority 11, no current_user_can() or any capability check attached. Any anonymous visitor's request flows straight into this function.
Source code walkthrough — the "validate-then-mutate" pattern
Reconstructing the logic from public research, viewPageTemplate() looks roughly like this:
public function viewPageTemplate($originalTemplate)
{
// ① Read vcv-template / vcv-template-type from the request
$current = $this->getCurrentTemplateLayout();
// ② Validation — runs against the RAW string
if (empty($current) || validate_file($current['value']) !== 0) {
return $originalTemplate;
}
// ③ Mutation — happens AFTER validation already passed
if ($current['type'] === 'vc-custom-layout'
&& strpos($current['value'], 'theme:') !== false) {
$current['value'] = str_replace('theme:', '', $current['value']);
}
// ④ Sink — the mutated value is used to locate and include a file
$result = locate_template($current['value']);
return $result ?: $originalTemplate;
}
Let's break this down step by step.
① getCurrentTemplateLayout()
Pulls vcv-template and vcv-template-type straight from the query string. At this point $current['value'] is a string the attacker fully controls.
② validate_file() — the check
This is a WordPress core function. It looks for a literal .. sequence or a leading / in the string, and returns non-zero if it finds either. A plain ../../etc/passwd gets caught right here.
The catch: this check only ever sees the string before it gets rewritten. If you craft a value that contains no literal .. yet, it sails through.
③ str_replace('theme:', '', ...) — the mutation
If the template type is vc-custom-layout and the value contains theme:, every occurrence of that substring gets stripped out. The intent was presumably to strip a "relative to theme folder" prefix — but doing this after validation is what breaks everything.
④ locate_template() — the sink
Another WordPress core function. It resolves the given path to an actual file and includes it. If that file happens to be a .php file, its code runs immediately.
Why the bypass works — a fragmented payload
Say the attacker sends this value:
theme:.theme:./.theme:./.theme:./wp-links-opml.php
-
At step ②: there is no literal
..anywhere in this string — thetheme:tokens are wedged between the dots, sovalidate_file()sees it as harmless. Passes. -
At step ③:
str_replace('theme:', '', ...)strips everytheme:occurrence:
theme:.theme:./.theme:./.theme:./wp-links-opml.php
→ (strip every "theme:")
..../.../.../wp-links-opml.php
→ (remaining dots and slashes collapse)
../../../../wp-links-opml.php
A brand-new, never-validated directory traversal sequence is assembled after the check already ran.
-
At step ④:
locate_template()takes this assembled relative path at face value and walks up out of the WordPress root to include the target file.
In one sentence: this is a textbook check-then-mutate (TOCTOU-flavored) logic flaw — the validation logic itself isn't wrong, it's just checking the wrong moment in the data's lifecycle.
Attack flow diagram
Walking through the five stages:
-
Stage 1 (gray) — The attacker fires off an unauthenticated request with the fragmented payload in
vcv-template. -
Stage 2 (blue) — The request hits WordPress's
template_includefilter. There's no capability check attached, so anonymous requests sail through exactly like authenticated ones. -
Stage 3 (green — looks safe) —
validate_file()inspects the string. No literal..is present at this point, so it's judged safe. The validation logic itself did its job correctly; it just wasn't looking at the value that ultimately gets used. -
Stage 4 (amber — the real problem) — After validation,
str_replace()strips outtheme:. The leftover dots and slashes fuse together into a fresh../../../../sequence that was never checked. -
Stage 5 (red — impact) — That unvalidated final path goes straight into
locate_template()and gets included. If it's a.phpfile — including one smuggled onto the server earlier, e.g. inside an uploaded image — this is remote code execution.
The line at the bottom sums up the whole bug: the check runs on the pre-mutation string, the sink runs on the post-mutation string. The validation function isn't broken — it's just validating a value that no longer exists by the time it matters.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
