HardStacked
Partager :

apache2 · systemd

Apache « Read-only file system » après le passage à Ubuntu 26.04

Publié le · HardStacked · 11 min

Tu as passé un serveur ou un poste sous Ubuntu 26.04 LTS. Apache et PHP tournaient bien en 24.04 ou en 25.10, et maintenant chaque script qui écrit sous /home reçoit Failed to open stream: Read-only file system. C'est le titre exact d'une question d'Ask Ubuntu de septembre (question 1569472, créée le 2026-09-06, 286 vues au 2026-11-06, une seule réponse, aucune acceptée). Tu as vérifié : le disque est monté en lecture-écriture, fsck ne trouve rien, les droits sont bons, le même script en ligne de commande écrit sans problème.

Ton disque n'est pas en lecture seule. C'est la vue qu'Apache en a. Depuis Ubuntu 26.04, apache2.service lance Apache dans un bac à sable systemd : /home et /etc y sont remontés en lecture seule, sudoers y est rendu invisible, /dev y est privé. Ton shell regarde le vrai système de fichiers, Apache regarde le sien, et c'est pour ça que tout « marche en ligne de commande ». Le correctif qui circule, ProtectHome=no, ouvre un de ces murs. Un autre, ProtectSystem=no, n'ouvre pas celui qu'on croit. Le bon correctif rouvre le dossier précis dont tu as besoin, et rien d'autre.

Mesuré sur une machine virtuelle Ubuntu 26.04.1 LTS (arm64, image cloud officielle), apache2 2.4.66-2ubuntu2.6, PHP 8.5 avec mod_php, systemd 259.5, le 2026-11-06.

01. Le disque est inscriptible, la vue d'Apache ne l'est pas

Tout ce qu'on crée ici contient demo dans son nom et est retiré à la fin (section 07). On redémarre Apache plusieurs fois : fais ça sur une machine de test ou hors des heures de service. /home/user est le nom générique de ton propre dossier personnel. Deux petits fichiers PHP (l'un écrit vers un chemin que tu passes, l'autre lance sudo -n id) et une ligne sudoers qui laisse www-data lancer id en root, rien de plus.

la mise en place
$ sudo mkdir /home/user/demo-site
$ sudo chown www-data: /home/user/demo-site
$ sudo install -o www-data -m 644 /dev/null /etc/demo-app.conf
$ printf '%s\n' '<?php' '$p = $_GET["p"];' 'echo @file_put_contents($p, "ok\n") === false ? error_get_last()["message"] : "wrote $p", "\n";' | sudo tee /var/www/html/demo-write.php >/dev/null
$ printf '%s\n' '<?php' 'echo shell_exec("sudo -n /usr/bin/id 2>&1");' | sudo tee /var/www/html/demo-sudo.php >/dev/null
$ echo 'www-data ALL=(root) NOPASSWD: /usr/bin/id' | sudo tee /etc/sudoers.d/50-demo-www
www-data ALL=(root) NOPASSWD: /usr/bin/id
$ sudo chmod 440 /etc/sudoers.d/50-demo-www

Apache échoue dans /home. Le même utilisateur, www-data, y écrit depuis un shell. findmnt dit rw. Et un témoin dans /var/www/html échoue aussi, avec un autre message :

apache contre le shell
$ curl -s 'http://localhost/demo-write.php?p=/home/user/demo-site/test.txt'
file_put_contents(/home/user/demo-site/test.txt): Failed to open stream: Read-only file system
$ sudo -u www-data touch /home/user/demo-site/test.txt && echo 'shell: written'
shell: written
$ findmnt -no TARGET,OPTIONS -T /home/user/demo-site
/ rw,relatime,discard,errors=remount-ro,commit=30
$ curl -s 'http://localhost/demo-write.php?p=/var/www/html/test.txt'
file_put_contents(/var/www/html/test.txt): Failed to open stream: Permission denied

Apprends à distinguer ces deux erreurs. Permission denied est un vrai problème de droits : l'utilisateur n'a pas le droit d'écrire là. Read-only file system ne parle pas de droits du tout, et un chmod ne le réglera jamais. Dans la question, la personne qui la pose a essayé fsck (« I use fsck as we can read lot of things about that. Nothing change. ») et les options de montage (« I put "rw, users" options to my mount directive. Nothing change. »). Les deux regardent le vrai disque, qui n'a jamais été le problème.

02. Ce qui a changé dans Ubuntu 26.04

L'unité elle-même te le dit. Les directives de durcissement, puis le score de systemd-analyze security (5.7 MEDIUM).

