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.
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
maliciousproduces an immediatedeny. The adapter returns HTTP 403 and deletes the temporary file.suspiciousdenies only when the domain’sheuristics.actionis set toquarantine. 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.unscannedanddegradedstay visible and are never reinterpreted asclean, 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.inican overrideauto_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
- 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.
- Leave
upload.on_erroratopenfor shared hosting; useclosedselectively for domains where a failed upload is preferable to an accepted one. - Promote
heuristics.actiontoquarantineper domain only after the heuristic findings on that domain have been boring for a while. - Verify the adapter is actually loaded for every lsphp version you run, and re-verify after CyberPanel or PHP upgrades.
- 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.
- BSI IT-Grundschutz-Kompendium 2023 — APP.3.2 Webserver (PDF) — accessed 2026-09-04
- nsacyber/Mitigating-Web-Shells (NSA repository) — accessed 2026-09-04
- Wordfence, 2024 Annual WordPress Security Report — accessed 2026-09-04
- fanotify(7) — Linux manual page — accessed 2026-09-04
More from the research desk
CyberPanel malware scanner setup with Shelltrap
Install a host-level malware scanner on a stock CyberPanel server: requirements, signed packages, report-only first, and …
ComplianceGDPR and data residency in malware scanning
If your scanner uploads customer files, your vendor is a processor. What Art. 28 and 32 require, what BSI says about …
ComplianceNIS2, GDPR and malware scanning for hosting providers
Germany's NIS2 law is in force since 6 December 2025. What it obliges hosters to do, what the BSI requires, and how a …
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.