Security
Joomla 3-forensics: 47 verborgen admins en cron in /tmp
Een vrijdag, een handover. Een phpMyAdmin-URL zonder auth en 47 super users die niemand kon plaatsen. De Joomla 3-site die we overnamen was al gecompromitteerd.

De overdracht
Het was een vrijdag in oktober. Een logistiek bedrijf in Rotterdam had het vorige bureau ontslagen en vroeg ons de Joomla-site over te nemen. De inloggegevens kwamen in één PDF: SSH, FTP, database, en een phpMyAdmin-link zonder poortrestrictie en zonder IP-allowlist. We openden de link om te kijken of hij werkte. Hij laadde. We waren niet ingelogd. We konden inloggen met de root MySQL-user uit het gegevensbestand.
Dat was minuut één. De volgende zes uur hebben we het bloeden gestelpt.
De schade in kaart brengen voor je iets aanraakt
Regel één als je een site overneemt waarvan je vermoedt dat hij gecompromitteerd is: niet beginnen met opruimen. Je maakt eerst een snapshot, daarna ga je in kaart brengen. We trokken een volledige tarball van het filesystem via SFTP, een mysqldump van de database, en een kopie van /var/log en /etc/cron.d. Alles ging naar een geïsoleerde VPS die we voor forensisch werk paraat houden. Pas daarna openden we phpMyAdmin opnieuw, dit keer via een SSH-tunnel, nadat we de publieke listener hadden gekilld.
De #__users-tabel had 47 rijen. De klant had ons verteld dat er "misschien drie of vier" admins waren. We sorteerden op registerDate en het beeld werd scherper: negen accounts aangemaakt in het eerste jaar van de site, 38 aangemaakt om 03:14, 03:15 en 03:16 UTC op één nacht in 2022. Allemaal in de super user-groep. Allemaal met lastvisitDate NULL. Accounts die nooit zijn gebruikt. Stonden er gewoon. Wachtend.
SELECT id, username, email, registerDate, lastvisitDate
FROM j_users
ORDER BY registerDate DESC;
-- 38 rijen geclusterd binnen drie minuten, allemaal NULL lastvisitDate.
-- 9 rijen uit de legitieme sitehistorie.
-- Het gat ertussen was het moment dat de site van eigenaar wisselde.De phpMyAdmin die niemand beheerde
Een publieke phpMyAdmin is het soort vondst dat je grijs maakt. De installatie op /pma was versie 4.7.0, uit 2017. De Joomla front-end draaide op 3.10.x, waarvoor het project end-of-life verklaard heeft op 17 augustus 2023 (zie de officiële Joomla 3.10 end-of-life-aankondiging). De site stond al meer dan twee jaar voorbij EOL en het patchbudget was nul geweest.
phpMyAdmin in die versie had drie jaar aan ongepatchte advisories bovenop wat voor maatwerkproblemen er ook in config.inc.php zaten. We openden het bestand niet uit nieuwsgierigheid. We verplaatsten de directory uit de webroot en zetten een firewall-regel die verkeer naar poort 3306 vanaf overal behalve localhost dropt.
Vind je een publieke phpMyAdmin op een site die je overneemt, ga er dan vanuit dat de database al van iemand anders is. Eerst snapshotten, dan toegang afsnijden. Nooit in-place bewerken: je vernietigt het bewijs dat je vertelt waar je vervolgens moet zoeken.
/tmp bleek niet tijdelijk
De cron viel me op tijdens de tweede ronde door /etc/cron.d. Een user-mode crontab voor www-data draaide /tmp/.x elke vijf minuten. /tmp/.x was een ELF-binary van 17 KB. strings liet callbacks zien naar een domein dat drie weken eerder was geregistreerd. De mtime van het bestand stond op 2019. De inode change time zei twee maanden geleden.
Dit is de standaardvorm van webshell-persistentie op een PHP-site. De aanvaller dropt een payload via een uploadformulier of een ongepatchte component, registreert een @reboot- of */5-cron, en heeft het oorspronkelijke entry point niet meer nodig. De webserver is het bruggenhoofd. Cron is het huurcontract.
We killden de cron, verwijderden het binary, en checkten elke andere cron-entry tegen de documentatie van de klant. Er stonden er nog twee die we niet verwachtten: een dagelijkse job in /var/spool/cron/crontabs/root die een shellscript van een paste-URL ophaalde en uitvoerde, en een @hourly-regel die /tmp/.x herschreef zodra het ooit verdween. De cron had een wakende cron. De waker had ook een waker. We groeven door tot we niets meer vonden.
# Wat we feitelijk hebben gedraaid, in volgorde, nadat we de bak hadden geïsoleerd.
sudo crontab -l -u www-data
sudo crontab -l -u root
ls -lat /tmp /var/tmp /dev/shm
find / -newer /etc/hostname -type f 2>/dev/null | head -200
ss -tlnp
De volgorde waarin we het moeras leegtrokken
Je triëert op blast radius. Wij werkten in deze volgorde, en raden hem aan iedereen aan die een gecompromitteerde PHP-site binnenloopt.
- Snapshot alles offline. Filesystem, database, alle logs, alle crontabs,
/etc/passwd, het hele zwikje. Zet het op een host die de aanvaller nog nooit heeft gezien. - Snij publieke toegang tot admin-oppervlakken af: phpMyAdmin, de Joomla-admin-URL, alles op dezelfde host dat niet op het open internet hoeft te staan. Een Cloudflare Access-regel of een Apache
Require ip-blok regelt het allebei binnen een minuut. - Roteer elke credential die het vorige bureau had. In deze volgorde: MySQL, SSH, FTP, Joomla super user, e-mail, DNS-registrar. De DNS-registrar telt zwaarder dan mensen denken. Bezitten zij DNS, dan bezitten ze je e-mailrecoveryflow.
- Audit
#__usersen#__user_usergroup_map. Kijk naarregisterDate,lastvisitDate,requireReset. Anomalieën clusteren. - Audit cron, at, systemd timers en de user-crontab voor
www-data. PHP-sites worden via de webuser uitgebuit, dus de automatisering van die user is waar de persistentie woont. - Audit
/tmp,/var/tmp,/dev/shmop ELF-binaries en shellscripts die er niet horen. - Pas dan begin je met migreren.
Migratie was de oplossing, niet patchen
We hebben niet geprobeerd Joomla 3.10 terug naar clean te patchen. Het componentenkerkhof alleen al maakte het zinloos: de klant had vier commerciële extensions waarvan de leveranciers waren omgevallen, en de bridge-tooling van Joomla 3 naar 4 was voor twee ervan al deprecated. Een site die twee jaar EOL is, wordt niet genezen. Die wordt vervangen.
We bouwden de site in vier weken opnieuw op een actuele stack, porteerden de content-tabellen over met een klein Python-scriptje en namen de oude VPS na een schone snapshot uit dienst. Het nieuwe admin-oppervlak zit achter Cloudflare Access. phpMyAdmin staat er niet op; de paar keer dat we hem nodig hebben, draait hij lokaal via een SSH-tunnel. Database-connecties accepteren alleen localhost. Cron staat onder versiebeheer in /etc/cron.d met file integrity monitoring via aide. We draaien geen binaries uit /tmp.
Waar de klant niet wist dat hij voor betaalde
De klant was door het vorige bureau maandelijks gefactureerd voor "security updates". Er was in 19 maanden geen Joomla core-update toegepast. Er waren geen patches doorgevoerd op third-party componenten. De phpMyAdmin-installatie was onbeheerd. De gecompromitteerde cron stond minstens twee maanden live voordat wij aankwamen, op basis van de inode-timestamps van het binary en de registratiedatum van het command-and-control-domein.
Dit is het standaardpatroon als een verouderde site wordt "onderhouden" zonder dat iemand naar de daadwerkelijke server kijkt. De factuur blijft uitgaan. De audit trail is leeg. Tot het merk op een dag wordt aangemeld bij Google Safe Browsing en de telefoontjes in je inbox belanden.
Een nuttige baseline: OWASP's actuele Top Ten zet broken access control nog steeds op nummer één en outdated components op nummer zes. Beide speelden op deze site. Geen van beide bevindingen had een auditor verrast die daadwerkelijk had ingelogd.
Twee checks die elke operator vanmiddag kan doen
Draai je een oudere PHP-site en kun je dit kwartaal geen volledige audit betalen, voer dan deze twee checks uit voor het einde van de week. Ze kosten tien minuten.
Check één: log in op de admin en open de users-tabel. Tel de super users. Klopt het aantal niet met de namen van de mensen in je bedrijf die super user-toegang horen te hebben, dan is je probleem nu urgent, niet theoretisch. Let op lastvisitDate-waardes die NULL zijn: een super user die nog nooit heeft ingelogd, is bijna nooit een echte gebruiker.
Check twee: SSH in op de bak en draai crontab -l -u www-data, daarna ls -lat /tmp /var/tmp /dev/shm. Heeft iets in die directories een recente mtime die je niet kunt verklaren, dan heb je huiswerk. Iets dat eruitziet als een dotfile-binary in /tmp is huiswerk met een deadline.
Toen we de migratie van deze Joomla 3-site bouwden, liepen we ertegenaan dat twee van de custom componenten van de klant content opsloegen als geserialiseerde PHP, en dat overleeft een schone export uit MySQL naar een nieuw schema niet. We schreven uiteindelijk een kleine unserializer die platte JSON uitspuugt, en importeerden via de content-API van het nieuwe CMS. Dat soort eenmalige bridges is waar een legacy migratie in de praktijk grotendeels uit bestaat. Het opruimen is de makkelijke helft; de datavorm is het lastige stuk.
Als het enige is dat je deze week doet crontab -l -u www-data draaien op elke PHP-server die je beheert, dan loop je al voor op waar de meeste bureaus hun klanten achterlaten. Dat is het niveau.
Kern
Een site die end-of-life is en onbewaakt blijft, wordt niet onderhouden. Die wordt verhuurd. Snapshotten voor je iets aanraakt, en dan opruimen op volgorde van blast radius.
FAQ
Hoe weet ik of een Joomla-site is gecompromitteerd?
Begin bij de users-tabel. Sorteer op registerDate en lastvisitDate. Super user-rijen die nooit zijn gebruikt, of die binnen minuten van elkaar geclusterd staan, zijn het luidste signaal.
Heeft het zin een end-of-life Joomla 3-site te patchen in plaats van te migreren?
Nee. Joomla 3 ging op 17 augustus 2023 EOL en de meeste third-party extensions stopten al eerder met patches uitbrengen. Dicht de directe blootstelling, en plan dan een migratie.
Wat is het eerste dat je doet als je een legacy PHP-site overneemt?
Snapshot alles naar een geïsoleerde host voordat je iets aanraakt: filesystem, database, logs, crontabs. Je kunt geen bewijs reconstrueren nadat je bent begonnen met opruimen.
Waarom is een publieke phpMyAdmin-installatie zo gevaarlijk?
Het is een ingelogde shell naar je database met jaren aan bekende CVE's, vaak draaiend op een verouderde versie. Is hij bereikbaar vanaf het open internet, behandel de database dan als al gecompromitteerd.