CVE-2026-16391
Overview
Background
- `EncryptingOutputStream`
- Gecko output stream that encrypts buffered data into fixed-size
EncryptedBlocks before writing to a base stream, used to encrypt IndexedDB files at rest. - `EncryptedBlock`
- container holding an encrypted cipher prefix plus a payload, whose plaintext must be padded up to the cipher’s
BasicBlockSizeboundary. - `BasicBlockSize`
- the cipher’s block granularity that any plaintext length must be rounded up to before calling
mCipherStrategy.Cipher. - `mNextByte`
- the count of actual plaintext bytes currently buffered in
mBufferawaiting a flush, i.e. the real payload length.
Root Cause Analysis
In EncryptingOutputStream<CipherStrategy>::FlushToBaseStream, the plaintext in mBuffer was encrypted over a span rounded up from mNextByte to the next BasicBlockSize multiple, but only the first mNextByte bytes of mBuffer had been written by the caller. The bytes in the padding region between mNextByte and the rounded-up length were never zeroed, so uninitialized or stale buffer contents were fed into mCipherStrategy.Cipher and persisted as ciphertext in the on-disk block. Additionally, the random-fill of the unused payload started at payload.From(mNextByte) instead of the rounded boundary, so it did not cover this alignment gap in a way that neutralized the leak.
The fix computes roundedNextByte once, shifts the random-fill to payload.From(roundedNextByte), and adds std::fill to zero mBuffer between mNextByte and roundedNextByte before encryption, guaranteeing the padding is deterministic zeros. Because the cipher now consumes only zeroed padding, no residual memory reaches the encrypted output. The invariant restored is that every byte encrypted into a block must be explicitly initialized, never leftover buffer memory.
mBuffer without initializing the bytes past mNextByte, leaking stale memory into the stored ciphertext; the fix zero-fills that padding gap (and aligns the random-fill offset) so only defined data is ever encrypted.Attack Path
- Populate stale memory
An attacker causes the victim to store IndexedDB records so that
mBufferin the encrypting stream retains sensitive plaintext from a prior, larger write. - Trigger a short flush
A subsequent smaller write ends with
mNextBytenot aligned toBasicBlockSize, leaving prior bytes in the padding region betweenmNextByteand the rounded-up length. - Encrypt uninitialized padding
FlushToBaseStreamencrypts the full rounded span, baking the stalemBufferbytes into the persistedEncryptedBlock. - Recover the database file The attacker who can read the encrypted IndexedDB file on disk obtains ciphertext whose padding encodes leftover in-process data.
- Infer disclosed bytes Analysis of the padding ciphertext reveals residual buffer contents that should never have left the process.
Impact Assessment
mBuffer across writes, making it a moderate-severity leak rather than a direct memory-corruption primitive.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifdom/quota/EncryptingOutputStream_impl.h |
modified |
Files Changed
dom/quota/EncryptedBlock.hdom/quota/EncryptingOutputStream_impl.h
Audit Directions
- Block-alignment paddingAudit every cipher call that rounds a plaintext length up to
BasicBlockSizeand confirm the source buffer is explicitly initialized across the entire rounded span, not just up to the logical length. - Fill-offset consistencyCheck that any
payload.From(offset)orSpanused for random-fill versus encryption uses the same aligned offset, so no gap is left encrypting undefined data. - Persisted uninitialized memoryReview serialization and at-rest encryption paths (
dom/quota, storage backends) for spans derived from lengths that exceed the number of bytes actually written into the backing buffer.
Patch
diff --git a/dom/quota/EncryptedBlock.h b/dom/quota/EncryptedBlock.h
index 6c4ced7d6e2..339e614dceb 100644
--- a/dom/quota/EncryptedBlock.h
+++ b/dom/quota/EncryptedBlock.h
@@ -44,6 +44,10 @@ class EncryptedBlock {
SetActualPayloadLength(MaxPayloadLength());
}
+ static constexpr size_t RoundedUpToBasicBlockSize(const size_t aValue) {
+ return (aValue + BasicBlockSize - 1) / BasicBlockSize * BasicBlockSize;
+ }
+
size_t MaxPayloadLength() const {
return mData.Length() - CipherPrefixLength - CipherPrefixOffset();
}
@@ -85,10 +89,6 @@ class EncryptedBlock {
return RoundedUpToBasicBlockSize(sizeof(uint16_t));
}
- static constexpr size_t RoundedUpToBasicBlockSize(const size_t aValue) {
- return (aValue + BasicBlockSize - 1) / BasicBlockSize * BasicBlockSize;
- }
-
nsTArray<uint8_t> mData;
};
diff --git a/dom/quota/EncryptingOutputStream_impl.h b/dom/quota/EncryptingOutputStream_impl.h
index e539c83304d..775e0a6a213 100644
--- a/dom/quota/EncryptingOutputStream_impl.h
+++ b/dom/quota/EncryptingOutputStream_impl.h
@@ -207,6 +207,9 @@ nsresult EncryptingOutputStream<CipherStrategy>::FlushToBaseStream() {
return NS_OK;
}
+ const size_t roundedNextByte =
+ mEncryptedBlock->RoundedUpToBasicBlockSize(mNextByte);
+
if (mNextByte < mEncryptedBlock->MaxPayloadLength()) {
if (!mRandomGenerator) {
mRandomGenerator =
@@ -218,13 +221,16 @@ nsresult EncryptingOutputStream<CipherStrategy>::FlushToBaseStream() {
const auto payload = mEncryptedBlock->MutablePayload();
- const auto unusedPayload = payload.From(mNextByte);
+ const auto unusedPayload = payload.From(roundedNextByte);
nsresult rv = mRandomGenerator->GenerateRandomBytesInto(
unusedPayload.Elements(), unusedPayload.Length());
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
+
+ std::fill(mBuffer.begin() + mNextByte, mBuffer.begin() + roundedNextByte,
+ 0);
}
// XXX The compressing stream implementation this was based on wrote a stream
@@ -241,13 +247,10 @@ nsresult EncryptingOutputStream<CipherStrategy>::FlushToBaseStream() {
// Encrypt the data to our internal encrypted buffer.
// XXX Do we need to know the actual encrypted size?
- nsresult rv = mCipherStrategy.Cipher(
- mEncryptedBlock->MutableCipherPrefix(),
- mozilla::Span(mBuffer.Elements(),
- ((mNextByte + (CipherStrategy::BasicBlockSize - 1)) /
- CipherStrategy::BasicBlockSize) *
- CipherStrategy::BasicBlockSize),
- mEncryptedBlock->MutablePayload());
+ nsresult rv =
+ mCipherStrategy.Cipher(mEncryptedBlock->MutableCipherPrefix(),
+ mozilla::Span(mBuffer.Elements(), roundedNextByte),
+ mEncryptedBlock->MutablePayload());
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}