open_basedir and disable_functions
Two hardening settings that are worth having and are not fixes. Knowing exactly what each one confines tells you what is still reachable when you meet them.
What they are
open_basedir confines PHP's filesystem access to a list of directories. disable_functions removes named functions from the language.
Both are worth enabling. Neither fixes an inclusion bug, and PHP's own documentation is explicit that they are not a security boundary — they are a way to make a compromise less useful, which is a real thing to want and a different thing from prevention.
What matters for testing is knowing precisely what each one covers, because the gaps are specific rather than general.
What open_basedir confines
What that leaves
Read the table the other way round and the picture is clear: open_basedir is effective against everything outside the application and ineffective against everything inside it or synthesised from nothing.
That is a meaningful reduction. It removes /etc/passwd, /proc, logs, sessions, SSH keys and mounted secrets from reach — which on a container is a large fraction of what a read primitive is worth.
What it does not remove:
- The application's own source and configuration, which is usually the highest-value read anyway.
- Filter chains, which is to say full code execution. This is the important one: a host with
open_basedirset is not protected against the single most powerful technique on this site. data://andphp://input, on a configuration whereallow_url_includeis On.
So when you meet an open_basedir restriction in effect warning, do not conclude the finding is limited. Conclude that your traversal targets need to move inside the app tree, and that the escalation path is a filter chain rather than log poisoning.
Notes on how it behaves
; Directories are separated by : on Unix, ; on Windows.
open_basedir = /var/www/html:/tmp
; A trailing slash means "this directory and below".
; NO trailing slash means the value is treated as a PREFIX — so
; open_basedir = /var/www/html
; also permits /var/www/htmlbackup and /var/www/html-old.
; This is a documented behaviour and a recurring configuration bug.
; Always write the trailing slash:
open_basedir = /var/www/html/:/tmp/
; It is checked at the point of resolution, so symlinks out of the tree are
; caught — realpath() runs first.
; It does NOT apply to:
; - streams that touch no filesystem (data://, php://input, php://temp)
; - anything a spawned process does. exec('cat /etc/passwd') is unaffected;
; open_basedir is a PHP-level check, not a kernel one. Which is exactly
; why disable_functions exists alongside it.disable_functions
disable_functions removes functions from the language at startup. The usual list targets command execution:
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,
pcntl_exec,pcntl_fork,dl,putenv,mail,imap_open,
posix_kill,posix_setuid,error_log
The important thing to understand for testing: this constrains what your payload can do after execution, not whether execution happens. A filter chain still runs your code. It just runs it in a PHP where system() does not exist.
The list also has to be complete, and completeness is hard. Historically bypasses have come from mail() and imap_open() reaching a shell through their fifth argument or through putenv('LD_PRELOAD=…'), from proc_open being forgotten while popen is blocked, and from FFI, which can call libc directly and makes the entire list irrelevant if it is enabled.
And it stops nothing that is not a function call: file reads, file writes, network connections via fsockopen or curl, and include itself are all unaffected unless separately disabled — at which point the application stops working.
What to do when you meet them
With open_basedir set: stop aiming outside the tree. Read the application's source and config through php://filter — still available, still the highest-value target. If you need execution, go straight to a filter chain, which is unaffected.
With disable_functions set: you may already have code execution and not realise it. Test with something that needs no disabled function — echo, file_put_contents, phpinfo(), or an outbound request via file_get_contents — before concluding the payload failed. A blank response after a filter chain often means the chain worked and system() was missing.
In a report: rate the finding on what you achieved, and note both settings as mitigating factors rather than as the fix. Recommending that they be enabled is fine as defence in depth; recommending them instead of fixing the sink is not, and a report that does so tends to result in exactly that outcome.
Related
Stack enough iconv conversions and php://filter stops reading files and starts producing them. A read-only primitive becomes code execution on a completely stock php.ini — no upload, no writable directory, no outbound connection.
include() executes PHP instead of showing it to you. Base64-encode the stream first and it comes back as data — the single most useful request in a PHP inclusion engagement.
A read primitive plus a file whose contents you control is code execution. Write PHP into a log through a request header, then include the log.
The only runtime with a stream-wrapper layer, and therefore the only one where a filename can become a URL. What to fix, in the order it matters.
One fix works and the rest are mitigations. Knowing which is which is the difference between a remediation that holds and a config change that gets reported again next year.