← Blog

Security

Joomla stored XSS: AI-vuln-discovery op een legacy intranet

Het intranet was acht jaar lang stil geweest. Toen we er een AI-vulnerability-scanner op richtten, lichtte er een commentscomponent op die niemand sinds 2011 had aangeraakt.

Jacob Molkenboer· Oprichter · A Brand New Company· 5 jun 2026· 9 min
Leren logboek met linnen lint, messing sleutel op indexkaart uit 2011, gebarsten groene lakzegel, rubberen stempel, inktkussen.

Toen we verbinding maakten met het intranet van de klant, had de server geen Joomla-update meer gezien sinds de eerste termijn van Obama. Hij bediende ongeveer 80 interne gebruikers, voornamelijk operations- en magazijnpersoneel bij een logistiek bedrijf in Rotterdam. De loginpagina werkte. Voorraadcijfers werden geladen. Niemand had geklaagd.

De directie had om een security review gevraagd voor de migratie naar een nieuw systeem, en de IT-lead overhandigde ons SSH-credentials met een schouderophalen. "Zeg gewoon of het bloedt."

We richtten Anthropic's open-source framework voor AI-gedreven vulnerability discovery op de codebase. Twee uur later markeerde het een stored XSS in een commentscomponent waar sinds 2011 niemand meer aan had gezeten.

Deze post gaat over hoe dat ging, hoe de bug eruitzag, en wat het framework goed en fout deed.

Het intranet dat niemand wilde aanraken

Het systeem was Joomla 1.7 met een handvol custom componenten, gebouwd door een freelancer die al lang met pensioen was. De database droeg veertien jaar magazijnactiviteit, interne mededelingen, ploegenroosters en (zo bleek) één commentstabel die operations gebruikte als een soort intern prikbord.

Migreren naar Joomla 5 was om budgetredenen geen optie. Het plan was om de server achter een strengere VPN te zetten, te bevriezen als read-mostly, en het prikbord te vervangen door een Mattermost-channel. Maar voordat dat zou gebeuren wilde de directie weten wat ze hadden draaien.

Een normale audit op een codebase van deze leeftijd gaat één van twee kanten op. Of een mens leest de PHP langzaam door en levert in een week een gedeeltelijk antwoord, of een static scanner spuugt 600 false positives uit die niemand triageert. Geen van beide is fijn als het doel is: "vertel ons met zekerheid wat een aanvaller op het LAN echt kan doen."

Het framework richten

Het framework neemt een doelcodebase, een doelomschrijving en een budget. Het gebruikt een model om attack-surface enumeratie te plannen, en stuurt vervolgens sub-agents om de code te lezen, hypothesen op te bouwen en die te proberen te bevestigen. Outputs worden gerangschikt op exploiteerbaarheid, niet op checklist-categorieën. Die rangschikking is het onderdeel dat ertoe doet in een triagemeeting.

We gaven het deze briefing:

Target:   /var/www/intranet (Joomla 1.7.5 + custom components)
Reach:    Authenticated internal user, lowest role
Goal:     Find anything that lets an attacker execute code in
          another user's browser, read another user's data, or
          escalate role to administrator.
Budget:   200 model turns.

De setup was weinig spannend. Een snapshot van de codebase, een read-only MySQL-kopie met het productieschema (en synthetische data), en een Docker-container met PHP 5.6 omdat het framework snippets daadwerkelijk tegen de target stack wilde draaien. Geen productieverkeer, geen productiedata.

Wat het opmerkte in de eerste pass

Het framework leverde 14 findings op, gerangschikt op wat het "demonstrated exploitability" noemde. De meeste waren lauw. CSRF op een zoekformulier. Een verouderde jQuery. Verbose error pages op /administrator die path strings lekten.

Maar finding #2 was specifiek. Het artefact luidde vrijwel woordelijk:

com_warehouseboard: door gebruikers ingediende commentbodies worden unescaped opgeslagen en gerenderd met echo in views/board/tmpl/default.php. Bevestigd door payload <svg/onload=alert(1)> in te dienen via authenticated POST en daarna de board-view te laden, wat de alert afvuurde in een aparte browsersessie.

Framework run artefact, finding #2

Het framework had de bug niet alleen gemarkeerd. Het had de payload ingediend, de pagina opgehaald als een andere sessie, en bevestigd dat het script uitgevoerd werd. Die laatste stap is de kloof die de meeste static analyzers niet kunnen overbruggen.

Takeaway

Het verschil tussen "dit ziet er kwetsbaar uit" en "dit is exploiteerbaar" zit in de tweede request, gedaan als een andere gebruiker. AI-gedreven vuln discovery is interessant omdat het die stap kan zetten zonder dat een mens een Burp-macro hoeft te schrijven.