l'unité
$ dpkg-query -W apache2
apache2 2.4.66-2ubuntu2.6
$ systemctl cat apache2 | grep -E '^(Protect(Home|System)|PrivateDevices|ReadWritePaths|InaccessiblePaths)='
PrivateDevices=yes ProtectHome=read-only ProtectSystem=full ReadWritePaths=/var/log/apache2 ReadWritePaths=/var/cache/apache2/mod_cache_disk InaccessiblePaths=/boot InaccessiblePaths=/root InaccessiblePaths=-/etc/sudoers InaccessiblePaths=-/etc/sudoers.d InaccessiblePaths=-/etc/ssh InaccessiblePaths=-/etc/apt InaccessiblePaths=-/etc/.git InaccessiblePaths=-/etc/.svn
$ systemd-analyze security apache2 --no-pager | tail -1
→ Overall exposure level for apache2.service: 5.7 MEDIUM :-|

ProtectSystem=full met /usr, /boot, /efi et /etc en lecture seule, ProtectHome=read-only fait pareil pour /home, /root et /run/user (systemd.exec(5)). ReadWritePaths= rouvre un chemin. InaccessiblePaths= le cache derrière un remplaçant vide et sans droits (section 03), et le préfixe - veut dire « ignore-le s'il n'existe pas ».

Le bloc vient de Debian. Le changelog d'apache2 2.4.66-1 (unstable, Bastien Roucariès) le dit sans détour : « Harden systemd services. Set ProtectSystem=full ProtectHome=read-only, RestrictSUIDSGID=yes. This may break read-write CGI script to /home and WebDaV or other CGI/php/lua uses. » (« Durcir les services systemd. [...] Ça peut casser les scripts CGI en lecture-écriture vers /home, WebDAV et d'autres usages CGI/php/lua. ») Ubuntu 26.04 livre 2.4.66-2ubuntu2.6 avec ce bloc. Les unités de 24.04 (2.4.58-1ubuntu8.17), 25.04 (2.4.63-1ubuntu1.1) et 25.10 (2.4.64-1ubuntu3.5) n'ont que PrivateTmp=true, comme Debian 13 (2.4.69-1~deb13u1), qui n'est pas touchée aujourd'hui. Le changement vient de Debian unstable : la prochaine version de Debian le portera probablement aussi.

03. Voir le système de fichiers avec les yeux d'Apache

L'instrument, c'est findmnt -N : les montages vus depuis l'espace de noms d'un processus, ici le processus principal d'Apache. Même disque, même chemin, deux réponses.

la vue d'apache
$ sudo findmnt -N $(systemctl show -p MainPID --value apache2) -no TARGET,OPTIONS -T /home/user/demo-site
/home ro,relatime,discard,errors=remount-ro,commit=30
$ sudo findmnt -N $(systemctl show -p MainPID --value apache2) -no TARGET,OPTIONS -T /etc
/etc ro,relatime,discard,errors=remount-ro,commit=30
$ sudo nsenter -t $(systemctl show -p MainPID --value apache2) -m ls -l /etc/sudoers
---------- 1 root root 0 Nov 6 19:22 /etc/sudoers

/home et /etc sont en lecture seule pour Apache. Et /etc/sudoers est un fichier de 0 octet sans aucune permission (----------) : dans le bac à sable, il est remplacé par un fichier vide inaccessible, alors qu'il est intact sur le vrai système. Même famille de piège que un service systemd qui tourne en root et reçoit « Permission denied » : le bac à sable change ce que le service voit, et ton shell ne peut pas te le dire.

04. Quatre murs, pas un seul

/home était le premier mur. Le deuxième est /etc, même erreur que tout à l'heure. Le troisième est sudo, et il te ment. Depuis un shell, www-data peut lancer sudo -n /usr/bin/id et obtient root. Depuis PHP, via Apache, sudo répond comme si aucune règle n'existait :

/etc et sudo
$ curl -s 'http://localhost/demo-write.php?p=/etc/demo-app.conf'
file_put_contents(/etc/demo-app.conf): Failed to open stream: Read-only file system
$ sudo -u www-data sudo -n /usr/bin/id
uid=0(root) gid=0(root) groups=0(root)
$ curl -s http://localhost/demo-sudo.php
sudo: I'm sorry www-data. I'm afraid I can't do that

La règle existe. Ce sont /etc/sudoers et /etc/sudoers.d qu'Apache ne peut pas lire, et sudo-rs, le sudo par défaut de 26.04, répond la même chose qu'à un utilisateur sans aucune règle, au lieu de dire qu'il ne peut pas lire ses fichiers. Le quatrième mur est /dev : PrivateDevices=yes donne à Apache un /dev privé. Sur la même machine, PHP voit /etc/ssh/sshd_config comme absent, et /dev/ttyAMA0 (un port série, sur un Pi ou sur la VM) est absent pour Apache et présent dans un shell. Les scripts qui parlent à du matériel, de la domotique sur un Pi par exemple, cassent de la même façon.

