Vulnerabilities

DevTools IndexedDB Callback Use-After-Free (CVE-2026-15107)

CVE-2026-15107 lived in the gap between two clocks: a DevTools request began while its V8 inspector session was alive, yet IndexedDB returned the last row only after navigation had disposed that session, leaving the callback able to reach its agent, copy a stale native pointer, and ask freed inspector state to wrap the row.

Warm hand-drawn investigation room with two clocks over blue and red stains, a cart holding three geometric objects, and gloved hands examining a clear glass sphere in a black tray while a lone researcher stands in the adjoining archive.
In this article

Research basisChromium commit f507e6c6334f pins the defect to one handoff: after IndexedDB.requestData starts, a database cursor can keep returning across navigation; the InspectorIndexedDBAgent may remain reachable even though its borrowed V8 inspector session has ended. The parent, patch, regression, and Chrome release record together define what this route proves while preserving the distinctions among ordinary browsing, inspector participation, and the renderer sandbox.

SourceChrome Security / Chromium Gitiles and Gerrit / Blink IndexedDB inspector source and ASAN regression / Chrome DevTools Protocol / SOSEC source review

1 The final database row returned after navigation had already closed the room

1.1 Code and response map: a late cursor callback borrowed a retired inspector session

1.2 From command dispatch to a success event striking a disposed session

Picture an inspector asking a page for the contents of an object store. At the instant the question leaves DevTools, the page exists, its execution context is usable, and the native V8 inspector session can create remote objects. Nothing looks fragile. The danger arrives after that clean beginning, because IndexedDB.requestData does not carry the answer back on the same C++ stack. It starts work whose ending will be delivered by database events.

The protocol method accepts a security origin, storage key, or storage bucket, plus the database and object-store names. It can select an index, skip records, limit the page, and apply a key range. Those parameters make the command useful to the Application panel and to automation clients: the result is a page of entries containing a key, a primary key, and a value, each represented as a DevTools Runtime.RemoteObject.

InspectorIndexedDBAgent::requestData() first converts the protocol key range into Blink's IDBKeyRange. It resolves the execution context associated with the requested storage identity, rejects inconsistent input, and creates a reference-counted DataLoader. The loader receives the protocol callback and the pagination state, then begins opening the database. From this point, the original command handler has already handed responsibility to objects with longer and different lifetimes.

Database opening itself is asynchronous. The reusable loader infrastructure waits for an IDBDatabase, obtains a ScriptState, and only then calls DataLoader::Execute(). A slow storage backend, a busy renderer, a large value, or ordinary task scheduling can stretch that interval. No artificial race primitive is required. The operation naturally crosses turns of Blink's event loop while the inspected document remains free to navigate.

Once the database is available, Execute() creates a readonly transaction and finds the named object store. If an index was requested, it resolves that index; otherwise it reads the store directly. Both routes call openCursor() in the forward direction. The returned IDBRequest does not contain a row yet. It becomes the event source to which Blink attaches an OpenCursorCallback.

Every successful cursor event advances the same small state machine. The listener may skip an initial number of records, stop when the requested page is full, or append one more entry and wait for another event. It deliberately calls Continue() before injected-script work so the transaction does not finish too early. That ordering keeps the database side healthy, but it also means inspector-side conversion can happen well after the first request was accepted.

The first clock therefore belongs to IndexedDB: open, transact, deliver a cursor, continue, and eventually finish the page. The second belongs to the inspected target: navigate, replace its document, detach its debugging session, or close. CVE-2026-15107 appeared because the callback carried enough state to survive movement of the second clock, yet one native resource it intended to borrow did not publish that its time had ended.

A root-frame navigation is not merely another database event. It can reorganize the inspection target and dispose agents associated with the old session. The page's database task may already be queued, while the V8 inspector session used to describe values for DevTools is torn down as part of that transition. The two operations have no promise to complete in the order in which a human sees them on screen.

The IndexedDB agent did know how to release its indexeddb object group when the domain was disabled or the root frame committed a load. That cleanup concerns remote objects already created for the client. It did not, before the fix, give the borrowed session pointer a terminal state during generic agent disposal. A cleanup method and a lifetime signal are related, but they answer different questions.

The pending listener held a weak reference to InspectorIndexedDBAgent. Weakness prevented the event listener from forcing the agent to live forever, which is desirable. It did not mean that every resource reachable through an agent had the same lifetime. In the failing schedule, the agent object was still obtainable when the event ran, so the first guard in OpenCursorCallback::Invoke() succeeded.

Inside that reachable agent, v8_session_ was a raw_ptr annotated DanglingUntriaged. The address came from outside the agent and was not owned by it. When the external owner destroyed the session, the stored bits did not automatically become zero. A simple non-null test would still describe the old address as present even though the native object behind it was gone.

The callback also checked script_state_->ContextIsValid(). That guard remains important: an invalid V8 context must not be entered. Yet context validity and inspector-session lifetime are separate facts. The old code could observe a usable script context during the race window, then fetch the stale session address. Passing one lifetime check supplied no proof about the other object.

The next three calls were the memory-safety sink. wrapObject() converted the cursor key, primary key, and value into inspector-managed descriptions in the indexeddb object group, with preview generation enabled. If v8_session_ named freed storage, the first conversion entered released native state. The remaining two calls show why this was not a decorative pointer: it mediated the protocol objects returned to the client.