De kwetsbare code

De component handelde comments ongeveer zo af. De freelancer had JRequest::getVar gebruikt met de JREQUEST_ALLOWRAW flag, die het inputfilter volledig uitschakelt.

// components/com_warehouseboard/controllers/board.php (Joomla 1.7)
function saveComment()
{
    $user = JFactory::getUser();
    if ($user->guest) {
        JError::raiseError(403, JText::_('ALERTNOTAUTH'));
        return;
    }

    $comment = JRequest::getVar(
        'comment',
        '',
        'post',
        'string',
        JREQUEST_ALLOWRAW
    );

    $db = JFactory::getDBO();
    $q  = 'INSERT INTO #__warehouseboard_comments '
        . '(user_id, body, created) VALUES ('
        . (int) $user->id . ', '
        . $db->quote($comment) . ', '
        . $db->quote(date('Y-m-d H:i:s')) . ')';
    $db->setQuery($q);
    $db->query();

    $this->setRedirect('index.php?option=com_warehouseboard&view=board');
}

En de view:

// components/com_warehouseboard/views/board/tmpl/default.php
foreach ($this->comments as $row) :
?>
    <li class="comment">
        <span class="author"><?php echo $row->author_name; ?></span>
        <div class="body"><?php echo $row->body; ?></div>
    </li>
<?php
endforeach;

Twee deuren open laten staan. Input kwam raw binnen vanwege JREQUEST_ALLOWRAW. Output ging raw naar buiten omdat er geen htmlspecialchars en geen Joomla JFilterOutput-aanroep op de body stond. Elke authenticated gebruiker kon een payload planten die zou afvuren in de browser van elke andere gebruiker zodra die het bord opende, inclusief de administrator die gemarkeerde comments beoordeelde.

Wat stored XSS een aanvaller hier opleverde

Op een publieke Joomla-site is stored XSS al erg. Op dit intranet was het erger, want de administrator-sessie had ook toegang tot de /administrator-backend. Vanaf het moment dat een admin het bord laadde, kon een aanvaller:

  1. De session-cookie van de admin lezen, want HttpOnly was in deze versie niet ingesteld.
  2. Namens hem backend-requests doen naar de com_users-component en een nieuwe Super User toevoegen.
  3. De sporen wissen via het bestaande comment-edit endpoint, dat dezelfde controller ook al niet filterde.

Het framework demonstreerde stap 1 en 2 tegen de staging-kopie. Stap 3 hebben we geëxtrapoleerd door de code te lezen. Vanaf een startpositie als "authenticated user met de laagste rol" was volledige administrative compromise ongeveer zes HTTP-requests verwijderd.

Voor achtergrond bij waarom output encoding ertoe doet, zelfs als je denkt dat de input schoon was, is de OWASP XSS Prevention Cheat Sheet nog steeds de canonieke referentie. De CWE-classificatie, CWE-79, dekt precies dit patroon: improper neutralization of input during web page generation.

De fix

We hebben de component ter plekke gepatcht in plaats van het hele ding te ontwarren. Drie wijzigingen.

Eén: stop met het accepteren van raw HTML aan de deur. JREQUEST_ALLOWRAW is bijna nooit wat je wilt voor vrije-tekst gebruikersinvoer op een prikbord.

$comment = JRequest::getVar('comment', '', 'post', 'string');
$comment = trim($comment);
if ($comment === '' || mb_strlen($comment) > 2000) {
    JError::raiseWarning(400, JText::_('COM_WAREHOUSEBOARD_BAD_INPUT'));
    return;
}

Twee: escape bij de output. De oude Joomla-helper is prima, maar htmlspecialchars met de juiste flags is net zo goed en makkelijker te overzien.

foreach ($this->comments as $row) :
    $author = htmlspecialchars(
        $row->author_name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'
    );
    $body = nl2br(htmlspecialchars(
        $row->body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'
    ));
?>
    <li class="comment">
        <span class="author"><?php echo $author; ?></span>
        <div class="body"><?php echo $body; ?></div>
    </li>
<?php endforeach; ?>

Drie: voeg een Content Security Policy header toe die de originele payload onschadelijk had gemaakt, zelfs als we beide andere fixes hadden gemist. Op Joomla 1.7 is de schoonste plek de index.php van de template, niet een .htaccess-regel, omdat de applicatie sommige response headers zelf beheert.

JResponse::setHeader(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self'; object-src 'none'; "
    . "base-uri 'self'; frame-ancestors 'none'",
    true
);

We hebben de patch uitgerold in een maintenance-window op maandagochtend en daarna het framework opnieuw gedraaid met dezelfde briefing. De XSS-finding viel van de lijst. Twee van de andere findings met lagere severity, beide gerelateerd aan header hardening, verdwenen ook omdat de CSP ze afdekte.

