Striga
Back to research

Zero Sum: 937 Bytes Stop sudo's Audit Log Server Forever

An unvalidated username field in sudo_logsrvd drives a line-wrapping budget to exactly zero, and one unauthenticated packet spins the root daemon forever.

Stanisław Kwiatkowski

Overview

sudo ships sudo_logsrvd, an optional daemon that collects audit events and I/O logs from every host in a fleet. It runs as root, serves all clients from a single event loop, and on a default install listens on *:30343 in plaintext with no authentication of any kind.

One field in that protocol is submituser, the name of the user who ran sudo on the client host. sudo_logsrvd stores it exactly as sent, with no length bound and no character check. do_syslog_sudo() then subtracts its length from the configured syslog line limit to work out how many bytes of message fit in one syslog(3) call. With the shipped defaults, a submituser of exactly 937 bytes drives that subtraction to zero, and the line-wrapping loop can no longer advance.

One unauthenticated 1339-byte message is enough. The daemon spins, emitting a 960-byte syslog record per iteration for as long as the process lives. It stays up and keeps its listening socket, so a bare TCP connect still succeeds, which makes the failure look healthier than it is. No client is ever served again, SIGTERM is never processed, and SIGKILL is required.

The recording above runs the whole chain on stock Ubuntu 24.04: the packaged /usr/sbin/sudo_logsrvd started on its defaults, a client pointed at it, sudo cat /etc/shadow succeeding, one packet from a third host, and the same command failing. The kit that produces it is striga-ai/sudo-logsrvd-wedge.

Affected. Reproduced on sudo 1.9.17p2 built from the release tarball, on current upstream main, and on the sudo 1.9.15p5-3ubuntu5.24.04.2 package Ubuntu 24.04 ships. On that last one nothing was built and nothing was installed beyond the distribution's own binary. do_syslog_sudo() is byte-for-byte identical between v1.9.17p2 and main (git diff v1.9.17p2..HEAD -- lib/eventlog/eventlog.c). The wrapping loop itself has been in the tree since 2007, but nothing reached it from the network until sudo 1.9.0 on 2020-05-11 added sudo_logsrvd. Every release since then is affected.

Not affected. A stock sudo installation with no central collector. Defaults log_servers has no default value and sudo_logsrvd is not started by installing sudo.

Striga surfaced the bug during open-source research on sudo 1.9.17p2, which was the newest stable release then and still is. We reported it to the sudo maintainer, sudo's documented security contact. After five weeks with no acknowledgement of any kind, we escalated to CERT Polska. CERT Polska said that where the only impact is denial of service they lean towards treating such defects as bugs rather than security vulnerabilities, leaving any CVE decision to the project. No CVE has been assigned.

How a Username Becomes Log Output

A client host with Defaults log_servers in its sudoers sends every sudo invocation to the collector over a protobuf protocol. The session opens with an AcceptMessage carrying key/value info pairs; four of them are required, namely submituser, submithost, runuser and command, and they are the client's account of what happened, which the collector has no way to check.

evlog_new() walks those pairs. This is the whole of the handling for submituser:

logsrvd/iolog_writer.c

	    if (strcmp(key, "submituser") == 0) {
		if (type_matches(info, source, INFO_MESSAGE__VALUE_STRVAL)) {
		    free(evlog->submituser);
		    if ((evlog->submituser = strdup(info->u.strval)) == NULL) {
			sudo_warnx(U_("%s: %s"), __func__,
			    U_("unable to allocate memory"));
			goto bad;
		    }
		}
		continue;
	    }

type_matches() checks that the key is present and that the protobuf value case is a string. That is the entire validation: no length limit, no character-class check, no lookup against the password database. Note also the free() before the strdup(), because it means a second AcceptMessage on the same connection overwrites submituser rather than being rejected, and that is what makes the length sweep below possible on a single TCP connection.

That matters more than it looks. On a client, submituser comes from getpwuid(getuid()), and /etc/passwd cannot hold a newline or a 900-byte username because those are its delimiters. Every intuition about what a username can contain comes from that constraint, and on the collector the constraint does not exist. submituser is a network string wearing a username's name.

With the default event log settings, which are log_type = syslog and log_format = sudo, the event travels eventlog_accept()do_syslog()do_syslog_sudo().

The Budget That Reaches Zero

syslog records have a length limit, so do_syslog_sudo() splits a long event across several calls, each prefixed with the submitting user's name. The budget for the message body is the limit minus the cost of that prefix.

