[0] Application
From chasquid’s homepage (blitiri.com.ar/p/chasquid):
« chasquid is an SMTP (email) server with a focus on simplicity, security, and
ease of operation. »
chasquid is a single-binary, single-process MTA: one process serves all three listener modes (MX/25, submission/587, submission+TLS/465), runs the queue, and performs deliveries. It ships in Debian (trixie: 1.15.0-1+b4), in Ubuntu (26.04: 1.16.0, 24.04: 1.13, 22.04: 1.8), as an upstream tarball, and as a Docker image. Unlike Postfix or Exim, there is no master/child split: any code path that terminates the process takes down every listener at once.
All three findings below were verified on a stock install of the Debian trixie package (1.15.0-1+b4), from the position of an unauthenticated remote host, with nothing but SMTP dialogues and TCP connections. The relevant code is identical in 1.15.0 and 1.18.0 except where noted. Per-finding affected ranges: [II] exists since 1.14.0, which introduced the built-in DKIM verification it abuses. [I] relies on the MTA-STS code, present since 0.06 (2018). [III] relies on the accept loop, unchanged since the project’s first listener: we found it in every release from 0.01 (2016) through 1.18.0.
[I] Persistent crash-loop via slash in the bounce recipient domain
A) Overview
A single unauthenticated email to the MX port can crash the entire daemon, and
the crash re-fires on every restart. The email fails delivery, so the server
queues a bounce back to the attacker’s address, and every attempt to deliver
that bounce crashes the process, including the one at each startup. The outage
persists until an operator restarts the service after the queue’s give-up time
(20 hours by default), or deletes the queue entry by hand before that. The trigger is a MAIL FROM address
whose domain contains a single /. Neither net/mail, nor the idna
normalization, nor chasquid’s own validators reject it, but the MTA-STS cache
builds a file path out of the domain and treats the slash as unreachable
defensive code.
B) Required data
- The target’s MX port, and one local recipient that exists (so the
RCPTis accepted). No account, no STARTTLS, nothing else.
C) Explanation
The sender address of any message reaches the MTA-STS policy cache as a domain
string. The cache derives a file path from it (sts.go):
func (c *PolicyCache) domainPath(domain string) string {
// We assume the domain is well formed, sanity check just in case.
if strings.Contains(domain, "/") {
panic("domain contains slash")
}
return c.dir + "/" + pathPrefix + domain
}
The comment says “we assume”, but nothing upstream enforces it. Go’s net/mail accepts a / in the domain part
(x@evil.com/x parses cleanly under Go’s net/mail, which among dot-related forms
rejects consecutive, leading and trailing dots), and chasquid’s normalization
layer is a case-folding pass, not a validator (normalize.go):
func Domain(domain string) (string, error) {
// For now, we just convert them to lower case and make sure it's in NFC
// form for consistency.
// There are other possible transformations (like nameprep) but for our
// purposes these should be enough.
// https://tools.ietf.org/html/rfc5891#section-5.2
// https://blog.golang.org/normalization
d, err := idna.ToUnicode(domain)
if err != nil {
return domain, err
}
d = norm.NFC.String(d)
d = strings.ToLower(d)
return d, nil
}
idna.ToUnicode with the zero-option profile (the call above) passes evil.com/x
through unchanged, error nil. Nothing between the SMTP dialogue and domainPath
rejects it.
The unauthenticated route is the bounce: the attacker
sends MAIL FROM:<x@evil.com/x> to one existing local recipient whose delivery
fails (any MDA misconfiguration, a full mailbox, a receive-only user. On our
stock Debian install every local delivery failed, because the package does not
pull in any MDA: maildrop is the configured default but is not a dependency).
The failure handler queues a DSN back to the sender (queue.go:436 sendDSN in
1.15.0, :471 in 1.18.0, body identical), the queue’s delivery loop resolves MX
for evil.com/x, net.LookupMX returns IsNotFound, the fallback treats the
domain itself as the next hop (courier/smtp.go:289-292 in 1.15.0, :325-328 in
1.18.0), and the outbound delivery fetches the MTA-STS policy for the slash
domain before any network I/O. There is no recover() on the path.
D) Exploitation
Observed on the stock Debian trixie package (1.15.0-1+b4), from an external host, one dialogue:
EHLO attacker.example
MAIL FROM:<x@evil.com/x>
RCPT TO:<existing local user>
DATA
Subject: hi
.
QUIT
The connection is closed at this point and the attacker needs to do nothing else. Within seconds, the target’s journal shows the queue accepting the message, the local delivery failing, the DSN being queued, then:
panic: domain contains slash
sts.(*PolicyCache).domainPath(...) sts.go:364
sts.(*PolicyCache).load(...) sts.go:373
sts.(*PolicyCache).Fetch(...) sts.go:438
courier.(*SMTP).fetchSTSPolicy(...) smtp.go:262
courier.(*SMTP).Deliver(...) smtp.go:85
queue.(*Item).deliver(...) queue.go:419
queue.(*Item).sendOneRcpt(...) queue.go:353
queue.(*Item).SendLoop(...) queue.go:319
The panic terminates the whole process (exit status 2, all three listeners
down). systemd’s Restart=always re-launches it, the boot path re-reads the
poisoned DSN item from disk, re-enters SendLoop, and re-panics within the same
second. Observed: six consecutive restart cycles, then systemd’s rate limiter
gives up (“Start request repeated too quickly”) and the service stays down
indefinitely.
Within the queue’s give-up time (20 hours by default) no restart recovers it, because the poisoned item is on disk and is re-loaded at every boot: recovery requires stopping the daemon and deleting the queue entry as root. Past that time the item is dropped on the next start, but nothing restarts the service by itself once systemd has given up. Even if the process were kept alive, the queue’s own retry ladder (1, then 5, then 10, then 20 minutes, each perturbed by up to 60 seconds of jitter, observed waits: 1m27s, 1m32s, 1m56s on the first rung) would re-attempt the item for up to the give-up time, each attempt re-panicking.
The same panic is also reachable by an authenticated submission user putting
the slash domain directly in RCPT TO (the relay check passes by definition for
outbound), so closing the MX port is not enough.
[II] CPU exhaustion via uncapped DKIM h= tag
A) Overview
A single unauthenticated email can keep the single-process daemon burning CPU
for minutes to hours before it replies 250. The DKIM verifier iterates over the
h= tag of an incoming signature, and for every entry rescans every header of
the message, with no cap on the number of entries. Both factors of the
multiplication (the number of h= entries and the number of headers) are
chosen by the sender. The default max message size (50 MB) bounds the product.
our measurements on the verifier code in isolation extrapolate to tens of
CPU-hours (see D) for a message built to the maximum size.
The code already caps a related value, the number of DKIM-Signature headers,
with the comment “Protect from potential DoS by capping the
number of signatures” (verify.go:122-129, citing RFC 6376 §4.2 and §8.4. The
RFC’s own verifier-side guidance is §6.1: “A Verifier MAY limit the number of
signatures it tries, in order to avoid denial-of-service attacks”). The h=
tag has no such cap.
B) Required data
- The target’s MX port, one existing local recipient, and a DNS zone the
attacker controls, for a TXT record on
<selector>._domainkey.<d>. The lookup only has to succeed: NXDOMAIN aborts early as PERMFAIL, but the record does not need to contain a valid key. No account, no TLS.
C) Explanation
Incoming, unauthenticated DATA goes to the verifier (conn.go):
if c.completedAuth {
err = c.dkimSign()
...
} else {
c.dkimVerify()
}
The h= tag is parsed by splitting on colons with no limit (header.go:210):
sig.h = strings.Split(eatWhitespace.Replace(tags["h"]), ":")
and the verifier walks it, one full header scan per entry (verify.go:264-280):
for _, h := range hTag {
all := headers.FindAll(h)
slices.Reverse(all)
...
FindAll is a linear scan over all headers, and allocates a fresh slice on
every call (message.go:18-26, the quoted block is
verbatim):
func (h headers) FindAll(name string) headers {
hs := make(headers, 0)
for _, header := range h {
if strings.EqualFold(header.Name, name) {
hs = append(hs, header)
}
}
return hs
}
A signature that names existing headers therefore costs O(len(h=) x len(headers)) comparisons plus one allocation per entry. All verification of an incoming message happens inside the DATA handler, before the 250 is sent, so the client has already finished transmitting while the server spins. The connection deadline does not preempt the loop (it only gates reads), and there is no per-connection or per-message CPU limit, and a second connection simply adds another goroutine’s worth of work.
D) Exploitation
Observed on the stock Debian trixie package (1.15.0-1+b4): a 626 KB message
(80,000 X: y headers, an h= tag with 80,000 entries, a signature whose d=
resolves via the attacker’s zone and whose bh= matches the body, the b=
signature itself is never reached before the burn) held the daemon at 171% CPU,
9.5 CPU-minutes accumulated in top when we interrupted the run, with the
attacker’s connection idle. Scaled to the 50 MB default ceiling, our model of the same
construction measures in the tens of CPU-hours per message on the verifier
code run in isolation (~44 h by our extrapolation, from ~4.5 ns per
comparison in the no-match mode. The match mode additionally drives heavy GC
churn, and header-name-length choices easily move the estimate by a factor of
two). A few such messages keep the daemon burning CPU for days.
[III] Process exit on accept() error (fd exhaustion)
A) Overview
The accept loop of every listener terminates the daemon on any accept error,
including EMFILE. There is no connection cap, no per-IP limit, and no retry:
exhausting the process’s file descriptors (by holding idle connections, no
bytes needed) kills the process, which under Restart=always becomes a restart
loop, and under a sustained flood reaches systemd’s restart rate limiter,
leaving the service down.
Go’s own standard library does the opposite: net/http’s accept loop retries transient errors with exponential backoff and
only returns (never exits the process) on shutdown or fatal listener error
(net/http/server.go, Serve()). Exim removed the same behavior long ago
(its daemon.c keeps the historical comment: smail crashed the daemon after 10 successive accept
failures; “Exim originally followed suit, but this appears to have caused
problems. Now it just keeps going”).
B) Required data
- Any reachable port (25, 587, or 465, the loop is common to all modes), and more concurrent connections than the process’s file descriptor limit.
C) Explanation
smtpsrv/server.go (the loop is common to all three listener modes,
1.15.0:329-333, 1.18.0:356-360):
for {
conn, err := l.Accept()
if err != nil {
log.Fatalf("Error accepting: %v", err)
}
log.Fatalf exits the process. There is no error-type discrimination (a
transient EMFILE is treated like a listener failure), no Temporary() retry,
and no connection accounting anywhere in the daemon: the only ingress limits
are on line length, recipient count (100), message size, and Received-hop
count (plus the queue’s item cap).
D) Exploitation
Observed on the stock Debian trixie package (1.15.0-1+b4): with the service’s
LimitNOFILE lowered to 1024 for the demonstration, a single external host
opening idle TCP connections to the submission port (587) killed the process
at connection ~1021:
Error accepting: accept tcp [::]:587: accept4: too many open files
systemd restarted the daemon (NRestarts 0 -> 1), and re-flooding repeated the
cycle. With waves spaced inside systemd’s StartLimitInterval, the unit reaches
“Start request repeated too quickly” and stays failed. On the stock Debian
package the default LimitNOFILE is 524288, so the practical bar for a single
source is ~500k concurrent connections. Distributed sources, or deployments
with tighter fd limits, lower it accordingly. The issue is that
any accept error is fatal, whatever the limit.
[IV] Mitigation
In all three cases, one message or one connection takes down or ties up the whole process.
1. Reject or sanitize the domain before it can reach a filesystem path.
normalize.Domain is the single choke point and is currently a case-folding
pass. Validating there (reject /, or apply an LDH check) fixes the panic at
every call site. Independently, the panic in domainPath can become an error
return, and a recover() in the queue’s per-recipient goroutine would contain
any future panic in the delivery path to one message.
2. Cap the h= tag. RFC 6376 §6.1 lets verifiers limit signature fields to
avoid denial of service, and the code already caps signature count on that basis.
The same kind of limit applied to h= entries (an entry cap on the order of a
thousand) bounds the quadratic scan. Building a name->headers index once per message instead of rescanning
per entry removes the quadratic altogether.
3. Make the accept loop match the standard library: tolerate
net.ErrClosed, retry Temporary() errors with backoff, and return (not exit)
on anything else. A connection cap, global and per client (Postfix defaults to
50 per client), or an equivalent limit at the OS level, makes the
fd-exhaustion variant moot.
[V] Timeline
2026/10/07: Advisory sent to the chasquid maintainer 2026/10/08: Maintainer reply, fixes announced
[VI] Author
John JEAN is a French security researcher, CTO at Wargan (https://www.wargan.com).
Follow me on X @johnjean
[VII] PGP
My current PGP key is 0xDC3CB82BF0967AF0, you can get it on the Work with me page.