— Artikel — № 045

045 —Drupal

Drupal 7 pharma hack: een oude uploads-injectie traceren

De Google-snippet van de klant zei 'Buy Cialis 20mg' maar de pagina zelf zag er schoon uit. De Drupal 7 pharma hack traceren ging via htaccess cloaking.

Bovenaanzicht op linnen: geannoteerde sitemap, .htaccess-vel, manilla map, cv-pagina, koperen Drupal 7-plaat, rode lakzegel.
Hero · gestileerd stilleven№ 045

De Loom kwam binnen om 23:41 op een dinsdag. Een Nederlandse bureau-lead waar we mee samenwerken had een screenshot doorgestuurd gekregen van de marketingmanager van zijn klant: het Google-resultaat voor de belangrijkste landingspagina van die klant las nu "Buy Cialis 20mg without prescription". De pagina zelf laadde prima in een browser. De gecachete snippet was vergiftigd. Klassieke Drupal 7 pharma hack, het soort dat al sinds ongeveer 2014 stilletjes draait op legacy site-deployments.

De site was Drupal 7, voor het laatst aangeraakt in 2019, gehost op een shared cPanel-bak, en het bureau had hem in 2022 overgenomen van de vorige ontwikkelaar. Zo'n geval traceren is vooral een kwestie van geduld. De injectie zit bijna nooit in de code waar je als eerste naar zou kijken. Hieronder het pad dat we liepen van de SERP-screenshot terug naar het oorspronkelijke entry point, de opruiming die we die nacht draaiden, en de ene Apache-regel die deze categorie aanvallen definitief dichtzet.

SERP vergiftigd, browser schoon

Het eerste nuttige signaal bij elke Drupal 7 pharma hack is dat de pagina schoon rendert voor jou en vuil voor Googlebot. De aanvaller wil niet dat de site-eigenaar de injectie ziet, want dan repareert die hem. Ze willen dat Google het lang genoeg ziet om link juice en clicks naar een pharmacy-affiliate te sturen, en ze willen een brede staart aan gecachete snippets die het SEO-werk doet.

Je bevestigt de cloak met curl. Twee requests, dezelfde URL, andere user-agent strings:

curl -s https://example.com/over-ons | grep -ic cialis
# 0

curl -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
  -s https://example.com/over-ons | grep -ic cialis
# 3

Drie matches met de Googlebot user-agent, nul zonder. De site cloakte. De volgende vraag is altijd waar de cloak zit. In onze ervaring is dat één van drie plekken: Apache (.htaccess), PHP (een require_once-regel gedropt in index.php of settings.php), of de database (een hook_boot-implementatie geregistreerd door een nepmodule). Specifiek voor Drupal 7 is de goedkoopste plek voor een aanvaller om zich te verstoppen de .htaccess in de root, want Drupal levert er al één en de diff is klein genoeg om te missen.

De htaccess-toevoeging

De root-.htaccess van Drupal 7 is sinds het aftakken van de 7.x-branch niet wezenlijk veranderd. Het live bestand diffen tegen een schone 7.99-tarball is een klusje van 30 seconden:

curl -sL https://ftp.drupal.org/files/projects/drupal-7.99.tar.gz \
  | tar -xzOf - drupal-7.99/.htaccess > /tmp/clean-htaccess
diff /tmp/clean-htaccess /home/site/public_html/.htaccess

Het live bestand had onderaan negen extra regels. Drie ervan deden ertoe:

RewriteCond %{HTTP_USER_AGENT} (googlebot|bingbot|yandex|baidu) [NC]
RewriteCond %{REQUEST_URI} !^/sites/default/files/\.cache/
RewriteRule ^(.*)$ /sites/default/files/.cache/index.php?u=$1 [L]

Vertaling: elke zoekmachinebot die een willekeurige URL raakt, wordt stilletjes herschreven naar een PHP-bestand in de uploads-directory. Menselijke bezoekers matchen niet op de user-agent conditie, dus zij blijven de echte pagina zien. De conditie op REQUEST_URI voorkomt dat de regel in een lus terechtkomt wanneer het herschreven request zelf weer langskomt. De cloak werkt, en hij werkt alleen voor crawlers, wat precies is wat de SERP-screenshot liet zien.

De Apache rewrite-documentatie behandelt dezelfde primitieven als je een referentie wilt: httpd.apache.org/docs/2.4/mod/mod_rewrite.html.

Een directory die niet zou moeten bestaan

Het pad /sites/default/files/.cache/ bestaat niet in een standaard Drupal-installatie. Drupal schrijft naar /sites/default/files/, prima, en naar een paar subdirectories genoemd naar field machine names, maar nooit naar een dotfile-directory. Even listen:

ls -la /home/site/public_html/sites/default/files/.cache/
# -rw-r--r-- 1 site site  47821 Mar 14 03:12 index.php
# -rw-r--r-- 1 site site 124003 Mar 14 03:12 favicon.jpg
# -rw-r--r-- 1 site site  18204 Mar 14 03:12 robots.jpg

De index.php las een lijst spintax-templates uit favicon.jpg en robots.jpg (beide bestanden hadden echte JPG-headers, gevolgd door ongeveer 100KB base64-encoded pharma-copy). Hij renderde een nep landingspagina gevoed door de inkomende URL-slug, dus /over-ons kreeg een pagina over goedkope generieke Cialis met de merknaam van de klant in de H1 geïnterpoleerd. De hele opstelling was drie bestanden, 190KB totaal, en stond er sinds 14 maart. Twee maanden.

Het eerste instinct is om de drie bestanden te verwijderen. Niet doen. Nog niet. Kopieer ze eerst ergens naartoe buiten de docroot. Je hebt favicon.jpg later nodig om in de access log te grep'en op fetches ernaar, en je hebt index.php nodig om te begrijpen hoe het er kwam te staan. Eerst forensics, daarna opruimen.

mkdir -p ~/forensics/drupal-pharma-2026-05/
cp -p /home/site/public_html/sites/default/files/.cache/* \
  ~/forensics/drupal-pharma-2026-05/
chmod -R 000 ~/forensics/drupal-pharma-2026-05/

De chmod 000 voorkomt dat je later per ongeluk dubbelklikt op het PHP-bestand op een machine waar PHP geïnstalleerd staat. Kleine gewoonte, heeft ons één keer gered.

Terugwerken door de access log

stat liet zien dat alle drie de bestanden binnen dezelfde seconde waren geschreven op 14 maart om 03:12:47 UTC. cPanel bewaart standaard drie maanden aan access logs, wat bij een incident van 14 maart dat op 26 mei ontdekt wordt nét binnen het venster valt. zgrep:

zgrep " 14/Mar/2026:03:12:4" /home/site/logs/example.com-Mar-2026.gz \
  | grep -v "GET / "

Eén POST request viel op: een 200 op /?q=file/ajax/field_attachment/und/form-XYZ/field-attachment-upload-button, vanaf een residentieel IP in Brazilië, body 4.1MB. Dat endpoint is de AJAX file upload-handler van Drupal 7, beschikbaar vanaf elk node-form met een aangehecht file field. De site had een vacaturepagina met een CV-uploadwidget waarvan niemand aan de klantkant nog wist dat hij aanstond. Hij accepteerde .pdf en .doc. Hij accepteerde ook .phtml, omdat het bureau dat de site in 2017 bouwde per ongeluk de toegestane extensies op pdf doc docx phtml had gezet (waarschijnlijk autocomplete op het extensieveld). .phtml is uitvoerbaar onder de standaard Apache-config op cPanel.

De aanvaller uploadde een phtml-bestand, riep het één keer aan, en kreeg code execution als de web-user. Vanaf daar kostte het droppen van de drie bestanden in /sites/default/files/.cache/ en het toevoegen van negen regels aan de root-.htaccess misschien een minuut scripten. Het originele phtml-bestand was al weg tegen de tijd dat wij keken. De aanvaller ruimde de ingang op maar liet de moneymaker staan, wat normaal is.

De opruiming op volgorde

Een werkende volgorde voor een Drupal 7 pharma hack, in de volgorde waarin we het die nacht uitvoerden:

  1. Haal de site offline via de load balancer als je er een hebt. Heb je die niet, drop dan een onderhouds-.htaccess in de docroot die elke request die niet van jouw IP komt een 503 geeft.
  2. Maak een snapshot van de docroot en de database. tar -czf voor de bestanden, mysqldump --single-transaction voor de database. Bewaar beide buiten de docroot. Dit is je bewijsmateriaal én je rollback.
  3. Herstel .htaccess vanuit een schone Drupal 7.99-tarball. Zet echt eigen regels uit je versiebeheer er opnieuw bij, en verifieer elke regel voordat je hem terugplakt.
  4. Verwijder /sites/default/files/.cache/ en alles wat daar verder niet hoort. Draai find sites/default/files \( -name "*.php" -o -name "*.phtml" -o -name "*.phar" \). Op een schone site levert dat niets op. Levert het wel iets op, lees het dan.
  5. Audit de database. De pharma-payload zat dit keer alleen in het filesystem, maar controleer block_custom-bodyvelden, node_revision-bodyvelden, de variable-tabel op site_mail en site_name, en menu_links op elke link_path die begint met javascript: of verwijst naar een domein dat niet van jou is. Check system op modules met een filename die niet overeenkomt met de modulenaam.
  6. Roteer elk credential dat de web-user kan hebben gelezen. Het wachtwoord van Drupal user 1, het database-wachtwoord in settings.php, elke API-sleutel in de variable-tabel, de SFTP-login, het cPanel-wachtwoord. Behandel de bak alsof hij van boven naar beneden is uitgelezen, want dat is ook gebeurd.
  7. Update Drupal core en elke contrib-module naar de huidige 7.x-release. Drupal 7 bereikte in januari 2025 zijn end-of-life, maar het Drupal Security Team publiceert nog steeds advisories via het Drupal 7 Vendor Extended Support programma. Check drupal.org/project/drupal/releases op de meest recente 7.x point release voordat je patcht.

De database-audit is het deel dat bureaus overslaan en waar ze later spijt van krijgen. Wil je sneller door de verdachte tabellen, dan slaat een echte MySQL editor naast de bestandsboom het springen tussen phpMyAdmin en een SFTP-client. We draaiden de menu_links- en variable-queries naast de find-output en de hele audit duurde 20 minuten.

Eén Apache-blok dat deze categorie afsluit

Opruimen is de voor de hand liggende helft. De helft die de volgende voorkomt is het file upload-veld op het vacatureformulier, plus één Apache-blok onder de uploads-directory. We zetten de toegestane extensies op pdf doc docx, voegden een hook_file_validate-implementatie toe die het MIME-type opnieuw checkt tegen de daadwerkelijke file bytes (vertrouw de extensie niet, vertrouw de door de browser meegegeven MIME niet), en dropten een deny-regel onder /sites/default/files die weigert ook maar iets uit te voeren met een server-side extensie:

# /sites/default/files/.htaccess
<FilesMatch "\.(php|phtml|phar|pl|py|jsp|cgi|asp|aspx)$">
  Require all denied
</FilesMatch>

# Belt and braces: schakel de PHP handler uit in deze tree
php_flag engine off
RemoveHandler .php .phtml .phar
RemoveType .php .phtml .phar

Dat blok hoort in de .htaccess die Drupal meelevert in /sites/default/files. Check die van jou. Op drie van de laatste vijf verouderde sites die we bekeken was dat bestand overschreven of verwijderd tijdens een mislukte migratie in 2020, meestal door een rsync-commando dat dotfiles uitsloot. De OWASP file upload-richtlijn behandelt dezelfde verdediging in meer detail: owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload.

Aantekeningen de volgende ochtend

Het Drupal 7 pharma hack-verhaal eindigde om 04:30. De SERP van de klant had elf dagen nodig om op te klaren, omdat Google elke vergiftigde URL opnieuw moest crawlen en de cache-TTL op sommige ervan een week was. Het bureau factureerde zes uur. De fix in versiebeheer was 14 regels, bijna allemaal het bovenstaande deny-blok plus de gecorrigeerde file_validate-hook.

We doen er veel van bij legacy site-klussen, en het deel dat altijd het meeste tijd kostte was niet de opruiming. Het was uitzoeken welk bestand wanneer was veranderd, en zeker weten dat de rollback geen goede edit van vorige week meesleurde. Toen we Pier bouwden liepen we hier precies tegenaan bij een Drupal 6-incident, en de manier waarop we het uiteindelijk aanpakten was om automatische version history in te bakken in elke SFTP-edit, zodat de diff tussen wat de site oorspronkelijk had en wat er nu op de server staat altijd één klik weg is.

Het kleinste dat je vandaag kunt doen

Open een terminal, SSH naar de verouderde site die je vorig kwartaal vergat, en draai find sites/default/files -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.phar" \) 2>/dev/null. Komt er iets terug, dan heb je een avond werk voor de boeg, en die heb je liever nu dan om 23:41 op een dinsdag.

— Vragen —

Is het in 2026 nog veilig om Drupal 7 te draaien?

Alleen met betaalde Vendor Extended Support en een gehardende uploads-directory. Core-EOL was januari 2025, maar VES-partners leveren tot 2027 nog securitypatches.

Hoe check ik of mijn site content cloakt richting Googlebot?

Haal dezelfde URL twee keer op met curl, één keer met een normale user-agent en één keer met de Googlebot-string. Diff de outputs. Verschillen ze op een publieke pagina, dan cloakt je site.

Moet ik de kwaadaardige bestanden meteen verwijderen?

Kopieer ze eerst buiten de docroot, met chmod 000 erop. Grep'en in de access log op fetches naar die bestandsnamen vertelt je wanneer de aanvaller binnenkwam en welk endpoint hij gebruikte.