Medium firefox Info Disclosure 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionInformation disclosure in the Storage: IndexedDB component
ComponentDOM
Bug ClassInfo Disclosure
Tracker2044536
Fix commitde2f0aa3b7c9 (firefox) +15/-12
CISA KEVNot listed
CreditedTomoya Nakanishi
Disclosed2026-07-21

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 BasicBlockSize boundary.
`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 mBuffer awaiting 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.

Key insight
The single mistake was encrypting the block-alignment padding straight from 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

  1. Populate stale memory An attacker causes the victim to store IndexedDB records so that mBuffer in the encrypting stream retains sensitive plaintext from a prior, larger write.
  2. Trigger a short flush A subsequent smaller write ends with mNextByte not aligned to BasicBlockSize, leaving prior bytes in the padding region between mNextByte and the rounded-up length.
  3. Encrypt uninitialized padding FlushToBaseStream encrypts the full rounded span, baking the stale mBuffer bytes into the persisted EncryptedBlock.
  4. Recover the database file The attacker who can read the encrypted IndexedDB file on disk obtains ciphertext whose padding encodes leftover in-process data.
  5. Infer disclosed bytes Analysis of the padding ciphertext reveals residual buffer contents that should never have left the process.

Impact Assessment

An attacker gains information disclosure of uninitialized or stale in-process buffer memory that becomes persisted inside the encrypted IndexedDB storage files, within the content/parent process handling quota storage encryption. Exploitation requires the ability to inspect the encrypted database files on disk and depends on sensitive data lingering in mBuffer across writes, making it a moderate-severity leak rather than a direct memory-corruption primitive.

Changed Functions

FunctionChangeNotes
if
dom/quota/EncryptingOutputStream_impl.h
modified

Files Changed

  • dom/quota/EncryptedBlock.h
  • dom/quota/EncryptingOutputStream_impl.h

Audit Directions

  • Block-alignment padding
    Audit every cipher call that rounds a plaintext length up to BasicBlockSize and confirm the source buffer is explicitly initialized across the entire rounded span, not just up to the logical length.
  • Fill-offset consistency
    Check that any payload.From(offset) or Span used for random-fill versus encryption uses the same aligned offset, so no gap is left encrypting undefined data.
  • Persisted uninitialized memory
    Review 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.
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;
   }
Loading diff…