lib/eventlog/eventlog.c:1013-1045

    fmt = _("%8s : %s");
    maxlen = evl_conf->syslog_maxlen -
	(strlen(fmt) - 5 + strlen(evlog->submituser));
    for (p = logline; *p != '\0'; ) {
	len = strlen(p);
	if (len > maxlen) {
	    tmp = memrchr(p, ' ', maxlen);
	    if (tmp == NULL)
		tmp = p + maxlen;
 
	    save = *tmp;
	    *tmp = '\0';
 
	    syslog(pri, fmt, evlog->submituser, p);
 
	    *tmp = save;
 
	    /* Advance p and eliminate leading whitespace */
	    for (p = tmp; *p == ' '; p++)
		continue;
	} else {
	    syslog(pri, fmt, evlog->submituser, p);
	    p += len;
	}
	fmt = _("%8s : (command continued) %s");
	maxlen = evl_conf->syslog_maxlen -
	    (strlen(fmt) - 5 + strlen(evlog->submituser));
    }

The budget is computed twice: before the loop with the short format, and again at the bottom of every iteration with the longer continuation format. Term by term:

  • evl_conf->syslog_maxlen is the total byte budget for one record; the shipped default is 960 (logsrvd/logsrvd_conf.c:1678).
  • strlen(fmt) - 5 is the template's literal text with the two conversion specifiers discounted, since %8s is 3 characters and %s is 2. For "%8s : %s" that leaves 3; for "%8s : (command continued) %s", 23.
  • strlen(evlog->submituser) adds back the width of the %8s conversion, and this is the term the attacker controls.

So with the defaults and the continuation format, a submituser of 937 bytes gives:

maxlen = 960 - (28 - 5 + 937) = 0

maxlen is size_t (eventlog.c:997), as is syslog_maxlen (include/sudo_eventlog.h:78). Everything is unsigned, there is no lower clamp anywhere on the path, and no length check on submituser before it.

At zero the loop cannot move. len > maxlen is always true, so the splitting branch always runs. memrchr(p, ' ', 0) returns NULL, as both glibc's and sudo's bundled fallback (lib/util/memrchr.c, compiled only where the platform lacks one) do for n == 0 without reading memory, and the NULL fallback sets tmp = p + 0, which is p. The byte at p is NUL'd, logged, restored. The advance step for (p = tmp; *p == ' '; p++) fails its condition immediately, because the restored byte is not a space, so p is assigned its own value. fmt and maxlen are recomputed identically. The outer for has an empty third clause, so nothing else moves p, and there is no break, no error path and no iteration counter.

Each spin is not a no-op. The call is syslog(pri, "%8s : (command continued) %s", <937 bytes>, ""), which formats to 937 + 23 + 0 = 960 bytes. The body is empty; the prefix is not.

Longer is safe, and that is the interesting part

The instinct on seeing unsigned arithmetic is to make the username longer than the budget and look for a wrap. Here the wrap is the harmless case.

submituserContinuation maxlenBehaviour
9352Splits normally
9361Terminates, but one syslog() call per byte
9370Hangs forever
93819, then SIZE_MAXTerminates after two calls; splitting off
the continuation budget falling one byte at a time as the username grows, meeting exactly zero at 937 bytes where the loop hangs, and wrapping to 18446744073709551615 beyond it

figure 1. the budget for one record against the length of the attacker-controlled username. the framing and the configured line limit both move where zero falls, which is why a hardcoded constant is not the fix.

938 is worth following carefully, because only the second budget wraps. The pre-loop computation uses the short format and gives 960 - (8 - 5 + 938) = 19, so the first iteration splits the line normally and emits a record. Only then does fmt switch, the overhead exceed 960, and maxlen wrap to 18446744073709551615. After that len > maxlen is false, p += len reaches the NUL, and the function returns. Two calls in total, with line splitting silently disabled from the second onward: a real defect, but not this one.

The hang needs the exact zero, and zero is a value no bounds check flags, because zero is a perfectly ordinary length. One more detail rules out a hardcoded constant as the fix: both format strings are wrapped in _() and the daemon calls setlocale(LC_ALL, ""), so strlen(fmt) is measured on the translated string. 937 is exact for the C locale or a --disable-nls build; under a translated locale the triggering length shifts. The missing clamp is there regardless.

Reaching It From the Network

If no listen_address is configured, logsrvd_conf_apply() unconditionally adds the plaintext listener on *:30343, on every interface. A missing configuration file is not an error, because defaults are installed and the daemon runs, and the shipped example config has every relevant key commented out. There is no authentication mechanism in the daemon at all; for the non-TLS case new_connection() goes straight to start_protocol(). The optional TLS listener is no better, since tls_checkpeer defaults to false.