Chrome classified the issue as a medium-severity use-after-free in IndexedDB, reachable through crafted HTML and capable of code execution inside the renderer sandbox. The public record does not claim a sandbox escape or confirmed active exploitation. The source gives the more precise story: attacker-influenced page state and navigation met an active storage-inspection request, and a late success event arrived after the inspection room had been dismantled.

Hand-drawn pair of mechanical clocks: a warm database clock advances a row through open, cursor and callback, while a blue navigation clock folds away the inspector session before the row arrives.
Figure 1. The command began in a valid session, but IndexedDB and navigation completed on independent clocks; the late row needed a fresh check at the moment it crossed into inspector objects.

2 Four ownership systems met inside one callback, but their clocks never became one

2.1 The loader, listener, agent, and session each had a valid reason to live differently

The most useful way to read this defect is to put four cards on the desk. One says DataLoader, another OpenCursorCallback, a third InspectorIndexedDBAgent, and the last V8InspectorSession. The callback connects all four, but no single collector or smart pointer owns the set. Safety depends on the handoffs between their individual rules.

DataLoader is reference-counted. Database opening can finish after requestData() returns, so a stack lifetime would be too short. The loader owns the pending protocol callback until it constructs the cursor listener, together with the store name, optional index, key range, skip count, and page size. Its WeakPersistent agent link avoids keeping the inspector agent alive solely because storage work is pending.

OpenCursorCallback belongs to Blink's Oilpan world. MakeGarbageCollected creates it, the IndexedDB request registers it as a native event listener, and tracing covers its agent and script-state references. It owns the protocol callback after DataLoader transfers it and accumulates the protocol array. This is the object that can keep receiving success events as a cursor walks the store.

The listener's agent link is a WeakMember. If garbage collection makes the agent unavailable, agent_.Get() yields null and the callback returns a session-detached error. That check already existed in substance before the patch. It protected against dereferencing a collected agent; it said nothing about an agent that remained allocated after its logical Dispose() transition.

InspectorIndexedDBAgent is the protocol-facing coordinator. It knows whether the IndexedDB domain is enabled, resolves frames or worker scopes, launches loaders, and holds the borrowed V8 inspector session. An agent can be disposed as part of target teardown while its C++ object remains reachable long enough for pending event work to observe it. Logical service life and storage life are not synonyms.

V8InspectorSession follows an ownership system outside Oilpan. The agent receives a native pointer in its constructor and stores it as raw_ptr<..., DanglingUntriaged>. The agent does not delete the session. That borrowing relationship is normal, provided every user can tell when the lender has ended it. Before commit f507e6c6334f, disposal left no such observable marker.

Reference counting, tracing, weak reachability, and pointer instrumentation were therefore all present, yet they proved different facts. The loader could finish. The listener could be traced. The agent could still be found. The address could still be represented. None of those facts established that the separately owned native session accepted another call. The memory bug occupied precisely that missing sentence.

2.2 Reachable agent was mistaken for live borrowed resource

It is tempting to describe the flaw as “a weak pointer failure,” but the weak pointer did what Blink asked. It returned null when its target vanished and returned the agent when the target still existed. The mistake came one step later, when code treated a non-null agent as a certificate for the lifetime of a resource merely stored inside it.

The old session member even carried the DanglingUntriaged annotation. That name is a warning, not a lifetime policy. Chromium's raw_ptr machinery can improve diagnostics and make classes of dangling access harder to exploit on supported configurations, yet it neither owns the pointee nor automatically coordinates external teardown. A borrowed pointer still needs a protocol for revocation.

Agent disposal and agent destruction can be separated deliberately. Protocol agents often release domain state, detach from a session, or stop servicing work before the surrounding garbage-collected graph disappears. Pending callbacks may still need to settle their protocol result without touching resources that the disposal transition has retired. Deleting every callback synchronously is not always available or desirable.

That distinction suggests a compact invariant: after InspectorIndexedDBAgent::Dispose() publishes the session as unavailable, no later event may invoke a method through the old V8InspectorSession address. The event may still run. It may still discover a valid agent and script context. It may even need to finish the client request. Its permitted outcome changes from data conversion to a single defined error.

The best check belongs immediately before the borrowed resource is used. A check in requestData() proves only that the session existed when the command began. A second check while the database opens still precedes cursor delivery. The repair checks after cursor continuation and context validation, just before the key, primary key, and value cross into inspector-managed objects.

Publishing null also makes repeated cleanup comprehensible. DidCommitLoadForLocalFrame(), domain disable(), and generic disposal can all converge on one helper. Once the session is gone, the helper returns without attempting another object-group release. Before it is gone, cleanup uses the live session once. A visible terminal state turns a fragile temporal assumption into ordinary control flow.

The four cards on the desk now tell a coherent story. Reference counting lets database work finish, Oilpan lets event state exist safely, a weak member refuses to retain the agent, and a nullable borrowed pointer reports whether the native service is still callable. The fix did not merge their ownership models. It added the missing translation at the place where those models meet.

Hand-drawn four-card relay showing a reference-counted loader, an Oilpan event listener, a weakly reached agent, and an externally owned native inspector session, each with a different clock and connector.
Figure 2. Every ownership mechanism answered its own question; the missing rule was how a reachable agent reports that its separately owned inspector session has already retired.

3 The vulnerable route was a straight line once every asynchronous handoff was named

