— Artikel — № 071

071 —Operations

chmod -R 777 herstel: rechten terugzetten op een legacy site

Een chmod -R 777 in paniek voelde om 23:41 als de snelste oplossing. Twaalf minuten later weigerde PHP-FPM elk script en gaf de loginpagina 500s.

Stilleven van boven op linnen: Unix-rechten spiekbriefje, PHP-FPM foutenlog, manila map wp-content, koperen CHMOD-plaatje, rode lakzegel.
Hero · gestileerd stilleven№ 071

Een ontwikkelaar bij een Nederlands bureau waar we mee werken voerde om 23:41 chmod -R 777 /var/www/clients/wonen-23 uit, omdat een klant zat te wachten op een media-upload die in de WordPress-admin steeds faalde. De upload ging erdoor. De volgende vier ook. Twaalf minuten later gaf elke pagina op de site een blanke 500 terug, en de error log liep vol met Premature end of script headers. Om 00:15 zat de bureaulead op een Loom-opname uit te leggen wat ze hadden gedaan, en te vragen hoe ze het terug konden draaien.

Dit bericht is het herstelplan dat we de volgende ochtend terugstuurden, gegeneraliseerd voor elke legacy site op een shared of VPS hosting met een UID-bewuste PHP-handler. De commando's zijn de echte die we gebruikten. De foutmeldingen zijn die je in productie ook daadwerkelijk te zien krijgt.

Recursieve 777 en de afwijzing door de handler

De handboekreden waarom een recursieve 777 slecht is, is security: world-writable bestanden betekenen dat iedereen met shell-toegang je scripts kan aanpassen. De operationele reden, degene die je diezelfde nacht bijt, is dat moderne PHP-handlers met opzet weigeren om world-writable code uit te voeren. Apache suEXEC weigert scripts waarvan de bovenliggende directory door group of other beschrijfbaar is. PHP-FPM pools die draaien onder suEXEC, mod_ruid2 of CageFS doen hetzelfde. cPanel's suPHP geeft de bekende SoftException in Application.cpp:597: Directory "..." is writable by group. Op nginx met PHP-FPM zie je meestal FastCGI sent in stderr: Primary script unknown, omdat de worker weigert te forken voordat de echte reden gelogd wordt.

WordPress merkt het ook. Wanneer wp-config.php world-writable is, zetten verschillende hardeningplugins het bestand in quarantaine en laadt het dashboard niet meer. mod_security-rulesets op managed hosting matchen op patronen van 777-directories en gaan requests droppen op de WAF. En als je backups draaien met rsync's -a-vlag, dan houdt de volgende snapshot de fout netjes in stand, dus de voor de hand liggende 'gewoon de tarball van gisteravond terugzetten' is geen schone rollback meer.

De schade zit ook in de cache. OPcache houdt gecompileerde bytecode vast op basis van pad en mtime, dus de eerste request na de chmod kan nog een paar seconden de eerder gecompileerde versie uitvoeren, waardoor de outage in eerste instantie intermitterend lijkt. Tegen de tijd dat OPcache de boel evict en de worker probeert te herladen, wordt het bestand geweigerd en beginnen de 500s pas echt. PHP-FPM herstarten na het herstel maakt die cache leeg; tot je dat doet, kun je afwijzingen blijven zien op bestanden die je al gefixt hebt.

Niets hiervan is theoretisch. Elke regel hierboven komt overeen met een foutmelding die we dit jaar in een productielog hebben gezien.

De juiste ownership bepalen voor je iets aanraakt

De eerste zet is niet om opnieuw chmod te draaien. Het is om uit te zoeken wat de rechten en ownership voor de paniek hoorden te zijn.

Drie plekken om te kijken, in volgorde van betrouwbaarheid:

  1. Een naburige vhost op dezelfde machine. ls -la /var/www/clients/ laat meestal hetzelfde patroon zien bij verschillende klanten op dezelfde bak.
  2. Een backup van voor het incident. stat -c '%U:%G %a %n' filename meldt user, group en mode in één regel en is makkelijk te greppen over een teruggezette tarball.
  3. Het PHP-FPM pool-bestand, dat je vertelt onder welke UID het script gelezen wordt.

Die laatste is de bron van waarheid, want de pool config bepaalt welk proces de code bij de volgende request gaat lezen. Op Debian en Ubuntu staat dat onder /etc/php/8.2/fpm/pool.d/. De relevante regels zijn user, group, listen.owner en listen.group. Wat user ook is, dat is wie owner moet zijn (of dezelfde group moet delen met) elk uitvoerbaar PHP-bestand op schijf.

grep -E '^(user|group)' /etc/php/8.2/fpm/pool.d/wonen-23.conf
# user = web23
# group = client5

Op cPanel is de pool user meestal de account user, iets als wonen23. Op DirectAdmin is dat hetzelfde. Op Plesk onder PHP-FPM is de group meestal psacln. Op een kale LAMP-bak zonder paneel is het bijna altijd www-data op Debian-achtige systemen en apache op RHEL-achtige systemen. Bevestig dit door de draaiende process list te lezen in plaats van de documentatie te raden:

ps -eo user,group,cmd | grep php-fpm | grep -v grep

Vergelijk de user die ps rapporteert met de user die in het pool-bestand staat. Als die niet overeenkomen, dan speelt er iets anders (een verouderd init-script, een handmatige chown op de binary, een oude pool die nooit herstart is), en moet je dat eerst oplossen voor je begint met het herstellen van de docroot. Modes toepassen tegen de verkeerde target user brengt je niet dichter bij een werkende site; je herschrijft de fout alleen over meer bestanden.

Directory- en bestandsmodes terugzetten

Het standaard, saaie, correcte patroon voor bijna elke PHP-site is 755 voor directories en 644 voor bestanden. De netste manier om dat toe te passen op een tree zonder een script te schrijven, zijn twee find-aanroepen.

cd /var/www/clients/wonen-23/htdocs

# Directories: rwx voor owner, rx voor group en other
find . -type d -exec chmod 755 {} +

# Bestanden: rw voor owner, r voor group en other
find . -type f -exec chmod 644 {} +

Gebruik de afsluitende + in plaats van \;. De plus-vorm bundelt argumenten in één enkele chmod-aanroep, wat op een Drupal-installatie van 40.000 bestanden het verschil is tussen drie seconden en drie minuten.

Dat brengt je op een verstandige default. Daarna komen de CMS-specifieke verscherpingen.

WordPress

De WordPress hardening guide raadt 640 of 600 aan voor wp-config.php. De uploads-directory moet schrijfbaar zijn voor de PHP-user, maar 755 met juiste ownership is voldoende. Zet hem nooit terug op 777, ook niet tijdelijk.

chmod 640 wp-config.php
chown www-data:www-data wp-config.php

# Uploads moeten schrijfbaar zijn voor de PHP-user, niet voor de wereld
find wp-content/uploads -type d -exec chmod 755 {} +
find wp-content/uploads -type f -exec chmod 644 {} +

Drupal

De Drupal-rechtengids is conservatiever. sites/default/settings.php hoort 444 te zijn en idealiter eigendom van root, met alleen de webserver-user in de read-group. De boom onder sites/default/files moet schrijfbaar zijn voor de webserver-user en verder voor niemand. Drupal schrijft ook Twig compile output naar sites/default/files/php; bij een verse recovery is die directory vaak leeg en regenereert hij stilletjes bij de eerste authenticated request, dus raak niet in paniek wanneer hij daarna ineens opduikt.

Joomla

Joomla verwacht dezelfde 755 / 644 basis. configuration.php hoort 644 te zijn met de PHP-user als owner, of 444 als je hem alleen via deploys aanpast. De administrator en de front-end schrijven beide naar cache, tmp, logs en administrator/cache, dus die vier directories moeten schrijfbaar zijn voor de PHP-user met de juiste ownership.

chmod 644 configuration.php
find cache tmp logs administrator/cache -type d -exec chmod 755 {} +
find cache tmp logs administrator/cache -type f -exec chmod 644 {} +

Magento 2

Magento 2 heeft 770 nodig op var, generated en pub/media, met de setgid-bit op directories zodat nieuwe bestanden de juiste group erven. De officiële bin/magento-volgorde regelt dit, maar bij handmatig herstel:

find var generated pub/media pub/static -type d -exec chmod 2770 {} +
find var generated pub/media pub/static -type f -exec chmod 660 {} +
chmod 440 app/etc/env.php

De setgid-bit (de leidende 2 in 2770) is het cruciale onderdeel. Magento's CLI schrijft nieuwe bestanden naar var/cache en generated/code bij elke deploy, en zonder setgid erven die bestanden de primaire group van de CLI-user in plaats van de webserver-group, wat betekent dat de volgende request ze niet kan lezen. De Adobe Commerce file system reference documenteert de volledige ownership-matrix, en is een keer doorlezen waard, ook als je maar één Magento-winkel beheert.

Ownership terugzetten

Mode is de helft van het verhaal. De andere helft is de user en group die eigenaar zijn van de bestanden. Eén chown over de hele tree zet dat recht.

# Kale LAMP op Debian of Ubuntu
chown -R www-data:www-data /var/www/clients/wonen-23/htdocs

# cPanel onder PHP-FPM
chown -R wonen23:wonen23 /home/wonen23/public_html

# Plesk onder PHP-FPM
chown -R wonen23:psacln /var/www/vhosts/wonen.example/httpdocs
# Setgid zodat nieuwe bestanden de gedeelde group erven
find /var/www/vhosts/wonen.example/httpdocs -type d -exec chmod g+s {} +

De setgid-truc op Plesk en andere shared-group setups is het onthouden waard. Zonder dat krijgen bestanden die later door het PHP-proces worden gemaakt de primaire group van het proces in plaats van de vhost-group, en zit je over maanden in een langzame drift terug naar permission errors als nieuwe uploads in de verkeerde group landen.

Het herstel verifiëren

Je wilt twee checks doen voor je het afsluit. De eerste lijst alles op wat nog steeds world-writable is, precies wat je probeerde terug te draaien.

find /var/www/clients/wonen-23/htdocs -perm /o+w

