CVE-2026-14241
Overview
Background
- Web Serial API
- a DOM feature exposing serial ports to web content via the
SerialPortinterface, backed by readable and writable streams. - `SerialPort::Shutdown`
- the teardown path that runs when the
SerialPortobject is being destroyed or its global goes away, releasing OS resources throughmChild->Shutdown(). - Underlying source/sink algorithms
- the author-supplied (or spec-defined)
cancel/abortsteps that a stream runs when it is gracefully cancelled or aborted, which can dispatch events and re-enter script. - `StreamCloseMode`
- the new
uint8_tenum (Graceful/Forced) added to choose whetherCloseStreamsruns author-observable algorithms or merely errors the streams.
Root Cause Analysis
During teardown, SerialPort::Shutdown called CloseStreams, which cancelled the readable stream and aborted the writable stream by running their underlying source/sink algorithms; these algorithms can dispatch events and re-enter author-visible JavaScript. Running script from within a destruction path violates the invariant that no author-observable, script-re-entrant algorithm may execute while the object is being torn down, because the re-entrant script can observe, mutate, or free the half-destroyed SerialPort and its streams. This creates a memory-corruption window (use-after-free / reentrancy) in the DOM.
The fix adds a StreamCloseMode parameter and routes the shutdown-time caller through StreamCloseMode::Forced, which calls ErrorNative on mReadable/mWritable and nulls them out without running any author-observable algorithm or dispatching events. Because the actual OS resources are released separately by mChild->Shutdown(), forcing an error rather than a graceful cancel/abort is safe and still frees the streams. Genuinely graceful callers (Close, Forget, MarkForgotten, NotifyDisconnected) are switched to StreamCloseMode::Graceful, preserving their intended behavior.
cancel/abort algorithms from the object’s own destruction path; the fix distinguishes a Forced mode that errors the streams via ErrorNative without running author-observable code, so shutdown can no longer re-enter script into a partially destroyed SerialPort.Attack Path
- Open a serial port
Author code obtains a
SerialPortand opens it, creating livemReadableandmWritablestreams with observable cancel/abort algorithms. - Trigger shutdown
The attacker forces the teardown path (e.g. navigating away or destroying the global) so
SerialPort::Shutdownruns and callsCloseStreams. - Re-enter via stream algorithms The graceful cancel/abort run their underlying algorithms, dispatching events and re-entering attacker-controlled JavaScript in the middle of destruction.
- Corrupt state
The re-entrant script manipulates or frees the
SerialPort/stream objects that shutdown is still operating on, producing a use-after-free or other memory corruption. - Escalate With careful heap grooming the freed-then-reused memory is leveraged toward arbitrary code execution in the content process.
Impact Assessment
SerialPort) and being able to trigger the shutdown/teardown path while streams are still active. The corruption occurs in the DOM component within the content process’s JavaScript/DOM context.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifdom/webserial/SerialPort.cpp |
modified |
Files Changed
dom/webserial/SerialPort.cppdom/webserial/SerialPort.h
Audit Directions
- Script re-entrancy in destructorsAudit any
Shutdown/~Dtor/teardown path that invokes streamcancel/abort, event dispatch, or otherMOZ_CAN_RUN_SCRIPThelpers, since re-entering author code mid-destruction risks use-after-free. - Graceful vs. forced teardownFor every DOM API backed by streams (WebSerial, WebUSB, WebHID, etc.), verify shutdown paths error/null streams natively rather than running author-observable algorithms.
- `MOZ_CAN_RUN_SCRIPT` on cleanup pathsFlag callers of
MOZ_CAN_RUN_SCRIPT-annotated functions likeCloseStreamsreached from object destruction, and confirm object lifetime is safe across the potential script call.
Patch
diff --git a/dom/webserial/SerialPort.cpp b/dom/webserial/SerialPort.cpp
index 818fb9acea0..d5dcee6705a 100644
--- a/dom/webserial/SerialPort.cpp
+++ b/dom/webserial/SerialPort.cpp
@@ -137,7 +137,7 @@ void SerialPort::Shutdown() {
if (mState == State::Opened || mState == State::Closing) {
mState = State::Closed;
// Don't have to wait for this
- RefPtr<Promise> ignoredPromise = CloseStreams();
+ RefPtr<Promise> ignoredPromise = CloseStreams(StreamCloseMode::Forced);
}
if (mOpenPromise) {
@@ -529,7 +529,7 @@ already_AddRefed<Promise> SerialPort::Close(ErrorResult& aRv) {
// promise to wait for all (steps 5-8). Our pendingClosePromise is
// effectively resolved once both streams are nulled (step 6), so the
// combined promise just waits for the cancel and abort to finish.
- RefPtr<Promise> combinedPromise = CloseStreams();
+ RefPtr<Promise> combinedPromise = CloseStreams(StreamCloseMode::Graceful);
if (!combinedPromise) {
combinedPromise = Promise::CreateResolvedWithUndefined(global, aRv);
if (NS_WARN_IF(aRv.Failed())) {
@@ -598,7 +598,7 @@ already_AddRefed<Promise> SerialPort::Forget(ErrorResult& aRv) {
if (wasActive) {
// Don't have to wait for this
- RefPtr<Promise> ignoredPromise = CloseStreams();
+ RefPtr<Promise> ignoredPromise = CloseStreams(StreamCloseMode::Graceful);
}
UpdateWorkerRef();
@@ -652,7 +652,7 @@ void SerialPort::MarkForgotten() {
if (wasActive) {
// Don't have to wait for this
- RefPtr<Promise> ignoredPromise = CloseStreams();
+ RefPtr<Promise> ignoredPromise = CloseStreams(StreamCloseMode::Graceful);
}
UpdateWorkerRef();
@@ -767,7 +767,7 @@ void SerialPort::NotifyDisconnected() {
}
mPhysicallyPresent = false;
// Don't have to wait for this
- RefPtr<Promise> ignoredPromise = CloseStreams();
+ RefPtr<Promise> ignoredPromise = CloseStreams(StreamCloseMode::Graceful);
UpdateWorkerRef();
NotifySharingStateChanged(false);
@@ -951,7 +951,7 @@ void SerialPort::SettleClosePromise(nsresult aResult) {
}
}
-already_AddRefed<Promise> SerialPort::CloseStreams() {
+already_AddRefed<Promise> SerialPort::CloseStreams(StreamCloseMode aMode) {
nsIGlobalObject* global = GetRelevantGlobal();
if (!global) {
return nullptr;
@@ -963,8 +963,9 @@ already_AddRefed<Promise> SerialPort::CloseStreams() {
MOZ_LOG(gWebSerialLog, LogLevel::Info,
("SerialPort[%p]::CloseStreams closing streams "
- "(readable=%p, writable=%p)",
- this, mReadable.get(), mWritable.get()));
+ "(readable=%p, writable=%p, mode=%s)",
+ this, mReadable.get(), mWritable.get(),
+ aMode == StreamCloseMode::Forced ? "forced" : "graceful"));
AutoJSAPI jsapi;
if (!jsapi.Init(global)) {
@@ -973,14 +974,33 @@ already_AddRefed<Promise> SerialPort::CloseStreams() {
JSContext* cx = jsapi.cx();
- // Cancel the readable and abort the writable, collecting their promises.
- nsTArray<RefPtr<Promise>> streamPromises;
-
RefPtr<DOMException> exception =
DOMException::Create(NS_ERROR_DOM_NETWORK_ERR, "Port has been closed"_ns);
JS::Rooted<JS::Value> errorVal(cx);
bool hasError = ToJSValue(cx, exception, &errorVal);
+ // Error the streams instead of aborting, so we don't dispatchevents
+ // or runauthor-visible algorithms. The OS resources are released by
+ // mChild->Shutdown() in Shutdown().
+ if (aMode == StreamCloseMode::Forced) {
+ if (mReadable && hasError) {
+ IgnoredErrorResult rv;
+ RefPtr readable = mReadable;
+ readable->ErrorNative(cx, errorVal, rv);
+ }
+ if (mWritable && hasError) {
+ IgnoredErrorResult rv;
+ RefPtr writable = mWritable;
+ writable->ErrorNative(cx, errorVal, rv);
+ }
+ mReadable = nullptr;
+ mWritable = nullptr;
+ return nullptr;
+ }
+
+ // Cancel the readable and abort the writable, collecting their promises.
+ nsTArray<RefPtr<Promise>> streamPromises;
+
if (mReadable && hasError) {
IgnoredErrorResult rv;
RefPtr readable = mReadable;
diff --git a/dom/webserial/SerialPort.h b/dom/webserial/SerialPort.h
index fb319115eaa..3950affdafe 100644
--- a/dom/webserial/SerialPort.h
+++ b/dom/webserial/SerialPort.h
@@ -116,11 +116,21 @@ class SerialPort final : public DOMEventTargetHelper {
private:
~SerialPort() override;
+ enum class StreamCloseMode : uint8_t {
+ // Cancel the readable and abort the writable, running their underlying
+ // source/sink algorithms.
+ Graceful,
+ // Error both streams without running author-observable algorithms or
+ // dispatching events.
+ Forced,
+ };
+
MOZ_CAN_RUN_SCRIPT ReadableStream* CreateReadableStream();
MOZ_CAN_RUN_SCRIPT WritableStream* CreateWritableStream();
void CloseAfterStreamsClosed();
void SettleClosePromise(nsresult aResult);
- MOZ_CAN_RUN_SCRIPT already_AddRefed<Promise> CloseStreams();
+ MOZ_CAN_RUN_SCRIPT already_AddRefed<Promise> CloseStreams(
+ StreamCloseMode aMode);
void NotifySharingStateChanged(bool aConnected);
void UpdateWorkerRef();