Skip to content

fix(bplist): reject implausible element count — unbounded-allocation DoS on a crafted plist#79

Merged
xunholy merged 1 commit into
mainfrom
fix/bplist-unbounded-alloc
Jun 21, 2026
Merged

fix(bplist): reject implausible element count — unbounded-allocation DoS on a crafted plist#79
xunholy merged 1 commit into
mainfrom
fix/bplist-unbounded-alloc

Conversation

@xunholy

@xunholy xunholy commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Bug

bplist_decode parses an untrusted binary plist (iOS/macOS forensics loot, dumped preference files). A string/array/set/dict object encodes its element count in a size marker that, when the low nibble is 0xF, is an extended integer up to int64-max. That count flowed into the per-object span computations unbounded:

  • refs(): need = n * objectRefSize
  • UTF-16 string: bytesAt(p, n*2)

With a large n (e.g. 2^63-1) and objectRefSize=2, n*objectRefSize overflows uint64 and wraps small, so the p + need > len(d.b) range check passes — then make([]uint64, n) (or make([]uint16, n) / make(map, n)) allocates the original huge n. Decode has no recover, so a crafted plist as small as 51 bytes triggers an uncaught makeslice: len out of range panic (or an OOM), crashing the agent turn.

The existing numObjects <= len(data) trailer check doesn't help — it bounds the object count, not a single object's declared element count. The fuzz harness never crafted a deep-enough valid skeleton to reach it.

Fix

sizedCount now rejects a count greater than the file size. No object can hold more elements than there are bytes on disk (a data byte costs 1, a UTF-16 char 2, an object ref objectRefSize>=1), so the bound never rejects a legitimate plist — but it keeps n <= len(d.b), so the n*2 / n*objectRefSize spans can no longer overflow and every downstream make is bounded by the input size.

Verification

Regression test crafts the minimal repro (header + one array object with a 2^63-1 extended count + one-entry offset table + trailer with objectRefSize=2) and asserts Decode returns an error instead of panicking — red→green proven (pre-fix panics makeslice, fixed code errors).

Sibling sweep of the count*size-overflow-before-make class: internal/wsframe bounds the payload against len(input) first, internal/quic's CRYPTO length is a <=2^62 varint that can't overflow the uint64 check, and internal/subghz.BytesToBits has no non-test callers — bplist was the only live instance.

  • gofmt / go vet clean · golangci-lint run ./...0 issues
  • go test -race ./internal/bplist/ok; 15s fuzz clean; legitimate plists still decode.

No registry/risk change (internal decoder hardening).

…-allocation DoS

bplist_decode parses an untrusted binary plist (iOS / macOS forensics loot,
dumped preference files). A string / array / set / dict object encodes its
element count in a size marker that, when the low nibble is 0xF, is an extended
integer up to int64-max. That count flowed into the per-object span computations
unbounded:

  - refs(): need = n * objectRefSize
  - object() UTF-16 string: bytesAt(p, n*2)

With a large n (e.g. 2^63-1) and objectRefSize=2, n*objectRefSize overflows
uint64 and wraps to a small value, so the `p + need > len(d.b)` range check
passes — and then make([]uint64, n) (or make([]uint16, n) / make(map, n))
allocates the ORIGINAL huge n. Decode has no recover, so a crafted plist as
small as 51 bytes triggers an uncaught `makeslice: len out of range` panic (or,
for other size/refSize combinations, an OOM), crashing the agent turn. The
existing numObjects <= len(data) trailer check does not help — it bounds the
object *count*, not a single object's declared element count. The decoder's
fuzz harness never crafted a deep-enough valid skeleton (header + offset table +
trailer + an extended-count object) to reach it.

Fixed at the source: sizedCount now rejects a count greater than the file size.
No object can hold more elements than there are bytes on disk (a data byte costs
1, a UTF-16 char 2, an object ref objectRefSize>=1), so the bound never rejects a
legitimate plist; it does keep n <= len(d.b), so the n*2 / n*objectRefSize spans
can no longer overflow and every downstream make is bounded by the input size.

Regression test crafts the minimal repro (header + one array object with a
2^63-1 extended count + a one-entry offset table + a trailer with
objectRefSize=2) and asserts Decode returns an error instead of panicking;
verified red->green (the pre-fix code panics makeslice, the fixed code errors).

Sibling sweep of the count*size-overflow-before-make class: internal/wsframe
bounds the payload against len(input) before allocating, internal/quic's CRYPTO
length is a <=2^62 varint that cannot overflow the uint64 check, and
internal/subghz.BytesToBits has no non-test callers — bplist was the only live
instance. gofmt / go vet / golangci-lint (0 issues) / go test -race + 15s fuzz
all green; legitimate plists still decode.
@xunholy xunholy merged commit fb1c874 into main Jun 21, 2026
10 of 11 checks passed
@xunholy xunholy deleted the fix/bplist-unbounded-alloc branch June 21, 2026 15:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant