shelltrap.com
en de

Product

Shelltrap for CyberPanel

A webshell scanner is only as good as the two things nobody puts on a feature list: what it does when it is not sure, and what it does when it is broken. Here is all of it.

What it watches

Shelltrap watches the document roots of the accounts on your CyberPanel host — /home by default — and reacts to file events as they happen. The event mask deliberately includes more than “a file was written”:

  • CLOSE_WRITE — a write finished, the usual moment to scan
  • MOVED_TO / MOVED_FROM — a rename into or out of a watched path
  • CREATE / DELETE — appearance and disappearance
  • ATTRIB — attribute changes, so that a later chmod, a setuid bit or an ownership change is an event of its own and not something the scanner learns about next Sunday

Hot non-customer paths are marked as ignored on purpose: a database data directory, log trees and panel caches. A filesystem-level mark on the root filesystem otherwise delivers every open on the host, which is a performance problem, not a security feature.

Queue overflow is not swallowed. FAN_Q_OVERFLOW is counted, alarmed and answered with a time-boxed reconciliation scan. The same reconciliation runs after a reboot, a crash, an upgrade and after any new mount appears under a customer root. “Not scanned” and “scanner impaired” are their own states and are never displayed as clean.

Four moves between a write and a verdict

Shelltrap architecture: event sources, root broker, isolated worker, verdict and responseFour event sources feed the parser-free root broker. The broker hands a read-only file descriptor to an unprivileged worker running ClamAV, YARA, hash sets and heuristics. The worker returns one of five verdicts, which drives the response. The control plane, signed feeds and telemetry attach to the broker on the right.01 · EVENT SOURCESWeb uploadlsphp · auto_prepend_filesynchronous, 2 sfanotifytier A–D · CLOSE_WRITEreal timeFTPpure-uploadscriptpost-uploadSchedulebaseline · incrementalI/O budget02 · ROOT BROKERshelltrapd — runs as root, contains no parseropenat2 · RESOLVE_NO_SYMLINKSjob journal in SQLite (WAL)opaque object IDs onlyno YARA, no ClamAV codeSCM_RIGHTS · read-only fd03 · SCANNER WORKERown user · namespaces · seccomp · Landlock · no networkClamAVclamd socket · full profileYARA 4.5precompiled per generationHash setsplus known-good allowlistHeuristicsseveral signals requiredper-tenant limits: file size, scan time, archive depth, expanded bytes, temp storage04 · VERDICTcleansuspiciousmaliciousunscanneddegraded“not scanned” and “degraded” are states of their own and are never shown as cleanCONTROL PLANECyberPanel plugin · CLI/run/shelltrap/api.sockSO_PEERCRED · rolesno free-form pathsSignature feedsEd25519 · generationsprovenance per rulecorpus gate · canarycircuit breaker · rollbackLicenceEd25519 token, checked offlinefingerprint per serverno licence, no feedsResponsequarantine as a transactionrestore with metadatamail · webhook · digestpolicy per domainHealth · metrics · auditPrometheus text formathash-chained audit trail
The broker holds the privileges and no parser. The worker holds the parsers and no privileges. Everything sharp happens on the right of that line.

The split in that picture is the whole security argument. A scanner is a program that opens files chosen by an attacker and runs them through archive, image, document and executable parsers. That is the most dangerous code in the system, so it does not run as root:

The root broker (shelltrapd) is a statically linked Go binary without cgo. It holds the fanotify groups, normalises events, opens files safely (openat2 with RESOLVE_NO_SYMLINKS, or a component-wise openat walk with O_NOFOLLOW on older kernels), records minimal metadata, hands scan jobs to workers, runs quarantine transactions and serves the local API. It contains no YARA, no ClamAV code and no archive, Office or ELF parsing. It accepts no free-form path from the panel or the UI, only opaque object IDs. The single exception is the root CLI call scan <path>, verified as uid 0 through SO_PEERCRED.

