Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

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

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 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 — the theme: tokens are wedged between the dots, so validate_file() sees it as harmless. Passes.
  • At step ③: str_replace('theme:', '', ...) strips every theme: 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_include filter. 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 out theme:. 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 .php file — 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.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.