I remember watching Simon Scannell’s REcon 2023 talk, You Have Become the Very Thing You Swore to Destroy, and being deeply impressed by how he turned ClamAV’s own detection logic into an oracle for exploiting a service with no direct output channel. I remember hoping that, someday, I might have the opportunity to throw a similar exploit myself. Now, with the help of OpenAI’s GPT-5.6 Sol, this article describes one such exploit against ZendTo: a public upload workflow, a ClamAV parser flaw, and a path to native code execution.
Most upload vulnerabilities begin with the application trusting a file too much. This one begins with the application doing exactly what it was designed to do: accepting a file, handing it to an antivirus daemon, and waiting for a clean-or-infected answer. That answer crosses more security boundaries than it first appears to.
On a default ZendTo system built from the installer, an external sender does not need a ZendTo account to reach this attack path. The public verification workflow can issue a short-lived upload capability, which allows the sender to place attacker-controlled bytes at a predictable location and submit a single, ordered archive for ClamAV to scan.
Inside that archive, the exploit chains several parser behaviors together. A PESpin cleanup bug first creates forged glibc chunks. Old-SIS then uses one of those chunks to overwrite part of a live RTF callback structure. Finally, a relative-control-flow sequence redirects execution without requiring the full libclamav base address and loads a previously staged shared object.
That shared object repairs the damaged process state, executes a bounded command as uid=101(clamav), and returns the command output through a separate ZendTo capability that is used only for retrieving results.
The remote exploit never learns or exposes a full process address. Instead, it reduces the remaining ASLR uncertainty to just four bits, giving sixteen possible page-alignment values. ClamAV’s own production signature database is then used as a one-bit oracle to determine whether the current clamd process has the one useful 4-bit page value.
If the process has the wrong layout, the exploit can intentionally terminate that scanner process and allow Debian’s socket activation to start a fresh one. Once the oracle identifies a process with the correct layout, only that process receives the actual corruption request.
There is also a separate and conditional path from the clamav account to root. It relies on ZendTo’s shared Smarty compile directory and its hourly maintenance job. I treat that as a separate stage because it depends on additional filesystem permissions and mandatory-access-control conditions.
The primary result of the exploit is therefore code execution as the ClamAV service account (uid=101). Root access is a separate escalation path and should not be assumed.

Getting to the scanner
ZendTo’s default preferences permit external users to send files:
Copy to Clipboard
The same file selects Google CAPTCHA by default, but the keys in the archive are placeholders. The defaults describe the intended deployment; they do not make the untouched tarball operational. A real administrator must supply working CAPTCHA and mail configuration.
For a user who is not logged in, www/verify.php checks allowExternalUploads(), validates CAPTCHA when configured, constructs a Verify object, and calls sendVerifyEmail():
Copy to Clipboard
Verify::sendVerifyEmail() takes the sender’s name and email address from the public form, validates the address, and creates an AuthData record. If email confirmation is disabled, ZendTo immediately redirects the user to dropoff.php?auth=<token>. Otherwise, it emails that same link using the site’s normal mail server.
The exploit does not use an attacker-controlled SMTP server. ZendTo sends a normal verification email to a mailbox the tester controls. The PoC therefore needs no SMTP or IMAP credentials. The operator solves the site’s CAPTCHA, receives the message, and pastes either the 32-character token or the full verification URL into the PoC.
The important part is how the token works. It is a bearer token: anyone who has it can use it until it expires. The associated AuthData record stores the token, sender information, organization, and expiration time, but the token is not tied to the browser session, login cookie, IP address, User-Agent, Origin, or Referer. By default, it is typically valid for 24 hours.
This gives an external user permission to upload files and have them scanned without creating a ZendTo account. It is not an authentication bypass; it is intended ZendTo functionality for external senders. If external uploads are disabled, this part of the exploit chain is no longer available.
There is one additional requirement: the external sender still needs a valid recipient. ZendTo normally restricts external uploads to internal recipients. With the installer-generated Local policy, the PoC first tries a random local username and lets defaultEmailDomain turn it into a full address. If that does not work, it can use the domain from the verification email’s From address and try a random address there.
That behavior depends on the site’s recipient policy. A customized ZendTo deployment may require a specific, real recipient address, in which case the operator must provide one with --recipient. There is no reliable way to automatically discover a valid recipient on every deployment.