Op een schone tree levert dit niets op. Als er iets in staat dat geen bewust schrijfbare cache-directory is, dan staat dat bestand nog steeds in de kapotte staat en heeft de chmod het niet bereikt (vaak omdat het een symlink was of op een ander filesystem stond).

De tweede check is om de echte PHP error log te tailen terwijl je de homepage en de admin-login laadt. Als suEXEC of PHP-FPM nog steeds ongelukkig is, zie je de afwijzing binnen een paar seconden.

tail -f /var/log/apache2/suexec.log /var/log/php8.2-fpm.log

Open de log-paden die bij jouw stack horen, niet die hierboven. Op een cPanel-bak betekent dat /usr/local/apache/logs/suexec_log en de per-user error log onder /home/USER/logs/. Op een Plesk-bak is dat /var/log/plesk-php-fpm/USER/error_log. Op een kale nginx-host is dat /var/log/nginx/error.log naast /var/log/php8.2-fpm.log. De handler-afwijzing logt altijd het foutieve pad, dus grep op de directory die je net gefixt hebt en bevestig dat de regels stoppen met verschijnen.

Tot slot: doe een echte request. Op WordPress is dat de admin-login; op Drupal is dat /user/login; op Magento is dat de homepage van de storefront. Tail de access log en bevestig dat er een 200 terugkomt. Een 500 in dit stadium betekent meestal een OPcache die nog de file-metadata van voor het herstel vasthoudt; systemctl reload php8.2-fpm leegt die zonder connecties te verbreken.

Een perms-baseline bewaren waar je naar terug kunt

De reden dat het herstel hierboven in twintig minuten te doen was en niet in twintig uur, is dat het bureau een naburige vhost had waar we de juiste waarden vanaf konden lezen. Als die van jou een eenmalige bak is, schrijf dan de baseline op voor je hem nodig hebt.

Een klein script ingecheckt in de site-repo is genoeg:

#!/usr/bin/env bash
# scripts/restore-perms.sh
set -euo pipefail
ROOT="${1:-/var/www/clients/wonen-23/htdocs}"
OWNER="${2:-www-data:www-data}"

chown -R "$OWNER" "$ROOT"
find "$ROOT" -type d -exec chmod 755 {} +
find "$ROOT" -type f -exec chmod 644 {} +
chmod 640 "$ROOT/wp-config.php" 2>/dev/null || true

Gecommit in scripts/ is dit het bestand dat je om 23:41 draait in plaats van chmod -R 777. Het is ook het bestand dat je een nieuwe junior in het team aanreikt als die vraagt wat de rechten horen te zijn. Het antwoord stopt met stamkennis te zijn en wordt een commando van één regel.

Twee verfijningen verdienen zich de tweede keer dat je het script gebruikt al terug. Ten eerste: log elke run naar een bekende locatie zodat je een audit trail hebt: echo "$(date -Iseconds) restore-perms $ROOT $OWNER" >> /var/log/perms-restore.log. Ten tweede: weiger te draaien tenzij de PHP-FPM pool het ook eens is met het OWNER-argument. Een grep van één regel tegen het pool-bestand vangt het geval af waarin een collega de pool-user heeft gewijzigd zonder het te zeggen, en voorkomt dat het script zelfverzekerd de verkeerde owner over 40.000 bestanden uitsmeert.

Permission-wijzigingen behandelen als discrete events

Toen we Pier bouwden liepen we op klantsites tegen precies dit patroon aan: iemand had maanden eerder de rechten genuked, en de enige manier om de oorspronkelijke modes te achterhalen was door de vhost van een andere klant te lezen of door een backup-tarball door te ploegen. De manier waarop we het uiteindelijk hebben aangepakt, is om elke permission- en ownership-wijziging vast te leggen als een discrete event, zodat de version history van een tree net zo teruggespoeld kan worden als een file-edit. Dezelfde aanpak dekt de MySQL editor: elke UPDATE is een eigen ongedaan te maken stap in plaats van een enkele reis.

Het kleinste wat je vandaag kunt doen, met of zonder dat alles: draai find /var/www -perm /o+w op de bakken die je beheert, en schrijf de resultaten in een tekstbestand naast je runbook. De meeste verouderde hosts hebben minstens één 777-directory die niemand zich herinnert te hebben aangemaakt. Weten waar ze al staan, is de helft van de opruiming.

— Vragen —

Waarom breekt chmod -R 777 PHP-execution op de meeste moderne hosts?

Handlers als suEXEC, suPHP, mod_ruid2 en CageFS weigeren bewust om scripts in world-writable directories uit te voeren. De worker wijst het bestand af voordat PHP er ooit aan toekomt.

Wat is de juiste mode voor wp-config.php?

640 met de PHP-user als owner is de veiligste default waarmee WordPress het bestand nog kan lezen. 600 werkt ook op single-user setups. Laat hem nooit op 777 staan, ook niet kort.

Laat 755 op een directory aanvallers gevoelige bestanden lezen?

755 laat elke lokale user de directory listen en lezen. Houd gevoelige bestanden daarbinnen op 640 of strikter, met de PHP-user als owner. Mode alleen is geen security-grens op een shared bak.