ClientHello is optional and only logs; handle_client_message() dispatches the accept message with no state or credential gate, and handle_accept() validates only that the connection is not finished, that submit_time is non-NULL and that n_info_msgs is non-zero. The only size bound is the 2 MB cap on a wire message, which is roughly 2,200 times what the attack needs.

937 depends on a default an operator can change and a format string translation can change. Neither matters, because the attacker can sweep. handle_accept() deliberately permits repeated AcceptMessages for the life of a session, there is no per-connection message counter, no rate limit, and no read timeout, the last of those by explicit design, since "client messages may happen at arbitrary times". One TCP connection can stream lengths until one lands. The sweep is noisy in one direction worth stating: every probe that fails to hang the daemon still writes a real syslog record, so a collector that alerts on a burst of absurd usernames would catch this in progress.

Missing the exact value is not a failed attack, just a different one. For lengths roughly in [900, 936] the budget is a small positive number and the loop terminates after emitting one nearly-full record per maxlen bytes. The cost scales with 1/maxlen, so the band is not uniform: a single 2 MB ClientMessage yields on the order of 57,000 calls at length 900, and about 1.75 million at 936, where the budget is down to one byte. That second figure is roughly 1.7 GB of log writes for 2 MB of uplink.

Proof

Three hosts in an isolated VPC: a collector running the vulnerable daemon, an attacker, and a stock sudo client. Ubuntu 24.04 on x86-64, systemd-journald as the syslog sink. The collector build is stock, no sanitizer:

./configure --prefix=/opt/sudo-plain --sysconfdir=/opt/sudo-plain/etc \
    --enable-openssl --disable-nls --without-pam

--disable-nls pins the format strings to their untranslated lengths, fixing the trigger at 937. The proof of concept opens one plaintext TCP connection and sends a single AcceptMessage with the four required keys; command is long enough to force the loop past its first iteration, which gets a budget of 20.

MeasurementBeforeAfter
CPU jiffies in window095 in 3 s, 31.7%
Accumulated daemon CPU0.00 s16 m 42 s by the time we killed it
Answers ServerHelloyesno
TCP connect acceptedyesyes, Recv-Q 9 → 10 → 11
Process statealivealive, same PID
Shutdownn/aSIGTERM ignored, exit 137

Four rows are the actual proof, and each rules out a mundane explanation. The jiffy delta is steady and the accumulated total never stops climbing, so the process is spinning rather than blocked. It stops returning ServerHello while alive under its original PID, so it has not crashed or restarted. Recv-Q rising by one per client connection shows the kernel completing handshakes the daemon will never accept, which is why a TCP health check reports it as fine. And SIGTERM was delivered, went unhandled for the full grace period, and the process only died on SIGKILL; exit 137 is 128 + 9. Confirmed on two separate wedges.

The percentage is a property of this host, not the bug: each iteration blocks briefly on the syslog socket, and in a container with no syslog listener the same attack reads closer to 100%. What matters is that it is sustained and cumulative.

The log flood is not theoretical either. Two observations put the emission rate at roughly 100 continuation records per second, sustained: 59,999 over ten minutes on one wedged collector, 21,000 over three minutes on another. Each carries the attacker's 937-byte prefix, unchanged, into the system log of the host whose job is retaining audit history:

sudo[48286]: uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu…

Both collectors ran with RateLimitIntervalSec=30s and RateLimitBurst=1000, so what the journal retains is capped. What the loop emits is not, and the cap does nothing to slow the spin.

sudo-logsrvd-wedge is the full reproduction kit. It holds wedge.py, a Docker lab with a vulnerable collector, a sudo client and an attacker, and a README covering both the container path and a plain Linux host. Five minutes end to end, most of it apt. The numbers above are predictions you can check on your own hardware rather than claims you have to take on trust.

One methodology note: testing responsiveness with a bare TCP connect does not work, because the connect succeeds against the listen backlog even when the daemon will never accept(). It has to be tested at the protocol level, by waiting for ServerHello. An early pass using the connect test concluded this bug was already fixed upstream.

Impact

A network attacker permanently stops the audit collector for a fleet with one packet, and the daemon looks alive while doing it. Recovery requires SIGKILL and a restart, and nothing prevents the next packet.