The unauthenticated writer
Before triggering ClamAV, the exploit needs somewhere stable to put native payload bytes. ZendTo supplies a route that is easy to overstate: www/savechunk.php.
The route has no route-local authorization gate. It reads three multipart values:
Copy to Clipboard
It later opens:
Copy to Clipboard
The installer sets PHP’s upload temporary directory to /var/zendto/incoming, so a fresh request such as:
Copy to Clipboard
creates or appends to:
Copy to Clipboard
This is not an arbitrary-path write. The effective basename grammar is /var/zendto/incoming/[A-Za-z0-9].(|Array): scalar suffixes are reduced to digits, while an array-valued suffix stringifies to the fixed word Array. Neither form permits traversal or an attacker-selected extension; the exploit deliberately uses the ordinary .1 form. The file is also outside the web root and the destination is append-only, so replaying an uncertain request with the same name can concatenate two ELF files and ruin the payload.
But dlopen() does not care whether a shared object ends in .so. Arbitrary bytes at a known absolute path are enough. The driver generates a fresh 128-bit staging name, accepts only an exact HTTP 200/Success response, and never retries an ambiguous append with that name.
This gives an unauthenticated, constrained content writer. It does not reach the scanner by itself. The later dropoff.php request still needs the upload AuthData capability from the previous section.

native parser code
The scanner trigger is not shell injection. ZendTo handles the filenames conservatively: it strips repeated ../ components and passes each path through escapeshellarg() before invoking the configured command.
The dangerous action is intentional:
Copy to Clipboard
The shipped preference is:
Copy to Clipboard
The installer explicitly installs clamav and clamav-daemon, adds clamav to www-data, adds www-data to clamav, and adjusts the host so Apache’s temporary files can be read by the daemon. The web application therefore moves attacker-controlled content across a Unix socket into libclamav’s archive, executable, document, and legacy-installer parsers.
The escaped pathname is not the vulnerability. The file is.

Building the Payload
The first heap construction used three inputs: a groom, a stage, and an RTF object that would consume the staged allocator state. Sending them as three top-level files looked natural and was wrong.
clamdscan --fdpass can submit each pathname as a separate FILDES connection. A daemon with multiple workers is free to give the first file to worker A, the second to worker B, and the third to worker C. Each input remains valid, each may scan clean, and the heap state never composes.
The fix was structural rather than probabilistic: put all three members in one outer ZIP, in order.
Copy to Clipboard
One top-level archive becomes one FILDES job. ClamAV recursively scans its members synchronously in one scan thread. ZIP member order is now part of the exploit’s memory model, not presentation metadata.
The compressed HTTP carrier is only around 93 KiB in documented relative-bridge runs even though its PESpin members expand to 24 MiB each. Each member stays under the stock 25 MiB file limit and the total recursive scan remains inside the configured scan budget.

The bug
The underlying ClamAV issue is CVE-2026-20217. Cisco’s July 2026 advisory describes the demonstrated impact as scanner termination and states that it had no evidence of code execution for this vulnerability class. The upstream fix is unusually clear about the ownership mistake.
PESpin’s unspin() keeps an array named sects. Some entries own separately allocated decompression buffers. Other entries are merely pointers back into the PE image:
Copy to Clipboard
The function records which entries are owned in a bitmap. Before rebuilding the PE, it copies the ownership state:
Copy to Clipboard
If the large aggregate allocation later fails, cleanup runs:
Copy to Clipboard
The condition tests bitmap, but the loop shifts bitman. If bitmap bit zero is set, the condition never advances and every sects[j] is passed to free(), including the non-owning pointers into the input image. The one-line correction is to shift bitmap.
The research here did not discover CVE-2026-20217; Atuin — Automated Vulnerability Discovery Engine and Tianchu Chen of Tencent Xuanwu Lab received vendor credit. The contribution was to ask a different question: can the invalid frees be made valid enough for glibc to accept them, then useful enough to redirect ClamAV control flow on the exact ZendTo installer profile?
The trigger starts from ClamAV’s benign PESpin unit-test seed and expands its PE table to 91 sections. Overlapping raw sections let a 24 MiB physical file claim a logical aggregate of 0x6b9e3220 bytes—about 1.806 GB decimal, or 1.681 GiB. That exceeds ClamAV’s 1 GiB per-operation allocation cap, forcing allocation rejection and entering the faulty cleanup without sending a multi-gigabyte upload.
A simple construction dies at free() with munmap_chunk(): invalid pointer. That proves reachability. It does not provide code execution.

grooming the heap
The wrongly freed pointers come from PE section raw offsets, which makes them attacker-selected and aligned. A valid fake chunk header placed immediately before each pointer turns the cleanup bug into a House-of-Spirit primitive. The small forged chunks in the 0x220–0x410 size classes populate tcache. The distinct forged 0x690 target instead follows the unsorted-bin path and is recalled by the later RTF allocation.
There was still a lifetime problem. A 24 MiB PE copy normally comes from mmap(). Once the enclosing allocation is unmapped, all of the beautiful fake chunks disappear with it.
The groom member solves that through glibc’s adaptive allocation policy:
-
The first large PESpin copy is mmap-backed.
-
Freeing it raises glibc’s adaptive mmap and trim thresholds.
-
The second 24 MiB stage copy comes from the worker arena instead.
-
PESpin’s faulty cleanup seeds tcache entries that point inside that stage.
-
Freeing the enclosing stage does not unmap its address range, so later allocations can recall the forged interiors.
The final balanced stage keeps the 91-section topology because section count changes the daemon’s intervening allocation traffic. It expects 88 forged entries that the constructor must later drain: one special 0x240 victim and 87 auxiliaries distributed across size classes from 0x220 through 0x410. No ordinary auxiliary class exceeds the calibrated per-class capacity. A separate forged 0x690 chunk supplies the allocation that becomes the live RTF state.
This is the point where “works on my ClamAV” becomes a dangerous sentence. The construction depends on the exact Debian glibc behavior, production signature databases, worker history, MaxThreads, IdleTimeout, and the allocation sequence induced by those databases. The source bug is broad. This exploitation layout is not.

Taking control
Allocator control needs a consumer. ClamAV’s RTF parser provides one.
Its state begins with four particularly attractive fields:
Copy to Clipboard
During ordinary text processing, the parser invokes them:
Copy to Clipboard
The forged 0x690 entry makes RTF’s malloc(0x680) return a chosen address inside the old PESpin stage. That address becomes a real, live rtf_state. The exploit then needs to write back into it after initialization.
For that, the RTF contains an old Symbian SIS object. SIS reads a declared uncompressed size, allocates a destination, and inflates into it:
Copy to Clipboard
The stage places the forged 0x240 chunk 0x220 bytes before the RTF state. SIS requests malloc(0x230), receives that chunk, and inflates 0x22a controlled bytes. The old-SIS write begins at state offset 0x220, so it reaches only ten bytes into the state: all eight bytes of cb_begin and the low two bytes of cb_process. It does not directly overwrite cb_end, cb_data, or the saved stack.states pointer. That saved pointer resides separately in the callback/return stack frame. A later fortified formatting operation clobbers state qwords 0–3, after which the constructor clears them.
No single parser supplies the exploit:
-
PESpin creates reusable forged chunks.
-
RTF turns one forged address into live indirect-call state.
-
SIS supplies a bounded, in-process overwrite after RTF initializes that state.
-
A later normal RTF text run invokes the corrupted callbacks.
That is the real chain inside the archive.

Defeating ASLR
An earlier version of the exploit used a much more conventional approach: overwrite complete callback pointers and execute a 59-word ROP chain. That chain follows state->cb_data->name to the still-live RTF object, creates a memfd, copies the staged DSO into it using a libclamav transfer helper, builds the path /proc/self/fd/<n>, and finally calls dlopen@plt.
This version works well when the base address of libclamav is already known. Every executable address in the chain points somewhere inside the same libclamav image. It does not require libc or dynamic-loader gadget addresses because imported functions are reached through libclamav’s existing PLT/GOT entries.
That made it useful for testing and instrumentation, but it was not suitable for the final remote exploit because it still required knowing the full libclamav base address.
The final exploit avoids that requirement by using a much smaller relative-control-flow bridge.
Old-SIS completely overwrites cb_begin with a signed offset:
Copy to Clipboard
For cb_process, however, SIS changes only the lowest two bytes. The upper bytes of the original, legitimate libclamav callback pointer remain intact.
For one of the possible ASLR page alignments, those two modified bytes redirect execution to:
Copy to Clipboard
This small dispatcher combines the preserved parser state with the attacker-controlled delta in cb_begin, producing a target address at:
Copy to Clipboard
The surrounding parser state already provides the rest of what the target code needs. In particular, RBX points to the staged DSO pathname and remains intact across the fortified formatting call on the way there. That call also happens to overwrite the first four state qwords before execution continues into the target’s dlopen@plt(RBX, ...) path.
The important part is that none of this requires knowing the full address of libclamav. The existing RTF state provides the useful registers and pointers, while the partial callback overwrite keeps the unknown high address bytes from the legitimate callback.
ASLR therefore leaves only one small uncertainty: the 4-bit value that determines the low part of the original callback address. There are sixteen possible values. Fifteen redirect execution somewhere useless; one lands on the relative dispatcher.
So the remote exploit no longer needs to discover an entire code address.
It only needs to determine four bits.

Finding a memory oracle
Blindly trying one of the sixteen possible 4-bit values can work if every incorrect guess crashes clamd and the service reliably restarts with a fresh process. But that approach is noisy and crash-heavy.
A better option, when the heap layout cooperates, is to first test whether the current clamd process has the one useful 4-bit page value before triggering the corruption.
ClamAV’s XLM extractor provides that test through stale heap data. Its compressed BIFF STRING handler contains the following logic:
Copy to Clipboard
For the crafted compressed record, the length calculation causes three bytes to be included that the current record never initialized.
With the right allocator placement, XLM’s 8,228-byte BIFF buffer reuses memory that previously contained a grown and freed RTF callback-state array. Those three stale bytes can therefore come from the old rtf_object_begin, rtf_object_process, and rtf_object_end callback pointers.
Importantly, those address bytes are never returned to ZendTo. This is not a direct memory disclosure.
Instead, the exploit turns the stale bytes into a yes-or-no test.
The XLM carrier emits almost-complete strings corresponding to real ClamAV production signatures. Each string is missing its final character, and a stale callback byte supplies that character:
The remaining parts of the logical signature are generated normally.
On this exact libclamav build, the callback-byte combination 6c / 6c / 6b corresponds to the useful 4-bit page value: 8.
If all three stale bytes match, the existing Doc.Dropper.Dridex-10016786-0 logical signature evaluates true. clamd reports an infection, and ZendTo returns its normal virus-detected response.
If the bytes do not match, the signature does not fire and the scan remains clean.
So the externally visible signal is:
Copy to Clipboard
No pointer value or address byte ever crosses the HTTP boundary. The response only answers one question:
Does this clamd process have the 4-bit page value needed by the exploit?
There was one important trap in the early version of this oracle. I initially combined several callback layouts into a single file. It produced very reliable detections, but those detections did not prove that all three callback bytes came from the same layout.
ClamAV logical-signature counts apply across the entire file. That meant the begin match could come from one layout, the process match from another, and the end match from a third.
The corrected construction therefore gives each child file only one callback layout. It also includes three matching negative controls that independently break the begin, process, and end conditions. A positive result now demonstrates that all three required stale bytes came from the same layout.

