CVE-2026-5278
Overview
Files Changed
media/midi/midi_manager_android.h
Patch
From ab92725b0824c9fd98aea65a5b4c7fb1a27fcc5e Mon Sep 17 00:00:00 2001
From: Hongchan Choi <hongchan@chromium.org>
Date: Thu, 12 Mar 2026 11:37:09 -0700
Subject: [PATCH] [WebMIDI/Android] Fix destruction order n MidiManagerAndroid
Moving the `devices_` declaration to the end of the class ensures
it is destroyed first, safely closing ports before the hash maps
are destroyed.
Bug: 490254128
Change-Id: I1a7857ef3f0f58772cd300f603dc74720ac49964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7659363
Commit-Queue: Hongchan Choi <hongchan@chromium.org>
Reviewed-by: Thomas Guilbert <tguilbert@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1598579}
---
diff --git a/media/midi/midi_manager_android.h b/media/midi/midi_manager_android.h
index 2671308..4a46cfa 100644
--- a/media/midi/midi_manager_android.h
+++ b/media/midi/midi_manager_android.h
@@ -71,7 +71,6 @@
// released on the main thread.
base::Lock lock_;
- std::vector<std::unique_ptr<MidiDeviceAndroid>> devices_;
// All ports held in |devices_|. Each device has ownership of ports, but we
// can store pointers here because a device will keep its ports while it is
// alive.
@@ -86,6 +85,12 @@
all_output_ports_;
absl::flat_hash_map<MidiOutputPortAndroid*, size_t> output_port_to_index_;
+ // `devices_` must be declared after the port index maps so that it is
+ // destroyed first (in reverse declaration order). This ensures that MidiPorts
+ // are closed and stop receiving callbacks before the maps are destroyed.
+ // See https://crbug.com/490254128.
+ std::vector<std::unique_ptr<MidiDeviceAndroid>> devices_;
+
base::android::ScopedJavaGlobalRef<jobject> raw_manager_;
};
Original Bug Report
Use-after-free in MidiManagerAndroid due to incorrect member destruction order on Android
Summary
On Android, MidiManagerAndroid destroys its input_port_to_index_ hash map before closing MIDI input ports, allowing a concurrent Android MIDI system callback to call find() on the destroyed map. This is a use-after-destroy that executes directly in the browser process, reachable from any page that uses the Web MIDI API while a MIDI device is actively sending data. Because the vulnerable code runs in the browser process rather than a sandboxed renderer, successful exploitation would not require a sandbox escape; a compromised renderer or even a normal web page with MIDI access can trigger the bug to corrupt browser process memory. The platform affected is Android only.
Bisect
Introducing Commit: e8b2e7dee39f272747554f0b26451d15b786e06d
- Date: 2015-09-17
- Author: yhirano
- Review: https://chromium-review.googlesource.com/c/chromium/src/+/1346563
This commit introduced MidiManagerAndroid with input_port_to_index_ declared after devices_ in the class definition, establishing the incorrect destruction order that has persisted ever since.
Root Cause
C++ destroys class members in reverse declaration order. In MidiManagerAndroid, the members are declared as follows:
// media/midi/midi_manager_android.h:72-89
base::Lock lock_;
std::vector<std::unique_ptr<MidiDeviceAndroid>> devices_;
std::vector<raw_ptr<MidiInputPortAndroid, VectorExperimental>> all_input_ports_;
absl::flat_hash_map<MidiInputPortAndroid*, size_t> input_port_to_index_;
std::vector<raw_ptr<MidiOutputPortAndroid, VectorExperimental>> all_output_ports_;
absl::flat_hash_map<MidiOutputPortAndroid*, size_t> output_port_to_index_;
base::android::ScopedJavaGlobalRef<jobject> raw_manager_;
Because input_port_to_index_ is declared after devices_, it is destroyed before devices_. When devices_ is destroyed, each MidiDeviceAndroid destroys its owned MidiInputPortAndroid objects, and their destructors call Close(), which crosses JNI to set the Java-side port reference to null. Only after Close() does the Android MIDI system stop invoking the onSend callback for that port.
Between the destruction of input_port_to_index_ and the Close() call on each port, the Android MIDI system thread may deliver data through a JNI callback that reaches MidiManagerAndroid::OnReceivedData:
// media/midi/midi_manager_android.cc:111-117
void MidiManagerAndroid::OnReceivedData(MidiInputPortAndroid* port,
base::span<const uint8_t> data,
base::TimeTicks timestamp) {
const auto i = input_port_to_index_.find(port);
DCHECK(input_port_to_index_.end() != i);
ReceiveMidiData(i->second, data, timestamp);
}
The find() call operates on a destroyed absl::flat_hash_map. In HWASAN/ASAN builds, the Swiss table’s internal kDestroyed sentinel catches this and produces a fatal abort. In production release builds, this sentinel is never set; the find() call silently reads memory belonging to the destroyed object, constituting undefined behavior that may corrupt state or produce incorrect port indices passed to ReceiveMidiData.
The Java-side synchronized block in MidiInputPortAndroid.java serializes close() and onSend() calls, but it cannot prevent onSend() from executing before close() is ever called. The UnbindInstance() call in ~MidiManagerAndroid() only prevents PostBoundTask callbacks; it does not affect JNI callbacks arriving on the Android MIDI system thread. The DCHECK on the iterator result is compiled out in release builds.
When only a single MIDI port is connected, the absl::flat_hash_map uses Small Object Optimization (SOO), storing its single entry inline rather than in a separate heap allocation. In this configuration, find() reads stale but physically valid inline data from the still-allocated parent object. With two or more ports, the map allocates a heap-backed control and slot array; after destruction, find() follows the dangling internal pointer into freed heap memory, a conventional heap use-after-free.
Reproduce
Tested on commit 89d6357f16ea4 on an Android arm64 device (Pixel 7 Pro, HWASAN userdebug build). The vulnerability is in the browser process and requires a connected MIDI device that is actively sending data.
To reproduce, first apply the patch that widens the race window. From the Chromium source root, run git apply patch.diff to add a 500ms sleep in ~MidiInputPortAndroid(). This sleep delays port closure during ~MidiManagerAndroid() member destruction, giving the Android MIDI callback thread time to invoke OnReceivedData() while input_port_to_index_ is already destroyed.
Configure the HWASAN build with the following args.gn in out/android:
is_debug = false
dcheck_always_on = false
target_cpu = "arm64"
is_component_build = false
target_os = "android"
is_hwasan = true
android_static_analysis = "off"
incremental_install = false
Build with autoninja -C out/android chrome_public_apk and install the resulting APK on the device with out/android/bin/chrome_public_apk install.
A MIDI device that continuously sends data must be connected to the Android device. The included VirtualMIDI.apk is a virtual MIDI service that registers as an Android MIDI device and floods MIDI output data. Install it with adb install VirtualMIDI.apk and verify it appears in adb shell dumpsys midi before starting Chrome. Alternatively, a physical USB MIDI controller or BLE MIDI device that sends continuous data will work.
Serve the PoC from the Chromium source root by running python3 -m http.server 8888 on the host, then forward the port with adb reverse tcp:8888 tcp:8888. Launch Chrome with out/android/bin/chrome_public_apk launch --args='--enable-logging=stderr --disable-features=BlockMidiByDefault' http://localhost:8888/poc.html. The --disable-features=BlockMidiByDefault flag bypasses the MIDI permission prompt so that requestMIDIAccess() succeeds without manual interaction; it is not required to trigger the vulnerability, which is equally reachable after a user grants MIDI permission through the normal prompt.
The page calls navigator.requestMIDIAccess(), waits two seconds for MIDI data to flow, then navigates to about:blank. This navigation tears down the MIDI session and triggers ~MidiManagerAndroid(). During member destruction, the 500ms sleep in ~MidiInputPortAndroid() holds the destructor thread while the MIDI callback thread continues to deliver data. The callback invokes OnReceivedData(), which calls input_port_to_index_.find(port) on the already-destroyed absl::flat_hash_map, producing a fatal abort with the message “Use of destroyed hash table.” The tombstone or logcat will contain this message; check with adb logcat -d | grep FATAL or inspect /data/tombstones/.
The crash log from the Android tombstone, with native frames symbolized:
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'Android/aosp_cheetah_hwasan/cheetah:Baklava/MAIN/13282683:userdebug/test-keys'
Revision: 'MP1.0'
ABI: 'arm64'
Timestamp: 2026-03-06 21:28:06.704553922+0800
Process uptime: 25s
Cmdline: org.chromium.chrome
pid: 11051, tid: 11231, name: Thread-8 >>> org.chromium.chrome <<<
uid: 10136
tagged_addr_ctrl: 0000000000000001 (PR_TAGGED_ADDR_ENABLE)
signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------
Abort message: '[FATAL:raw_hash_set.h:3314] NOTREACHED hit. Use of destroyed hash table.
51 total frames
backtrace:
#00 pc 000000000007bcf0 libc.so (abort+288)
#01 pc 000000000bdd3a1c libchrome.so (base::ImmediateCrash, base/immediate_crash.h:185)
#02 pc 000000000bdd33b8 libchrome.so (logging::LogMessage::Flush, base/logging.cc:741)
#03 pc 000000000bdd3c54 libchrome.so (logging::LogMessageFatal::~LogMessageFatal, base/logging.cc:1050)
#04 pc 000000000bdd1e5c libchrome.so (AbslAbortHook, base/logging.cc:630)
#05 pc 0000000003e1d9f0 libchrome.so (absl::raw_log_internal::RawLog, raw_logging.cc:251)
#06 pc 000000001042aa48 libchrome.so (raw_hash_set<...MidiInputPortAndroid*...>::AssertNotDebugCapacity, raw_hash_set.h:3314)
#07 pc 000000001042aa94 libchrome.so (raw_hash_set::AssertOnFind, raw_hash_set.h:3295)
#08 pc 0000000010429b28 libchrome.so (raw_hash_set::find, raw_hash_set.h:2805)
#09 pc 0000000010429c64 libchrome.so (MidiManagerAndroid::OnReceivedData, midi_manager_android.cc:114)
--- JNI bridge ---
#10 tr6.onSend (MidiInputPortAndroid JNI callback)
#11 android.media.midi.MidiReceiver.send (MidiReceiver.java:128)
#12 com.android.internal.midi.MidiDispatcher.onSend (MidiDispatcher.java:91)
#13 android.media.midi.MidiReceiver.send (MidiReceiver.java:128)
#14 android.media.midi.MidiOutputPort$1.run (MidiOutputPort.java:78)
The crashing thread (Thread-8) is the Android MIDI system callback thread, not an IO thread or renderer thread. The call chain starts from MidiOutputPort$1.run, dispatches through the MIDI framework, crosses JNI into native code at MidiInputPortAndroid::OnData, and reaches MidiManagerAndroid::OnReceivedData where find() on the destroyed map triggers the fatal abort.
The crash is caught by a Swiss table invariant that only exists in sanitizer and debug builds. The absl::flat_hash_map destructor sets a kDestroyed sentinel on the capacity field after tearing down the backing storage:
// third_party/abseil-cpp/absl/container/internal/raw_hash_set.h:2310-2315
~raw_hash_set() {
destructor_impl();
if constexpr (SwisstableAssertAccessToDestroyedTable()) {
common().set_capacity(InvalidCapacity::kDestroyed);
}
}
The guard SwisstableAssertAccessToDestroyedTable() returns true only when a sanitizer is active or the build is non-NDEBUG:
// third_party/abseil-cpp/absl/container/internal/raw_hash_set.h:276-281
constexpr bool SwisstableAssertAccessToDestroyedTable() {
#ifndef NDEBUG
return true;
#endif
return SwisstableGenerationsEnabled();
}
And SwisstableGenerationsEnabled() is controlled by ABSL_SWISSTABLE_ENABLE_GENERATIONS, which is only defined when ASAN, HWASAN, or MSAN is active:
// third_party/abseil-cpp/absl/container/internal/raw_hash_set.h:234-244
#if (defined(ABSL_HAVE_ADDRESS_SANITIZER) || \
defined(ABSL_HAVE_HWADDRESS_SANITIZER) || \
defined(ABSL_HAVE_MEMORY_SANITIZER)) && \
!defined(NDEBUG_SANITIZER)
#define ABSL_SWISSTABLE_ENABLE_GENERATIONS
#endif
When find() is called on a destroyed map in an HWASAN build, it enters AssertOnFind, which calls AssertNotDebugCapacity. This function detects the kDestroyed sentinel on the capacity field and issues the fatal abort:
// third_party/abseil-cpp/absl/container/internal/raw_hash_set.h:3299-3315
void AssertNotDebugCapacity() const {
#ifdef NDEBUG
if (!SwisstableGenerationsEnabled()) {
return; // production release: returns immediately
}
#endif
if (ABSL_PREDICT_TRUE(capacity() < InvalidCapacity::kAboveMaxValidCapacity)) {
return;
}
// ...
if constexpr (SwisstableAssertAccessToDestroyedTable()) {
if (capacity() == InvalidCapacity::kDestroyed) {
ABSL_RAW_LOG(FATAL, "Use of destroyed hash table."); // the crash we observe
}
}
}
In a production release build (NDEBUG, no sanitizer), this entire detection path is compiled out. SwisstableGenerationsEnabled() returns false, so AssertNotDebugCapacity returns at the first #ifdef NDEBUG guard without ever checking capacity. The destructor never sets kDestroyed either, so the capacity field retains its pre-destruction value. The find() call proceeds into the actual lookup logic.
For a single-port configuration, the map uses SOO (Small Object Optimization) because sizeof(pair<MidiInputPortAndroid*, size_t>) is 16 bytes, equal to sizeof(HeapOrSoo). The destructor_impl handles SOO tables by destroying the single slot and returning early without calling dealloc:
// third_party/abseil-cpp/absl/container/internal/raw_hash_set.h:3035-3050
void destructor_impl() {
// ...
if (is_small()) {
if (!empty()) {
destroy(single_slot()); // no-op for trivially destructible pair<ptr, size_t>
}
if constexpr (SooEnabled()) return; // returns here; no dealloc, no metadata reset
}
// ...
}
After this destructor runs, the SOO inline storage still contains the original key-value pair, the capacity field still holds the SOO capacity, and the size field still indicates one element. When the concurrent callback thread calls find(port), it takes the SOO path through find_small:
// third_party/abseil-cpp/absl/container/internal/raw_hash_set.h:2967-2970
iterator find_small(const key_arg<K>& key) {
return empty() || !equal_to(key, single_slot()) ? end() : single_iterator();
}
Since empty() returns false (size was not zeroed) and the key still matches the stale inline slot, find_small returns a valid-looking iterator. The caller then reads i->second to obtain a port index and passes it to ReceiveMidiData. In this single-port scenario, the stale data happens to be correct, so the undefined behavior manifests silently without an immediate crash.
With two or more MIDI ports, the map exceeds SOO capacity and allocates a separate heap-backed array for control bytes and slots. The destructor_impl calls dealloc(), freeing that heap allocation, but the internal ctrl_ pointer is not reset. In production, find() takes the find_large path, which reads freed heap memory through the dangling ctrl_ pointer to probe control bytes, a conventional heap use-after-free.
Credit
Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.