//nefariousplan

CVE-2026-55200: libssh2's chacha20-poly1305 Branch Skipped the Precheck Its Sibling Already Had

pattern

cve

proof of concept

_libssh2_transport_read in libssh2's src/transport.c forks on whether the negotiated cipher requires full-packet decryption. The non-AEAD branch reads packet_length off the wire, rejects it if it exceeds LIBSSH2_PACKET_MAXPAYLOAD (35000, the RFC 4253 ceiling), then computes the allocation size. The branch the next twelve lines down was written for chacha20-poly1305 reads packet_length, computes the allocation size from it, then rejects the allocation size if it exceeds the same constant. The check the second branch runs is the same LIBSSH2_PACKET_MAXPAYLOAD the first branch runs. It is not looking at the same number.

Send the chacha20 branch a packet whose decrypted packet_length is 0xffffffff and a 16-byte Poly1305 auth tag. The arithmetic libssh2 wrote produces 19. 19 < 35000 is true, the check passes, the library calls LIBSSH2_ALLOC(session, 19) and then writes the rest of the packet, sized off the unchanged p->packet_length, into the 19-byte buffer. CVE-2026-55200 is the heap write that follows. The cipher that hits the unchecked branch is the one OpenSSH negotiates first by default.

_libssh2_transport_read in libssh2's src/transport.c forks on whether the negotiated cipher requires full-packet decryption. The non-AEAD branch reads packet_length off the wire, rejects it if it exceeds LIBSSH2_PACKET_MAXPAYLOAD (35000, the RFC 4253 ceiling), then computes the allocation size. The branch the next twelve lines down was written for chacha20-poly1305 reads packet_length, computes the allocation size from it, then rejects the allocation size if it exceeds the same constant. The check the second branch runs is the same LIBSSH2_PACKET_MAXPAYLOAD the first branch runs. It is not looking at the same number.

Send the chacha20 branch a packet whose decrypted packet_length is 0xffffffff and a 16-byte Poly1305 auth tag. The arithmetic libssh2 wrote produces 19. 19 < 35000 is true, the check passes, the library calls LIBSSH2_ALLOC(session, 19) and then writes the rest of the packet, sized off the unchanged p->packet_length, into the 19-byte buffer. CVE-2026-55200 is the heap write that follows. The cipher that hits the unchecked branch is the one OpenSSH negotiates first by default.

The branch carved for chacha20-poly1305 is the branch the modern world walks first

_libssh2_transport_read is the function libssh2 calls once per incoming SSH packet. It reads, decrypts, authenticates, and hands the cleartext upward. Before it can do any of that, it has to decide how big a buffer to allocate. The deciding fork in libssh2 1.11.1 lives around line 553:

if(!encrypted || !CRYPT_FLAG_R(session, REQUIRES_FULL_PACKET)) {
    /* sibling branch: handles unencrypted, CTR/CBC + HMAC, and AES-GCM */
    if(p->packet_length < 1) {
        return LIBSSH2_ERROR_DECRYPT;
    }
    else if(p->packet_length > LIBSSH2_PACKET_MAXPAYLOAD) {
        return LIBSSH2_ERROR_OUT_OF_BOUNDARY;
    }

    if(etm) {
        p->packet_length = _libssh2_ntohu32(block);
        total_num = 4 + p->packet_length + remote_mac->mac_len;
    }
    else {
        p->padding_length = block[4];
        if(p->padding_length > p->packet_length - 1) {
            return LIBSSH2_ERROR_DECRYPT;
        }
        total_num = p->packet_length - 1 +
                    (encrypted ? remote_mac->mac_len : 0);
    }
}
else {
    /* divergent branch: chacha20-poly1305@openssh.com only */
    total_num = 4;

    p->packet_length = _libssh2_ntohu32(block);
    if(p->packet_length < 1)
        return LIBSSH2_ERROR_DECRYPT;

    total_num += p->packet_length +
                 (remote_mac ? remote_mac->mac_len : 0) + auth_len;

    p->padding_length = 0;
}

if(total_num > LIBSSH2_PACKET_MAXPAYLOAD || total_num == 0) {
    return LIBSSH2_ERROR_OUT_OF_BOUNDARY;
}

p->payload = LIBSSH2_ALLOC(session, total_num);

Two branches, two contracts. The sibling branch validates p->packet_length against the 35000-byte RFC ceiling before it computes total_num from it. The divergent branch validates total_num against the same ceiling after computing it from p->packet_length + mac_len + auth_len. Then both branches fall through to a single if(total_num > LIBSSH2_PACKET_MAXPAYLOAD || total_num == 0) gate and a single LIBSSH2_ALLOC(session, total_num). The fall-through gate looks like a second line of defense. It is the only line of defense the divergent branch has.

The flag CRYPT_FLAG_R(REQUIRES_FULL_PACKET) is set by exactly one cipher method in src/crypt.c:

{
    "chacha20-poly1305@openssh.com",
    ...
    LIBSSH2_CRYPT_FLAG_REQUIRES_FULL_PACKET,    /* flags */
    ...
}