05. Les correctifs, mesurés

D'abord ProtectHome=no, celui qui circule, en drop-in. L'écriture dans /home passe. /etc, non :

ProtectHome=no
$ sudo mkdir -p /etc/systemd/system/apache2.service.d
$ printf '[Service]\nProtectHome=no\n' | sudo tee /etc/systemd/system/apache2.service.d/demo.conf
[Service] ProtectHome=no
$ sudo systemctl daemon-reload && sudo systemctl restart apache2
$ curl -s 'http://localhost/demo-write.php?p=/home/user/demo-site/test.txt'
wrote /home/user/demo-site/test.txt
$ curl -s 'http://localhost/demo-write.php?p=/etc/demo-app.conf'
file_put_contents(/etc/demo-app.conf): Failed to open stream: Read-only file system

Ensuite ProtectSystem=no seul, une autre alternative qui circule. C'est l'inverse : /etc devient inscriptible, mais /home reste en lecture seule. ProtectSystem n'est pas la directive qui protège /home, donc si ton problème est /home, celle-ci ne fait rien pour toi.

ProtectSystem=no
$ printf '[Service]\nProtectSystem=no\n' | sudo tee /etc/systemd/system/apache2.service.d/demo.conf
[Service] ProtectSystem=no
$ sudo systemctl daemon-reload && sudo systemctl restart apache2
$ curl -s 'http://localhost/demo-write.php?p=/home/user/demo-site/test.txt'
file_put_contents(/home/user/demo-site/test.txt): Failed to open stream: Read-only file system
$ curl -s 'http://localhost/demo-write.php?p=/etc/demo-app.conf'
wrote /etc/demo-app.conf

Le correctif qu'on recommande est ReadWritePaths= avec le seul dossier. Le dossier est inscriptible, un fichier ailleurs dans /home reste en lecture seule, et tout le reste du bac à sable reste en place :

ReadWritePaths=
$ printf '[Service]\nReadWritePaths=/home/user/demo-site\n' | sudo tee /etc/systemd/system/apache2.service.d/demo.conf
[Service] ReadWritePaths=/home/user/demo-site
$ sudo systemctl daemon-reload && sudo systemctl restart apache2
$ curl -s 'http://localhost/demo-write.php?p=/home/user/demo-site/test.txt'
wrote /home/user/demo-site/test.txt
$ curl -s 'http://localhost/demo-write.php?p=/home/user/demo-other.txt'
file_put_contents(/home/user/demo-other.txt): Failed to open stream: Read-only file system

Pour sudo, tu ne peux pas retirer un seul chemin de InaccessiblePaths= : les affectations s'empilent. Tu vides la liste avec une affectation vide, puis tu remets les autres, sans les lignes sudoers. Résultat : sudo répond, et /etc/ssh reste d--------- pour Apache.

InaccessiblePaths=
$ printf '[Service]\nInaccessiblePaths=\nInaccessiblePaths=/boot /root -/etc/ssh -/etc/apt -/etc/.git -/etc/.svn\n' | sudo tee /etc/systemd/system/apache2.service.d/demo.conf
[Service] InaccessiblePaths= InaccessiblePaths=/boot /root -/etc/ssh -/etc/apt -/etc/.git -/etc/.svn
$ sudo systemctl daemon-reload && sudo systemctl restart apache2
$ systemctl show apache2 -p InaccessiblePaths
InaccessiblePaths=/boot /root -/etc/ssh -/etc/apt -/etc/.git -/etc/.svn
$ curl -s http://localhost/demo-sudo.php
uid=0(root) gid=0(root) groups=0(root)
$ sudo nsenter -t $(systemctl show -p MainPID --value apache2) -m ls -ld /etc/ssh
d--------- 2 root root 40 Nov 6 19:22 /etc/ssh

Un site web qui a besoin de sudo, c'est exactement ce que ce durcissement vise : une faille PHP devient alors une commande en root. Si tu peux, sors cette tâche du processus web au lieu d'ouvrir le mur.

La seule réponse à la question d'Ask Ubuntu, d'un autre utilisateur et avec un score de 0, dit d'installer ou de reconfigurer php-fpm. Elle est juste, et elle ne dit pas pourquoi. On l'a mesuré : php8.5-fpm.service n'a aucune directive de bac à sable, donc PHP quitte le processus d'Apache et son bac à sable. /home passe, et /etc renvoie enfin la vraie erreur, Permission denied. Ça marche parce que PHP a déménagé, pas parce que l'ancienne configuration était fausse.

06. Quand Apache ne démarre plus du tout

Même bac à sable, autre symptôme. Un hôte virtuel met son ErrorLog dans /home. configtest est content, le redémarrage échoue, et le journal ne dit pas pourquoi :

