The latest PHP2Shell vulnerabilities show how important it is to secure PHP installations against malicious actors. One very effective security measure is using AppArmor.
In this article I want to showcase how I use AppArmor profiles (or more accurately, AppArmor “hats”) to secure my PHP setup, utilizing Apache2 and PHP-FPM.
We will use Roundcube as an example PHP application.
This setup uses Debian, Apache2 and PHP-FPM Version 8.4, if you use something else you might need to adapt this to your environment.
For up-to-date information also take a look at the Debian HowTo.
Install the needed packages.
apt install apparmor apparmor-utils apparmor-profiles apparmor-profiles-extra
Enable the AppArmor LSM. Edit /etc/default/grub and add the AppArmor settings:
GRUB_CMDLINE_LINUX_DEFAULT="audit=1 apparmor=1 security=apparmor"
After changing the line run update-grub to update the boot configuration.
There are multiple security modules (LSM) for the kernel, for example my setup uses these:
GRUB_CMDLINE_LINUX_DEFAULT="audit=1 apparmor=1 security=apparmor lsm=lockdown,capability,landlock,yama,apparmor,bpf"Please look at the documentation for each of them before enabling.
(Optional) Configure the auditd daemon.
The auditd daemon is responsible for receiving audit events from the kernel. AppArmor is on the other side of this chain and emits audit events. The daemon itself runs without any special configuration but I like to increase the backlog to buffer more audit messages so that auditd has more time to process them.
To do this, edit /etc/audit/rules.d/audit.rules and increase the -b parameter.
I have it set pretty high, this depends on how much memory you have available:
## First rule - delete all
-D
## Increase the buffers to survive stress events.
## Make this bigger for busy systems
-b 65536
## This determine how long to wait in burst of events
--backlog_wait_time 60000
## Set failure mode to syslog
-f 1
Reboot to enable AppArmor. You should see active profiles now, check with aa-status:
▶ aa-status
apparmor module is loaded.
191 profiles are loaded.
67 profiles are in enforce mode.
/usr/bin/man
/usr/bin/pidgin
/usr/bin/pidgin//sanitized_helper
/usr/bin/totem
[..]
Also, you can check which LSMs are active in the kernel, this should include apparmor:
▶ cat /sys/kernel/security/lsm
lockdown,capability,landlock,yama,apparmor,bpf
AppArmor profiles are a complex mechanism. Please make yourself familiar with them before you change things.
I’m using the terms “profile” and “hat” somewhat interchangeably when it comes to AppArmor. A “hat” in AppArmor is a special kind of profile where the running program itself can request to “change the hat” and switch to that profile when necessary by using the
aa_change_hatcall.
I have a file that I include in every php-fpm hat. This is not part of the
Debian package and needs to be created first. The file is
/etc/apparmor.d/abstractions/php-fpm-hats and contains these two lines:
signal (receive) peer=php-fpm,
network unix stream,
The first line in the file allows the PHP-FPM hat to receive signals from the parent php-fpm profile/process. The second line allows it to use unix stream sockets, which are needed for PHP-FPM to handle requests over the pool socket.
The Debian apparmor package is configured so that hats for PHP-FPM pools are
loaded from files in /etc/apparmor.d/php-fpm.d/.
For Roundcube the file is /etc/apparmor.d/php-fpm.d/roundcube. Content:
^roundcube {
include <abstractions/php-worker>
include <abstractions/nameservice>
include <abstractions/php-fpm-hats>
@{run}/php/php-fpm-roundcube.sock rw,
/etc/roundcube/{,**} r,
/usr/share/roundcube/{,**} r,
/var/lib/roundcube/{,**} r,
owner /var/log/roundcube/* rw,
}
The first line specifies the hat (^) with the name “roundcube”. Everything between the curly braces is part of this hat.
The next three lines starting with include do what they say. They include the
specified files. All includes are relative to the /etc/apparmor.d directory.
The line @{run}/php/php-fpm-roundcube.sock rw, allows full access to the
named socket /run/php/php-fpm-roundcube.sock. This socket is where Apache
will send requests and receive the response from the PHP-FPM pool. You will
need to change this for every pool you create.
Then there are file access rules. The hat allows read-only access to every file
under /etc/roundcube, /usr/share/roundcube and /var/lib/roundcube as well
as the directories themselves.
The last rule in the hat profile grants read-write access to files in the
directory /var/log/roundcube/, but only if the owner of the file matches the
uid of the running process.
AppArmor denies every access that is not specified in a profile. So an application can’t do anything you didn’t explicitly allow.
Load the new hat by reloading the parent php-fpm profile.
▶ apparmor_parser -r /etc/apparmor.d/php-fpm -v
Replacement succeeded for "roundcube".
Replacement succeeded for "php-fpm".
PHP-FPM uses AppArmor hats to explicitly switch profiles for each pool that you configure. Each pool and corresponding AppArmor hat only has the permissions it needs to run the PHP application.
Separate PHP-FPM pools make it possible to have separate AppArmor profiles (hats) for each PHP application you want.
Each pool has a configuration file under /etc/php/8.4/fpm/pool.d. For
roundcube the file is /etc/php/8.4/fpm/pool.d/roundcube.conf:
[roundcube]
user = www-data
group = www-data
apparmor_hat = $pool
listen = /run/php/php-fpm-$pool.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
The first line defines the name of the pool, in this case “roundcube”. You can
use the name later in the pool configuration by using the $pool variable.
The important line for AppArmor is apparmor_hat = $pool which sets the
AppArmor hat to “roundcube”. The next line specifies the socket for the pool
that will be configured in Apache and that was whitelisted in the AppArmor hat.
Everything else was taken from the default pool settings and you can change it depending on your needs.
Now restart the php-fpm service to activate the new pool. This should also
place it directly in the new AppArmor hat. The Z option to ps shows the
apparmor profile a process is running under.
▶ systemctl restart php8.4-fpm.service
▶ ps auxwwfZ | fgrep php
php-fpm (enforce) root 3615484 0.0 0.0 1239472 76104 ? Ss Oct22 0:05 php-fpm: master process (/etc/php/8.4/fpm/php-fpm.conf)
php-fpm//roundcube (enforce) www-data 3615495 0.0 0.0 1240296 69808 ? S Oct22 0:00 \_ php-fpm: pool roundcube
php-fpm//roundcube (enforce) www-data 3615496 0.0 0.0 1240296 71444 ? S Oct22 0:00 \_ php-fpm: pool roundcube
Now the only thing left to do is to associate a specific PHP application with a PHP-FPM pool.
Enable mod_macro
To make this reusable I use the Apache Macro Module. Enable
it with a2enmod macro.
Configure and enable the PHP_FPM macro.
My macros all live in the file /etc/apache2/conf-available/macros.conf. Here is the PHP_FPM macro in that file:
<Macro PHP_FPM $pool>
<IfModule proxy_fcgi_module>
<IfModule setenvif_module>
SetEnvIfNoCase ^Authorization$ "(.+)" HTTP_AUTHORIZATION=$1
</IfModule>
<FilesMatch ".+\.ph(?:ar|p|tml)$">
SetHandler "proxy:unix:/run/php/php-fpm-$pool.sock|fcgi://localhost"
</FilesMatch>
<FilesMatch ".+\.phps$">
Require all denied
</FilesMatch>
<FilesMatch "^\.ph(?:ar|p|ps|tml)$">
Require all denied
</FilesMatch>
</IfModule>
</Macro>
After adding it to the file, enable the configuration with a2enconf macros.
Use the macro inside your Apache config.
This next step highly depends on your individual configuration. You can use the macro everywhere you would normally use a SetHandler to hand off requests to PHP-FPM.
The Roundcube PHP application is installed under
/var/lib/roundcube/public_html which is aliased to /webmail in the
server. You can use the Macro in a directory directive inside the Apache
VirtualHost:
<VirtualHost *:443>
ServerName mail.example.org
ServerAlias webmail.example.org
DocumentRoot /var/www/mail
CustomLog /var/log/apache2/mail.example.org-transfer.log combined
ErrorLog /var/log/apache2/mail.example.org-error.log
<IfModule headers_module>
Header set X-Clacks-Overhead "GNU Terry Pratchett"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</IfModule>
Alias /postfixadmin /usr/share/postfixadmin/public
Alias /webmail /var/lib/roundcube/public_html
<Directory /var/lib/roundcube/public_html>
Use PHP_FPM roundcube
</Directory>
<Directory /usr/share/postfixadmin/public>
Use PHP_FPM postfixadmin
</Directory>
</VirtualHost>
As you can see there is a second pool in the same VirtualHost using the
postfixadmin AppArmor hat.
Restart Apache
systemctl restart apache2.service and you’re done. The roundcube application
is now confined by the AppArmor hat and will not be able to access or execute
anything else in the system.
The default www PHP-FPM pool also has an AppArmor profile that is set to
complain mode by default. This means the pool is not confined by AppArmor but
every violation is logged. I changed this policy to “enforce”. You can do this
by executing aa-enforce /etc/apparmor.d/php-fpm but be prepared for things to
break.
If you want to create a hat for an application where you don’t know the needed
permissions you can set the hat to complain mode. To do that add the complain
flag to the profile like this:
^myapp flags=(complain) {
include <abstractions/php-worker>
include <abstractions/nameservice>
include <abstractions/php-fpm-hats>
@{run}/php/php-fpm-myapp.sock rw,
}
This way you will see policy violations in the log but the access will still be
allowed. You can then add the needed policy rules. Just don’t forget to replace
the profile(s) after doing so with apparmor_parser -r /etc/apparmor.d/php-fpm -v.
Profiles log violations via the audit framework of the kernel. You can see the
raw logs in /var/log/audit/audit.log but they are pretty hard to read. More
comfortable is accessing the logs via the systemd journal.
To get the current AppArmor audit events you can use this journalctl invocation:
▶ journalctl _TRANSPORT=audit _AUDIT_FIELD_APPARMOR='"ALLOWED"' _AUDIT_FIELD_APPARMOR='"DENIED"' -f
Oct 21 23:10:00 server1 audit[2501153]: AVC apparmor="DENIED" operation="open" class="file" profile="php-fpm//analytics" name="/dev/tty" pid=2501153 comm="ps" requested_mask="r" denied_mask="r" fsuid=33 ouid=0
Oct 21 23:10:00 server1 audit[2501153]: AVC apparmor="DENIED" operation="open" class="file" profile="php-fpm//analytics" name="/proc/sys/kernel/pid_max" pid=2501153 comm="ps" requested_mask="r" denied_mask="r" fsuid=33 ouid=0
Oct 21 23:10:00 server1 audit[2501153]: AVC apparmor="DENIED" operation="open" class="file" profile="php-fpm//analytics" name="/proc/" pid=2501153 comm="ps" requested_mask="r" denied_mask="r" fsuid=33 ouid=0
Oct 21 23:10:00 server1 audit[2501160]: AVC apparmor="DENIED" operation="open" class="file" profile="php-fpm//analytics" name="/dev/tty" pid=2501160 comm="ps" requested_mask="r" denied_mask="r" fsuid=33 ouid=0
Oct 21 23:10:00 server1 audit[2501160]: AVC apparmor="DENIED" operation="open" class="file" profile="php-fpm//analytics" name="/proc/sys/kernel/pid_max" pid=2501160 comm="ps" requested_mask="r" denied_mask="r" fsuid=33 ouid=0
In this example the application running under the analytics hat wanted to
access some files that were not in the profile and access was denied. Profiles
running in complain mode would display a similar line, only the apparmor field
would be set to ALLOWED.