3.1 requestData became a readonly transaction and a chain of cursor events

The route begins with validation, not memory access. requestData() converts a protocol KeyRange only when one was supplied and rejects a range Blink cannot parse. It then calls ResolveExecutionContext() with the inspected frames or worker global scope and exactly one storage identity. A failure at either stage completes the protocol callback without constructing a loader.

On success, the agent creates DataLoader and calls its inherited Start() operation with the execution context, storage bucket, and database name. The generic database-opening machinery holds a scoped reference until open succeeds or fails. The call path must therefore continue beyond requestData(); the command method contains neither the cursor nor the eventual inspector call.

DataLoader::Execute() performs its own weak-agent check when the database arrives. If the agent can no longer be obtained, it reports that the DevTools session detached before completion. In the exploitable schedule, this check can pass because disposal does not require immediate object collection. The loader then advances into database work with a valid agent object but no new statement about the native session.

The loader asks TransactionForDatabase() for a readonly transaction tied to the requested store. It resolves the store and, when applicable, the named index. Each lookup has an explicit server-error path. These checks make malformed protocol input and disappearing schema ordinary failures. They are not involved in the use-after-free, which occurs only after the database operation has successfully produced cursor state.

Both the index and object-store routes call openCursor() with mojom::IDBCursorDirection::Next. The loader creates one OpenCursorCallback, moves the protocol callback into it, and registers the listener for success. At that point, the cursor listener owns the only normal route to complete IndexedDB.requestData. A late event cannot simply be ignored without considering the waiting client.

Invoke() checks the event type and the IDBAny result. An IDBValue result marks the end of iteration and completes the page with hasMore=false. Anything other than IDBCursorWithValue is rejected. A valid cursor then feeds the pagination logic. These branches narrow the path to a real row whose key and value are ready for protocol conversion.

Skip handling calls advance() once and clears the stored count. Page completion sends the accumulated array with hasMore=true. For a row that will be included, the listener calls Continue() before entering injected script, preserving the transaction for the next event. Only after these database responsibilities are settled does the code approach V8 inspector state.

3.2 Three wrapObject calls turned the stale address into a memory-safety fault

After continuing the cursor, the listener verifies script_state_->ContextIsValid(). If the context has gone away, it returns. A valid context allows ScriptState::Scope to enter V8. The fix did not weaken or move this guard. It inserted the missing session decision after it, preserving the established order for transaction progress and script-state checks.

The decisive line copies agent->v8_session() into a local pointer. Before the patch, control moved directly from that copy into V8 scope and object conversion. Since disposal never cleared the member, the local value could name storage already reclaimed by the external session owner. The address looked stable precisely because the old code had no revocation state to observe.

The listener builds a StringView for the constant object group indexeddb. Grouping lets DevTools release the remote objects created by storage inspection as a set. That lifecycle is why cleanup order matters later: the live session must be available to release its group, but no late callback may create new group members after disposal has begun.

A DataEntry is constructed from three inspector conversions. The cursor's key becomes one Runtime.RemoteObject, its primary key becomes another, and the stored value becomes a third. Each call requests preview generation. The operation therefore passes actual V8 values and a live context into a native service whose bookkeeping belongs to the debugging session.

Preview generation can inspect enough structure to describe a value to the client, but the public fix does not claim one universal freed field or one fixed exploitation layout. Allocator state, pointer hardening, value shape, and timing can change the visible failure. The source-proven property is narrower and sufficient: code invoked a method through a session address after that session could be destroyed.

The complete failing schedule is now short. A client starts requestData. Database opening and cursor work continue asynchronously. Navigation disposes the inspector agent's service relationship and destroys the external V8 session. The agent object remains reachable. A cursor success event passes the agent and context checks, copies the stale pointer, and enters wrapObject(). No single step needs malformed IndexedDB bytes.

This sequence also defines safe validation. Tests can prove that disposal publishes null, a late event produces the detached-session error, and no wrapObject() call follows. They do not need to steer heap reuse or preserve a crash. The most reliable evidence appears before memory corruption: one callback, one terminal session state, and zero conversions after that state.

Hand-drawn waterway carrying discs from an open file drawer through a stone gate and a seated operator, past a lantern and a lens, into four trays of key-shaped and geometric pieces, with a raised drawbridge behind them.
Figure 3. The database path was healthy all the way through cursor continuation; the missing decision sat immediately before three inspector conversions through a separately owned native session.

4 Disposal learned to leave a visible closed sign

4.1 Existing cleanup released object groups but never revoked the session address

The class declaration already reveals that InspectorIndexedDBAgent serves more than one environment. inspected_frames_ is null while inspecting workers, while worker_global_scope_ is null while inspecting frames. The same protocol agent therefore participates in teardown patterns shaped by documents and workers. Its native session pointer sits beside these Oilpan members but is governed elsewhere.

The constructor receives v8_inspector::V8InspectorSession* and assigns it directly to v8_session_. That pointer is later used both to wrap values and to release the IndexedDB object group. The old header did not document a post-disposal null state, and the class did not override Dispose(). In isolation, the member appeared valid for the agent's entire object lifetime.

There were two explicit cleanup sites. When the root local frame committed a load, DidCommitLoadForLocalFrame() called releaseObjectGroup(). When the protocol domain was disabled, disable() cleared the enabled flag and made the same call. Both sites assumed the session pointer was callable at that instant.