The scanner worker is a separate process under its own system user, in its own mount and PID namespace, with seccomp, rlimits and cgroup limits, a defined timeout, crash-retry — and no network. It receives a read-only descriptor from the broker over SCM_RIGHTS; it never resolves a path itself.

The engines

Inside the worker, in order:

  1. Type detection by magic bytes. It steers priority only. Size filters, media, backups and caches are never silently counted as clean — they are reported unscanned and queued for a bounded deep scan or a scheduled special scan. Image headers followed by PHP, .phar, .phtml, .user.ini and nested archives always go through the full pipeline.
  2. ClamAV over the clamd socket, in the full profile only.
  3. YARA 4.5, with rules precompiled per feed generation against exactly that engine version.
  4. Hash sets — SHA-256 sets plus a known-good allowlist covering WordPress core and widely used plugins and themes, Laravel and Joomla. The allowlist may not blindly override a path-context hit: a known-good hash in an uploads directory is still a finding.
  5. Heuristics, with explainable, per-signal scoring: PHP obfuscation markers (eval, base64_decode, gzinflate, str_rot13, chr chains, long hex strings, variable function calls, entropy), PHP inside upload directories, .htaccess and .user.ini tricks (AddHandler, AddType, auto_prepend_file), symlinks pointing out of the home directory, world-writable and setuid files. A hit needs several independent signals. Entropy or one long line, on their own, never produce a suspicious verdict — that lesson cost us a handful of false positives on ordinary JPEGs and Wordfence’s own transient cache, and it is fixed.

Per-tenant and global limits apply to file size, scan time, archive recursion, file count, compression ratio, expanded bytes and temporary storage. Exceeding one kills the worker process and marks the object unscanned with a reason — a bounded failure instead of an unbounded one.

Want to see the reasoning rather than read about it? The Webshell Signal Explorer walks through six real shapes, including the false positive that must not happen.

Five verdicts

VerdictMeaningDefault action
cleanScanned by every engine the profile provides, nothing foundnone
suspiciousSeveral independent signals, below the action thresholdreport
maliciousSignature or hash match, or a heuristic score above the thresholdquarantine for signature and hash hits
unscannedA limit was hit; the reason is recordedreport, retry as scheduled
degradedThe scanner itself was impairedreport, alarm

The verdict cache key contains the mount ID, inode, high-resolution mtime and ctime, size, content hash and the rule set, engine and policy generations. A signature update invalidates old clean verdicts automatically. Path-dependent policy verdicts (such as “PHP in uploads”) live in a separate cache, because the same inode can change context through a hard link or a rename.

Response, and why quarantine is a transaction

Quarantine as a crash-safe transactionThe broker copies the file through descriptors, compares the hash, syncs file and directory, publishes atomically, commits manifest and database, and only then unlinks the original through the directory descriptor with a fresh inode and hash comparison. If anything differs, the operation ends in a visible recovery state instead of claiming success.THE ORDER IS THE SAFETY01Copy through a descriptorinto a temporary file readable only by root02Hash comparisonthe copy must match the scanned descriptor byte for byte03fsyncfile and directory, so a power cut cannot halve anything04Publish atomicallyrename into the quarantine store05Manifest and databaseowner, mode, times, ACLs, xattrs, SELinux context06Unlink the originalthrough the directory fd, with a fresh inode and hash checkMismatchinode changedhash differstarget existswrite failedRecovery statevisible, auditednever reported as successEXDEV is the normal caseRestorenever overwrites a targetthere is no chmod 000 · quarantine is evidence and is removed on purge only behind an explicit gate
Every step is ordered so that a crash between any two of them leaves recoverable state, not a deleted customer file.

Restore never overwrites an existing target, and puts back owner, mode, times, ACLs, extended attributes and the SELinux context. There is no chmod 000 action: breaking a file in place is not containment, it is a support ticket. EXDEV — quarantine and original on different filesystems — is the normal case, not an error.

Notification is admin mail immediately on malicious, a digest for suspicious, optional customer mail through the CyberPanel contact, and webhooks restricted to an allowlist of targets. Ignore rules cover path globs per site, signature IDs, hash allowlists and users. An ignore rule suppresses the action, never the scan or the record of the finding.

The upload gate

Sequence of the synchronous upload gateA visitor uploads a file. The PHP adapter runs before any application code, passes the temporary descriptors to the broker and waits at most two seconds. The worker scans, the broker answers allow or deny. On deny the request ends with 403 and the temporary file is removed; on a gate error the on_error policy decides.VisitorHTTP requestlsphpshelltrap-prepend.phpBroker/run/shelltrap/upload.sockWorkerisolated, no networkPOST multipart/form-data$_FILES not emptyotherwise the adapter does nothing{v:1, docroot, server_name, files[]}at most 128 files · 1 MiB per lineread-only fd per temp fileO_RDONLY|O_NOFOLLOW · regular file, matching ownerverdict per file{"decision":"allow|deny","reason","millis"}deny → HTTP 403, temp file removedallow → the application continues untouchedTIME BUDGETupload.timeout_ms — default 2000 ms, capped at 60000 msGATE ERRORupload.on_error = open (default: the request continues) · closed (HTTP 503)
The synchronous path. Everything else — SFTP, the panel file manager, WebDAV, CLI — is caught asynchronously by the watcher instead.

A single PHP file, loaded through auto_prepend_file in a global lsphp ini per PHP version, does nothing at all unless $_FILES is non-empty. When it is, it passes the temporary descriptors to the broker over a local socket and waits for at most upload.timeout_ms (default 2000 ms, capped at 60000 ms). A malicious answer means HTTP 403 and a deleted temporary file. Fail-open is the default on a gate error; fail-closed is available per domain.

This is the mechanism that works identically on OpenLiteSpeed and LiteSpeed Enterprise without ModSecurity, and it is the honest reason it was chosen. We also state its limit plainly: auto_prepend_file can be overridden by a .user.ini inside the site. That exact pattern is itself a heuristic hit, and the real-time watcher stays behind the gate as the catch-all.

FTP uploads are enqueued through pure-uploadscript and scanned right after the upload; that post-upload character is stated rather than glossed over.

More detail: how the upload gate works .

Platform tiers

Four watcher tiers, chosen from runtime capability rather than kernel versionThe installer probes the host's capabilities. Tier A uses fanotify with file handles and gives full real time. Tier B works without file handles, so renames arrive late. Tier C runs without CAP_SYS_ADMIN using budgeted inotify plus a crawler. Tier D does not install and only reports diagnostics.RUNTIME PROBE, NOT A VERSION ASSUMPTIONAfullfanotify with FAN_REPORT_DFID_NAME, marks per mountUbuntu 22.04/24.04 · Alma/Rocky/RHEL 9 and 10real time, every featureBfd modefanotify without file handles, renames via ctime reconciliationAlma/Rocky/RHEL/CloudLinux 8 · Ubuntu 20.04 GA kernelreal time on write, renames delayedCcontainerbudgeted inotify plus a checkpointing crawlerunprivileged LXC/OpenVZ without CAP_SYS_ADMINdelayed detection, stated in the productDunsupportedno usable backend or an unknown filesystemeverything elseno installation, diagnostics only
The installer probes capabilities at runtime. A kernel version number is a hint, never a promise.

shelltrapd --check returns JSON with tier, diagnostic.tier and, per mount, a reason and error. When a backend is configured explicitly, the top-level tier reflects that choice — so for a real capability statement, always read diagnostic.tier as well.

Debian is only third-party supported by CyberPanel itself, so we do not promise it. Global inotify sysctls are never raised behind your back: the installer counts watches and asks.

Full and Lite profiles

The profile is chosen after measurement, not from a version table. Full requires a running or installable clamd, a socket actually reachable by the shelltrap-scan user, and at least 1.5 GiB of MemAvailable remaining after the measured reload peak. Otherwise the install chooses Lite, sets scanner.enable_clamd = false, and says so. Lite has visibly reduced coverage: the software support promise is the same, the detection promise is not.

