Skip to content

Commit c31cab2

Browse files
committed
fix(utxo): a corrupt record could panic the amount decoder
`decompress_amount` ended in a `while` loop multiplying by ten up to nine times, with no bound on its input. `read_varint` hands it whatever a record contains, and `validate_encoded` runs it over every output of every record loaded from a snapshot, so a file on disk could reach it with an arbitrary `u64`. `decompress_amount(u64::MAX)` is 2.05e22 — a panic in a debug build, a silent wrap in a release one. Reproduced before fixing: `an_absurd_compressed_amount_is_rejected_rather_than_overflowing` failed with `attempt to multiply with overflow` from `compress.rs:193`. The function now returns `Option` and requires the decompressed value back inside the compressible domain. That also closes a canonicality hole that was open until now: the compact form may encode only amounts the escape refuses, and the escape refuses exactly the amounts the compact form covers, so every amount has one spelling and no other. `decompress_accepts_exactly_the_encoder_image` states it as a property over every `u64` — whatever the decoder accepts must round-trip back to the same compressed value through the encoder, which pins the accepted set to the encoder's image rather than merely checking that nothing panics. Found while investigating the fixed ~12 ns `find_output` cost, which is what the same loop was suspected of. Replacing it with a power-of-ten lookup does help, but only 12.5 ns -> 11.3 ns (1.1x): the remaining fixed cost is the directory read and building the returned view, not the arithmetic. Quoted that way in the doc rather than as a speed win, and the crossover table is re-measured. The `miss` arms are unchanged at every record size, which is the control this wants: that path never decompresses an amount, so a change to the amount transform must not move it.
1 parent 7d3c948 commit c31cab2

4 files changed

Lines changed: 131 additions & 25 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 4e2ac50ef64e8005fb57c4087442a76f3afce04d99bd88960085967f87b78607 # shrinks to value = 2049638230412201671
8+
cc b5d65560bbc086aba36aa4e4932cc62072e7c017b83c25612b4ade02a28ad9a7 # shrinks to a = 0, b = 2049672486957746581

crates/utxo/src/compress.rs

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -164,36 +164,59 @@ thread_local! {
164164
pub(crate) static DECOMPRESS_CALLS: core::cell::Cell<usize> = const { core::cell::Cell::new(0) };
165165
}
166166