Releasing an object group ends the client-facing lifetime of remote objects already created under that name. It does not change the pointer stored by the agent, and it does not stop a queued cursor event from attempting to create another entry. The old methods tidied one collection of inspector objects without expressing that the service used to create them could itself be gone.

Generic agent disposal came through InspectorBaseAgent. With no override in the IndexedDB class, the transition could retire the external session relationship while leaving v8_session_ unchanged. Later code had no local signal that distinguished “session supplied at construction and still alive” from “session supplied earlier and since destroyed.”

This was the critical representational gap. The program had a real terminal event—agent disposal—but did not map it to a terminal value in the member used by callbacks. As a result, the callback's strongest cheap observation was misleading: the address was non-zero. A stale non-zero address is exactly the shape that turns ordinary asynchronous completion into use-after-free.

The commit message states the problem directly: InspectorIndexedDBAgent must not use v8_session_ after disposal. It also points to InspectorDOMAgent as an established pattern. The repair is therefore not a new lifetime framework. It applies a known inspector-agent convention to a module whose asynchronous cursor path had been missing it.

4.2 Release, null, delegate: the order carries the safety proof

Commit f507e6c6334f3791b4b0e808343ceb0e95832273 adds an IndexedDB-specific Dispose(). Its first line calls ReleaseObjectGroup(). Its second assigns v8_session_ = nullptr. Its third invokes InspectorBaseAgent<protocol::IndexedDB::Metainfo>::Dispose(). Those three lines are the core repair, and their order is intentional.

Object-group release comes first because the session is still live at that point in the agent's disposal procedure. Remote objects created for keys, primary keys, and values can be retired through the service that owns their inspector bookkeeping. Nulling first would remove the only pointer the helper can use and could strand group state. The patch preserves cleanup before publishing closure.

Nulling comes next because every later observer needs an unambiguous answer. Once the assignment executes, code calling v8_session() can see that no native inspector operation is permitted. The value does not attempt to describe why the session ended. Navigation, target detach, and other disposal causes all converge on the same callable-or-closed decision.

The new ReleaseObjectGroup() helper checks the pointer before calling releaseObjectGroup(). Root-frame commit and domain disable now use this helper as well. Multiple cleanup routes can therefore run without dereferencing a session already cleared by disposal. The helper centralizes one idempotent rule instead of scattering assumptions across lifecycle hooks.

OpenCursorCallback::Invoke() now copies the pointer and immediately checks it. Null completes the protocol operation with DevTools session detached during operation. and returns before entering ScriptState::Scope or building a DataEntry. The same text is also used when the weak agent is unavailable, so clients receive one consistent terminal explanation.

The fix does not attempt to cancel every IndexedDB request at the instant of navigation. Cancellation would need to coordinate database opening, transaction state, queued events, and protocol completion. Publishing session closure makes late delivery harmless: a callback may arrive, observe that its final service is unavailable, settle the request, and stop before native conversion.

The closed sign now appears at both ends of the relationship. Disposal writes it while the session owner is still in a controlled teardown path. The callback reads it at the last responsible moment. Between those operations, C++ object reachability and database scheduling may vary freely. Safety no longer depends on which event loop wins the race.

Hand-drawn three-step inspector shutdown: first remote object cards return to a live blue session, then a borrowed red cable is unplugged and marked null, then the base agent door closes; a late database row is redirected to a clear error tray.
Figure 4. Cleanup uses the live session one last time, null publishes the terminal state, and the late cursor result reads that state before any native object conversion.

5 The regression made a timing fault repeatable without teaching the heap to fail

5.1 Two rounds, sixty-four rows, and one immediate navigation widened the natural race

A useful lifetime regression does not need to manufacture corrupted memory. It needs to hold ordinary work open long enough for teardown to overtake it, then assert that the program survives and reports a sensible result. Chromium's new inspector-protocol test follows that rule. It amplifies valid IndexedDB work and uses a normal navigation to exercise the relationship that production code already had to manage.

Three constants define the workload: rounds = 2, valueCount = 64, and valueSize = 8192. Each record therefore carries an 8,192-character base payload plus its numeric suffix. Across sixty-four rows, the store contains enough value material to keep cursor conversion busy while remaining small, deterministic, and benign.

The decisive protocol call requests the full page: database name, object-store name, skipCount: 0, and pageSize: valueCount. The test deliberately does not await dp.IndexedDB.requestData(). That omission is not a lost assertion. It leaves the command's asynchronous completion in flight while the next protocol instruction changes the inspected document.

Navigation follows immediately to a data: document containing a body and an iframe. This choice replaces the old document and exercises inspection teardown without requiring an attacker-specific URL or malformed database. The iframe also gives target and context machinery real work during the transition. The regression stays within public browser behavior while increasing scheduling variety.

A 500-millisecond promise gives teardown and cursor delivery time to overlap. Fixed-duration waits are usually weak proofs on their own, but here they are paired with a large deterministic workload, two rounds, and an AddressSanitizer failure that the commit says reproduced every time before the repair. The wait is an amplifier around a real invariant, not a claim about one exact instruction schedule.

The expected output is intentionally plain: a description line, then “Round 0 completed without a crash” and “Round 1 completed without a crash.” No freed address, stack layout, or malicious payload is embedded in the fixture. Success means the browser lived through the disposal race twice. More detailed local instrumentation can additionally verify the null-and-error path during backport acceptance.