aes256-gcm@openssh.com and aes128-gcm@openssh.com set INTEGRATED_MAC | PKTLEN_AAD instead, because GCM mode sends the packet length as cleartext associated data. The CTR and CBC cipher families set no flags at all. Only chacha20-poly1305 hits the else branch, because chacha20-poly1305 is the OpenSSH variant where the packet length itself is encrypted with a separate stream key and then authenticated together with the body, and libssh2's parser cannot decide the allocation size until it has decrypted the length field. The whole-packet handling requirement is what carved a new branch in the parser.

OpenSSH has offered chacha20-poly1305 as its first-preference cipher since OpenSSH 6.5 in 2014. The IETF blessed the AEAD construction in RFC 8439. Every modern SSH stack negotiates it by default when both sides offer it. The branch in libssh2 that was added to handle the cipher the world prefers is the branch that does not enforce the RFC bound until the arithmetic has already destroyed the evidence.

The arithmetic the check was inspecting

Take the values a malicious server can send and walk them through the divergent branch's expression.

p->packet_length = 0xffffffff      (uint32_t off the wire)
remote_mac is NULL                 (AEAD ciphers do not use a separate MAC)
auth_len = 16                      (Poly1305 tag length)

total_num = 4
total_num += 0xffffffff + 0 + 16
          == (uint32_t)(0xffffffff + 0 + 16)
          == (uint32_t)(0x0000000f)
          == 15
total_num == 19

The operands p->packet_length, mac_len, and auth_len are all narrower than 64-bit on the path libssh2 actually compiles, and the additions happen at the operand width before the result is stored into size_t total_num. The sum 0xffffffff + 0 + 16 wraps the 32-bit type, producing 15, and total_num = 4 + 15 = 19. The post-addition gate if(total_num > LIBSSH2_PACKET_MAXPAYLOAD || total_num == 0) then asks whether 19 exceeds 35000. It does not. LIBSSH2_ALLOC(session, 19) succeeds. p->payload now points to a 19-byte heap chunk, and p->packet_length still says 0xffffffff.

Tristan Madani published an arithmetic verifier alongside the public PoC that demonstrates the wrap on 64-bit Linux and on Windows-MinGW 64-bit, and observes the same result on both: vulnerable32_decision=accepted, vulnerable32_allocation=19. The verifier's evidence file records the loopback test against a malicious server scaffold that negotiates curve25519-sha256, RSA host key auth, and chacha20-poly1305@openssh.com, then sends a server-to-client packet whose decrypted SSH packet_length is 0xffffffff. The library accepts the packet. The malformed length passes the check.

What happens next is the OOB write. The function then runs the chacha20 full-packet branch's memcpy(p->wptr, &p->buf[p->readidx], numbytes) and the larger read loop that uses p->packet_length - 1 as the body length. The 19-byte allocation is the destination. The 4-gigabyte length is the size of the write the loop will try to perform until either the socket runs dry or the heap is destroyed. The CWE assignment is correct: this is CWE-680, Integer Overflow to Buffer Overflow, the canonical name for a length check that happens after the length has already been corrupted.

The patch is the sibling branch's check, copied across the if

The fix, commit 97acf3df on the libssh2 master branch, opened as PR #2052 by willco007 and merged June 12, 2026 with credit to TristanInSec, is four lines:

--- a/src/transport.c
+++ b/src/transport.c
@@ -645,8 +645,12 @@ int ssh2_transport_read(LIBSSH2_SESSION *session)
                 total_num = 4;

                 p->packet_length = ssh2_ntohu32(block);
