//nefariousplan

CVE-2026-55199: libssh2's EXT_INFO Loop Skipped the Return Check the Rest of the File Was Running

pattern

cve

proof of concept

_libssh2_packet_add() in libssh2's src/packet.c handles SSH_MSG_EXT_INFO in a loop, reading (name, value) pairs off the wire until the server-supplied extension count reaches zero. The function it uses to read each string, _libssh2_get_string(), returns -1 when the buffer is exhausted, and the EXT_INFO loop discards the return. The pre-loop guard rejects any extension count of 1024 or higher. The loop therefore runs at most 1023 times, no-op-ing every iteration after the buffer is empty.

NVD assigned CWE-835, "Loop with Unreachable Exit Condition," to the result. The commit that closes CVE-2026-55199 wrote in its message that the cap limits the worst case. The patch is two if(...) break; statements that exit the loop on the same -1 return value the file's other count-driven parsers had been checking against. The EXT_INFO loop was the parser in src/packet.c that did not. TristanInSec, who landed this fix on April 21, 2026, landed the same fix shape on _libssh2_transport_read eight weeks later, in the chacha20-poly1305 branch CVE-2026-55200 names.

Five call sites in this file check the return. The EXT_INFO loop did not.

The convention in _libssh2_packet_add() is to read protocol fields through string_buf helpers, check the return on every call, and bail with LIBSSH2_ERROR_BUFFER_TOO_SMALL (or a protocol-specific equivalent) on parse failure. The shape appears verbatim across the file's count-driven parsers:

if(_libssh2_get_string(&buf, &(listen_state->host), &temp_len)) {
    return _libssh2_error(session, LIBSSH2_ERROR_BUFFER_TOO_SMALL,
                          "Data too short extracting host");
}

That's packet_queue_listener, the forwarded-tcpip path of SSH_MSG_CHANNEL_OPEN, parsing the listener's host/port/source-host/source-port out of the channel-open payload. The same shape appears in packet_x11_open (the X11 path of SSH_MSG_CHANNEL_OPEN) on its shost field, in the KEXINIT algorithm-list parse used by the strict-kex detection, in the SSH_MSG_CHANNEL_REQUEST parser on the request type, and again in the SSH_MSG_CHANNEL_REQUEST parser on the exit-signal name. Five call sites in src/packet.c. Five return checks. Five early bails.

The SSH_MSG_EXT_INFO case in the same switch does not check the return at all:

case SSH_MSG_EXT_INFO:
    if(datalen >= 5) {
        uint32_t nr_extensions = 0;
        struct string_buf buf;
        buf.data = (unsigned char *)data;
        buf.dataptr = buf.data;
        buf.len = datalen;
        buf.dataptr += 1; /* advance past type */

        if(_libssh2_get_u32(&buf, &nr_extensions) != 0 ||
                            nr_extensions >= 1024) {
            rc = _libssh2_error(session, LIBSSH2_ERROR_PROTO,
                                "Invalid extension info received");
        }

        while(rc == 0 && nr_extensions > 0) {

            size_t name_len = 0;
            size_t value_len = 0;
            unsigned char *name = NULL;
            unsigned char *value = NULL;

            nr_extensions -= 1;

            _libssh2_get_string(&buf, &name, &name_len);
            _libssh2_get_string(&buf, &value, &value_len);

            if(name && value) {
                _libssh2_debug((session,
                               LIBSSH2_TRACE_KEX,
                               "Server to Client extension %.*s: %.*s",
                               (int)name_len, name,
                               (int)value_len, value));
            }

            if(name && name_len == 15 &&
                memcmp(name, "server-sig-algs", 15) == 0) {
                /* server-sig-algs handling, allocates a copy of value */
            }
        }
    }

Two _libssh2_get_string() calls per iteration. Neither return value is read. The downstream if(name && value) guard and the if(name && name_len == 15 && memcmp(name, "server-sig-algs", 15) == 0) guard do handle the NULL-name case correctly. They just no-op. The loop runs to completion either way.

The arithmetic the cap was already inspecting

The pre-loop guard rejects any extension count of 1024 or higher. A malicious server is free to send 1023.

Send a SSH_MSG_EXT_INFO message with nr_extensions = 1023 and a payload short of what 1023 extensions would require, in the limit case, the four bytes of the count and nothing after. The loop enters with nr_extensions = 1023 and a string_buf whose dataptr already points at the end of data. Iteration one runs:

  1. Reinitialize name = NULL, value = NULL, name_len = 0, value_len = 0.
  2. nr_extensions -= 1 (now 1022).
  3. _libssh2_get_string(&buf, &name, &name_len).

Read _libssh2_get_string() in src/misc.c:

int _libssh2_get_string(struct string_buf *buf, unsigned char **outbuf,
                        size_t *outlen)
{
    uint32_t data_len;
    if(!buf || _libssh2_get_u32(buf, &data_len) != 0) {
        return -1;
    }
    if(!_libssh2_check_length(buf, data_len)) {
        return -1;
    }
    *outbuf = buf->dataptr;
    buf->dataptr += data_len;

    if(outlen)
        *outlen = (size_t)data_len;

    return 0;
}

The buffer is exhausted, so _libssh2_get_u32() cannot read the 4-byte length field and returns -1 without advancing buf->dataptr. _libssh2_get_string() returns -1 without assigning *outbuf or *outlen. The caller throws the -1 away. name is still NULL.

  1. _libssh2_get_string(&buf, &value, &value_len). Same. value is still NULL.
  2. if(name && value): skipped, both NULL.
  3. if(name && name_len == 15 && memcmp(...)): skipped, name is NULL.
  4. Loop guard rc == 0 && nr_extensions > 0 is true. Iterate.

Iterations 2 through 1023 are byte-identical to iteration 1: decrement, two failed string reads with the returns discarded, two skipped if guards. After 1023 iterations, nr_extensions == 0 and the loop exits. The handler returns 0.

The CPU work per iteration is two function calls that immediately hit the check_length() boundary, two pointer reinitializations, and a couple of zero comparisons. On commodity x86_64 a single iteration runs in the low hundreds of nanoseconds. The full 1023-iteration worst case completes in well under a millisecond. A malicious server can repeat the packet inside one session and force the client to spend that millisecond per packet. Across a long-lived SSH/SFTP session against a hostile server, the wasted cycles add up. The cap, sitting at the entry of the handler, bounds the per-packet work every time.

That bound is what the patch author wrote about in the commit message. It is also what NVD's CWE assignment talks past.

CWE-835, and what the loop's exit condition actually is

NVD assigned CWE-835, "Loop with Unreachable Exit Condition," to CVE-2026-55199. The MITRE definition reads, verbatim:

The product contains an iteration or loop with an exit condition that cannot be reached, i.e., an infinite loop.

The exit condition for the EXT_INFO loop is nr_extensions > 0 becoming false. The pre-loop guard at the entry of the handler rejects any nr_extensions >= 1024 before the loop runs. The loop decrements nr_extensions by 1 on every iteration. The exit condition is reachable in at most 1023 iterations, on every input, including every malicious one. It is reached, on every malicious packet, before the function returns.

CWE-835 is the wrong CWE.

The commit that closes the CVE puts the right framing in its own message:

The SSH_MSG_EXT_INFO handler discards the return values from _libssh2_get_string() when parsing extension name/value pairs. When the buffer is exhausted before all claimed extensions are parsed, the loop continues with no-op iterations until nr_extensions reaches zero.

The nr_extensions >= 1024 cap limits the worst case, but the loop should still break on parse failure for correctness and consistency with other parsers in this file (e.g. SSH_MSG_CHANNEL_OPEN, SSH_MSG_KEXINIT) that check _libssh2_get_string() return values.

The author named the bound ("reaches zero"). The author named the cap ("limits the worst case"). The author called the fix what it is ("correctness and consistency"). The published CVE record carries CWE-835.

The CVSS scoring (7.5 from NIST under CVSS 3.1, 8.2 from VulnCheck under CVSS 4.0, both rated High) attaches to the per-packet CPU waste in bulk. That impact is real and measurable across a malicious-server session. The CWE classification, "the loop has no reachable exit," is not.

The patch is the break statements the rest of the file had been running

The fix, commit 17626857 on the libssh2 master branch, opened as PR #1864 by TristanInSec and merged April 21, 2026, is four lines:

--- a/src/packet.c
+++ b/src/packet.c
@@ -890,8 +890,10 @@ _libssh2_packet_add(LIBSSH2_SESSION * session, unsigned char *data,

                     nr_extensions -= 1;

-                    _libssh2_get_string(&buf, &name, &name_len);
-                    _libssh2_get_string(&buf, &value, &value_len);
+                    if(_libssh2_get_string(&buf, &name, &name_len))
+                        break;
+                    if(_libssh2_get_string(&buf, &value, &value_len))
+                        break;

                     if(name && value) {
                         _libssh2_debug((session,

The two added break statements use the same _libssh2_get_string() return-value check the canonical call sites in this file already use, the ones in packet_queue_listener, packet_x11_open, the KEXINIT algorithm-list parse, and the CHANNEL_REQUEST request and exit-signal parses. The PR title, "packet: check _libssh2_get_string() return in EXT_INFO handler," is precise. The fix is the file's convention, copied into the case that did not have it.

Parallel-implementation-gap, second exhibit, same disclosure, same researcher

This is the parallel-implementation-gap pattern, the second exhibit from the libssh2 1.11.1 audit cluster disclosed on oss-security on June 23, 2026. The first exhibit was CVE-2026-55200 in _libssh2_transport_read, where the chacha20-poly1305 branch validated the allocation size against LIBSSH2_PACKET_MAXPAYLOAD after computing it from a 32-bit-wrappable expression, while the non-AEAD branch validated packet_length against the same constant before the computation. The convention was twelve lines up, in the sibling branch of the same if.

For CVE-2026-55199 in _libssh2_packet_add(), the convention is across five call sites in distinct switch-case parsers in the same translation unit. In _libssh2_transport_read the convention was across two branches of one fork. The same researcher landed both fixes:

Date CVE File Function Convention the patch restored Diff size
2026-04-21 CVE-2026-55199 src/packet.c EXT_INFO case "check the _libssh2_get_string() return and break" (5 prior call sites) 4 lines
2026-06-12 CVE-2026-55200 src/transport.c _libssh2_transport_read "validate packet_length against LIBSSH2_PACKET_MAXPAYLOAD before computing the allocation" (sibling branch) 4 lines

Both diffs are the smallest possible. Both patches copy a check already present in the same translation unit. Both authors of the original divergent code worked from the protocol's documentation rather than reading the rest of the file. RFC 8308 describes how to parse SSH_MSG_EXT_INFO; it does not say "check the return of your string reader and break." RFC 7634 and the OpenSSH chacha20-poly1305 specification describe how to layer the cipher; they do not say "validate packet_length against your library's max-payload constant before you compute the buffer size." The convention is in the library, not the protocol.

The structural reason the gap survives review in libssh2 specifically is that the divergent code reads its near-context, the EXT_INFO loop's own per-iteration NULL guards, the chacha20 branch's own post-addition gate, and the author believes the guard is doing the job. The reviewer reads the same near-context. The far-context, five other parsers in the same file, the sibling branch twelve lines up, goes unread because nothing in the diff forces it to be read. The pattern survives until a researcher who has read the far-context lands a four-line diff.

CVE-2025-15661, the third CVE in the same OSS-security disclosure, is a different class (a heap over-read in sftp_symlink on malformed SSH_FXP_NAME responses, fixed in PR #1717 by willco007 with discovery credit to Joshua Rogers and the issue report from MegaManSec). Two of the three CVEs from this audit cluster are TristanInSec's. Both of TristanInSec's are parallel-implementation-gaps in libssh2, eight weeks apart.

Release-lag and blast radius

libssh2 1.11.1 was released October 2024. As of the June 23, 2026 oss-security disclosure from James Addison of NHS Digital Cyber Security Operations, no point release carries any of the three June 2026 CVE fixes. The master branch carries 17626857; downstream packagers must backport it. Sevan Janiyan noted on the same thread that the CVE-2025-15661 patch does not apply cleanly to 1.11.1; the CVE-2026-55199 patch applies cleanly because the EXT_INFO handler's surrounding code has not changed since 1.11.1.

The blast radius is the same as the sibling CVE: libgit2 (and every Git tool built on it, GitHub Desktop, GitKraken, Tower, the JetBrains Git plugin, every language binding from pygit2 to NodeGit), curl --with-libssh2 (including the Microsoft-shipped curl on Windows), git-lfs in SSH mode, Ansible's ssh plugin where it links against libssh2, the long tail of embedded NAS and router shells that ship libssh2.so alongside a Git or SCP entry point. The triggering event is the same: a git clone ssh://attacker-host/repo.git or equivalent against a hostile SSH server. After the SSH handshake completes, the malicious server returns SSH_MSG_EXT_INFO with nr_extensions = 1023 and a truncated payload, and the client spends sub-millisecond CPU in the no-op loop. The cost per packet is small. The cost across a session of repeated packets is what NVD calls high availability impact.

The CVSS scoring split between NIST's CVSS 3.1 (7.5 High, AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) and VulnCheck's CVSS 4.0 (8.2 High, AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H) disagrees on attack complexity and how the malicious-server position is modeled. Both vectors carry the same A:H/VA:H impact field. Neither references the 1024 cap that bounds the per-packet work.

PoC and disclosure thread: seclists.org oss-sec 2026/q2/1010, James Addison, NHS Digital Cyber Security Operations, June 23, 2026.

The CVE record classifies the loop as unreachable. The commit message names the cap that bounds it at 1023. Both statements describe the same four-line patch.