After both rounds, the test returns to the original origin and deletes the two databases. Cleanup accepts success, error, or blocked completion so stale test data does not pollute later runs. This final return also demonstrates that the protocol session and page remain usable after surviving the race. A regression that only avoids a crash but leaves DevTools broken would not be a complete repair.

5.2 Commit identity, four changed files, and ASAN behavior form one reviewable proof

The fix landed as f507e6c6334f3791b4b0e808343ceb0e95832273 on June 26, 2026. Steve Becker authored it, Chromium LUCI CQ committed it, and the mainline position is #1653288. Its direct parent is f51640cf965e2aea01e44a32ba6798dc170f32c1 at position #1653287, leaving downstream branches a precise before-and-after pair.

In inspector_indexed_db_agent.cc, the patch adds one shared detached-session string, the null check beside v8_session(), the Dispose() override implementation, and ReleaseObjectGroup(). It replaces direct object-group release at root-frame commit and domain disable with the helper. These edits connect shutdown state to every production use visible in the diff.

The header change adds the virtual override declaration, declares the private cleanup helper, and documents that v8_session_ is null after disposal. That comment is part of the interface contract for future maintainers. A downstream backport that copies only the callback guard but omits disposal nulling would always see a non-null stale value and preserve the defect.

The commit message says the test reproduces 100 percent on AddressSanitizer builds before the fix. ASAN identifies the invalid native access without requiring an inference from a generic renderer exit, but this does not mean production exploitation is equally deterministic. Sanitizer allocation behavior is designed for detection and changes timing and memory reuse.

Positive regression should be paired with normal-function controls. A stable page must still enumerate an object store, respect skip and page-size behavior, return keys and values, and release its object group when the domain is disabled. These controls show that the new terminal state closes only detached work and does not turn ordinary storage inspection into spurious failure.

Lifecycle coverage should extend beyond the upstream navigation case. Explicit target detach, page close, domain disable followed by disposal, worker termination, and rapid reattachment can all vary the order of agent and session events. Each case should assert no sanitizer report, no native conversion after null publication, and one protocol completion appropriate to the final state.

For an upstream-based product, ancestry of f507e6c6334f plus the exact regression is the cleanest source proof. A vendor may use an equivalent patch or a branch-specific hash, but it should map every production edit, the test semantics, and the compiled artifact back to this contract. A screenshot of a similar null check cannot prove the delivered engine runs the repaired sequence.

Hand-drawn laboratory with two identical rounds, sixty-four small database drawers carrying large folded payloads, an unawaited request cart, an immediate navigation lever, and a green sanitizer lamp after the session closes safely.
Figure 5. The public regression widens ordinary asynchronous work, navigates immediately, and asks ASAN to verify survival twice without encoding a malicious sample or heap recipe.

6 The page supplied the data, but an inspector client opened the dangerous door

6.1 Crafted HTML and active storage inspection meet at one narrow source path

The public CVE description summarizes CVE-2026-15107 as a use-after-free in Chrome's IndexedDB implementation before 150.0.7871.115. It says a remote attacker could use crafted HTML to execute code inside the sandbox and records Chromium severity as Medium. That statement establishes product impact. The landed source explains the operation through which page-controlled state reaches the stale session.

The vulnerable file is not the ordinary web-facing IndexedDB API implementation. It is Blink's InspectorIndexedDBAgent, which implements the DevTools Protocol IndexedDB domain. The dangerous conversion occurs while fulfilling IndexedDB.requestData. A page can own database contents and cause navigation, while an attached inspector client starts the storage enumeration that eventually wraps those values.

This division of roles matters for exposure analysis. Crafted page behavior supplies one side of the schedule; DevTools, an automation framework, a remote-debugging client, or another debugger consumer supplies the protocol request. Source evidence does not require the page itself to possess debugging privileges. It requires inspection activity to be present when attacker-influenced storage and navigation exercise the asynchronous route.

The Chrome DevTools Application panel is an obvious legitimate consumer. Protocol clients can also call the method directly to inventory databases, browse object stores, paginate records, or diagnose application state. Browser-testing frameworks, support consoles, crawler instrumentation, security scanners, and embedded developer tools may perform similar operations without a human visibly opening DevTools.

A browsing session with no inspector attached has a less direct route to this exact callback. That observation helps prioritize investigation, but it is not a reason to retain a vulnerable build. Debugging can be enabled later, a background automation owner may be unknown to endpoint inventory, and products can expose protocol capabilities through interfaces that do not resemble the desktop DevTools window.

Remote debugging deserves separate hygiene because control of such an endpoint already grants broad browser capabilities. Bind it to intended interfaces, place authentication and network policy around the service, and remove accidental public exposure. These measures reduce opportunity for many attacks. They do not repair a legitimate local inspector session that encounters the lifetime race.

Shared browser farms deserve early deployment. A single long-running worker can visit untrusted sites while a harness collects storage, console, network, or page state. Its process may survive many jobs, and the harness may issue IndexedDB commands only for selected workloads. The combination makes both reachability and stale-process risk easy to miss in host-level asset records.

The class can serve frames and workers, as its paired InspectedFrames and WorkerGlobalScope members show. The public regression demonstrates root-frame navigation, not every possible worker teardown sequence. Products should test the contexts they expose, while published claims should remain tied to the frame-navigation evidence and shared disposal contract visible in the patch.

