Unauthenticated remote crash: unbounded Count in GetLeavesByRange on PREORDERED_LOG trees
- Dominant language
- Go
- Stars
- 3.7k
- Forks
- 465
- Avg merge
- 2d 16h
- Merged PRs (30d)
- 5
Description
## Summary
`TrillianLog.GetLeavesByRange` does not bound the client-supplied `Count`
field for `PREORDERED_LOG` trees before using it as a slice-allocation size.
A large `Count` (e.g. `math.MaxInt64`) reaches `make([]*trillian.LogLeaf, 0,
count)` unclamped and panics with `runtime error: makeslice: cap out of
range`. No interceptor in the gRPC stack recovers panics, so this crashes
the whole `trillian_log_server` process — every tree/tenant it hosts, not
just the targeted one.
Combined with `TrillianAdmin.ListTrees` carrying no request-level
authorization (tracked by the `// TODO(codingllama): This needs access
control` comment in `server/admin/admin_server.go`), an attacker with **zero
credentials** can enumerate a `PREORDERED_LOG` tree ID via `ListTrees` and
crash the server with it via `GetLeavesByRange`. Tree IDs are otherwise
`crypto/rand`-generated over the full int64 range and infeasible to guess
blind, so the `ListTrees` gap is what turns this from "needs insider
knowledge" into "fully unauthenticated, any time."
## Root cause
`storage/{mysql,postgresql,crdb}/log_storage.go`,
`getLeavesByRangeInternal`:
```go
if t.treeType == trillian.TreeType_LOG {
treeSize := int64(t.root.TreeSize)
// ...
// Ensure no entries queried/returned beyond the tree.
if maxCount := treeSize - start; count > maxCount {
count = maxCount
}
}
// TODO(pavelkalinnikov): Further clip `count` to a safe upper bound like 64k.
args := []interface{}{start, start + count, t.treeID}
rows, err := t.tx.QueryContext(ctx, selectLeavesByRangeSQL, args...)
// ...
ret := make([]*trillian.LogLeaf, 0, count) // count is still attacker-controlled here for PREORDERED_LOG
```
The `TreeSize`-based clamp only runs for `TreeType_LOG`. For
`TreeType_PREORDERED_LOG` it's skipped — likely intentional, since
pre-ordered logs can have leaves sequenced but not yet Merkle-integrated,
beyond the published `TreeSize` — but the standing `TODO` directly above the
vulnerable line shows the missing upper bound was never resolved.
`server/validate.go`'s `validateGetLeavesByRangeRequest` only checks
`Count > 0`.
## Reproduction
```go
count := int64(9223372036854775807) // attacker-controlled Count field
ret := make([]*trillian.LogLeaf, 0, count)
// panics: runtime error: makeslice: cap out of range
```
I have a PR ready with:
- An unconditional `maxGetLeavesByRangeCount` cap (`1<<16`, matching the
TODO's own suggested value) applied regardless of tree type, in all three
SQL backends plus the in-memory backend for interface parity.
- Two regression tests that panic/fail against unpatched code and pass
against patched code: a storage-layer repro needing no external DB
(`storage/memory/log_storage_dos_test.go`), and a full-chain PoC driving
the real `admin.Server` + `TrillianLogRPCServer` + `TrillianInterceptor`
with an unauthenticated `context.Background()`
(`security/poc/dos_chain_test.go`).
## Disclosure note
Reported first to the Google OSS VRP (g.co/vulnz). Confirmed as a real
finding; not eligible for a monetary reward under OSS VRP tiering for
DoS-class issues in this project's tier, and I was invited to file directly
here.
Contributor guide
Research direction
Start with getLeavesByRangeInternal in storage/{mysql,postgresql,crdb}/log_storage.go and the corresponding in-memory implementation, then review server/validate.go. Run storage/memory/log_storage_dos_test.go and security/poc/dos_chain_test.go; done means the large Count case no longer crashes and the regression tests pass across the storage and full request paths.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, mysql, postgresql
- Domain
- backend, databases, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 25/100