← WebKit Silent-Fix Report — 2026-W23

917854a9c245b87b333e23ed4b195505d574a333  [IndexedDB] Use-After-Free caused by use of `-0.0` for HashMap Key

severity high class UAF confidence 0.90 WebCore IndexedDB exploitable-grade
Sihui Liu Wed Jun 3 07:59:18 2026 -0700 full: 917854a9c245b87b333e23ed4b195505d574a333 view on GitHub ↗
Primitive: Use-after-free from using -0.0 as a HashMap key in IndexedDB unique index
Triage note: Message and test (index-unique-negative-zero) show -0 and +0 must hash equal for a unique index; treating -0.0 distinctly corrupted HashMap key handling leading to a UAF. Web-reachable from IDBObjectStore.put with a -0 indexed value.
Contents

The bug at a glance

The primary bug is reachable from ordinary web content: a page calls IDBObjectStore.put with an indexed value of -0 into a unique index, which lives in the network/storage process. The hash/equality inconsistency lets a unique index’s IndexValueStore HashMap hold two entries that compare equal, so removing one can destroy the other and leave a cursor referencing freed memory — a use-after-free in the network process. High/CVSS 8.1 is appropriate: no user interaction, cross-process memory-corruption surface, though driving the freed entry to a controlled reuse is non-trivial. The patch also hardens two adjacent issues (cursor invalidation on abort, and an unvalidated IPC message).

A single denormalized floating-point bit turns IndexedDB’s key HashMap against itself. IDBKeyData for Number/Date keys hashes the raw double bits, so -0.0 and +0.0 hash to different buckets and can occupy two distinct HashMap entries — yet IDBKeyData::operator== uses IEEE-754 equality, where -0.0 == +0.0 is true. That contradiction between hash and equality is exactly the invariant a HashMap relies on, and violating it corrupts the map: removing the entry for +0.0 can match and destroy the entry for -0.0 instead, stranding a cursor on the freed record. The fix is disarmingly small — add 0.0 before hashing to normalize -0.0 to +0.0 — plus two defense-in-depth hardenings for cursor invalidation on abort and IPC validation of the version-change-finished message.

Root cause

IndexValueStore backs a unique IndexedDB index with a HashMap keyed by IDBKeyData. For Number and Date key types the underlying value is a double, and IDBKeyData’s Hasher specialization feeds the raw bit pattern of that double into the hash. IEEE-754 gives -0.0 and +0.0 different bit patterns, so they hash to different values and the HashMap treats them as candidates for separate buckets/entries. But IDBKeyData::operator== compares numerically, so it reports -0.0 == +0.0 as equal, matching the IndexedDB key comparison algorithm.

A HashMap’s correctness depends on the contract that equal keys hash equal. Here they do not, so the map can be driven into an inconsistent state: two entries that are equal-by-operator== coexist, and a subsequent lookup/removal keyed on one value can, depending on probe order, match and destroy the entry belonging to the other value. In the storage process this corrupts the unique index’s record set. The new test walks the exact scenario: put({v: -0}) creates the -0 entry, put({v: 0}) must be rejected with a ConstraintError (the values are equal for uniqueness), a count() must return 1, and a cursor over the index must yield exactly one record. On the vulnerable code the mismatched hash/equality leaves the map holding a stale/duplicated entry, and a cursor can end up referencing an index record that was destroyed when the ‘other’ entry was removed — a use-after-free.

The core fix normalizes the sign of zero before hashing. In IDBKeyData.h’s add(Hasher&, const IDBKeyData&), the Number case becomes add(hasher, keyData.number() + 0.0) and the Date case add(hasher, keyData.date() + 0.0). Adding +0.0 maps -0.0 to +0.0 (while leaving all other doubles unchanged and NaN still NaN), so equal keys once again hash equally and the HashMap invariant holds; -0 and +0 now collapse to a single index entry.

The patch bundles two further hardenings. In MemoryIndex::transactionAborted, a call to notifyCursorsOfAllRecordsChanged() is added before the rollback replays and removeIndexRecord destroys records, so cursors are invalidated up-front instead of being left pointing at index records freed during the abort. And NetworkStorageManager::didFinishHandlingVersionChangeTransaction now MESSAGE_CHECKs databaseConnection->checkedDatabase()->isVersionChangeTransactionFinishingOrFinished(transactionIdentifier) before acting: an uncompromised web content process never sends this IPC while the version-change transaction is still in progress, and the handler resets state such as UniqueIDBDatabase::m_versionChangeTransaction, so a malicious/early message could otherwise desynchronize network-process state. UniqueIDBDatabaseTransaction now tracks an m_isFinishingOrFinished flag set in abort(), abortWithoutCallback(), and commit(), exposed via isFinishingOrFinished(), and UniqueIDBDatabase gains isVersionChangeTransactionFinishingOrFinished plus a checkedDatabase() accessor on the connection to support the message check.