No public source supplies a complete cross-process exploit chain, a sandbox escape, persistence, or confirmed in-the-wild use. Those unknowns should remain explicit. A source-proven use-after-free on a protocol path handling attacker-influenced values still justifies urgent browser updates, especially where storage inspection is automatic or debugging access is broadly reachable.

6.2 Product version, embedded Chromium, and the running inspector process are three separate facts

Chrome's July 8 desktop notice delivered Stable in 150.0.7871.114 or 150.0.7871.115 for Windows and macOS, and 150.0.7871.114 for Linux. The same bulletin lists the $2,000 reward, issue 503553615, and the April 17 report by zh1x1an1221 of Ant Group Tianqiong Security Lab. Preserve that platform detail when writing deployment rules.

The CVE record expresses the affected line generically as Chrome before 150.0.7871.115. That is useful for vulnerability databases but can mislead a package gate if applied without platform context: Linux's fixed build in the bulletin is .114, and Windows or macOS may receive either adjacent build from the release. Use the vendor's actual channel package, not one universal suffix comparison.

Chromium embedders require a source mapping. Electron applications, CEF shells, automation browsers, vendor support tools, kiosk runtimes, IDE webviews, and private desktop shells can carry their own Chromium snapshot. Commit f507e6c6334f, or a documented equivalent backport, identifies the repair more precisely than an outer product version that follows another calendar.

One host can contain several independent copies: system Chrome, Chrome for Testing, a framework-downloaded browser, an application-bundled engine, an old rollback directory, and a container image. Marking the host “patched” after updating one package erases the copy most likely to be used by automation. Inventory should attach CVE state to the executable and loaded Blink module.

Protocol reachability is also a runtime fact. Command-line switches, enterprise policy, an embedding API, port forwarding, test harness configuration, and extension permissions can all affect which client reaches a target. Record the effective debugging route in the real workload, including the process owner and service account. A default-off setting in documentation cannot describe an overridden production launch.

Installation alone does not replace running code. Desktop Chrome can leave background processes alive; browser farms keep workers warm; an IDE may preserve its embedded renderer until the whole application exits. Evidence should show the fixed package landed, old browser and renderer processes ended, and new processes mapped files from the repaired installation.

Persistent automation needs controlled draining. Stop assigning new jobs, let or terminate active work according to service policy, install the fixed browser, restart the controller and workers, then run a benign storage-inspection check. A fleet that updates only the worker template can keep vulnerable instances alive until autoscaling happens to recycle them.

Restricting debugger access can be a temporary exposure reduction while packages move, but it needs an owner and expiry. Confirm that no local sidecar, extension, support tunnel, or embedded client can still issue storage commands. Restoring the service must depend on fixed runtime proof. A network firewall entry by itself says nothing about on-host protocol consumers.

Hand-drawn map with an untrusted page holding database rows and a navigation lever on one side, several legitimate DevTools and automation clients on the other, meeting only at a guarded IndexedDB requestData doorway inside the renderer.
Figure 6. The page controls storage and navigation while an inspector client initiates enumeration; product inventory must capture both roles and the exact Chromium process that joins them.

7 A crash stack is one timestamp in the story, not the verdict

7.1 Join build, protocol request, navigation, and session teardown before assigning the CVE

A user may see a renderer disappear, a DevTools target disconnect, an Application panel request fail, or an automation job report that the page closed. None of those symptoms names CVE-2026-15107. Navigation and debugging routinely produce similar messages. The investigation becomes specific only when a vulnerable build, active IndexedDB inspection, session disposal, and late cursor conversion occupy the same timeline.

Begin with immutable version facts. Record the outer product and full four-part version, the embedded Chromium revision if available, package source and digest, executable path, loaded Blink module identity, process start time, and whether the process survived a recent update. For framework-managed browsers, capture the download cache key and launch path used by the failing job.

A strong native stack may contain OpenCursorCallback::Invoke, V8InspectorSession::wrapObject, remote-object preview code, IndexedDB event dispatch, or inspector-agent disposal. AddressSanitizer can identify the allocation and free sites for the session object. Symbol quality varies across vendor builds, so absence of one exact function name should not end the inquiry.

Reconstruct protocol time separately from renderer time. Preserve the request identifier for IndexedDB.requestData, its parameters excluding sensitive values, the target or session identifier, page navigation events, detach notifications, and the final client outcome. Normalize clocks, but keep original timestamps so later analysis can detect skew introduced by logging pipelines.

A crash during ordinary navigation with DevTools open does not prove hostile intent. Developers, support engineers, and automated tests routinely change pages while viewing storage. Look for repeated delivery of the same page, deliberate database amplification, immediate navigation patterns, unexpected debugger connections, or coordinated failures across targets before making an incident attribution.

No crash is weak negative evidence. The event window depends on database latency, task scheduling, sanitizer presence, allocator reuse, pointer hardening, and whether the client requests enough records. A vulnerable process can execute the old path many times without a visible fault. Fleet remediation should be driven by source and runtime version, not crash frequency.

When a pre-update event merits reproduction, use an isolated environment and the public benign regression first. Confirm the old build fails under ASAN and the fixed build publishes null before conversion. Preserve the incident sample only under the organization's evidence rules. There is no need to refine the input into a reliable exploit to decide that the engine must be updated.

7.2 Low-noise telemetry can observe the lifetime decision without recording database contents

