shelltrap.com
en de

Guides

How the PHP upload gate works (auto_prepend_file)

Scanning an upload before the application accepts it: the auto_prepend_file adapter, the socket protocol, the decisions it returns and why it fails open.

Illustration — How the PHP upload gate works (auto_prepend_file)

The most useful moment to look at an uploaded file is before the application accepts it. At that point the file is still a temporary file, nothing has been moved into a document root, and refusing it costs one HTTP 403 instead of an incident.

That is not our idea. BSI IT-Grundschutz makes it a Basis requirement for web servers, in APP.3.2.A3, verbatim:

Alle mithilfe des Webservers veröffentlichten Dateien MÜSSEN vorher auf Schadprogramme geprüft werden.

All files published by means of the web server MUST be checked for malware beforehand (IT-Grundschutz APP.3.2 ). “MUSS” in the BSI’s grading is not advice.

It is also aimed at the right target. Wordfence’s 2024 annual report found arbitrary file upload to be the most common high-threat vulnerability type of the year — the class where the attacker’s bytes reach your disk in a single request. The routes those uploads take are catalogued in how webshells get into WordPress uploads .

Where the gate sits

PHP has a hook for running code before every request: auto_prepend_file. Shelltrap’s package writes one small ini file per lsphp version present on a CyberPanel host:

; /usr/local/lsws/lsphp83/etc/php/8.3/mods-available/shelltrap.ini
auto_prepend_file=/usr/lib/shelltrap/php/shelltrap-prepend.php

The prepended adapter is deliberately thin. It does not scan anything itself. When a request carries uploaded files, it hands the temporary file paths to the local broker over a Unix socket and applies the answer:

{"v":1,"docroot":"/home/example.com/public_html","server_name":"example.com",
 "files":[{"tmp":"/tmp/phpAbC123","name":"foto.jpg","size":12345}]}
{"decision":"allow","reason":"clean","finding_ids":[],"scanned":1,"millis":37}

One request line, one response line, on /run/shelltrap/upload.sock. No TCP, no cloud, no file leaving the machine.

Doing this in the PHP request path rather than purely in the kernel is a deliberate choice: at this point the document root, the server name and the file the application is about to accept are all known, which is what makes a per-domain policy decision possible. The kernel does offer a blocking primitive — fanotify permission events, where “the recipient must write a response which decides whether access is granted or not” (fanotify(7) ) — and that is the right mechanism for a different job; the comparison is in fanotify vs inotify .

What the gate decides

  • malicious produces an immediate deny. The adapter returns HTTP 403 and deletes the temporary file.
  • suspicious denies only when the domain’s heuristics.action is set to quarantine. If heuristics are still in report mode for that domain, a heuristic hit is recorded and the upload proceeds — which is exactly what you want while you are still learning what your customers’ sites look like.
  • unscanned and degraded stay visible and are never reinterpreted as clean, but neither one denies on its own.
  • A genuine scanner or transport error, or an unknown verdict, follows upload.on_error.

The hard limits are policy, with documented defaults: at most 128 files per request, one MiB per protocol line, an initial read limit of five seconds, and an effective upload.timeout_ms of 2000 ms, capped at 60000 ms. Each temporary file is opened with O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC, has to be a regular file with the expected owner, and is subject to upload.max_size; anything that fails those checks is skipped with a recorded reason. In production the gate only talks to peer UIDs of 1000 and above — the PHP worker, not root.

Why it fails open

upload.on_error defaults to open. When the broker cannot answer — it is restarting, the queue is full, the timeout expires — the upload proceeds and the error is recorded.

That default is a hosting decision, and worth defending in the open. A security control in the request path of every PHP application on the server is also an availability risk: fail closed by default and one bad minute in the scanner becomes an outage across every site on the box, including the ones that never accept uploads at all. Fail open, and a file gets through that you would rather have stopped — but the asynchronous watcher still sees the write, and you still get the finding.

closed is available where it is justified, per domain:

curl --silent --unix-socket /run/shelltrap/api.sock -X PUT \
  -H 'Content-Type: application/json' \
  -H 'X-Request-ID: 37700c8fcf884a98b89337a6e4593a60' \
  --data '{"upload.on_error":"closed"}' \
  http://localhost/v1/policies/domain/42