-                if(p->packet_length < 1)
+                if(p->packet_length < 1) {
                     return LIBSSH2_ERROR_DECRYPT;
+                }
+                else if(p->packet_length > LIBSSH2_PACKET_MAXPAYLOAD) {
+                    return LIBSSH2_ERROR_OUT_OF_BOUNDARY;
+                }

                 /* total_num may include size field, however due to existing
                  * logic it needs to be removed after the entire packet is read

The added else if(p->packet_length > LIBSSH2_PACKET_MAXPAYLOAD) { return LIBSSH2_ERROR_OUT_OF_BOUNDARY; } is byte-identical to the gate the sibling branch had been running twelve lines earlier. Same operand, same comparator, same constant, same return value. The patch is not new logic. It is the existing logic, copied across the if-statement, with braces added so the early-return formats consistently with the new sibling case.

The PR title is "transport.c: Additional boundary checks for packet length." It is more honest than vendor advisories of this shape usually manage. The check is not additional. The check is the one the function already had, applied to the branch where the function forgot to apply it.

Parallel-implementation-gap, inside one function, between two branches of one fork

This is the parallel-implementation-gap pattern, where a security check exists in one of two or more parallel modules implementing the same client-facing contract, and the CVE names the module that didn't get the check. Prior exhibits in the catalog include Astro's image endpoint, where the gate failed to copy across packages (the canonical TypeScript endpoint imported isRemoteAllowed, the Cloudflare adapter's parallel endpoint was created two directories away and shipped without it); Vite's fetchModule, where the gate failed to copy across callers (the HTTP /@fs/ middleware enforced server.fs.allow, the WebSocket caller of the same loader didn't); and Netty's HttpContentDecompressor, where the gate failed to copy across five if/else branches of one method, with gzip and deflate routing through a factory whose signature named the limit field while brotli, snappy, and zstd routed through no-arg constructors that didn't.

The closest sibling exhibit is radare2's fN, where the gate failed to copy across r2-command emit sites in the PDB parser: every other RAD-mode command wrapped user content through a base64 envelope, fN was the call site that didn't. radare2's parser had an envelope convention every command had adopted except one. libssh2's transport reader has a precheck convention the non-AEAD branch had adopted, and the chacha20-poly1305 branch, when it was added, did not. In both, the security check was on the worksite. In both, the author of the divergent code did not have to read the sibling to find it. In libssh2's case the sibling is in the same function, twelve lines away.

The structural reason these gaps survive disclosure is that the divergent branch reads its post-addition guard (if(total_num > LIBSSH2_PACKET_MAXPAYLOAD || total_num == 0)) and the author believes the guard is doing the job. The fall-through if(total_num > ...) was placed at the join point of the two branches, where it does serve as a second check for the sibling branch (which has already passed its precheck). For the divergent branch, the fall-through is the only check. It is also the wrong check, because it inspects a value the addition produced, not the value the addition consumed. From the inside, the code looks defended. From the outside, with a 32-bit wrap available, the defense is a constant equal to the wrap's tail.

What libssh2 is inside

The CVE record names libssh2 and stops there. The blast radius is what links against libssh2 as a client library and exposes that client to attacker-chosen SSH servers.

The largest consumer is libgit2, which links libssh2 for its SSH transport. libgit2 is what powers most non-git-CLI Git tools: GitHub Desktop, GitKraken, Tower, Visual Studio's source-control surface, the JetBrains IDE family's Git plugin, every language binding (pygit2, rugged, NodeGit), and CI runners that use libgit2 instead of shelling out to git. A git clone ssh://attacker-host/repo.git against a hostile SSH server is the unauthenticated triggering event. The git tool offers chacha20-poly1305 first, the malicious server accepts it, the malicious server sends one malformed transport packet, the client process is dead or worse.

The second-largest is curl with --with-libssh2, which provides curl's scp:// and sftp:// schemes on builds that prefer libssh2 over libssh. Many distributions ship that build by default; the Microsoft-shipped curl on Windows 10/11 is built with libssh2.

Then the long tail: git-lfs for SSH-mode LFS endpoints, Ansible's ssh connection plugin on builds that wrap libssh2, OpenWrt's curl, every embedded NAS or router shell that ships libssh2.so together with a Git or SCP entry point. The exposure model is the same in each. The local process makes an outbound SSH connection to a host the operator does not control, and the server returns the bytes.

The CVSS vector AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H scores 8.1 because NVD reads "attack complexity high" as the need to occupy a server position. The VulnCheck advisory scores it 9.2 under CVSS 4.0 because once you have that position the trigger is one packet. Both are correct readings of the same primitive. The disagreement is about how often the position exists.

Release-lag context

The disclosure on oss-security on June 23, 2026, by James Addison of NHS Digital Cyber Security, announced three libssh2 CVEs in one message: CVE-2025-15661 (high), CVE-2026-55199 (high), and CVE-2026-55200 (this one). The patches for all three are in master. They are not in any released libssh2 version. libssh2 1.11.1 shipped in October 2024. Twenty months have elapsed; the master branch carries the fixes; the distributions ship the bug.

Sevan Janiyan's reply on the same thread, June 24, noted that the CVE-2025-15661 patch does not apply cleanly to the 1.11.1 release. For CVE-2026-55200 specifically, the four-line diff above applies to 1.11.1 with a one-hunk adjustment; downstream packagers can backport it without waiting for an upstream point release. The upstream point release does not appear to be imminent.

The cluster matters because it indicates active audit attention on libssh2 from at least two parties (Tristan Madani for the patch, NHS Digital for the public disclosure). The October 2024 release date matters because every Linux distribution package, every container base image, every language binding pinning a stable libssh2 ships the unpatched parser today. The fix is four lines. The fix has been a public commit on master since June 12, 2026. There is no release tag in front of it.

The branch that was added is the branch that didn't inherit

Three CVEs in one disclosure, one fixed in four lines, the fix matching a check the same function already enforced in a sibling branch, on a library that ships inside every cross-platform Git tool. The pattern is what it is. The chacha20-poly1305 branch was added because chacha20-poly1305 required different transport handling. It was added without copying the precheck the existing transport handling already had. The post-addition gate was carried over the join point of the two branches and was the only defense the new branch ever had. For a year and eight months of release, the only defense was a check on a value that was free to wrap before the check ran.

PoC: bikini/exploitarium/libssh2-cve-2026-55200-poc

The non-AEAD branch checked the packet length. The chacha20 branch checked what was left of it after the addition wrapped. The patch is the sibling branch's check, copied across the if.