loading the staged library
When the process has the correct 4-bit page value, the RTF callback reaches the relative dispatcher described earlier. RBX already points to the staged shared-object path and stays intact through the formatting function used by the target code. That function also overwrites the first four state qwords, after which execution reaches libclamav’s existing dlopen@plt(RBX, ...) path.
Up to this point, every executable address used by the exploit comes from the exact target libclamav module. The exploit does not need to guess addresses inside libc or ld.so.
That does not mean the exploit is independent of the operating system. The heap layout still depends on glibc, the PLT eventually calls into libc and the dynamic loader, and the kernel still provides the normal process and memory-mapping behavior. Using only target-module addresses simply reduces the number of unknown addresses the exploit has to deal with.
The shared-object constructor is where the exploit stops behaving like a simple crash-based proof of concept and starts repairing the process before doing anything else.
It first verifies that it is running in the expected environment. The constructor:
-
Locates the loaded
libclamavimage withdl_iterate_phdr(). -
Verifies the expected GNU Build ID:
e6427ab62146ee3001fe463d12e797e9d25bf81a. -
Confirms that the stack contains the expected return sequence left by the dispatcher.
-
Uses the saved RTF state pointer to recover the location of the PESpin staging area.
-
Calls normal
malloc()until all 88 forged tcache entries have been returned and checked. -
Intentionally leaves those chunks allocated so later thread cleanup cannot follow the corrupted freelist pointers.
-
Repairs the callback fields and parser state that were damaged during exploitation.
-
Replaces the return from
dlopenwith a small shim that restores the expected stack layout, returnsCL_VIRUS, and resumes the normal RTF parser path. -
Only after all of those checks and repairs succeed does it perform the command and result-handling step.
The Build ID check is a safety check for the payload, not a way to remotely determine the installed ClamAV version. It also happens after control flow has already been redirected. If the target library is incompatible, the process may crash before the constructor ever has a chance to reject it safely.
The fixed-path shared-object loader also has an important deployment limitation. On Debian and Ubuntu systems where the ZendTo installer detects AppArmor, it adds:
Copy to Clipboard
AppArmor adds an important limitation to the fixed-path loader.
Loading a file as executable requires AppArmor’s m permission, not just r. On the unconfined installer-based test system, ClamAV could load the staged DSO directly from ZendTo’s incoming directory. Under an enforcing clamd AppArmor profile that grants only the installer’s normal access, that load is expected to be blocked.
The older 59-word memfd loader avoids this problem because it copies the DSO into an in-memory file instead of asking the dynamic loader to map the staged file directly. An earlier version of that approach successfully reached its constructor under an enforcing AppArmor profile.
However, the current hardened 59-word chain, including its command and SQLite result-handling code, has not been fully retested under that configuration.
For that reason, the complete no-account fixed-path command/output chain should not be claimed as demonstrated when an enforcing clamd AppArmor profile is active.
Returning command output
Now that we have code execution, the ideal next step is to retrieve the output directly through the web application. Otherwise, the exploit has to operate blindly or rely on an out-of-band callback to return results. Ideally, the output should come back through ZendTo itself over normal HTTPS and remain accessible from the same unauthenticated scope that started the chain, without requiring an existing claim, user account, or writable executable web path.
To this end, the shared object uses ZendTo’s existing SQLite database as the return channel. It dynamically loads the installer’s libsqlite3.so.0 and opens /var/zendto/zendto.sqlite for writing. On the default installer configuration, that database is group-writable and the clamav account is also a member of the www-data group, giving the ClamAV process the required access.
Before running the command, the constructor creates a new database row specifically for storing and retrieving the result:
Copy to Clipboard
Execution proceeds only when sqlite3_changes() reports exactly one inserted row.
The result bearer is a separate random 128-bit value known to the client and valid for 30 minutes. Email is deliberately empty and FullName contains no spaces. ZendTo’s upload finalizer recognizes that shape as pickup-style state and refuses to use it as another verified-sender upload token.
Anonymous GET /dropoff.php?auth=<result-token> still renders FullName. That asymmetry supplies an output-only channel.
The payload runs /bin/sh -c <caller-command> with a 60-second timeout, kills the process group on timeout, merges stdout and stderr, caps captured output at 12 KiB, and records truncation and exit/signal status. It frames the result with the run ID, exact libclamav Build ID, UID/GID, and SHA-256 of the requested command, Base64-encodes it, then performs a guarded update that only transforms the exact pending row:
Copy to Clipboard
The client polls the random result URL and accepts output only if the proof marker, run ID, command hash, and completion marker all match. There is no DNS/HTTP callback, no claim ID/passcode, no recipient email for the result, and no low-privilege file written under the web root.

privilege escalation
Native execution begins as:
Copy to Clipboard
That supplementary group creates a second chain in the installer filesystem.
The relevant permissions are:
Copy to Clipboard
The scanner cannot edit files inside templates_c merely through the directory’s mode. It can, however, rename a child directory through the group-writable parent /var/zendto.
ZendTo’s root maintenance path loads the same Smarty compile directory used by the web application:
Copy to Clipboard
The hourly root cron invokes cleanup.php at minute 25. cleanup.php includes Smartyconf.php, so root eventually includes the deterministic compiled PHP representation of zendto.conf from the shared cache.
The hardened privilege-escalation helper does not overwrite the live file in place. From the existing clamav foothold it:
-
Clones the complete
templates_cdirectory. -
Replaces the deterministic compiled config in the clone with a nonce-bound wrapper.
-
Records the genuine directory device/inode and compiled-file hash.
-
Uses
renameat2(RENAME_EXCHANGE)to atomically exchange the clone and live directory through the writable parent. -
Makes non-root web loads delegate to the hidden genuine compiled file.
-
When root cron includes the wrapper, atomically restores and validates the genuine directory first.
-
Includes the genuine config, then and only then runs the bounded root command and publishes a nonce JSON result under HTTPS.
This suffix was demonstrated on an ext4-backed installer-systemd volume and restored the original directory and compiled-file hash. It is not universal. Overlayfs with a lower-layer templates_c and redirect_dir=N can return EXDEV. Enforcing clamd AppArmor can block the directory exchange. It also depends on Linux/glibc renameat2, PHP CLI FFI/exec/POSIX, the deterministic compiled path, active root cron, and a writable result destination.
Most importantly, the integrated ClamAV-to-root run used an ordinary Local account to obtain scanner access. The no-account native tail and the root bridge were demonstrated independently; the literal CAPTCHA/email no-account bootstrap was not run all the way through root in the placeholder fixture. Root is a credible composition under the stated conditions, not a hidden adjective attached to every successful ClamAV run.

The POC
I created a Github repo with step by step instructions on how to setup the test environment to demonstrate the full RCE chain against a local ZendTo docker deployment. If you want to checkout this novel memory corruption to full root code execution exploit, give it a look.
Copy to Clipboard