What the clients behind it get is one of two things, and sudo's own defaults decide which. With I/O logging enabled, def_ignore_iolog_errors defaults to false and no new command runs through sudo at all. Without it, def_ignore_logfile_errors defaults to true, so commands still run, but only after a thirty-second wait on every single invocation. Neither outcome requires the operator to have configured anything unusual: both are what sudo does out of the box once a collector is pointed at.

The clients themselves are not compromised. They correctly enforce their own local policy and decline to run, nothing here crosses into code execution or data disclosure, and the daemon already runs as root with no sandbox. What an attacker gets is the ability to trigger that refusal remotely, on every host pointed at one collector, plus the permanent loss of the audit record those hosts were producing.

No new command runs through sudo

Wedging the collector takes privilege escalation away from every client that logs I/O to it:

$ sudo cat /etc/shadow
sudo: error initializing I/O plugin sudoers_io

No password prompt, no policy error, no partial output. sudo refuses before the command is executed, because src/sudo.c reaches if (!iolog_open(...)) goto access_denied; before exec and def_ignore_iolog_errors defaults to false. Same host, same sudoers, same command: against a healthy collector it exits 0 and runs in 21 ms; against a wedged one it exits 1 after 30,044 ms and never runs at all. The 30 seconds are log_server_timeout elapsing before sudo gives up. Two things still work, so the claim is not overread: sudo -l succeeds in 15 ms, never reaching iolog_open(), which is also the control attributing the denial to the I/O log path rather than to a broken lab, and root shells opened before the wedge keep working, since policy is evaluated at invocation. So the claim is narrower than "sudo stops working" and worse than it sounds: on every affected client no new privileged command can be started, remotely, from one packet sent to a third machine.

Two conditions have to hold:

  1. Defaults log_servers must be set in sudoers. It has no default value.
  2. I/O logging must be enabled. With log_servers set but no log_input/log_output/log_ttyin active, the I/O plugin reports itself disabled and sudo proceeds; the accept event then travels the audit plugin, governed by def_ignore_logfile_errors, which defaults to true. That path is fail-open, and it was measured: exit 0, command executed.

Fail-open is not harmless. sudo works, so nothing alerts, but every invocation takes 30 seconds instead of 0.02, fleet-wide, with no message explaining why.

Nothing is recorded, even when sudo succeeds

Denial is the loud failure. The quiet one is that the collector stops collecting. Sampling its own state across three client attempts, the counts never moved: three I/O log directories, three audit records, last write at 10:57:20, and they were unchanged at 11:45, at 11:48 after a client was denied, and at 11:51 after a client succeeded under the fail-open configuration. That last one is the point: a root command ran and left no trace, no audit record, no I/O log, nothing to replay. Fifty-four minutes of clients trying and nothing recorded. On restart at 11:54:01 the next event landed immediately, with a matching timestamp.

A backup log server does not help

The obvious mitigation is a second entry in log_servers, and for a crashed collector it works. For a wedged one it does not, established with a genuinely healthy second collector on port 30344:

log_servers, exactly as written in sudoersContacted firstExitCommandElapsed
10.240.1.85:30344 (healthy only)healthy0ran19 ms
10.240.1.85:30343 (wedged only)wedged1denied30.0 s
10.240.1.85:30343 10.240.1.85:30344healthy0ran17 ms
10.240.1.85:30344 10.240.1.85:30343wedged1denied30.0 s

The two columns say different things, and the difference is the point: the server written last in sudoers is contacted first. That reversal belongs to one code path, not to sudo in general. On the I/O-log path measured here the list is rebuilt with STAILQ_INSERT_TAIL (plugins/sudoers/iolog.c:244), which leaves it reversed, while the audit-log path undoes that with STAILQ_INSERT_HEAD and says so in a source comment (plugins/sudoers/logging.c:88-92).

The cause is in log_server_connect():

plugins/sudoers/log_client.c:605-638

STAILQ_FOREACH(server, closure->log_details->log_servers, entries) {
    ...
    sock = connect_server(host, port, tls, closure, &cause);
    if (sock != -1) {
        ...
        closure->sock = sock;
        ret = true;
        break;
    }
}

Failover happens at connect time and nowhere else. A wedged daemon still completes the TCP handshake from its listen backlog, so connect_server() succeeds and the loop breaks on that break. read_server_hello() runs afterwards, outside the loop, and its timeout has no path back into the server list. The same listen-backlog behaviour that makes this hang hard to detect defeats the one mitigation an operator would reach for.

The hang does not, by itself, kill running commands