167-
/// Inverse of [`compress_amount`].
167+
/// The powers of ten the transform can factor out, indexed by exponent.
168+
///
169+
/// Replaces a `while` loop of up to nine dependent multiplies. Decoding an
170+
/// amount is on the record read path, and that loop was most of its fixed cost.
171+
const POW10: [u64; 10] = [
172+
1,
173+
10,
174+
100,
175+
1_000,
176+
10_000,
177+
100_000,
178+
1_000_000,
179+
10_000_000,
180+
100_000_000,
181+
1_000_000_000,
182+
];
183+
184+
/// Inverse of [`compress_amount`], or `None` when `compressed` is not something
185+
/// [`compress_amount`] could have produced.
186+
///
187+
/// The rejection is not defensive tidiness. `read_varint` will hand this any
188+
/// `u64` a corrupt or hostile record contains, and the transform multiplies by
189+
/// up to 10^9: `decompress_amount(u64::MAX)` is 2.05e22, which **panics in a
190+
/// debug build** and wraps silently in a release one. `validate_encoded`
191+
/// decodes every output of every record loaded from a snapshot, so that path is
192+
/// reachable from a file on disk.
193+
///
194+
/// Requiring the result back inside the compressible domain also completes the
195+
/// canonicality rule: the compact form may encode only amounts the escape
196+
/// refuses, and the escape refuses exactly the amounts the compact form
197+
/// covers. Together they leave each amount exactly one spelling.
168198
#[inline]
169-
pub(crate) fn decompress_amount(compressed: u64) -> u64 {
199+
pub(crate) fn decompress_amount(compressed: u64) -> Option<u64> {
170200
#[cfg(test)]
171201
DECOMPRESS_CALLS.with(|calls| calls.set(calls.get() + 1));
172-
decompress_amount_inner(compressed)
173-
}
174202

175-
#[inline]
176-
const fn decompress_amount_inner(compressed: u64) -> u64 {
177203
if compressed == 0 {
178-
return 0;
204+
return Some(0);
179205
}
180206
let x = compressed - 1;
181207
let exponent = x % 10;
182208
let mut n = x / 10;
183209
if exponent < 9 {
184210
let last_digit = n % 9;
185211
n /= 9;
186-
n = n * 10 + last_digit + 1;
212+
n = n.checked_mul(10)?.checked_add(last_digit + 1)?;
187213
} else {
188-
n += 1;
214+
n = n.checked_add(1)?;
189215
}
190-
let mut result = n;
191-
let mut remaining = exponent;
192-
while remaining > 0 {
193-
result *= 10;
194-
remaining -= 1;
195-
}
196-
result
216+
// `exponent` is `x % 10`, so the lookup is in range by construction.
217+
let scale = POW10.get(usize::try_from(exponent).ok()?)?;
218+
let value = n.checked_mul(*scale)?;
219+
(value <= MAX_COMPRESSIBLE_AMOUNT).then_some(value)
197220
}
198221

199222
#[cfg(test)]
@@ -324,7 +347,7 @@ mod tests {
324347
let compressed = compress_amount(value).expect("within the money supply");
325348
assert_eq!(
326349
decompress_amount(compressed),
327-
value,
350+
Some(value),
328351
"amount round trip failed for {value}"
329352
);
330353
}
@@ -362,7 +385,23 @@ mod tests {
362385
#[test]
363386
fn compressed_amounts_round_trip(value in 0..=MAX_COMPRESSIBLE_AMOUNT) {
364387
let compressed = compress_amount(value).expect("in range");
365-
prop_assert_eq!(decompress_amount(compressed), value);
388+
prop_assert_eq!(decompress_amount(compressed), Some(value));
389+
}
390+
391+
/// No `u64` may panic the decoder, and every value it accepts must be
392+
/// one the encoder could have produced.
393+
///
394+
/// `read_varint` hands this whatever a corrupt or hostile record
395+
/// contains, and `validate_encoded` runs it over every output of every
396+
/// record loaded from a snapshot. The second half is the canonicality
397+
/// rule: if some compressed value outside the encoder's image were
398+
/// accepted, one amount would have two spellings.
399+
#[test]
400+
fn decompress_accepts_exactly_the_encoder_image(compressed in any::<u64>()) {
401+
if let Some(value) = decompress_amount(compressed) {
402+
prop_assert!(value <= MAX_COMPRESSIBLE_AMOUNT);
403+
prop_assert_eq!(compress_amount(value).ok(), Some(compressed));
404+
}
366405
}
367406

368407
/// The compression must be injective over the range it will ever see,

crates/utxo/src/record.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1472,7 +1472,10 @@ fn decode_output_at<'a>(
14721472
}
14731473
(value, end)
14741474
} else {
1475-
(crate::compress::decompress_amount(amount), cursor)
1475+
(
1476+
crate::compress::decompress_amount(amount).ok_or(UtxoError::CorruptRecord)?,
1477+
cursor,
1478+
)
14761479
};
14771480

14781481
let (packed, cursor) = crate::compress::read_varint(body, cursor)?;
@@ -2013,6 +2016,31 @@ mod tests {
20132016
Ok(())
20142017
}
20152018

2019+
/// A corrupt record must not be able to panic the decoder.
2020+
#[test]
2021+
fn an_absurd_compressed_amount_is_rejected_rather_than_overflowing() -> Result<(), UtxoError> {
2022+
// `varint(u64::MAX - 1)`: ten bytes, and not the escape sentinel, so it
2023+
// reaches the amount transform.
2024+
let mut payload = vec![0xFE_u8];
2025+
payload.extend_from_slice(&[0xFF; 8]);
2026+
payload.push(0x01);
2027+
payload.extend_from_slice(&[0x02, 0x51, 0xAC]);
2028+
2029+
let mut bytes = Hash256::default().to_le_bytes().to_vec();
2030+
bytes.extend_from_slice(&1_u32.to_le_bytes());
2031+
bytes.push(1);
2032+
bytes.push(0x11);
2033+
bytes.push(0x00);
2034+
bytes.push(u8::try_from(payload.len()).unwrap_or(0));
2035+
bytes.extend_from_slice(&payload);
2036+
2037+
assert!(matches!(
2038+
UtxoRecord::from_encoded(ThinRecordBuf::from_slice(&bytes)?),
2039+
Err(UtxoError::CorruptRecord)
2040+
));
2041+
Ok(())
2042+
}
2043+
20162044
/// v5 has no invalid bool byte — `coinbase` is one bit of a varint, so
20172045
/// every value is meaningful. What it has instead is several ways to spell
20182046
/// one output, and all of them must be refused: `UtxoRecord` compares by

