John Jean

Security research, advisories, and occasional (ultra) bike rides.

← all advisories

chasquid 1.18.0: multiple unauthenticated remote DoS

cve
to be assigned
written
2026-10-07
released
not yet
risk
high
affected
chasquid <= 1.18.0
type
input validation gaps + unbounded work in a single-process daemon
status
maintainer reply 2026-10-08, fix in progress

[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 RCPT is 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.

A fresh install of the Debian package, one email, then the restart loop.

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.

One email carrying a DKIM-Signature header against the Debian package, the daemon's CPU in top.

[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.

Idle connections from a single host against the Debian package, with LimitNOFILE lowered to 1024.

[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.