Key code

IDBKeyData.h — normalize negative zero before hashing Number/Date keys

     case IndexedDB::KeyType::Number:
-        add(hasher, keyData.number());
+        // Normalize negative 0.
+        add(hasher, keyData.number() + 0.0);
         break;
     case IndexedDB::KeyType::Date:
-        add(hasher, keyData.date());
+        // Normalize negative 0.
+        add(hasher, keyData.date() + 0.0);
         break;

Patch walkthrough

  • Source/WebCore/Modules/indexeddb/IDBKeyData.h — The root-cause fix. In the free function add(Hasher&, const IDBKeyData&), the Number and Date cases now hash keyData.number() + 0.0 and keyData.date() + 0.0 respectively. Adding positive zero canonicalizes -0.0 to +0.0 so that keys equal under IDBKeyData::operator== also hash equally, restoring the HashMap invariant and collapsing -0/+0 into one unique-index entry.
  • Source/WebCore/Modules/indexeddb/server/MemoryIndex.cpp — In transactionAborted, notifyCursorsOfAllRecordsChanged() is now called before the modified records are rolled back and removeIndexRecord destroys them. This invalidates any live cursors up front so they cannot continue to reference index records freed during the abort — closing a separate cursor-dangling hazard on rollback.
  • Source/WebCore/Modules/indexeddb/server/UniqueIDBDatabaseTransaction.cpp — abort(), abortWithoutCallback(), and commit() each now call setIsFinishingOrFinished() at entry, recording that the transaction has begun finishing. This state is what the new IPC validation consults to reject a DidFinishHandlingVersionChangeTransaction message that arrives while the version-change transaction is still in progress.
  • Source/WebCore/Modules/indexeddb/server/UniqueIDBDatabaseTransaction.h — Adds the bool m_isFinishingOrFinished { false } member, the public getter isFinishingOrFinished(), and private setter setIsFinishingOrFinished() used by the abort/commit paths.
  • Source/WebCore/Modules/indexeddb/server/UniqueIDBDatabase.cpp — Implements isVersionChangeTransactionFinishingOrFinished(transactionIdentifier): returns true when there is no matching in-progress version-change transaction, otherwise delegates to the transaction’s isFinishingOrFinished(). This is the predicate the network process checks before honoring the finish message.
  • Source/WebCore/Modules/indexeddb/server/UniqueIDBDatabase.h — Declares the WEBCORE_EXPORT isVersionChangeTransactionFinishingOrFinished accessor used by NetworkStorageManager.
  • Source/WebCore/Modules/indexeddb/server/UniqueIDBDatabaseConnection.h / .cpp — Adds checkedDatabase() returning a CheckedPtr<UniqueIDBDatabase>, giving the IPC handler a null-checked path to the database object for the new message validation.
  • Source/WebKit/NetworkProcess/storage/NetworkStorageManager.cpp — didFinishHandlingVersionChangeTransaction now wraps the handling in a MESSAGE_CHECK on isVersionChangeTransactionFinishingOrFinished(transactionIdentifier). A misbehaving content process that sends this IPC before the version-change transaction is committed/aborted is rejected rather than being allowed to reset m_versionChangeTransaction and desynchronize state.
  • LayoutTests/storage/indexeddb/resources/index-unique-negative-zero.js (+ html/expected) — New regression test: put({v:-0},‘first’) then put({v:0},‘second’) must fail with ConstraintError, count() must be 1, and an index cursor must yield exactly one record — directly asserting that -0 and +0 collapse to one unique-index key and that no dangling cursor survives.

Background

IDBKeyData and IndexValueStore — IDBKeyData is IndexedDB’s internal key representation; a unique index’s IndexValueStore holds a HashMap<IDBKeyData, …> of records. Number/Date keys are doubles, hashed by raw bits but compared numerically by operator==.

HashMap hash/equality contract — A HashMap requires that keys which compare equal produce equal hashes. Violating it (as -0.0 vs +0.0 did) lets equal keys land in different buckets, so lookups and removals can target the wrong entry and corrupt the table.

IEEE-754 signed zero — -0.0 and +0.0 are numerically equal but have distinct bit patterns. x + 0.0 yields +0.0 for x == -0.0 and is identity otherwise, a standard trick to canonicalize the sign of zero before bitwise hashing.

