098 —Drupal
Drupal SFTP-lockout: herstel in 90 minuten en fail2ban-regel
Een brute-force-run sloot om 23:41 de enige admin van een Drupal 8-site buiten. Dit is het herstel van 90 minuten en de fail2ban-regel die het stopte.
De telefoon ging op een dinsdag om 23:41. Een Nederlands bureau waarmee we samenwerken had een Drupal 8-site voor een regionale logistieke klant, één adminaccount, één SFTP-user, en een hostingpaneel dat drie jaar geleden stilletjes het verlopen van wachtwoorden aan de FTP-kant had uitgezet. Tegen de tijd dat de on-call lead de Loom opende, was de SFTP-user gelocked, werkte het Drupal-adminwachtwoord niet meer, en gaf de site na het eerste request een schone witte WSOD op elke pagina. Het paneel van de host laadde nog. SSH stond aan. De database stond aan. Al het andere zag er, in de woorden van het bureau, behoorlijk fout uit.
Wat volgt zijn de daadwerkelijke 90 minuten. Het Drupal SFTP-lockout-patroon hier komt op oudere sites zo vaak voor dat het herstel de moeite waard is om volledig op te schrijven, inclusief de fail2ban-regel die we achterlieten.
23:41, wat de logs lieten zien
Eerste actie was het auth log op de bak. De host had SSH en SFTP op dezelfde sshd staan, wat normaal is, maar betekent dat één brute-force burst beide laat oplichten. We hebben niet ge-tail'd; we vroegen het laatste uur op, filterden op fouten, en telden per source IP.
sudo journalctl -u ssh --since "1 hour ago" \
| grep -E "Failed password|Invalid user" \
| awk '{print $(NF-3)}' \
| sort | uniq -c | sort -rn | headEén IP in Litouwen, 4.812 fouten in 47 minuten, allemaal tegen de username deploy, het SFTP-account dat het bureau sinds 2019 gebruikte. Het account was door PAM gelocked na de drempel (de host had pam_tally2 geconfigureerd, wat ongebruikelijk is, maar achteraf nuttig). De brute-force kwam nooit binnen. Wat hij wel deed, was de enige SFTP-user buitensluiten, en dat was relevant voor wat daarna gebeurde.
De Drupal-adminlockout stond hier los van, en het kostte ons vijf minuten te veel om dat te zien. Een junior bij het bureau, die het auth log voorbij zag scrollen, had die dag al geprobeerd het adminwachtwoord met drush upwd te resetten vanuit een wankele SSH-sessie op zijn laptop die halverwege de write was afgebroken. De sessie liet een half geschreven settings.local.php achter op disk met een ontbrekende sluitaccolade. Drupal bootstrapte, liep tegen de parse error in de lokale include aan, en renderde niets. De wachtwoordwijziging was ook niet gecommit, omdat het drush-commando was afgebroken voordat de row update plaatsvond.
Twee losstaande failures, dezelfde nacht, dezelfde site. Zo verlopen de meeste incidenten in de praktijk.
00:02, weer binnenkomen zonder de SFTP-user
We konden geen SFTP gebruiken omdat het account gelocked was en het bureau geen root had op de bak, alleen de deploy-user en het hostingpaneel. Het paneel had een file manager, maar die hikte aan over bestanden van meer dan 200KB, en de kapotte settings.local.php was qua grootte natuurlijk prima, alleen lag hij in een map waarin het paneel weigerde af te dalen vanwege een symlink ergens hogerop.
De oplossing was om deploy te ontgrendelen via de "reset SSH lockouts"-knop van het paneel (elk fatsoenlijk paneel heeft er één; het roept onder de motorkap gewoon pam_tally2 --user=deploy --reset aan) en daarna in te SFTP'en met een verse key die het bureau ter plekke genereerde. We gebruikten de oude key niet opnieuw: als er een key op de laptop had gestaan die eerder was afgebroken, hadden we geen manier om de staat ervan te kennen. Nieuwe ed25519-key, via het paneel in ~/.ssh/authorized_keys geplakt, oude key verwijderd.
ssh-keygen -t ed25519 -C "deploy-recovery-2026-06-10" -f ~/.ssh/deploy_recovery
# paste the .pub line into authorized_keys via the panel
ssh -i ~/.ssh/deploy_recovery deploy@host "id"Van daaruit was de kapotte settings.local.php een delete van één regel. We probeerden niet hem te repareren. De site bootstrapte alleen op de hoofd-settings.php, wat prima was voor de volgende twintig minuten waarin we het adminwachtwoord aanpakten.
00:18, het Drupal-adminwachtwoord resetten zonder drush
Drush had op dit punt gewerkt, maar de drush van het bureau hing vast op een oude versie die niet meer matchte met de Drupal-core van de site na een half afgemaakte update uit januari. In plaats van dat om middernacht uit te zoeken, gingen we direct naar de database. Drupal 8 slaat adminwachtwoorden op in de users_field_data-tabel, gejoined op users, en de wachtwoordhash wordt gegenereerd door core/scripts/password-hash.sh dat in core meekomt.
cd /var/www/site
php core/scripts/password-hash.sh 'a-long-throwaway-passphrase-we-rotate-in-five-minutes'
# prints: $S$E9... (a Drupal-format hash)Vervolgens, in MySQL, tegen de users_field_data-row voor uid 1:
UPDATE users_field_data
SET pass = '$S$E9...'
WHERE uid = 1;
-- also clear any flood entries that might block the next login
DELETE FROM flood WHERE event IN ('user.failed_login_ip', 'user.failed_login_user');De flood-delete is belangrijk. De flood control van Drupal sluit je vrolijk een uur uit je eigen adminlogin als de brute-force burst ook de loginform raakte, wat op deze site niet was gebeurd, maar in ruwweg de helft van de incidenten die we zien wel. Ruim hem preventief op.
Admin binnen, wachtwoord via de UI geroteerd naar een passphrase die de lead van het bureau ook daadwerkelijk in 1Password had staan, en om 00:34 was de site weer bruikbaar. Drieënvijftig minuten na de Loom.
00:34, de fail2ban-regel die we achterlieten
Het resterende halfuur was het deel dat ertoe deed. De brute-force zou de volgende nacht terugkomen tegen een andere username, en de PAM-tally van de host zou ook die user buitensluiten. We wilden de IP's op de firewall blokkeren voordat ze de authstack van sshd ooit zouden bereiken.
De host had fail2ban geïnstalleerd, maar de SSH-jail stond uit, een default die we nog steeds zien op een verrassend aantal managed VPS-opstellingen. We zetten hem aan met een iets striktere filter dan de meegeleverde standaard, omdat de standaard sshd-filter in oudere fail2ban-builds een aantal logregels mist die nieuwere OpenSSH-versies uitspugen.
# /etc/fail2ban/jail.d/sshd-strict.local
[sshd]
enabled = true
port = ssh
filter = sshd
backend = systemd
maxretry = 4
findtime = 10m
bantime = 24h
ignoreip = 127.0.0.1/8 ::1 /32 Vier failures in tien minuten, een dag lang gebanned. De ignoreip-regel is de regel die mensen vergeten; zonder die regel sluit een vermoeide admin die op kantoor drie keer naast zijn key tikt het hele kantoor buiten. Wij voegen altijd het egress-IP van het kantoor en eventuele monitoring-probes toe voordat we de jail aanzetten.
We testten de ban door een wegwerp-VPS met drie verkeerde wachtwoorden op de server af te vuren en keken vervolgens naar de iptables chain:
sudo fail2ban-client status sshd
sudo iptables -L f2b-sshd -n --line-numbersHet IP van de wegwerp-VPS verscheen bij de vierde poging in de chain. We deden een reload, het bureau bevestigde dat ze nog steeds vanuit kantoor konden SSH'en, en om 01:11 sloten we het incident.
Wat we de volgende ochtend hebben aangepast
De post-mortem liep om 09:00 de volgende dag, en er kwamen drie dingen uit. Het eerste was structureel: deze legacy site had één SFTP-user en één adminuser, wat één failure verwijderd is van een totale lockout per nacht. Het bureau voegde een tweede SFTP-user toe, key-only, voor break-glass, en bewaarde de key in 1Password achter een sealed-envelope-policy. Het tweede was de fail2ban-jail, die we die middag uitrolden op de andere elf gehoste sites van het bureau. Het derde was een wekelijkse drush user:password-dry-run via cron die bevestigt dat de adminrow intact is, drush sql:query "SELECT uid, name, status FROM users_field_data WHERE uid = 1" draait, en de lead van het bureau mailt als de row verdwijnt of de status omklapt naar 0.
Niets hiervan is nieuw. De reden dat het 90 minuten kostte in plaats van 20, is dat de twee failures met elkaar verstrengeld waren: de kapotte settings.local.php liet het werk in het auth log op de verkeerde manier urgent voelen, en de symlink-quirk van het paneel maakte van een delete van één regel een speurtocht van een kwartier. Het meeste van de tijd die we die nacht kwijt waren, ging op aan navigatie, niet aan fixes.
Toen we Pier bouwden, liepen we precies hier tegenaan: bureaus moeten bestanden bewerken op een dichtgetimmerde bak, vaak via een paneel waarvan de file manager het niet doet, en ze willen een version history van elke wijziging, omdat middernachtelijke edits in de ochtend weer worden teruggedraaid. Wat wij uiteindelijk deden, was lokaal een mirror van de SFTP-tree bijhouden en elke write versioneren, zodat een half geschreven settings.local.php met één klik terug te draaien is, in plaats van een database-round-trip te kosten.
Heb je een Drupal- of WordPress-site met één admin en één SFTP-user, voeg vanavond nog die tweede SFTP-key toe. Het is een taak van vijf minuten en het scheelt tussen een herstel van 20 minuten en een van 90.
— Vragen —
Sluit fail2ban me buiten als ik mijn SSH-keypassphrase verkeerd typ?
Nee. fail2ban kijkt naar authenticatiefouten in sshd, niet naar lokale prompts van je key-agent. Een verkeerde passphrase aan jouw kant bereikt de server nooit, dus die kan ook niet meetellen voor de bandrempel.
Is de Drupal-users-tabel direct bewerken veilig?
Voor de wachtwoordhash op uid 1 wel, zolang je de hash genereert met core/scripts/password-hash.sh, zodat het formaat klopt. Schrijf hashes niet met de hand uit en kopieer ze niet van een andere site.
Waarom de flood-tabel leegmaken tijdens herstel?
De flood control van Drupal kan de adminlogin een uur lang dichtgooien na een serie mislukte pogingen. Door de relevante rows op te ruimen voorkom je dat een vers, correct wachtwoord als rate-limited wordt afgewezen.