Test and canary builds can instrument three points: entry to InspectorIndexedDBAgent::Dispose(), the callback's read of v8_session(), and each attempted wrapObject(). A monotonically increasing inspector-session generation makes the relationship explicit. After disposal of generation N, no conversion tagged N may begin.

Use a random, short-lived correlation identifier shared by the protocol request, agent, and callback. Do not reuse a user account, URL, database value, or stable device identifier as the join key. The goal is to order events within one debugging operation, not to create a new history of inspected page storage.

A compact event can contain build ID, process type, protocol method, target kind, session generation, callback phase, agent-present bit, session-present bit, context-valid bit, row ordinal, and completion category. These fields are enough to distinguish “agent collected,” “session disposed,” “context gone,” normal page completion, and unexpected conversion after closure.

Count protocol completions as well as crashes. For each request, observe exactly one success or failure visible to the client, and identify requests abandoned because the entire target vanished. A falling crash rate accompanied by growing hung requests is not a clean regression. Lifetime hardening should preserve a finished client contract.

Sanitizer browser farms remain the best place for detailed traces. Run the upstream fixture repeatedly across supported operating systems and important downstream branches, with symbols and allocation stacks enabled. Add deterministic assertions around the null state so a future scheduling change cannot turn disappearance of the original crash into a false pass.

Production telemetry should focus on rollout quality: vulnerable versions still running, old processes surviving installation, detached-session failures on fixed builds, unexpected renderer exits during storage inspection, and automation jobs that retry forever. These signals find deployment gaps and compatibility problems. They do not replace commit ancestry or the exact regression.

The best detection outcome is modest: a fixed process reports one detached operation, the callback records a null session, no conversion starts, and the next legitimate DevTools request works. That small sequence proves the closed sign is visible in practice. It is more specific than an empty crash dashboard and far safer than probing production with a memory-corruption trigger.

Hand-drawn investigation board joining a signed browser build, an IndexedDB request token, a navigation ticket, a session-disposal stamp, a late callback card, and a sanitizer trace, while database contents remain sealed in a privacy envelope.
Figure 7. Attribution comes from joining version and event order; privacy-safe telemetry can prove the late callback observed a closed session without collecting the row itself.

8 One release needs four proofs: source, build, process, and the late callback

A landed patch does not mean the risk has left the running environment. Source must show revocation at the right decision point; the regression must show a late event actually reaching that state; the artifact must contain the same semantics; and process evidence must show the old Blink module is gone. Only when all four proofs follow the same object identity can the stale inspector lifetime from the opening scene be considered gone from production.

8.1 Source and regression must prove the same state transition

The fixed starting points are commit f507e6c6334f3791b4b0e808343ceb0e95832273, parent f51640cf965e2aea01e44a32ba6798dc170f32c1, Chromium position #1653288, and Gerrit change 8007837. Four changed files implement one contract: disposal uses the live session for final object-group cleanup and then publishes null; the cursor callback reads that state before its first conversion. A downstream implementation may use a nullable handle, session generation, or active cancellation, but it must preserve the same revocation point, first-use decision, and cleanup order.

The search scope includes construction, Dispose(), DidCommitLoadForLocalFrame(), domain disablement, object-group cleanup, and every asynchronous reader of v8_session_. Textual patch similarity is not the result; every late path must be unable to reach wrapObject() after revocation. Build manifests must also select this implementation so an updated repository cannot coexist with an old snapshot or cached object in the target platform.

The upstream web regression makes its schedule readable. In each of two rounds it creates a same-origin page and database, writes 64 records with an 8,192-character value, sends IndexedDB.requestData with a page size of 64, navigates immediately to a data URL containing a body and iframe without awaiting the response, and waits 500 milliseconds. The commit reports reliable ASAN reproduction at the parent revision. That reproduction rate comes from the upstream commit and must not be recast as an independent result.

A fixed build needs a stronger result than “did not crash.” Event evidence should show navigation triggering disposal, the session becoming null, the cursor callback arriving afterward, the null decision executing, zero object conversions, and exactly one protocol completion; a normal IndexedDB inspection in a fresh target must then succeed. ASAN detects invalid memory access, while protocol assertions expose duplicate completion, abandoned work, or false safety achieved by permanently disabling the feature.

8.2 Installation takes effect only after old processes leave

The signed artifact enters deployment with platform, architecture, channel, version, digest, and source mapping. Chrome's July 8 bulletin anchors Windows and macOS at 150.0.7871.114/.115 and Linux at 150.0.7871.114. A derived product needs its own build identity and the upstream fix or an equivalent backport. A larger outer product number or a current About page cannot substitute for the provenance of the actual Blink module.

Before rollout, inventory running browsers, renderers, and automation workers; stop new work, drain or end existing sessions, replace them with the immutable artifact, and start clean processes. The canary then opens a controlled page, creates a small IndexedDB store, enumerates it through the client used in production, navigates, and issues a second normal request in a fresh target. That second success proves the fix retired the old session instead of permanently disabling inspection.

Historical events still need evidence tiers. An IndexedDB request followed by navigation on a vulnerable build proves that the route occurred. A matching ASAN or stack signature raises confidence. A log showing conversion begin after disposal is closer to the cause. The public record confirms neither active exploitation nor a sandbox-escape chain, so code execution, attacker identity, and actual data impact require separate local evidence.