A clamd that starts later does not silently upgrade you to Full. That is deliberate: a profile change is a security decision and it gets a measurement, not a guess.

Signature feeds

The path of a signature generation from source to activationSources are filtered by licence, given provenance and built into an Ed25519-signed generation. The client verifies digest and signature, the corpus gate checks against known-good and known-bad sets, and only then is the switch made atomically. A quarantine storm across several accounts triggers an automatic rollback.BUILD AT PANOMITYON YOUR SERVER01Sourcesown rules · curated third-party rules02Licence filteronly DRL-1.1, BSD-3, Apache-2.0, MIT03Provenancesource, author, commit, licence per rule04SignatureEd25519 · manifest.json + manifest.sig05Verificationdigest before signature, expiry, denylist06Corpus gateknown-good is hard, known-bad is a ratio07Activationatomic symlink swap, cache invalidatedCircuit breakerlast hour against the 24-hour mean: at least 50 quarantines and more than ten times the meanand at least three accounts and at least 20 % of active accounts — a single account can never trigger a server-wide rollbackautomatic rollback
Rules are curated, licence-filtered, signed, verified, gated against a corpus and only then activated — with an automatic rollback if the result looks like a false-positive storm.

Third-party rules are never activated straight from a foreign mirror. Every rule carries its provenance — source, commit, licence, author, whether it was modified — and the build only admits DRL-1.1, BSD-3-Clause, Apache-2.0 and MIT licensed rules (plus GPL-2.0-only for ClamAV databases). Where an upstream licence obliges us to keep author, URI and licence visible, that attribution travels all the way into the finding display and the notification mail.

ClamAV’s own daily, main and bytecode databases are fetched per server by freshclam and not bundled.

The CyberPanel plugin

The plugin is a user interface, not a second scanner. The broker and its workers keep running when the panel is down, being upgraded or broken.

  • Pages: dashboard and health, findings with filters, paging, detail and signals, quarantine with restore (purge is admin only), policies per domain with visible global → account → domain inheritance, ignore lists, feed generations, audit (admin only), jobs, and a help and status page.
  • Roles: administrators see everything; resellers and users see their own sites, can request a restore inside their scope, and may change only the policy keys an administrator has released. upload.on_error and heuristics.action stay locked for both roles, and the broker enforces that boundary independently of the UI.
  • Integrity: the CyberPanel installer patches core files line by line, so the plugin takes atomic snapshots before it touches anything, verifies them by checksum, and rolls back if the install or a repair fails. After a panel upgrade the repair path is idempotent and runs again.

Details: the plugin documentation .

Licensing

One licence per server. Activation binds the licence to a server fingerprint derived from /etc/machine-id; deactivate releases the slot again. The daemon verifies an Ed25519-signed token offline — the licence service does not have to be reachable for the scanner to work — and a systemd timer renews it daily with an hour of jitter.

Without a valid licence the daemon does not crash and does not loop: the watcher keeps counting events, the scheduler plans nothing, the upload gate answers allow with reason unlicensed, and health reports unlicensed with a precise reason. Feeds return 401. That is the commercial boundary, and it is designed to fail visibly rather than dangerously.

More: licensing and activation .

What Shelltrap is not

Honesty is cheaper than a refund:

  • It is not a WAF and not a firewall. It looks at files, not requests.
  • It does not prevent execution at the kernel level. Blocking a file open before the fact is a 1.x prototype, deliberately not promised today. What exists now is a synchronous gate on the web-upload path and fast asynchronous detection everywhere else.
  • It does not claim to catch everything. No scanner does. It claims to tell you honestly what it scanned, what it could not scan, and why it decided what it decided.
  • It cannot defend against an attacker who already has root on the machine. Nothing that runs on that machine can.

Coming from CXS?

The migration page maps concepts, quarantine and configuration, and the calculator works out how many licences a fleet needs.

CXS → Shelltrap migration