With closed, a gate error becomes HTTP 503 rather than an accepted upload. That is the right setting for a handful of high-risk sites and the wrong setting for a shared box. Note that upload.on_error and heuristics.action are locked against resellers and end users; the broker enforces that boundary independently of the panel UI.

Roll it out the way the NSA suggests rolling out enforcement

The NSA’s web-shell repository ships host-based file-integrity rules that block changes to web directories, with an explicit caveat: administrators should tailor the rules and add exceptions before enforcing them (nsacyber/Mitigating-Web-Shells ). That is the conceptual ancestor of an upload gate, and the caveat is the deployment plan.

So the adapter is not enabled by the default installation on a test host:

sudo env SHELLTRAP_SKIP_PHP_ADAPTER=1 apt-get install shelltrap shelltrap-cyberpanel

and is enabled later, once the findings have stopped surprising you, by running the package configuration again without the variable:

sudo dpkg-reconfigure shelltrap      # Debian / Ubuntu
sudo dnf reinstall shelltrap         # RPM

The maintainer script writes the ini for each lsphp ABI, touches CyberPanel’s /usr/local/lsws/admin/tmp/.lsphp_restart.txt marker first, and then restarts LiteSpeed gracefully — systemctl restart lsws for OpenLiteSpeed or lswsctrl restart for Enterprise. A forced killall -9 lsphp is never used, and if the marker cannot be written or the graceful restart fails, activation aborts with an error rather than leaving PHP half-configured. The full sequence is in the CyberPanel setup guide .

Checking that it is live

grep -rn 'auto_prepend_file' /usr/local/lsws/lsphp*/etc/php/*/mods-available/shelltrap.ini
test -S /run/shelltrap/upload.sock && echo "gate socket present"
shelltrap policy get global | grep -E 'upload\.'

What the gate does not do

  • A .user.ini can override auto_prepend_file. Where a customer does that, the gate is out of the path for that site and the asynchronous watcher becomes the catch-all. That is a documented limit, not a surprise.
  • It only sees PHP uploads. Code execution obtained some other way writes files directly; so does SFTP, so does a compromised deploy pipeline. The gate narrows the most common route; it does not close the category.
  • It cannot be stricter than your scanning is accurate. A gate that denies on heuristics before you have tuned them will block legitimate uploads. Hence per-domain promotion, and hence report-only first.

What this means for CyberPanel operators

  1. Install with the adapter skipped, run report-only, and only then enable the gate — in that order, for the same reason the NSA gives for its own blocking rules.
  2. Leave upload.on_error at open for shared hosting; use closed selectively for domains where a failed upload is preferable to an accepted one.
  3. Promote heuristics.action to quarantine per domain only after the heuristic findings on that domain have been boring for a while.
  4. Verify the adapter is actually loaded for every lsphp version you run, and re-verify after CyberPanel or PHP upgrades.
  5. Package options, the ini paths and the LiteSpeed restart behaviour are documented in the installation docs .

Shelltrap checks uploads in the request that carries them and watches the filesystem for everything else. See what it does .

Frequently asked

Why does the gate fail open by default?

Because a hosting control that breaks every upload on the server when the scanner has a bad minute is worse than one that lets a file through and records it. The default is on_error = open; closed is available per domain when a specific site justifies it.

Can a customer bypass the gate?

A .user.ini can override auto_prepend_file, and code execution obtained by other means writes files without going through PHP’s upload handling at all. That is why the gate is one layer and the asynchronous watcher is the catch-all.

Does the gate slow uploads down?

It adds a scan of the temporary file inside the request. The effective timeout is a policy value, 2000 ms by default and capped at 60000 ms, and what happens when it expires is decided by upload.on_error rather than by the scanner.

Sources

Every number, date and vendor claim in this article links to one of these.

  1. BSI IT-Grundschutz-Kompendium 2023 — APP.3.2 Webserver (PDF) — accessed 2026-09-04
  2. nsacyber/Mitigating-Web-Shells (NSA repository) — accessed 2026-09-04
  3. Wordfence, 2024 Annual WordPress Security Report — accessed 2026-09-04
  4. fanotify(7) — Linux manual page — accessed 2026-09-04

More from the research desk

Shelltrap watches the files this article is about

Real-time detection, an upload gate in front of your PHP, explainable verdicts, and nothing leaving your server.