journal dans /home
$ sudo rm /etc/systemd/system/apache2.service.d/demo.conf && sudo systemctl daemon-reload
$ printf '%s\n' '<VirtualHost *:80>' 'ServerName demo.invalid' 'ErrorLog /home/user/demo-site/error.log' '</VirtualHost>' | sudo tee /etc/apache2/sites-available/demo.conf >/dev/null
$ sudo a2ensite -q demo
Enabling site demo.
$ sudo apache2ctl configtest
Syntax OK
$ sudo systemctl restart apache2
Job for apache2.service failed because the control process exited with error code. See "systemctl status apache2.service" and "journalctl -xeu apache2.service" for details.
$ sudo journalctl -u apache2 -n 3 --no-pager -o cat
apache2.service: Main process exited, code=exited, status=1/FAILURE apache2.service: Failed with result 'exit-code'. Failed to start apache2.service - The Apache HTTP Server.
$ sudo tail -n 2 /var/log/apache2/error.log
(30)Read-only file system: AH00091: apache2: could not open error log file /home/user/demo-site/error.log. AH00015: Unable to open logs

status=1/FAILURE et rien d'autre : la cause est dans /var/log/apache2/error.log. Apache ne peut pas ouvrir son journal dans /home, (30)Read-only file system. Mets le journal dans /var/log/apache2, où le service peut écrire, ou donne son dossier à un ReadWritePaths= : mesuré, Apache redémarre et y crée son journal. On désactive le site :

site désactivé
$ sudo a2dissite -q demo && sudo systemctl restart apache2 && systemctl is-active apache2
Site demo disabled. active

Le deuxième symptôme est plus vicieux. mod_cache_disk est dans ReadWritePaths=, et en 26.04 ce chemin doit exister, sinon systemd ne peut pas construire le bac à sable. On déplace le dossier et on lit les 5 dernières lignes du journal. La ligne la plus visible accuse /usr/sbin/apachectl: No such file or directory, alors qu'apachectl est bien là. La vraie cause est la ligne juste au-dessus.

mod_cache_disk déplacé
$ sudo mv /var/cache/apache2/mod_cache_disk /var/cache/apache2/demo-moved
$ sudo systemctl restart apache2
Job for apache2.service failed because the control process exited with error code. See "systemctl status apache2.service" and "journalctl -xeu apache2.service" for details.
$ sudo journalctl -u apache2 -n 5 --no-pager -o cat
apache2.service: Failed to set up mount namespacing: /var/cache/apache2/mod_cache_disk: No such file or directory apache2.service: Failed at step NAMESPACE spawning /usr/sbin/apachectl: No such file or directory apache2.service: Main process exited, code=exited, status=226/NAMESPACE apache2.service: Failed with result 'exit-code'. Failed to start apache2.service - The Apache HTTP Server.
$ sudo mv /var/cache/apache2/demo-moved /var/cache/apache2/mod_cache_disk
$ sudo systemctl restart apache2 && systemctl is-active apache2
active

Le statut 226/NAMESPACE est l'indice. La version 2.4.68-1ubuntu4 du paquet, après 26.04, écrit ces chemins avec le préfixe - (ignoré si absent), mais 26.04 a l'ancienne forme. Alors ne supprime pas /var/cache/apache2/mod_cache_disk. Et un - dans ton propre drop-in ne suffit pas : les listes s'empilent. On a mesuré un drop-in avec ReadWritePaths=-/var/cache/apache2/mod_cache_disk seul, et il échoue toujours en 226, parce que l'entrée d'origine reste dans la liste. Un ReadWritePaths= vide suivi de la liste complète, - compris, a démarré sans le dossier.

07. Nettoyer

On retire tout ce qui porte demo, on recharge systemd et on redémarre Apache. Le dossier des drop-ins ne part que s'il est vide : si tu y avais déjà ton propre drop-in, il reste.

démontage
$ sudo rmdir --ignore-fail-on-non-empty /etc/systemd/system/apache2.service.d
$ sudo rm -r /etc/sudoers.d/50-demo-www /etc/demo-app.conf /etc/apache2/sites-available/demo.conf /var/www/html/demo-write.php /var/www/html/demo-sudo.php /home/user/demo-site
$ sudo systemctl daemon-reload && sudo systemctl restart apache2 && systemctl is-active apache2
active

Portée : mesuré sur une seule machine virtuelle Ubuntu 26.04.1 arm64, Apache avec mod_php, le 2026-11-06. Non mesuré : Nginx, Apache sur Debian testing. Les applications qui écrivent dans /var/www ne sont pas touchées par ce bac à sable, qui restreint seulement /home, /root, /run/user, /usr, /boot, /efi, /etc et la liste inaccessible, et donne à Apache ses propres /dev et /tmp.

Partager :