Debian/Ubuntu: PHP-FPM isolieren per Chroot-Jail unter separatem User

Gehen wir von folgender Situation aus: Wir haben eine Webseite auf einem Server mit PHP-FPM aufgesetzt, die PHP-Software, die die Website betreibt, ist auf der aktuellsten Version. Leider wurde erst letztens eine Sicherheitslücke in dem Code von der Software gemeldet, die noch nicht geschlossen wurde und den Hacker bereits ausnutzen können um Zugriff auf das Dateisystem des Servers zu bekommen. Deswegen haben wir sichergestellt, dass wir ein Backup haben für den Fall, dass die Webseite komplett von einem Angriff ruiniert wird und neu installiert werden muss. Aber geht es nicht auch irgendwie, den Server außerhalb der Webseite zumindest abzusichern, dass wir nicht sämtliche Software oder gar den Server komplett neu installieren/konfigurieren müssen nach einem Angriff? Die Antwort ist ein Chroot unter einem separaten Unix-Benutzer zur Einschränkung der Zugriffsrechte.

In diesem Artikel wird eine Möglichkeit der Umsetzung eines sogenannten Chroot-Jails für PHP-FPM vorgestellt, kombiniert mit separatem User zum Ausführen von PHP-Skripts. Es ist durchaus möglich, dass es bessere Methoden gibt um eine PHP-Webseite abzusichern, z.B. die Webseite in einen Docker-Container zu packen; einen Artikel dazu werde ich noch nachreichen.

Eigenen Benutzer zur PHP-Ausführung

Zu aller erst werden wir einen eigenen Systemuser erstellen um Zugriffsrechte für PHP-Skripte einzuschränken. Das ist für den Fall, dass es eventuell in Linux auch einen Exploit geben könnte um den Chroot zu durchbrechen, dass dann trotzdem Dateien von anderen Webseiten nicht ruiniert und optional auch nicht ausgelesen werden können. Wir brauchen dafür Root-Rechte.