Wat het framework goed en fout deed

Goed: het las genoeg van de codebase om te weten dat JREQUEST_ALLOWRAW een constante was, gedefinieerd in libraries/joomla/environment/request.php, die het inputfilter uitschakelde. Het volgde de variabele van saveComment naar de #__warehouseboard_comments-tabel en weer terug de view in. Het zag ook dat dezelfde controller een editComment-endpoint had met dezelfde fout, die we bij een eerste leesbeurt gemist zouden hebben omdat het bestand 900 regels lang was.

Fout: het rapporteerde met overtuiging een SQL injection in dezelfde component die een false positive bleek. De $db->quote-aanroep escapete de waarde correct; het framework had aangenomen dat quote een no-op was op basis van een verkeerd gelezen method-signature. We vingen het pas op door de payload met de hand te schrijven en in de query log te zien dat de string keurig geëscaped werd.

Let op

Behandel AI-gemarkeerde findings zoals je het rapport van een junior pentester zou behandelen. De goede komen met een werkende payload en een reproductiestap. De wankele komen met bijvoeglijke naamwoorden.

Wat we van de run hebben overgehouden

Vooral gewoontes. Het framework liet ons voor elke finding een gestructureerd artefact achter: target file, kwetsbaar regelbereik, payload, reproductie-transcript. We bewaren dat artefact nu naast elk security-ticket. Het scheelt heen-en-weer met de ontwikkelaar die het moet fixen, want de reproductie is niet langer een zin in een Jira-comment, het is een uitvoerbare curl.

We zijn ook gestopt met "de codebase is te oud om te scannen" zien als serieuze reden om niet te scannen. Vijftien jaar oude PHP is precies waar deze tools schitteren, omdat de patronen waarop ze getraind zijn (unescaped echo, raw request vars, ontbrekende CSRF-tokens, gevaarlijke string-concatenatie in queries) overal voorkomen in code van vóór moderne frameworks. Een senior PHP-reviewer zou dezelfde bugs uiteindelijk ook vinden. Het framework vond ze in een middag, met bewijs erbij.

Een voetnoot over kosten. De scan van twee uur verbrandde ruwweg het volledige budget van 200 turns. Vergeleken met een week handmatige pre-audit was het een afrondingsfout. Vergeleken met een prikbord dat in een lang weekend een admin-sessie exfiltreert, was het gratis.

Wat je het komende uur kunt doen

Draai je een interne app die ouder is dan vijf jaar? Grep de codebase op ALLOWRAW, op echo $_POST, op innerHTML =, en op eval. Lees wat die aanroepen doen. Vind je er één die door gebruikers ingediende content verwerkt, dan heb je vrijwel zeker dezelfde soort bug als wij vonden. Patch eerst de output met htmlspecialchars op elke echo, dan de input, en voeg dan een CSP-header toe zodat de volgende fout fail-closed afgaat.

Toen we het secure-migratieplan bouwden voor de klant in Rotterdam, was het patchen niet het pijnlijke deel. Het pijnlijke deel was reconstrueren wat veertien jaar aan medewerkers had ingetypt in een component die nooit was geaudit. Dat soort langzame opgraving is waar het legacy migratiewerk dat we bij ABN doen meestal begint, en waar AI vuln-discovery tooling een vaste plek heeft verdiend in onze security checklist.

Kern

AI vuln-discovery schittert op oude PHP omdat de gevaarlijke patronen overal zitten en de tooling exploiteerbaarheid nu kan bevestigen met een tweede request als andere gebruiker.

FAQ

Werkt het framework ook op moderne PHP-code, niet alleen op legacy Joomla?

Ja. We hebben het voor deze case study tegen PHP 5.6 gedraaid, maar de patronen die het detecteert zijn language-agnostic. We hebben het tegen PHP 8.2 en Node-services gebruikt met vergelijkbare resultaten.

Kun je het op een live productiesysteem richten?

Niet doen. Het probeert payloads tegen echte endpoints. Werk altijd vanuit een snapshot van de code en een read-only kopie van de database in een geïsoleerde container. Behandel het zoals elke dynamic scanner.

Hoe lang duurden de scan en de triage end-to-end?

Twee uur wall-clock voor de codebase-scan, ongeveer een uur voor het team om findings te verifiëren, en een maintenance-window op maandagochtend om de patch uit te rollen. Minder dan een werkdag in totaal.

Vervangt dit een handmatige pentest?

Nee. Het is een sterke eerste pass die voor de hand liggende en reproduceerbare bugs naar boven brengt. Een handmatige review blijft belangrijk voor business-logic flaws en chained exploits die het framework niet modelleert.

securityjoomlaphplegacy sitescase study

Iets bouwen?

Start een project