Skip to content

gix-pack: reachable panic via unchecked checked_sub().expect() on OFS_DELTA base distance during thin-pack fetch

Moderate
Byron published GHSA-mpf5-465h-mr53 Sep 25, 2026

Software

gix-pack

Affected versions

<= 0.74.2

Patched versions

>= 0.75.0

Description

Summary

When resolving a thin pack during fetch, gix-pack computes an OFS_DELTA base offset with pack_offset.checked_sub(base_distance).expect("distance to be in range of pack"). base_distance is an unvalidated leb64 varint read directly from the attacker-controlled entry header. A crafted base_distance larger than pack_offset makes checked_sub return None, and the .expect() panics — a remote denial of service on any gix/gix-pack fetch into a non-empty repository.

Root Cause

LookupRefDeltaObjectsIter::next (gix-pack/src/data/input/lookup_ref_delta_objects.rs:129-136):

if self.inserted_entries_length_in_bytes != 0 {
    if let Header::OfsDelta { base_distance } = entry.header {
        let base_pack_offset = entry
            .pack_offset
            .checked_sub(base_distance)
            .expect("distance to be in range of pack");

base_distance comes from leb64_from_read in the entry-header decoder (gix-pack/src/data/entry/decode.rs) with no validation against pack_offset. Every sibling conversion guards this — e.g. index/write/mod.rs:176 uses verified_base_pack_offset(..).ok_or(Error::…)?. Only this iterator uses .expect().

Impact

A malicious remote serving a thin pack triggers checked_sub → None → .expect() panic. The crate is built with panic = "unwind" and there is no catch_unwind in the gix-pack/gix-protocol/gix runtime read path, so the panic unwinds to the fetch caller, aborting the fetch/process = DoS.

Proof of Concept

Against a victim repo with at least one referenceable object, serve a thin pack containing (a) a REF_DELTA whose base is an object the victim already holds (so inserted_entries_length_in_bytes != 0), followed by (b) an OFS_DELTA entry whose leb64 base_distance exceeds its pack_offset. The .expect() panics.

Attack Chain

  1. Entry: attacker-served upload-pack → victim fetch into an existing repo → thin pack. Guard: none.
  2. Gate: a REF_DELTA whose base_id is an object the victim already has → self.lookup.try_find succeeds → track_change → inserted_entries_length_in_bytes != 0. Guard: if inserted_entries_length_in_bytes != 0 (line 129). Bypass proof: satisfied by normal thin-pack behaviour; the base exists because the victim has prior history.
  3. Sink: a subsequent OFS_DELTA with leb64 base_distance > pack_offset → checked_sub returns None → .expect(...) panics (lines 133-136). Guard: should be .ok_or(Error::…)? like sibling sites. Bypass proof: no comparison of base_distance to pack_offset before the checked_sub; the varint is read unvalidated.
  4. Impact: panic unwinds on the fetch thread → aborts the fetch/process = remote DoS.

Bypass Evidence

  • git show gix-pack-v0.74.2:gix-pack/src/data/input/lookup_ref_delta_objects.rs confirms the .expect() at lines 133-136 on the latest tag.
  • git log -- gix-pack/src/data/input/lookup_ref_delta_objects.rs shows no prior DoS-advisory commit touched this file (the GHSA-x494-mj8g-cj27 panic fixes b69f0a6/5850141e edited only data/entry/decode.rs).
  • Sibling guarded site confirmed at index/write/mod.rs:176 (verified_base_pack_offset(..).ok_or(..)?).
  • Reachability: receive_pack.rs:184-189 passes thin_pack_base_object_lookup = Some(...) → bundle/write/mod.rs wraps the stream in LookupRefDeltaObjectsIter.

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

Replace .expect("distance to be in range of pack") with .ok_or(Error::...)?, matching the sibling verified_base_pack_offset guard used everywhere else.

Precondition Note

Does NOT fire on a fresh clone (victim has no objects → REF_DELTA lookup returns None → gate stays false). Fires on a fetch into a repo with ≥1 referenceable object — the normal thin-pack case. Hence severity MEDIUM.


Reported by zx (Jace) — GitHub: @manus-use

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

CVE ID

No known CVE

Weaknesses

Reachable Assertion

The product contains an assert() or similar statement that can be triggered by an attacker, which leads to an application exit or other behavior that is more severe than necessary. Learn more on MITRE.

Credits