root@example:~# adduser --system --no-create-home --home /var/www/example.org www-example-org
Adding system user `www-example-org' (UID 113) ...
Adding new user `www-example-org' (UID 113) with group `nogroup' ...
Not creating home directory `/var/www/example.org'.
root@example:~# 

Wir haben den Benutzer hier www-example-org genannt und das Webseitenverzeichnis /var/www/example.org als dessen Homeverzeichnis übergeben was optional aber sauberer ist. Punkte vertragen sich im Übrigen nicht immer gut mit Unix-Benutzernamen, also habe ich zur Sicherheit stattdessen Bindestriche verwendet.

Konfiguration des PHP-Pools mit Chroot

Schauen wir jetzt mal was PHP selbst direkt bietet um einen Chroot aufzusetzen. Dafür werfen wir einen Blick auf die Konfigurationsdateien für PHP-Pools in PHP-FPM. Auch hier brauchen wir Root-Rechte.

root@example:~# cd /etc/php5/fpm/pool.d/
root@example:/etc/php5/fpm/pool.d# ls
www.conf
root@example:~# 

Standardmäßig findet sich in dem Ordner für die Poolkonfigurationen nur eine Konfiguration für den www-Pool der ein Socket aufsetzt über den Webserver (sowohl Apache als auch Nginx) mit PHP kommunizieren können.

Wir wollen aber nun einen separaten Pool für jede Webseite aufsetzen der so eingestellt ist, dass PHP-Skripte in diesem Pool in einer Chroot-Umgebung "eingesperrt" sind. Dafür erstellen wir eine neue Konfigurationsdatei, entweder durch Kopieren von www.conf und Hinzufügen/Auskommentieren/Bearbeiten entsprechender Zeilen oder einfach durch erstellen einer leeren Datei, je nach aktuellem Setup. Am bequemsten zu verwalten sind diese Konfigurationsdateien meiner Meinung nach wenn man sie gleich der Domain oder dem Verzeichnisnamen von der Webseite benennt, hier nennen wir es mal chroot-example.org.conf. Hier das Template mit Erklärung:

[chroot-example.org]

; Der Unixsocket auf dem dieser Pool auf den Webserver hört
listen = /var/run/php5-fpm-chroot-example.org.sock

; Das Verzeichnis unter dem PHP laufen wird
prefix = /var/www/example.org

; Das obere Verzeichnis wird unser Chroot-Ziel!
chroot = $prefix

; Wir wollen im neuen Wurzelverzeichnis arbeiten
chdir = /

; Der User und die Gruppe unter der PHP laufen wird, gemäß dem User den wir vorher erstellt haben
user = www-example-org  
group = www-data

; Der Besitzer und die Gruppe des Sockets.
; Als Besitzer nehmen wir den User, den wir vorhin angelegt haben und als Gruppe nehmen wir die gleiche Gruppe unter der unser Webserver läuft.
; Mit dem Zugriffsmodus (660) kombiniert stellt das sicher, dass nur der Webserver Zugriff auf diesen Socket hat.
listen.owner = www-example-org  
listen.group = www-data  
listen.mode = 0660

; Ein Ordner in der Chroot-Umgebung in dem wir alle PHP-Sessions sicher ablegen
php_value[session.save_path] = /sessions

; Optional, ein paar Performance-Einstellungen für diesen Pool
pm=dynamic  
pm.max_children=5  
pm.start_servers=2  
pm.min_spare_servers=1  
pm.max_spare_servers=3  

Zusammengefasst erstellen wir also hier einen Pool der unter dem gerade angelegten User www-example-org läuft in dem von uns gewollten Chroot. Außerdem ist dieser Pool dann nur für den Webserver zugänglich, über die Gruppe www-data.

So ähnlich wird auch mit jeder anderen Seite vorgegangen für die wir einen isolierten Chroot haben wollen. Dabei bekommt jeder Chroot einen eigenen Socketpfad (listen), einen eigenen Benutzer (listen.owner/user) und den entsprechenden eigenen prefix.

DNS-Auflösung im Chroot via nscd

Bevor wir fortfahren muss ich kurz erwähnen, dass in einer Chroot Umgebung PHP üblicherweise nicht ohne Probleme in der Lage ist DNS-Anfragen über gethostbyname oder gethostbyaddr aufzulösen. Damit das funktioniert wird man DNS-Anfragen aus dem Chroot irgendwie herausführen müssen da Linux-Programme das üblicherweise über Systemaufrufe erledigen die von Systemdateien abhängen die wir eventuell nicht in der Chroot-Umgebung haben wollen. Stattdessen verwenden wir eine Chroot-sichere Lösung die sich nscd ("Name Service Cache Daemon") nennt. Dieser Daemon stellt einen speziellen Socket bereit den DNS-Anfragen via Systemaufrufe alternativ nutzen um die Abfragezeit zu beschleunigen (daher "Cache"). Diesen Socket können wir später via mount in den Chroot durchgeben.

Die Installation läuft normalerweise sehr einfach und bequem über apt-get: (Nicht vergessen davor ein apt-get update auszuführen!)

root@example:~# apt-get install nscd -y
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following NEW packages will be installed:
  nscd
0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.
Need to get 0 B/242 kB of archives.
After this operation, 355 kB of additional disk space will be used.
Selecting previously unselected package nscd.
(Reading database ... 53350 files and directories currently installed.)
Preparing to unpack .../nscd_2.19-18+deb8u1_amd64.deb ...
Unpacking nscd (2.19-18+deb8u1) ...
Processing triggers for systemd (215-17+deb8u2) ...
Processing triggers for man-db (2.7.0.2-5) ...
Setting up nscd (2.19-18+deb8u1) ...
Processing triggers for systemd (215-17+deb8u2) ...
root@example:~# service nscd start
root@example:~# 

Der letzte Befehl stellt sicher, dass nscd auch wirklich bereits läuft. Danach sollte der Socket /var/run/nscd/socket existieren.

Chroot-Verzeichnisstruktur automatisch erstellen und abbauen

Als nächstes kümmern wir uns darum, dass die richtigen und nötigen Dateien in der Chroot-Umgebung existieren. Zur Verdeutlichung, meine Verzeichnisstruktur ist so, dass ich für jede Domain auf der eine Website läuft einen eigenen Ordner aufsetze in /var/www. Also als Tree-Ansicht:

├── example.org/                                     <= Soll im Chroot laufen
│   ├── htdocs/
│   │   └── index.php
│   ├── logs/
|   └── tmp/
├── sub.example.org/                                 <= Soll im Chroot laufen
│   ├── htdocs/
│   │   └── index.php
│   ├── logs/
|   └── tmp/
└── underconstruction.example.org/                   <= Das hier soll nachher nicht in einem Chroot laufen
    └── htdocs/
        └── index.html

Damit die Chroot-Umgebung anständig laufen kann müssen wir ein paar zusätzliche Ordner und Dateien freigeben auf die PHP dann von innen heraus zugreifen kann. mount --bind ist ein guter Weg um den Zugriff auf solche Dateien zu gewähren, das vermeidet vor allem zusätzliche Festplattenspeicherbelegung und es ist einfach generell eine sauberere Lösung.

Hier die Liste der Dateien und Ordner die ich freigebe samt Grund:

  • /dev/null, /dev/random, /dev/urandom, /dev/zero um Grundfunktionalitäten zu gewährleisten.
  • /usr/share/zoneinfo um die Zeitzonenberechnung in PHP nicht zu beeinträchtigen, praktisch für Foren etc.
  • /etc/ssl/certs und der eigentliche Ordner mit den Zertifikaten /usr/share/ca-certificates um kein Drama mit gültigen SSL-Zertifikaten bei HTTP-Verbindungen zu verursachen.
  • /var/run/mysqld für die Freigabe des MySQL Serversockets.
  • /etc/hosts, /etc/resolv.conf und /var/run/nscd für die Freigabe des oben genannten nscd Sockets damit DNS-Anfragen problemlos ablaufen.

Allerdings kann es ein bisschen nervig sein das für jede Webseite manuell machen zu müssen, daher habe ich mir dafür ein sogenanntes LSB-Init-Skript geschrieben, das man in /etc/init.d einfügen kann und das dann beim Hochfahren und Herunterfahren des Systems mitstarten kann um bei neuen Webseiten einfach den Chroot automatisch aufzusetzen und sauber die Mounts wieder abzubauen bevor das System herunterfährt.

Den Folgenden Inhalt habe ich unter /etc/init.d/php5-fpm-chroot-setup abgespeichert und dann mit chmod +x ausführbar gemacht. Required-Start: nscd stellt sicher, dass das Skript automatisch nach dem nscd hochfährt und die CHROOT_FILES-Variable enthält alle Ordner- und Dateipfade die in die Chroot-Umgebung gemountet werden sollen.

#!/bin/sh

### BEGIN INIT INFO
# Provides:          php5-fpm-chroot-setup
# Required-Start:    nscd
# Required-Stop:
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: Mounts needed sockets and other data into a previously set up chroot environment.
### END INIT INFO

# Hier die Dateien und Ordner die in die Chroot-Umgebung gemountet werden sollen
CHROOT_FILES="/etc/hosts /etc/resolv.conf /etc/ssl/certs /usr/share/ca-certificates /dev/null /dev/random /dev/urandom /dev/zero /var/run/mysqld /var/run/nscd /usr/share/zoneinfo"

case "$1" in  
    restart|force-reload|start)
        # Aufräumen bevor wir aufbauen
        $0 stop 2>/dev/null

        for chrootdir in /var/www/*; do
            # Nur in Ordnern mit eigenem /tmp Verzeichnis als Markierung einen Chroot aufsetzen
            if [ -d "${chrootdir}/tmp" ]; then
                # Berechtigungen von /tmp korrigieren
                chmod 777 "${chrootdir}/tmp"
                chmod +t "${chrootdir}/tmp"

                echo "Setting up ${chrootdir}..."
                for f in $CHROOT_FILES; do
                    if [ -d "$f" ]; then
                        # $f ist ein Pfad zu einem Verzeichnis
                        mkdir -p "${chrootdir}${f}"
                        mount --bind -o ro "${f}" "${chrootdir}${f}"
                    else
                        # $f ist ein Pfad zu einer Datei
                        mkdir -p "${chrootdir}$(dirname "${f}")"
                        touch "${chrootdir}${f}"
                        mount --bind -o ro "${f}" "${chrootdir}${f}"
                    fi
                done
            fi
        done
    ;;

    stop)
        for chrootdir in /var/www/*; do
            if [ -d "${chrootdir}/tmp" ]; then
                echo "Destructing ${chrootdir}..."
                for f in $CHROOT_FILES; do
                    umount "${chrootdir}${f}"
                    if [ -d "${chrootdir}${f}" ] && [ ! $(ls -A "${chrootdir}${f}") ]; then
                        # Leerer Ordner, kann man löschen
                        rmdir "${chrootdir}${f}"
                    elif [ -f "${chrootdir}${f}" ]; then
                        # Datei, kann man löschen
                        rm "${chrootdir}${f}"
                    fi
                done
            fi
        done
    ;;

    *)
        echo "Usage: $N {start|stop|restart|force-reload}" >&2
        exit 1
    ;;
esac

exit 0  

Klar kann dieses Skript ausgebaut werden, z.B. so, dass die Liste von Dateien die gemountet werden aus einer anderen Datei eingelesen wird, anstatt dass sie fest eingeschrieben wird. Außerdem wird hier einfach alles aus Sicherheitsgründen als "read-only" gemountet, was in manchen Situationen vielleicht unerwünscht ist. Aber als Basis funktioniert das wunderbar.

Zuletzt müssen wir, damit dieses Skript seine Arbeit automatisch beim Hoch- und Herunterfahren verrichtet, das Skript auch mittels update-rc.d installieren:

root@example:~# update-rc.d php5-fpm-chroot-setup defaults
root@example:~#

Das was jetzt noch manuell erstellt werden muss sind in jedem Chroot jeweils ein Ordner für die HTTP-Dokumente, einer für Logs, einer für PHP-Sessions und noch einer für temporäre Dateien. Ich habe das so gemacht im Chroot-Ordner:

  • htdocs Unterordner für HTTP Dokumente mit eigenem persönlichen Unix Account als Besitzer, nur Unterordner/Dateien die Schreibrechte brauchen haben entsprechende Berechtigung via chown oder chmod.
  • logs Unterordner für Webserverlogs mit dem vorher angelegten PHP-Benutzer als Besitzer.
  • tmp Unterordner mit dem vorher angelegten PHP-Benutzer als Besitzer, wird vom Skript automatisch mit den richtigen Berechtigungen ausgestattet wird. PHP nutzt standardmäßig diesen Ordner wenn nichts anderes manuell als temporäres Verzeichnis gesetzt wurde.
  • sessions Unterordner (weiter oben im PHP-Pool als Session-Speicherordner konfiguriert) mit oben angelegtem User als Besitzer und Modus 700 aus Sicherheitsgründen.

Oder kurz in ein paar Befehlen:

root@example:/var/www/example.org# mkdir -p htdocs logs tmp sessions
root@example:/var/www/example.org# chown icedream:icedream htdocs
root@example:/var/www/example.org# chown www-example-org:www-data logs
root@example:/var/www/example.org# chown www-example-org:www-data sessions
root@example:/var/www/example.org# chmod 700 sessions

Danach können wir /etc/init.d/php5-fpm-chroot-setup start ausführen und danach sollten wir sehen, dass das Skript in den Chroot-Ordnern die benötigten Sachen anständig gemountet hat:

root@example:~# /etc/init.d/php5-fpm-chroot-setup start
Destructing /var/www/example.org...
Destructing /var/www/sub.example.org...
Setting up /var/www/example.org...
Setting up /var/www/sub.example.org...
root@example:~# ls /var/www/example.org
dev  etc  htdocs  sessions  tmp  usr  var
root@example:~#

Die "Destructing" Meldung rührt daher, dass das Skript automatisch das Abbauen eines eventuellen fehlerhaften vorherigen Chroot-Setups probiert bevor er den Chroot neu aufbaut um inkonsistente Setups zu vermeiden. Dieses Abbauen kann manuell mittels /etc/init.d/php5-fpm-chroot-setup stop angestoßen werden um zum Beispiel vor Backups das Chroot-Verzeichnis zu reinigen.

Konfigurieren des Webservers

Als nächstes müssen wir unsere Webseite im Webserver so konfigurieren, dass sie auch durch den soeben konfigurierten PHP-Pool in der Chroot-Umgebung läuft. Hier benutze ich Nginx aber in Apache sollte das ebenfalls einfach zu machen sein.

Wir legen für jede Webseite die im Chroot laufen wird im /etc/nginx/sites-available Ordner eine eigene Konfigurationsdatei an, die so ähnlich wie diese aussieht:

server {  
        listen 80;
        listen [::]:80;

        root /var/www/example.org/htdocs;

        index index.php index.html index.htm index.nginx-debian.html;

        server_name example.org;

        # Hier ist der wichtige Teil! Damit sagen wir Nginx, dass er den PHP-Pool, den wir
        # speziell für diese Seite aufgesetzt haben, auch benutzen soll.
        location ~ \.(php|php/.*)$ {
                include snippets/fastcgi-php.conf;
                fastcgi_param   SCRIPT_FILENAME /htdocs/$fastcgi_script_name;
                fastcgi_pass unix:/var/run/php5-fpm-chroot-example.org.sock;
        }
}

Seiten die nicht im Chroot laufen werden brauchen dann auch natürlich nicht die Sektion in der Konfiguration mit dem eigenen PHP-Socket.

Nicht vergessen die Konfiguration dann auch unter /etc/nginx/sites-enabled korrekt zu verlinken damit Nginx diese auch beim Laden miteinbezieht:

root@example:/etc/nginx/sites-enabled# ln -s ../sites-available/example.org
Letzte Schritte

Nach einem service php5-fpm restart sollte dann der neue Socket für den Pool in /var/run/php5-fpm-chroot-example.org.sock angelegt sein und ein anschließendes service nginx reload oder service nginx restart sollte den Nginx dazu bringen die neuen Webseitenkonfigurationen zu laden.