Closed Bug 2025505 Opened 6 months ago Closed 6 months ago

Heap-use-after-free in [@ ImageDecoder::Close] via missing kungFuDeathGrip in [@ WorkerShutdownWatcher::OnShutdown]

Categories

(Core :: Audio/Video: Web Codecs, defect, P1)

defect

Tracking

()

RESOLVED FIXED
151 Branch
Tracking Status
firefox-esr115 --- unaffected
firefox-esr140 149+ fixed
firefox149 + fixed
firefox150 + fixed
firefox151 + fixed

People

(Reporter: bugmon, Assigned: alwu)

References

(Regression)

Details

(6 keywords, Whiteboard: [prefs-checked][adv-esr140.9.1+r][adv-main149.0.2+r])

Attachments

(9 files)

Summary

Heap-use-after-free in dom/media/webcodecs/ImageDecoder.cpp line 1078 (read of mComplete on freed this). Root cause: WorkerShutdownWatcher::OnShutdown() in dom/media/systemservices/MediaUtils.cpp line 268 invokes mConsumer->OnShutdown() through a raw ShutdownConsumer* pointer without taking a strong reference to the consumer. The consumer (ImageDecoder) can be freed by GC→CC→FreeSnowWhite during the nested call because the only remaining strong reference (ImageTrackList::mDecoder) is cleared inside Close() before JS-allocating code runs.

Affected Code

File: dom/media/systemservices/MediaUtils.cpp, lines 256-276
File: dom/media/systemservices/MediaUtils.h, line 254

// MediaUtils.h:254 — the raw pointer
ShutdownConsumer* mConsumer;

// MediaUtils.cpp:256-257 — lambda keeps the WATCHER alive, NOT the consumer
mWorkerRef = dom::WeakWorkerRef::Create(
    aWorkerPrivate, [self = RefPtr{this}] { self->OnShutdown(); });

// MediaUtils.cpp:266-271 — no kungFuDeathGrip
void OnShutdown() {
  if (mConsumer) {
    mConsumer->OnShutdown();   // ← calls through raw pointer; consumer can be
                               //    freed mid-call by GC→CC→FreeSnowWhite
  }
  Destroy();
}

ShutdownConsumer (MediaUtils.h:221-228) is a pure abstract interface with no refcounting — it cannot be held directly in a RefPtr. The [self = RefPtr{this}] capture keeps the WorkerShutdownWatcher alive for the duration of the callback, but the watcher only holds a raw pointer to the ImageDecoder. Nothing on the stack pins the ImageDecoder.

Why ImageDecoder becomes freeable mid-call — ImageDecoder::Close:

if (mTracks) {
  mTracks->MaybeRejectReady(aResult);
  mTracks->Destroy();                    // ← clears ImageTrackList::mDecoder (RefPtr<ImageDecoder>)
                                         //   → last non-wrapper strong ref dropped
}

if (!mComplete) {
  aResult.RejectTo(mCompletePromise);    // ← JS alloc (DOMException wrapper) — can trigger GC
                                         //   GC → wrapper finalized → refcount=0 → SnowWhite
                                         //   Worker GC_END → nsCycleCollector_collect → FreeSnowWhite
                                         //   → `this` FREED HERE
  mComplete = true;                      // ← write-after-free
}

if (mShutdownWatcher) {                  // ← read-after-free
  mShutdownWatcher->Destroy();
  mShutdownWatcher = nullptr;
}

On workers, WorkerJSRuntime::CustomGCCallback runs nsCycleCollector_collect synchronously at JSGC_END, and nsCycleCollector::BeginCollection runs FreeSnowWhite synchronously. So a GC triggered from inside RejectTo frees the object before the GC call returns.

Correct Pattern

void ImageDecoder::OnShutdown() {
  RefPtr<ImageDecoder> kungFuDeathGrip(this);  // pin `this` across Close()
  Close(MediaResult(NS_ERROR_DOM_ABORT_ERR, "Shutdown"_ns));
}

Exploit Chain

  1. Web content creates a dedicated Worker.
  2. Inside the worker, creates new ImageDecoder({ type: "image/png", data: ... }) — ImageDecoder::Constructor → media::ShutdownWatcher::Create(this) creates a WorkerShutdownWatcher holding a raw ShutdownConsumer* to the decoder and registers a WeakWorkerRef callback capturing [self = RefPtr{WorkerShutdownWatcher}].
  3. Worker waits for any pending MozPromise resolutions so their [self = RefPtr{this}] lambdas drop their refs to the decoder.
  4. Worker drops decoder = null. The ImageDecoder's JS wrapper is now unreachable but not yet collected. Remaining strong refs: (a) unreachable JS wrapper, (b) ImageTrackList::mDecoder (RefPtr<ImageDecoder> at ImageTrackList.h:91).
  5. Main thread calls worker.terminate() → WorkerPrivate::NotifyInternal(Canceling) → NotifyWorkerRefs → WeakWorkerRef::Notify invokes the stored lambda.
  6. Lambda calls WorkerShutdownWatcher::OnShutdown() → mConsumer->OnShutdown() (raw pointer, no stack ref) → ImageDecoder::OnShutdown() → ImageDecoder::Close().
  7. Inside Close(): mTracks->Destroy() sets mDecoder = nullptr. Refcount now = 1 (unreachable wrapper only).
  8. Next JS allocation — aResult.RejectTo(mCompletePromise) at line 1079 (when !mComplete), which calls Promise::MaybeRejectWith* → allocates a JS DOMException wrapper — triggers GC under heap pressure.
  9. GC finalizes the unreachable ImageDecoder wrapper → deferred Release → refcount = 0 → object enters SnowWhite set.
  10. GC end → WorkerJSRuntime::CustomGCCallback(JSGC_END) → nsCycleCollector_collect → BeginCollection → FreeSnowWhite → SnowWhiteKiller::~SnowWhiteKiller → delete ImageDecoder.
  11. RejectTo returns. mComplete = true at line 1080 writes to freed memory. Subsequent if (mShutdownWatcher) at line 1083 reads freed memory.

Steps to Reproduce

  • Pref: dom.media.webcodecs.image-decoder.enabled = true
  • Timing patch: Apply the provided patch to /firefox/dom/media/webcodecs/ImageDecoder.cpp which inserts JS::NonIncrementalGC immediately after mTracks->Destroy(). This makes the natural GC window (normally at RejectTo(mCompletePromise) under heap pressure) deterministic. Rebuild.
  • Load test.html — crashes on the first worker iteration.

Security Impact

  • Severity: High (sec-high). Heap-use-after-free on a 392-byte cycle-collected DOM object in the content process.
  • Attacker capability: Write-after-free (mComplete = true) on a freed region plus virtual call (mShutdownWatcher->Destroy()) through a member of freed memory. If the freed 392-byte region is reallocated between the free and the continued execution (possible because FreeSnowWhite frees multiple objects and RejectTo performs further allocations), the attacker can corrupt a replacement object, and the mShutdownWatcher->Destroy() virtual call reads a vtable pointer from attacker-controlled memory.
  • Preconditions: (1) dom.media.webcodecs.image-decoder.enabled enabled. (2) Worker thread — ImageDecoder must be used in a Worker. (3) JS wrapper unreachable at terminate() time — attacker-controllable. (4) !mComplete at shutdown — achievable by terminating before decode finishes. (5) GC fires during RejectTo — timing-dependent but reachable under heap pressure, which attacker can induce.

ASAN Report

==509518==ERROR: AddressSanitizer: heap-use-after-free on address 0x7d028bb10dbc at pc 0x7bc26a009205 bp 0x7bc235aec3f0 sp 0x7bc235aec3e8
READ of size 1 at 0x7d028bb10dbc thread T28
    #0 0x7bc26a009204 in mozilla::dom::ImageDecoder::Close(mozilla::MediaResult const&) /firefox/dom/media/webcodecs/ImageDecoder.cpp:1100:8
    #1 0x7bc26a013ea5 in mozilla::dom::ImageDecoder::OnShutdown() /firefox/dom/media/webcodecs/ImageDecoder.cpp:1120:3
    #2 0x7bc26a013ea5 in non-virtual thunk to mozilla::dom::ImageDecoder::OnShutdown() /firefox/dom/media/webcodecs/ImageDecoder.cpp:0:0
    #3 0x7bc269e39e31 in mozilla::media::WorkerShutdownWatcher::OnShutdown() /firefox/dom/media/systemservices/MediaUtils.cpp:268:18
    #4 0x7bc269e39e31 in mozilla::media::WorkerShutdownWatcher::Initialize(mozilla::dom::WorkerPrivate*)::'lambda'()::operator()() const /firefox/dom/media/systemservices/MediaUtils.cpp:257:55
    #10 0x7bc26c25c2cc in mozilla::dom::WorkerRef::Notify() /firefox/dom/workers/WorkerRef.cpp:102:3
    #11 0x7bc26c25c8f5 in mozilla::dom::WeakWorkerRef::Notify() /firefox/dom/workers/WorkerRef.cpp:137:14
    #12 0x7bc26c240369 in mozilla::dom::WorkerPrivate::NotifyWorkerRefs(mozilla::dom::WorkerStatus) /firefox/dom/workers/WorkerPrivate.cpp:5035:16
    #13 0x7bc26c2449db in mozilla::dom::WorkerPrivate::NotifyInternal(mozilla::dom::WorkerStatus) /firefox/dom/workers/WorkerPrivate.cpp:5843:5
    #14 0x7bc26c25fd8a in mozilla::dom::WorkerThreadRunnable::Run() /firefox/dom/workers/WorkerRunnable.cpp:438:12
    #15 0x7bc26c2440cb in mozilla::dom::WorkerPrivate::ProcessAllControlRunnablesLocked() /firefox/dom/workers/WorkerPrivate.cpp:4684:11
    #16 0x7bc26c241367 in mozilla::dom::WorkerPrivate::DoRunLoop(JSContext*) /firefox/dom/workers/WorkerPrivate.cpp:3857:21
    #17 0x7bc26c206733 in mozilla::dom::workerinternals::(anonymous namespace)::WorkerThreadPrimaryRunnable::Run() /firefox/dom/workers/RuntimeService.cpp:2307:42

0x7d028bb10dbc is located 380 bytes inside of 392-byte region [0x7d028bb10c40,0x7d028bb10dc8)
freed by thread T28 here:
    #0 0x556c680ca6f6 in __interceptor_free _asan_rtl_:3
    #1 0x7bc2601dd933 in SnowWhiteKiller::~SnowWhiteKiller() /firefox/xpcom/base/nsCycleCollector.cpp:2657:7
    #2 0x7bc2601c1d51 in nsCycleCollector::FreeSnowWhite(bool) /firefox/xpcom/base/nsCycleCollector.cpp:2848:3
    #3 0x7bc2601c8f9c in nsCycleCollector::BeginCollection(mozilla::CCReason, ccIsManual, nsICycleCollectorListener*) /firefox/xpcom/base/nsCycleCollector.cpp:3907:3
    #4 0x7bc2601c7f4d in nsCycleCollector::Collect(mozilla::CCReason, ccIsManual, JS::SliceBudget&, nsICycleCollectorListener*, bool) /firefox/xpcom/base/nsCycleCollector.cpp:3703:9
    #5 0x7bc2601ccaef in nsCycleCollector_collect(mozilla::CCReason, nsICycleCollectorListener*) /firefox/xpcom/base/nsCycleCollector.cpp:4268:28
    #6 0x7bc26c20749d in mozilla::dom::workerinternals::(anonymous namespace)::WorkerJSRuntime::CustomGCCallback(JSGCStatus) /firefox/dom/workers/RuntimeService.cpp:915:11
    #7 0x7bc26ffc4822 in js::gc::GCRuntime::callGCCallback(JSGCStatus, JS::GCReason) const /firefox/js/src/gc/GC.cpp:1746:3
    #13 0x7bc26ffcd8b6 in JS::NonIncrementalGC(JSContext*, JS::GCOptions, JS::GCReason) /firefox/js/src/gc/GCAPI.cpp:300:21
    #14 0x7bc26a009010 in mozilla::dom::ImageDecoder::Close(mozilla::MediaResult const&) /firefox/dom/media/webcodecs/ImageDecoder.cpp:1095:7
    #15 0x7bc26a013ea5 in mozilla::dom::ImageDecoder::OnShutdown() /firefox/dom/media/webcodecs/ImageDecoder.cpp:1120:3
    #17 0x7bc269e39e31 in mozilla::media::WorkerShutdownWatcher::OnShutdown() /firefox/dom/media/systemservices/MediaUtils.cpp:268:18

previously allocated by thread T28 here:
    #3 0x7bc26a00bd4b in RefPtr<mozilla::dom::ImageDecoder> mozilla::MakeRefPtr<mozilla::dom::ImageDecoder, ...>
    #4 0x7bc26a00bd4b in mozilla::dom::ImageDecoder::Constructor(mozilla::dom::GlobalObject const&, mozilla::dom::ImageDecoderInit const&, mozilla::ErrorResult&) /firefox/dom/media/webcodecs/ImageDecoder.cpp:531:23
    #5 0x7bc265c885ad in mozilla::dom::ImageDecoder_Binding::_constructor(JSContext*, unsigned int, JS::Value*) /firefox/obj-x86_64-pc-linux-gnu/dom/bindings/./ImageDecoderBinding.cpp:1582:58

SUMMARY: AddressSanitizer: heap-use-after-free (/firefox/obj-x86_64-pc-linux-gnu/dist/bin/libxul.so+0x1caa8204)

Suggested Fix

Add a kungFuDeathGrip in ImageDecoder::OnShutdown():

void ImageDecoder::OnShutdown() {
  // WorkerShutdownWatcher::OnShutdown() calls us through a raw
  // ShutdownConsumer* pointer and does not hold a strong ref. Close()
  // calls mTracks->Destroy() which drops the ImageTrackList→ImageDecoder
  // back-ref, and then calls RejectTo(mCompletePromise) which can GC.
  // On workers, GC synchronously runs CC→FreeSnowWhite, which can free
  // `this` while Close() is still on the stack.
  RefPtr<ImageDecoder> kungFuDeathGrip(this);
  Close(MediaResult(NS_ERROR_DOM_ABORT_ERR, "Shutdown"_ns));
}

The same audit should be performed for MainShutdownWatcher::Observe() at MediaUtils.cpp:228-230, which has the identical pattern.

Attached file REPRODUCE.md —
Attached file Crash stack trace —
Attached file Testcase: test.html —
Attached patch Patch — — Splinter Review
Group: core-security → dom-core-security
Component: DOM: Security → Audio/Video: Web Codecs
Whiteboard: [prefs-checked]

Pref check: dom.media.webcodecs.image-decoder.enabled defaults to true on all channels (StaticPrefList.yaml, no ifdef) — supported configuration. The timing patch is a repro aid for the GC race, not a gate.

Group: dom-core-security → media-core-security
Assignee: nobody → alwu
Attached file (secure) —

Add a temporary RefPtr at the start of Close() to keep the ImageDecoder
alive through its own execution. Close() calls mTracks->Destroy() which
clears ImageTrackList::mDecoder — potentially the last C++ strong reference
— before calling RejectTo() which may allocate a JS object. On workers, this
allocation can trigger a GC cycle that runs FreeSnowWhite synchronously,
freeing the object before Close() returns.

Severity: -- → S2
Priority: -- → P1

Comment on attachment 9557763 [details]
(secure)

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: Moderate. The patch reveals a UAF triggered when a Worker containing an ImageDecoder is terminated — all reproducible with standard web APIs (Worker, ImageDecoder, worker.terminate()). Reliable triggering requires inducing a GC at the right moment via heap pressure, which is achievable but not trivial.
  • Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: No
  • Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?: firefox after 133
  • If not all supported branches, which bug introduced the flaw?: Bug 1927020
  • Do you have backports for the affected branches?: Yes
  • If not, how different, hard to create, and risky will they be?:
  • How likely is this patch to cause regressions; how much testing does it need?: Low. The fix adds a temporary strong reference with no observable behavior change. Standard try CI is sufficient.
  • Is the patch ready to land after security approval is given?: No
  • Is Android affected?: Yes
Attachment #9557763 - Flags: sec-approval?
Attachment #9556889 - Flags: sec-approval?
  • Is the patch ready to land after security approval is given?: No

That's not the usual answer... mis-click?

Keywords: regression
Regressed by: 1927020
Whiteboard: [prefs-checked] → [prefs-checked][reminder-test 2026-05-05]

you don't intend to land the POC's "inject a compromise" patch, right? I'm confused why you're requesting sec-approval on those

Attachment #9556889 - Flags: sec-approval? → sec-approval-

Comment on attachment 9557763 [details]
(secure)

sec-approval+ to land now and request uplifts

Attachment #9557763 - Flags: sec-approval? → sec-approval+

(In reply to Daniel Veditz [:dveditz] from comment #12)

you don't intend to land the POC's "inject a compromise" patch, right? I'm confused why you're requesting sec-approval on those

No, I only did it on my own patch, not sure why bugzilla add sec-approval on those PoCs.

(In reply to Daniel Veditz [:dveditz] from comment #11)

  • Is the patch ready to land after security approval is given?: No

That's not the usual answer... mis-click?

Ah, sorry for typo. Yes, the patch can land it now.

firefox-beta Uplift Approval Request

  • User impact if declined/Reason for urgency: Security fix.
  • Code covered by automated testing?: no
  • Fix verified in Nightly?: no
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: Low. The fix adds a temporary strong reference with no observable behavior change.
  • String changes made/needed?: No
  • Is Android affected?: yes
Attachment #9559023 - Flags: approval-mozilla-beta?
Attached file (secure) —

firefox-esr140 Uplift Approval Request

  • User impact if declined/Reason for urgency: Security fix.
  • Code covered by automated testing?: no
  • Fix verified in Nightly?: no
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: Low. The fix adds a temporary strong reference with no observable behavior change.
  • String changes made/needed?: No
  • Is Android affected?: yes
Attachment #9559024 - Flags: approval-mozilla-esr140?
Attached file (secure) —
Status: UNCONFIRMED → NEW
Ever confirmed: true
Pushed by alwu@mozilla.com: https://github.com/mozilla-firefox/firefox/commit/0f2b6b79a66b https://hg.mozilla.org/integration/autoland/rev/e04c43d891dc Hold a strong reference in ImageDecoder::Close. r=media-playback-reviewers,padenot
Group: media-core-security → core-security-release
Status: NEW → RESOLVED
Closed: 6 months ago
Resolution: --- → FIXED
Target Milestone: --- → 151 Branch
Attachment #9559023 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
QA Whiteboard: [sec] [uplift] [qa-triage-done-c151/b150]
See Also: → 1932526
Attachment #9559024 - Flags: approval-mozilla-esr140? → approval-mozilla-esr140+
Whiteboard: [prefs-checked][reminder-test 2026-05-05] → [prefs-checked][reminder-test 2026-05-05][adv-main149+r]
Whiteboard: [prefs-checked][reminder-test 2026-05-05][adv-main149+r] → [prefs-checked][reminder-test 2026-05-05][adv-main149+r][adv-esr140.9.1+r]
Whiteboard: [prefs-checked][reminder-test 2026-05-05][adv-main149+r][adv-esr140.9.1+r] → [prefs-checked][reminder-test 2026-05-05][adv-esr140.9.1+r]
Whiteboard: [prefs-checked][reminder-test 2026-05-05][adv-esr140.9.1+r] → [prefs-checked][reminder-test 2026-05-05][adv-esr140.9.1+r][adv-esr149.0.2+r]
Whiteboard: [prefs-checked][reminder-test 2026-05-05][adv-esr140.9.1+r][adv-esr149.0.2+r] → [prefs-checked][reminder-test 2026-05-05][adv-esr140.9.1+r]
Whiteboard: [prefs-checked][reminder-test 2026-05-05][adv-esr140.9.1+r] → [prefs-checked][reminder-test 2026-05-05][adv-esr140.9.1+r][adv-main149.0.2+r]

a month ago, dveditz placed a reminder on the bug using the whiteboard tag [reminder-test 2026-05-05] .

alwu, please refer to the original comment to better understand the reason for the reminder.

Flags: needinfo?(alwu)
Whiteboard: [prefs-checked][reminder-test 2026-05-05][adv-esr140.9.1+r][adv-main149.0.2+r] → [prefs-checked][adv-esr140.9.1+r][adv-main149.0.2+r]
Flags: needinfo?(alwu)
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: