Production Security And Server Hardening

Configuration Hardening And Service Least Privilege

Dedicated Users And File Permissions

Every service runs as its own unprivileged user: php-fpm pool workers as an application user, nginx workers as www-data, the database as its own account. The sharpest edge for PHP is write access to code. The deployed application should be owned by a deploy user, readable — not writable — by the php-fpm user, with writable paths limited to the specific directories the app genuinely needs: storage, cache, uploads. When an attacker finds an upload flaw, "can they write a .php file somewhere the web server will execute it?" is the question these permissions answer.

/var/www/app          deploy:app-run   r-x   code: readable, never writable
/var/www/app/storage  app-run          rwx   writable, and non-executable to the web server

The upload directory pairs with proxy config: nginx should refuse to execute PHP from writable paths (location ^~ /uploads { ... } with no PHP handler). Writable or executable — never both — is the rule of thumb.

Systemd Sandboxing

Modern systemd units offer per-service confinement that costs one config block:

[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/www/app/storage
PrivateTmp=yes

ProtectSystem=strict makes the entire filesystem read-only to the service except the paths you name; NoNewPrivileges blocks privilege escalation via setuid binaries; PrivateTmp isolates the shared temp directory that so many local exploits traverse. Apply these to queue workers and custom daemons first — they are usually the least-reviewed processes on the box. systemd-analyze security <unit> scores a unit's exposure and is a fine worklist generator.

PHP-Level Configuration

php.ini hardening follows the same principle. expose_php = Off stops advertising the version. display_errors = Off in production keeps stack traces out of responses (errors go to logs). open_basedir and disable_functions (dropping exec, shell_exec, system, and friends when the app does not shell out) are imperfect controls — bypasses exist — but they raise the cost of turning a code-injection foothold into a system compromise, which is what depth means. Set limits that match reality: memory_limit, upload_max_filesize, and max_execution_time sized to the application, not left at whatever the package shipped.

Databases get the same treatment: the application's DB account has table-level rights on its own schema, no GRANT, no SUPER, no access to other databases. A SQL injection with a least-privilege account reads one app's data; with root it owns the server's every schema.

What To Check

Before moving on, make sure you can:

  • explain why write-access-to-code is the permission that matters most for PHP
  • lay out ownership so deploys can write, the runtime can read, and uploads cannot execute
  • apply and verify systemd sandboxing directives on a service
  • name the production php.ini settings and what each prevents
  • scope a database account to least privilege

What You Should Be Able To Do

After this lesson, you should be able to configure a PHP service stack where each process runs confined to its role, verify the confinement with tests rather than trust, and explain how each restriction shrinks the blast radius of a specific compromise.

Practice

Practice: Design Least-Privilege Layout

Design users, ownership, writable paths, systemd confinement, and database grants for a server running nginx, php-fpm, a queue worker, and MySQL for one application with user uploads.

Show solution

Filesystem: code readable by app-run, writable only by deploy. Writable paths for app-run are exactly storage/ and uploads/. nginx serves uploads/ with the PHP handler disabled for that location, so nothing writable is executable.

systemd: php-fpm and especially the queue worker get NoNewPrivileges, ProtectSystem=strict with ReadWritePaths naming storage and uploads, ProtectHome, PrivateTmp. The worker is first because it is long-running, custom, and least watched. systemd-analyze security scores are recorded as the baseline.

php.ini: expose_php Off, display_errors Off with logging on, disable_functions covering process-execution functions (the app does not shell out), limits sized to the app.

MySQL: the app account has CRUD on its own schema only; migrations run as a separate, more-privileged account used only by deploy tooling.

Verification is part of the design: a test asserts a PHP file written to uploads returns as text, not execution; a worker exploit simulation (touch a file outside allowed paths) fails; the app account's grants are dumped and diffed in CI.

Practice: Diagnose A Blast Radius

A file-upload vulnerability let an attacker place a webshell that ran with full write access to the codebase and a database account that could read every schema on the shared MySQL server, including two other applications.

Task

Identify each least-privilege failure that turned one bug into a multi-application compromise, and state the single most effective missing control.

Show solution
  • The upload directory was executable: the web server ran PHP from a path the application can write. Writable-or-executable-never-both would have turned the webshell into an inert text file — this is the single most effective missing control, because it breaks the chain at step one.
  • The runtime user could write to the codebase, letting the attacker persist beyond the upload directory and trojan legitimate files. Code owned by a deploy user, read-only to php-fpm, confines persistence to paths integrity monitoring watches hardest.
  • The database account was wildly over-scoped: cross-schema read on a shared server converted one app's compromise into three. Per-app accounts with per-schema grants are the boundary that failed.
  • Nothing confined the process: systemd sandboxing would not have stopped the webshell, but ProtectSystem=strict would have blocked codebase writes at a second layer, and disable_functions would have raised the cost of the shell-out the attacker used for reconnaissance.

The lesson generalizes: the vulnerability determines where an attacker starts; privileges determine where they stop. Response includes rebuilding the host and rotating every credential all three applications ever gave that server, per the compromise-response lesson.

Practice: Define Privilege Review Checks

Define the recurring checks that detect privilege drift across filesystem, service confinement, PHP configuration, and database grants.

Show solution
  • A script asserts the ownership contract: no file under the codebase writable by the runtime user, writable paths exactly the named set, uploads non-executable (verified by requesting a planted .txt.php fixture and expecting text).
  • systemd-analyze security scores per unit diffed against baseline; a score worsening means someone edited a unit.
  • Effective php.ini values (php -i on each pool) diffed against the committed hardening set — display_errors creeping on in production is the classic drift.
  • Database grants dumped (SHOW GRANTS) per application account and diffed against the committed expectation; any new privilege alerts.

Quarterly, human:

  • Re-justify every entry in ReadWritePaths, every writable directory, every disable_functions exception, every grant. Privilege reviews shrink scopes over time or they are theater.
  • Pick one service and attempt the confinement's promises from inside it (write outside allowed paths, escalate via setuid): a fire-drill for sandboxing.