docs/benchmarks/utxo-memory.md

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,18 +151,24 @@ of about 3 ns on a typical record and a lookup *win* on a fat one.**
151151
| outputs | `miss` | | | `hit_last` | | |
152152
|---|---:|---:|---:|---:|---:|---:|
153153
| | v4 | v5 | ratio | v4 | v5 | ratio |
154-
| 1 | 1.7 ns | 3.6 ns | 0.48x | 2.1 ns | 12.5 ns | 0.16x |
155-
| **3.626 (measured mainnet average)** | ~4 ns | ~7 ns | **~0.57x** | 3.7 ns | 18.4 ns | 0.20x |
156-
| 16 | 16.6 ns | 20.6 ns | 0.80x | 16.8 ns | 40.6 ns | 0.41x |
157-
| 64 | 109.3 ns | 79.5 ns | **1.37x** | 126.3 ns | 136.6 ns | 0.92x |
158-
| 256 | 471.8 ns | 298.7 ns | **1.58x** | 481.6 ns | 499.7 ns | 0.96x |
154+
| 1 | 2.6 ns | 7.6 ns | 0.35x | 2.5 ns | 15.6 ns | 0.16x |
155+
| **3.626 (measured mainnet average)** | 3.9 ns | 6.9 ns | 0.57x | 3.6 ns | 17.1 ns | 0.21x |
156+
| 16 | 16.6 ns | 20.7 ns | 0.80x | 17.1 ns | 39.7 ns | 0.43x |
157+
| 64 | 108.4 ns | 78.7 ns | 1.38x | 126.8 ns | 135.4 ns | 0.94x |
158+
| 256 | 462.1 ns | 294.1 ns | 1.57x | 476.7 ns | 492.5 ns | 0.97x |
159159

160-
v5 pays a fixed ~10 ns to read the directory header and decode the matched
160+
v5 pays a fixed ~11 ns to read the directory header and decode the matched
161161
payload, and then scans at a fraction of v4's per-output cost. Below roughly 64
162162
outputs the fixed cost dominates; above it the scan does. `hit_first` never
163163
crosses, because v4's best case is a single constant-offset read the optimizer
164164
reduces to almost nothing.
165165

166+
Replacing the amount transform's `while` loop of up to nine dependent multiplies
167+
with a power-of-ten lookup took that fixed cost from 12.5 ns to 11.3 ns — 1.1x,
168+
real but small. It was worth doing for a different reason (see below); the
169+
remaining fixed cost is the directory read and building the returned view, not
170+
the arithmetic.
171+
166172
**In absolute terms the loss is 3 ns per lookup at the mainnet average.** At
167173
~4,000 spent inputs per block that is 12 µs against a 50 ms commit budget —
168174
0.02% — bought with 21.7% of the record payload. The win concentrates on batch
@@ -233,6 +239,31 @@ The directories fix exactly that: a lookup scans one dense fixed-width array and
233239
sums a second, touching ~2 bytes per output instead of ~35. It is why `get_miss`
234240
ends up **2.3x faster than v4**, not merely level with it.
235241

242+
### A corrupt record could panic the decoder
243+
244+
Found while looking at why `find_output` has a fixed cost, and worth more than
245+
the answer to that question.
246+
247+
`decompress_amount` finished with a `while` loop multiplying by ten up to nine
248+
times. `read_varint` hands it whatever a record contains, and
249+
`validate_encoded` runs it over every output of every record loaded from a
250+
snapshot — so a file on disk could reach it with an arbitrary `u64`.
251+
`decompress_amount(u64::MAX)` is 2.05e22: **a panic in a debug build, a silent
252+
wrap in a release one.** Reproduced before fixing, as
253+
`an_absurd_compressed_amount_is_rejected_rather_than_overflowing`, which failed
254+
with `attempt to multiply with overflow`.
255+
256+
The fix returns `Option` and requires the decompressed value back inside the
257+
compressible domain, which also closes a canonicality hole that was open until
258+
now: the compact form may encode only amounts the escape refuses, and the escape
259+
refuses exactly the amounts the compact form covers, so each amount has one
260+
spelling and no other.
261+
262+
`decompress_accepts_exactly_the_encoder_image` states that as a property over
263+
every `u64`: whatever the decoder accepts must round-trip back to the same
264+
compressed value through the encoder. It does not merely check for absence of
265+
panics — it pins the accepted set to the encoder's image exactly.
266+
236267
### What it costs
237268

238269
Encoding is 1.6-2.4x slower, because the directory widths are a property of the

0 commit comments

Comments
 (0)