Until process replacement is complete, restricting remote-debugging interfaces, protecting automation credentials, isolating worker networks, and pausing nonessential storage inspection can reduce opportunity, but none is a permanent conclusion. Local DevTools and trusted automation use the same agent, while page content still controls database values and navigation timing. Temporary controls can expire only after old processes reach zero and the fixed behavior is observed.

The design question outlives this CVE. Whenever an asynchronous callback borrows an external native resource, name its owner, revocation event, publication mechanism, last legal operation, and every late consumer. A weak reference proves only that the agent is reachable; a script guard proves only that the context is valid. Publishing invalidation as observable state and checking it before the first resource-dependent operation gives different ownership systems one termination rule.

The final row will still arrive late. In the fixed build, disposal completes legitimate cleanup and writes the borrowed session as null. The callback reaches the first conversion, sees the defined closed state, and ends with a normal error. A new room opens with a fresh session, and the next request succeeds. Only when source, regression, artifact, and running processes all prove that closure does the story end.

Hand-drawn release desk where a reviewed source commit, sanitizer test card, signed browser package, restarted renderer process, and a late cursor row all receive matching proof stamps, with the stale row stopping at a clearly closed session gate.
Figure 8. The correction is complete only when reviewed source becomes the signed package, every old process is replaced, and the delayed callback observes the published closed state before conversion.

Research record

9Evidence, objects, and sources

The material below preserves the identifiers and references used in this report.

9.1Research objects

Products, actors, techniques, affected objects, and control points discussed in the report.

CVECVE-2026-15107

Blink IndexedDB inspector use-after-free

Upstream fixf507e6c6334f3791b4b0e808343ceb0e95832273

Dispose clears the borrowed V8 inspector session

Parent baselinef51640cf965e2aea01e44a32ba6798dc170f32c1

Clean parent for source comparison

Key callbackOpenCursorCallback::Invoke

Late cursor result reaches wrapObject

Protocol methodIndexedDB.requestData

Asynchronous object-store or index read

Regressionindexed-db-request-data-race-with-navigation-large-db.js

Two rounds, 64 rows, 8,192-character payload, immediate navigation

9.2Event chronology

  1. Issue reported to Chrome

    zh1x1an1221 of Ant Group Tianqiong Security Lab reported the IndexedDB use-after-free.

  2. Lifetime fix landed on Chromium main

    Commit f507e6c6334f published the disposal state and added the ASAN regression at position #1653288.

  3. Chrome shipped the stable security update

    Chrome assigned CVE-2026-15107, rated it Medium, and published platform packages in the 150.0.7871.114/.115 release.

  4. SOSEC completed the source reconstruction

    SOSEC connected the protocol entry, four independent lifetimes, callback ordering, product mapping, incident evidence, and release proof.

9.3Sources and material

  1. Chrome Stable Channel Update for Desktop, July 8, 2026https://chromereleases.googleblog.com/2026/07/stable-channel-update-for-desktop_01162222768.html
  2. NVD record for CVE-2026-15107https://nvd.nist.gov/vuln/detail/CVE-2026-15107
  3. Chromium fix f507e6c6334fhttps://chromium.googlesource.com/chromium/src/+/f507e6c6334f3791b4b0e808343ceb0e95832273
  4. Parent commit f51640cf965ehttps://chromium.googlesource.com/chromium/src/+/f51640cf965e2aea01e44a32ba6798dc170f32c1
  5. Chromium review 8007837https://chromium-review.googlesource.com/c/chromium/src/+/8007837
  6. Fixed InspectorIndexedDBAgent implementationhttps://chromium.googlesource.com/chromium/src/+/f507e6c6334f3791b4b0e808343ceb0e95832273/third_party/blink/renderer/modules/indexeddb/inspector_indexed_db_agent.cc
  7. Fixed InspectorIndexedDBAgent declarationhttps://chromium.googlesource.com/chromium/src/+/f507e6c6334f3791b4b0e808343ceb0e95832273/third_party/blink/renderer/modules/indexeddb/inspector_indexed_db_agent.h
  8. Two-round navigation race regression sourcehttps://chromium.googlesource.com/chromium/src/+/f507e6c6334f3791b4b0e808343ceb0e95832273/third_party/blink/web_tests/http/tests/inspector-protocol/storage/indexed-db-request-data-race-with-navigation-large-db.js
  9. Navigation race expected outputhttps://chromium.googlesource.com/chromium/src/+/f507e6c6334f3791b4b0e808343ceb0e95832273/third_party/blink/web_tests/http/tests/inspector-protocol/storage/indexed-db-request-data-race-with-navigation-large-db-expected.txt
  10. Chrome DevTools Protocol IndexedDB domainhttps://chromedevtools.github.io/devtools-protocol/tot/IndexedDB/
  11. Chromium raw_ptr documentationhttps://chromium.googlesource.com/chromium/src/+/refs/heads/main/base/memory/raw_ptr.md
  12. Blink Oilpan garbage-collection API referencehttps://chromium.googlesource.com/chromium/src/+/refs/heads/main/third_party/blink/renderer/platform/heap/BlinkGCAPIReference.md
  13. Chromium web-test documentationhttps://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/testing/web_tests.md
  14. InspectorDOMAgent disposal pattern referenced by the fixhttps://chromium.googlesource.com/chromium/src/+/284b36ac2742525000db2ca28f448f6cc8584f40/third_party/blink/renderer/core/inspector/inspector_dom_agent.cc