sudo sleep 600 was started against a healthy collector, the collector was then wedged, and the command was still alive 52 seconds later, both the sudo process and its child. sleep produces no output, so the client never writes I/O log data, never gets a write error, and never takes the branch commented "Break out of sudo event loop and kill the command." The in-flight kill needs I/O to be flowing.

Affected Distributions

Two things have to be true: the sudo release has to carry the defect, and the distribution has to package sudo_logsrvd, which is a separate decision from packaging sudo.

The first holds from 1.9.0 onward without exception. We checked all 48 tags from SUDO_1_9_0 to v1.9.17p2 and current main. Every one has the same two computation sites with no clamp, the same strdup() of submituser off the wire with no length bound, and the same default syslog_maxlen of 960, so 937 is the number on all of them.

The second is where distributions part ways. Debian, Ubuntu, openSUSE, Arch, Alpine and Gentoo ship the daemon inside the main sudo package, so the binary is already on disk wherever sudo is.

Distributionsudo
Debian 11 bullseye1.9.5p2
Debian 12 bookworm1.9.13p3
Debian 13 trixie1.9.16p2
Debian forky1.9.17p2
Ubuntu 22.041.9.9
Ubuntu 24.041.9.15p5
Ubuntu 25.101.9.17p2
Ubuntu 26.041.9.17p2
openSUSE Leap 15.61.9.15p5
openSUSE Leap 16.0, 16.11.9.17p1
openSUSE Tumbleweed1.9.17p2
Arch1.9.17p2
Alpine 3.19 to 3.241.9.15p2 to 1.9.17p2
Gentoo1.9.17p2

Gentoo's ebuild passes no --disable-log-server, so the upstream default applies, and SLES 15 SP6 shares sources with Leap 15.6.

Being current is no help. Debian forky, Ubuntu 26.04, Tumbleweed, Arch, Alpine 3.24 and Gentoo all sit on 1.9.17p2, the newest stable sudo and the version the hang was reproduced against. There is nothing to upgrade to.

Fedora 43 to 45 and Amazon Linux 2023 build it as a separate sudo-logsrvd package that installing sudo does not pull in.

RHEL 9 and 10 do not ship the daemon at all, though their sudo is 1.9.17p2. It is in no repository and not in the file list of the sudo package, while Fedora, which RHEL is derived from, does package it. Checked on Rocky 9 and 10, which rebuild RHEL sources unchanged.

Anything older than 1.9.0 is out of reach: Debian 10 at 1.8.27, Ubuntu 20.04 at 1.8.31, RHEL 8 at 1.8.29, Amazon Linux 2 at 1.8.23.

No distribution starts the daemon on its own, and Debian and Ubuntu ship no systemd unit for it, so the exposed population is not every Debian host but every fleet that deliberately deployed a collector. Inside that population the barrier is nothing: the binary is already there, and on its defaults it listens on *:30343 in plaintext with no authentication.

Mitigations

Nothing to do if you do not run the collector. Installing sudo does not start sudo_logsrvd, and Defaults log_servers has no default value, so a fleet without central sudo logging was never in scope. pgrep -a sudo_logsrvd on the hosts you are unsure about settles it.

Operators who can change the collector's event format have a complete workaround today: log_format = json routes events to do_syslog_json() (eventlog.c:1129-1136), which makes a single syslog(3) call and never enters the wrapping loop.

For those who cannot: firewall the collector so only known clients reach it, and set tls_checkpeer = true with client certificates. Note the spelling, because the directive is tls_checkpeer, while tls_check_peer is only the internal struct field, and an unknown key is fatal at startup, so a typo leaves a collector that will not come back after a restart. Neither measure removes the defect; both reduce the population that can reach it.

An upstream fix needs a minimum budget enforced at both computation sites, eventlog.c:1014-1015 and :1043-1044, and that minimum has to leave room for real content, since forcing it to 1 only converts the hang into one syslog(3) call per byte. A length bound and a control-character check on submituser at ingest in logsrvd/iolog_writer.c would close the same hole at the source, along with the log-injection weakness the unescaped prefix carries.

Timeline

DateEvent
2020-05-11sudo 1.9.0 adds sudo_logsrvd, putting the loop within reach
2025-07-24sudo 1.9.17p2 released; still the newest stable release
2026-07-25Striga surfaces the defect during open-source research
2026-07-27Hang reproduced against v1.9.17p2 and against current main
2026-07-27Reported to Todd.Miller@sudo.ws, sudo's documented security contact
2026-09-01No acknowledgement after five weeks; escalated to CERT Polska
2026-09-10CERT Polska replies: a DoS-only defect is the project's call to score

References


This article was prepared with AI assistance.