Summary
gix-pack's pack-header decoder explicitly accepts pack version 3 (returning Ok((Version::V3, num_objects))), but the very next step asserts the version equals 2 with assert_eq! — which is compiled into release builds. A malicious remote that sends a 12-byte pack header declaring version 3 triggers a panic that aborts the clone/fetch. This is a trivial, deterministic, pre-authentication remote denial of service that fires on every clone/fetch.
Root Cause
data::header::decode (gix-pack/src/data/header.rs:12-21):
let kind = match crate::read_u32(&data[ofs..ofs + N32_SIZE]) {
2 => data::Version::V2,
3 => data::Version::V3, // version 3 is ACCEPTED, not an error
v => return Err(decode::Error::UnsupportedVersion(v)),
};
Then BytesToEntriesIter::new_from_header (gix-pack/src/data/input/bytes_to_entries.rs:58):
let (version, num_objects) = crate::data::header::decode(&header_data)?;
assert_eq!(
version,
crate::data::Version::V2,
"let's stop here if we see undocumented pack formats"
);
assert_eq! (unlike debug_assert_eq!) is NOT compiled out in release. Note the same file uses debug_assert_eq! at line 133, confirming the line-58 hard assert is a distinct, deliberate release-time panic. The author's own message ("let's stop here") shows an intent to stop gracefully, but a panic was chosen instead of an error return.
Impact
The crate is built with panic = "unwind" and there is no catch_unwind in the runtime read path, so the panic unwinds to the fetch/clone caller → operation/process abort = DoS. It runs first-thing on every clone/fetch (both thin and non-thin branches), before any negotiation-specific state.
Proof of Concept
Serve a pack stream beginning 50 41 43 4B 00 00 00 03 <num_objects> ("PACK", version 3) to a victim gix clone/fetch. header::decode returns Ok((V3, ...)), then the assert_eq!(version, V2) panics.
Attack Chain
- Entry: attacker serves a pack beginning
50 41 43 4B 00 00 00 03 .... Guard: header::decode version check. Bypass proof: header.rs:14 returns Ok(Version::V3, ...) for byte value 3; only versions ≠ 2,3 error.
- Sink:
new_from_header runs assert_eq!(version, V2) (bytes_to_entries.rs:58). Guard: should be a graceful UnsupportedVersion error. Bypass proof: it is assert_eq! (not debug_assert_eq!) → panics on V3 in release.
- Impact: panic on the fetch/clone thread → process/operation abort = remote DoS. 12 attacker bytes, no negotiation state, no thin-pack precondition.
Bypass Evidence
git show gix-pack-v0.74.2:gix-pack/src/data/header.rs confirms version 3 → Ok(Version::V3, ...) on the latest tag.
git show gix-pack-v0.74.2:gix-pack/src/data/input/bytes_to_entries.rs confirms the assert_eq!(version, Version::V2, ...) at line 58 (and debug_assert_eq! at line 133, proving the distinction is deliberate).
- The GHSA-x494-mj8g-cj27 panic-fix commits b69f0a6/5850141e edited only
data/entry/decode.rs; this pack-header assert is untouched.
Affected Versions
gix-pack <= 0.74.2 (latest release tag gix-pack-v0.74.2; code present on HEAD). No post-tag fix.
Suggested Fix
Return a graceful decode::Error::UnsupportedVersion/dedicated error for any non-V2 version in new_from_header instead of assert_eq!.
Reported by zx (Jace) — GitHub: @manus-use
Summary
gix-pack's pack-header decoder explicitly accepts pack version 3 (returning
Ok((Version::V3, num_objects))), but the very next step asserts the version equals 2 withassert_eq!— which is compiled into release builds. A malicious remote that sends a 12-byte pack header declaring version 3 triggers a panic that aborts the clone/fetch. This is a trivial, deterministic, pre-authentication remote denial of service that fires on every clone/fetch.Root Cause
data::header::decode(gix-pack/src/data/header.rs:12-21):Then
BytesToEntriesIter::new_from_header(gix-pack/src/data/input/bytes_to_entries.rs:58):assert_eq!(unlikedebug_assert_eq!) is NOT compiled out in release. Note the same file usesdebug_assert_eq!at line 133, confirming the line-58 hard assert is a distinct, deliberate release-time panic. The author's own message ("let's stop here") shows an intent to stop gracefully, but a panic was chosen instead of an error return.Impact
The crate is built with
panic = "unwind"and there is nocatch_unwindin the runtime read path, so the panic unwinds to the fetch/clone caller → operation/process abort = DoS. It runs first-thing on every clone/fetch (both thin and non-thin branches), before any negotiation-specific state.Proof of Concept
Serve a pack stream beginning
50 41 43 4B 00 00 00 03 <num_objects>("PACK", version 3) to a victimgix clone/fetch.header::decodereturnsOk((V3, ...)), then theassert_eq!(version, V2)panics.Attack Chain
50 41 43 4B 00 00 00 03 .... Guard:header::decodeversion check. Bypass proof: header.rs:14 returnsOk(Version::V3, ...)for byte value 3; only versions ≠ 2,3 error.new_from_headerrunsassert_eq!(version, V2)(bytes_to_entries.rs:58). Guard: should be a gracefulUnsupportedVersionerror. Bypass proof: it isassert_eq!(notdebug_assert_eq!) → panics on V3 in release.Bypass Evidence
git show gix-pack-v0.74.2:gix-pack/src/data/header.rsconfirms version 3 →Ok(Version::V3, ...)on the latest tag.git show gix-pack-v0.74.2:gix-pack/src/data/input/bytes_to_entries.rsconfirms theassert_eq!(version, Version::V2, ...)at line 58 (anddebug_assert_eq!at line 133, proving the distinction is deliberate).data/entry/decode.rs; this pack-header assert is untouched.Affected Versions
gix-pack <= 0.74.2(latest release tag gix-pack-v0.74.2; code present on HEAD). No post-tag fix.Suggested Fix
Return a graceful
decode::Error::UnsupportedVersion/dedicated error for any non-V2 version innew_from_headerinstead ofassert_eq!.Reported by zx (Jace) — GitHub: @manus-use