CVE-2026-12462
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fmedia/mojo/clients/mojo_decryptor_unittest.cc |
modified |
Files Changed
media/mojo/clients/mojo_decryptor_unittest.ccmedia/mojo/services/mojo_decryptor_service.cc
Patch
From 054716cecb4c872cd2377f5f3850e8515c0e0db0 Mon Sep 17 00:00:00 2001
From: Feras Aldahlawi <frs@chromium.org>
Date: Wed, 03 Jun 2026 14:18:46 -0700
Subject: [PATCH] Enforce CDM API contract in MojoDecryptorService::DeinitializeDecoder.
A well-behaved client must not deinitialize the decoder while a
DecryptAndDecode read operation is still pending on the Mojo DataPipe.
MojoDecryptorService::DeinitializeDecoder() previously asserted this
precondition only via a debug-only DCHECK, which is compiled out in
release builds.
This CL upgrades the DCHECK to a runtime check that calls
mojo::ReportBadMessage() and returns early if there are pending reads.
This aligns DeinitializeDecoder's behavior with ResetDecoder and
enforces the CDM state machine contract at runtime.
We also add a corresponding unit test to mojo_decryptor_unittest.cc that
verifies that calling DeinitializeDecoder with a pending read correctly
reports a bad message and rejects the operation.
Tests added: { MojoDecryptorTest.Deinitialize_DuringDecryptAndDecode }
Bug: 517916024
Change-Id: Id356d27f44cf650a41bf9334cb7af88a6a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7896887
Reviewed-by: Sangbaek Park <sangbaekpark@chromium.org>
Commit-Queue: Feras Aldahlawi <frs@chromium.org>
Reviewed-by: Frank Liberato <liberato@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1641205}
---
diff --git a/media/mojo/clients/mojo_decryptor_unittest.cc b/media/mojo/clients/mojo_decryptor_unittest.cc
index 36b0fdb7..75727619 100644
--- a/media/mojo/clients/mojo_decryptor_unittest.cc
+++ b/media/mojo/clients/mojo_decryptor_unittest.cc
@@ -413,4 +413,97 @@
base::RunLoop().RunUntilIdle();
}
+TEST_F(MojoDecryptorTest, Deinitialize_DuringDecryptAndDecode) {
+ decryptor_ = std::make_unique<StrictMock<MockDecryptor>>();
+ mojo_decryptor_service_ =
+ std::make_unique<MojoDecryptorService>(decryptor_.get(), nullptr);
+
+ mojo::Remote<mojom::Decryptor> remote_decryptor;
+ receiver_ = std::make_unique<mojo::Receiver<mojom::Decryptor>>(
+ mojo_decryptor_service_.get(),
+ remote_decryptor.BindNewPipeAndPassReceiver());
+
+ // Create the four DataPipes.
+ mojo::ScopedDataPipeConsumerHandle audio_consumer;
+ mojo::ScopedDataPipeProducerHandle audio_producer;
+ ASSERT_EQ(MOJO_RESULT_OK,
+ mojo::CreateDataPipe(nullptr, audio_producer, audio_consumer));
+
+ mojo::ScopedDataPipeConsumerHandle video_consumer;
+ mojo::ScopedDataPipeProducerHandle video_producer;
+ ASSERT_EQ(MOJO_RESULT_OK,
+ mojo::CreateDataPipe(nullptr, video_producer, video_consumer));
+
+ mojo::ScopedDataPipeConsumerHandle decrypt_consumer;
+ mojo::ScopedDataPipeProducerHandle decrypt_producer;
+ ASSERT_EQ(MOJO_RESULT_OK,
+ mojo::CreateDataPipe(nullptr, decrypt_producer, decrypt_consumer));
+
+ mojo::ScopedDataPipeProducerHandle decrypted_producer;
+ mojo::ScopedDataPipeConsumerHandle decrypted_consumer;
+ ASSERT_EQ(MOJO_RESULT_OK, mojo::CreateDataPipe(nullptr, decrypted_producer,
+ decrypted_consumer));
+
+ remote_decryptor->Initialize(
+ std::move(audio_consumer), std::move(video_consumer),
+ std::move(decrypt_consumer), std::move(decrypted_producer));
+
+ bool deinitialized = false;
+
+ EXPECT_CALL(*decryptor_, InitializeAudioDecoder(_, _))
+ .Times(testing::AtMost(1))
+ .WillOnce([](const AudioDecoderConfig& config,
+ Decryptor::DecoderInitCB cb) { std::move(cb).Run(true); });
+
+ EXPECT_CALL(*decryptor_, DeinitializeDecoder(Decryptor::kAudio))
+ .Times(testing::AtMost(1))
+ .WillOnce(testing::Assign(&deinitialized, true));
+
+ EXPECT_CALL(*decryptor_, DecryptAndDecodeAudio(_, _))
+ .WillRepeatedly([&deinitialized](scoped_refptr<DecoderBuffer> buffer,
+ Decryptor::AudioDecodeCB cb) {
+ ASSERT_FALSE(deinitialized) << "DecryptAndDecodeAudio called after "
+ "DeinitializeDecoder!";
+ std::move(cb).Run(Decryptor::kSuccess, Decryptor::AudioFrames());
+ });
+
+ auto data_buffer = mojom::DataDecoderBuffer::New();
+ data_buffer->timestamp = base::TimeDelta();
+ data_buffer->duration = base::Seconds(1);
+ data_buffer->is_key_frame = false;
+ data_buffer->data_size = 256;
+
+ auto mojo_buffer = mojom::DecoderBuffer::NewData(std::move(data_buffer));
+
+ bool decode_called = false;
+ remote_decryptor->DecryptAndDecodeAudio(
+ std::move(mojo_buffer),
+ base::BindOnce(
+ [](bool* called, Decryptor::Status status,
+ std::vector<mojom::AudioBufferPtr> buffers) { *called = true; },
+ &decode_called));
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ remote_decryptor->DeinitializeDecoder(Decryptor::kAudio);
+
+ remote_decryptor->InitializeAudioDecoder(
+ AudioDecoderConfig(AudioCodec::kAAC, SampleFormat::kSampleFormatS16,
+ ChannelLayoutConfig::Stereo(), 44100,
+ std::vector<uint8_t>(),
+ EncryptionScheme::kUnencrypted),
+ base::BindOnce(
+ [](mojo::ScopedDataPipeProducerHandle producer, bool success) {
+ LOG(INFO) << "InitializeAudioDecoder callback called. Success="
+ << success;
+ std::vector<uint8_t> data(256, 0);
+ size_t bytes_written = 0;
+ std::ignore = producer->WriteData(data, MOJO_WRITE_DATA_FLAG_NONE,
+ bytes_written);
+ },
+ std::move(audio_producer)));
+
+ std::string bad_message = bad_message_observer.WaitForBadMessage();
+ EXPECT_EQ(bad_message,
+ "DeinitializeDecoder with pending DecryptAndDecode reads");
+}
} // namespace media
diff --git a/media/mojo/services/mojo_decryptor_service.cc b/media/mojo/services/mojo_decryptor_service.cc
index 0e7cc59..8f6fbc8 100644
--- a/media/mojo/services/mojo_decryptor_service.cc
+++ b/media/mojo/services/mojo_decryptor_service.cc
@@ -207,8 +207,16 @@
return;
}
- DCHECK(!reader->HasPendingReads())
- << "The decoder should be fully flushed before deinitialized.";
+ // A well-behaved client never deinitializes the decoder while a
+ // DecryptAndDecode read is still pending. A compromised renderer can stall
+ // the DataPipe to force this state and then fire DecryptAndDecode* into a
+ // deinitialized library CDM, so reject it with a bad message.
+ if (reader->HasPendingReads()) {
+ CHECK(mojo::IsInMessageDispatch());
+ mojo::ReportBadMessage(
+ "DeinitializeDecoder with pending DecryptAndDecode reads");
+ return;
+ }
decryptor_->DeinitializeDecoder(stream_type);
}
Regression Test / PoC
diff --git a/media/mojo/clients/mojo_decryptor_unittest.cc b/media/mojo/clients/mojo_decryptor_unittest.cc
index 36b0fdb7..75727619 100644
--- a/media/mojo/clients/mojo_decryptor_unittest.cc
+++ b/media/mojo/clients/mojo_decryptor_unittest.cc
@@ -413,4 +413,97 @@
base::RunLoop().RunUntilIdle();
}
+TEST_F(MojoDecryptorTest, Deinitialize_DuringDecryptAndDecode) {
+ decryptor_ = std::make_unique<StrictMock<MockDecryptor>>();
+ mojo_decryptor_service_ =
+ std::make_unique<MojoDecryptorService>(decryptor_.get(), nullptr);
+
+ mojo::Remote<mojom::Decryptor> remote_decryptor;
+ receiver_ = std::make_unique<mojo::Receiver<mojom::Decryptor>>(
+ mojo_decryptor_service_.get(),
+ remote_decryptor.BindNewPipeAndPassReceiver());
+
+ // Create the four DataPipes.
+ mojo::ScopedDataPipeConsumerHandle audio_consumer;
+ mojo::ScopedDataPipeProducerHandle audio_producer;
+ ASSERT_EQ(MOJO_RESULT_OK,
+ mojo::CreateDataPipe(nullptr, audio_producer, audio_consumer));
+
+ mojo::ScopedDataPipeConsumerHandle video_consumer;
+ mojo::ScopedDataPipeProducerHandle video_producer;
+ ASSERT_EQ(MOJO_RESULT_OK,
+ mojo::CreateDataPipe(nullptr, video_producer, video_consumer));
+
+ mojo::ScopedDataPipeConsumerHandle decrypt_consumer;
+ mojo::ScopedDataPipeProducerHandle decrypt_producer;
+ ASSERT_EQ(MOJO_RESULT_OK,
+ mojo::CreateDataPipe(nullptr, decrypt_producer, decrypt_consumer));
+
+ mojo::ScopedDataPipeProducerHandle decrypted_producer;
+ mojo::ScopedDataPipeConsumerHandle decrypted_consumer;
+ ASSERT_EQ(MOJO_RESULT_OK, mojo::CreateDataPipe(nullptr, decrypted_producer,
+ decrypted_consumer));
+
+ remote_decryptor->Initialize(
+ std::move(audio_consumer), std::move(video_consumer),
+ std::move(decrypt_consumer), std::move(decrypted_producer));
+
+ bool deinitialized = false;
+
+ EXPECT_CALL(*decryptor_, InitializeAudioDecoder(_, _))
+ .Times(testing::AtMost(1))
+ .WillOnce([](const AudioDecoderConfig& config,
+ Decryptor::DecoderInitCB cb) { std::move(cb).Run(true); });
+
+ EXPECT_CALL(*decryptor_, DeinitializeDecoder(Decryptor::kAudio))
+ .Times(testing::AtMost(1))
+ .WillOnce(testing::Assign(&deinitialized, true));
+
+ EXPECT_CALL(*decryptor_, DecryptAndDecodeAudio(_, _))
+ .WillRepeatedly([&deinitialized](scoped_refptr<DecoderBuffer> buffer,
+ Decryptor::AudioDecodeCB cb) {
+ ASSERT_FALSE(deinitialized) << "DecryptAndDecodeAudio called after "
+ "DeinitializeDecoder!";
+ std::move(cb).Run(Decryptor::kSuccess, Decryptor::AudioFrames());
+ });
+
+ auto data_buffer = mojom::DataDecoderBuffer::New();
+ data_buffer->timestamp = base::TimeDelta();
+ data_buffer->duration = base::Seconds(1);
+ data_buffer->is_key_frame = false;
+ data_buffer->data_size = 256;
+
+ auto mojo_buffer = mojom::DecoderBuffer::NewData(std::move(data_buffer));
+
+ bool decode_called = false;
+ remote_decryptor->DecryptAndDecodeAudio(
+ std::move(mojo_buffer),
+ base::BindOnce(
+ [](bool* called, Decryptor::Status status,
+ std::vector<mojom::AudioBufferPtr> buffers) { *called = true; },
+ &decode_called));
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ remote_decryptor->DeinitializeDecoder(Decryptor::kAudio);
+
+ remote_decryptor->InitializeAudioDecoder(
+ AudioDecoderConfig(AudioCodec::kAAC, SampleFormat::kSampleFormatS16,
+ ChannelLayoutConfig::Stereo(), 44100,
+ std::vector<uint8_t>(),
+ EncryptionScheme::kUnencrypted),
+ base::BindOnce(
+ [](mojo::ScopedDataPipeProducerHandle producer, bool success) {
+ LOG(INFO) << "InitializeAudioDecoder callback called. Success="
+ << success;
+ std::vector<uint8_t> data(256, 0);
+ size_t bytes_written = 0;
+ std::ignore = producer->WriteData(data, MOJO_WRITE_DATA_FLAG_NONE,
+ bytes_written);
+ },
+ std::move(audio_producer)));
+
+ std::string bad_message = bad_message_observer.WaitForBadMessage();
+ EXPECT_EQ(bad_message,
+ "DeinitializeDecoder with pending DecryptAndDecode reads");
+}
} // namespace media
Original Bug Report
State machine bypass in MojoDecryptorService leading to CDM decoder UAF
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential state machine bypass exists in MojoDecryptorService because DeinitializeDecoder fails to cancel or flush pending reads from its Mojo DataPipe in release builds. A compromised renderer can stall a decrypt-and-decode operation, deinitialize the CDM decoder, and then unblock the DataPipe to trigger decode operations on the deinitialized CDM instance. This violates the CDM API contract and could lead to a Use-After-Free (UAF) inside the sandboxed CDM utility process.
Affected files:
media/mojo/services/mojo_decryptor_service.cc
Estimated timestamp from git blame: 2017-12-14
Summary
A potential state machine bypass exists in MojoDecryptorService where DeinitializeDecoder fails to ensure that pending DataPipe read operations are flushed or cancelled in release builds. This allows a compromised renderer to potentially trigger decrypt-and-decode operations on a Content Decryption Module (CDM) decoder after it has already been deinitialized, violating the CDM API contract and potentially causing a Use-After-Free (UAF) in the sandboxed CDM utility process.
Vulnerability Analysis
In media/mojo/services/mojo_decryptor_service.cc, the method ResetDecoder correctly flushes pending reads before forwarding the request to the decryptor:
void MojoDecryptorService::ResetDecoder(StreamType stream_type) {
DVLOG(2) << __func__ << ": stream_type = " << stream_type;
MojoDecoderBufferReader* reader = GetBufferReader(stream_type);
if (!reader) {
CHECK(mojo::IsInMessageDispatch());
mojo::ReportBadMessage("Unexpected stream_type");
return;
}
reader->Flush(base::BindOnce(&MojoDecryptorService::OnReaderFlushDone,
weak_this_, stream_type));
}
However, DeinitializeDecoder only asserts that no reads are pending via a debug-only DCHECK:
void MojoDecryptorService::DeinitializeDecoder(StreamType stream_type) {
DVLOG(2) << __func__;
auto* reader = GetBufferReader(stream_type);
if (!reader) {
CHECK(mojo::IsInMessageDispatch());
mojo::ReportBadMessage("Unexpected stream_type");
return;
}
DCHECK(!reader->HasPendingReads())
<< "The decoder should be fully flushed before deinitialized.";
decryptor_->DeinitializeDecoder(stream_type);
}
In official release builds, DCHECK(!reader->HasPendingReads()) is compiled out. Consequently, DeinitializeDecoder acts as a direct, unvalidated forward to the underlying CdmAdapter and library CDM without ensuring that pending read callbacks are flushed, completed, or cancelled.
Neither MojoDecryptorService nor CdmAdapter keeps track of the deinitialization state or blocks subsequent decoding attempts.
Potential Attack Path
A compromised renderer with control over the Mojo IPC could potentially exploit this behavior via the following sequence (note that these are potential/suggested steps, as our tooling does not currently support code execution or running functional proof of concepts):
- Initialization: The compromised renderer invokes
InitializeAudioDecoder(orInitializeVideoDecoder) via themojom::DecryptorMojo interface to allocate and set up decoder state in the library CDM. - Stall Decode: The renderer calls
DecryptAndDecodeAudio(orDecryptAndDecodeVideo) with a non-zero buffer size but intentionally holds back writing any bytes to the Mojo DataPipe producer. On the service side,ProcessPendingReadsreceivesMOJO_RESULT_SHOULD_WAITand arms the watcher, leaving the read callback queued asynchronously inpending_read_cbs_indefinitely. - Deinitialize: The renderer sends a
DeinitializeDecoderIPC request. Because theDCHECKis stripped in release builds, this proceeds immediately and callscdm_->DeinitializeDecoderon the library CDM, causing it to deinitialize its decoder and free internal decoder contexts. - Trigger Decode: The renderer finally writes the withheld bytes to the Mojo DataPipe. This triggers the readable watcher, completes the pending read, and executes the queued read callback. This callback then invokes
DecryptAndDecodeAudio->CdmAdapter::DecryptAndDecodeAudio->cdm_->DecryptAndDecodeSamples()on the library CDM, which was already deinitialized.
Potential Impact
This sequence violates the CDM API contract (media/cdm/api/content_decryption_module.h), which requires a successful initialization prior to any decrypt-and-decode call.
- ClearKey CDM (Testing): In the test CDM, this results in a null-pointer dereference of
decoding_loop_inFFmpegCdmAudioDecoder::DecodeBuffer(withinffmpeg_cdm_audio_decoder.cc). - Widevine CDM (Production - Closed-source): Deinitialization typically frees heap-allocated decoder contexts. Accessing
DecryptAndDecodeSamplesorDecryptAndDecodeFrameafter deinitialization can result in a Use-After-Free (UAF) of these freed contexts on the CDM utility process heap. Since these library CDMs do not run with MiraclePtr protection, such UAFs can potentially be leveraged for Remote Code Execution (RCE) inside the sandboxed CDM utility process.
Suggested Fix
Modify MojoDecryptorService::DeinitializeDecoder to handle pending reads safely at runtime in release builds. It should either follow an asynchronous flush paradigm similar to ResetDecoder before invoking decryptor_->DeinitializeDecoder(stream_type), or apply a runtime check that rejects the operation or returns a bad message if reader->HasPendingReads() is true:
void MojoDecryptorService::DeinitializeDecoder(StreamType stream_type) {
DVLOG(2) << __func__;
auto* reader = GetBufferReader(stream_type);
if (!reader) {
CHECK(mojo::IsInMessageDispatch());
mojo::ReportBadMessage("Unexpected stream_type");
return;
}
if (reader->HasPendingReads()) {
CHECK(mojo::IsInMessageDispatch());
mojo::ReportBadMessage("Decoder has pending reads during deinitialization");
return;
}
decryptor_->DeinitializeDecoder(stream_type);
}
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.