Version-change transaction IPC — DidFinishHandlingVersionChangeTransaction is a content->network IPC whose handler resets UniqueIDBDatabase::m_versionChangeTransaction. The network process must not trust its timing, hence the added MESSAGE_CHECK against transaction finishing state.

Vulnerability window

  1. Latent inconsistency — IDBKeyData hashes Number/Date doubles by raw bits while operator== compares numerically, so -0.0 and +0.0 hash differently but compare equal in a unique index’s HashMap.
  2. Trigger — A page puts a -0 (and separately +0) indexed value into a unique index; the map ends up with equal-but-distinct entries, and a removal can destroy the wrong entry, leaving a cursor on a freed index record.
  3. Discovery — Tracked as rdar://172834266; the index-unique-negative-zero test captures the count/cursor divergence. Originally landed on the safari-7624.2.5.110 branch (rdar://176061219).
  4. Fix — Normalize -0.0 to +0.0 before hashing (IDBKeyData.h), invalidate cursors before abort rollback (MemoryIndex), and MESSAGE_CHECK the version-change-finished IPC (NetworkStorageManager) — 314464@main.

Proof of concept

Adapted from the committed test resources/index-unique-negative-zero.js. It demonstrates the correctness symptom that the freed-entry UAF rides on: on a fixed build the second put must fail with ConstraintError and the index must contain exactly one record. On the vulnerable build the -0/+0 hash mismatch lets the map diverge (count/cursor inconsistency) and a cursor can reference a destroyed entry. It is a reachability/repro test, not a memory-control primitive.

const req = indexedDB.open(dbname);
req.onupgradeneeded = e => {
  const store = e.target.result.createObjectStore('store');
  store.createIndex('index', 'v', { unique: true });
};
req.onsuccess = e => {
  const db = e.target.result;
  const tx = db.transaction(['store'], 'readwrite');
  const store = tx.objectStore('store');
  store.put({ v: -0 }, 'first');            // creates the -0 index entry
  const bad = store.put({ v: 0 }, 'second'); // must violate unique constraint
  bad.onerror = ev => {
    // event.target.error.name === 'ConstraintError' on a correct build
    ev.preventDefault();
    const tx2 = db.transaction(['store'], 'readonly');
    tx2.objectStore('store').count().onsuccess =
      c => console.log('count', c.target.result); // must be 1
  };
};

Exploitation

  1. Corrupt the unique-index map — From script, put a -0 indexed value and a +0 indexed value into a unique index so the IndexValueStore HashMap holds two entries that operator== treats as equal — the state the hash/equality mismatch permits.
  2. Free the wrong entry — Trigger a removal/replacement keyed on one zero value; depending on probe order it can match and destroy the entry for the other value, leaving an open index cursor referencing the freed record — the use-after-free in the storage process.
  3. Reuse (theoretical) — Turning the dangling cursor into a controlled reuse requires grooming the storage-process heap to reoccupy the freed index record before the cursor dereferences it; the patch supplies no primitive and the layout is opaque to the page, so weaponization is non-trivial.

Detection & hunting

For defenders and SOC / detection engineers:

  • IndexedDB unique-index count/cursor divergence — A unique index that reports count() != cursor-walk length, or accepts both -0 and +0 as distinct keys, indicates the pre-fix hash/equality mismatch. The index-unique-negative-zero test is the canonical check.
  • ASan UAF in the network/storage process — Heap-use-after-free stacks passing through IndexValueStore/MemoryIndex removeIndexRecord or index cursor iteration during a put or transaction abort are the corruption signature.
  • Unexpected DidFinishHandlingVersionChangeTransaction IPC — A MESSAGE_CHECK failure (or, pre-fix, state resets) for that message while a version-change transaction is still in progress signals a misbehaving or compromised content process.

Audit directions

  • Double-keyed HashMaps hashing raw bits — Audit any Hasher that consumes raw double/float bits where the corresponding operator== is numeric; -0.0/+0.0 (and potentially NaN) can break the hash/equality contract. Normalize before hashing.
  • Cursor lifetime across index mutation — Review MemoryIndex/IndexValueStore paths that destroy records (abort rollback, removeIndexRecord, replace) to ensure notifyCursorsOfAllRecordsChanged (or equivalent invalidation) precedes any free reachable by a live cursor.
  • Unvalidated IDB IPC handlers in NetworkStorageManager — Enumerate content->network IDB messages whose handlers reset transaction/database state (like m_versionChangeTransaction) and confirm each MESSAGE_CHECKs the sender’s claimed transaction state before acting.

Before / after

Loading diff…