← All reports

[3] TimingFunction shared with the scrolling thread under non-atomic refcounting

MediumWebCore threaded animationsOther

Two threads, one easing curve, and a reference count that couldn't count.

c541bf0

Rated Medium — the object is polymorphic and the first use after a premature free is a virtual dispatch, which is the shape that supports control-flow hijack. What holds it here is that winning the interleaving reliably is the hard part; the realistic baseline is a non-deterministic renderer crash.

Reference counting은 shared object에 대한 모든 acquire와 release가 어떤 방식으로든 serialize될 때만 memory-safe합니다. WebKit은 이 선택을 lock이 아니라 객체가 상속하는 base class에 인코딩합니다. macOS에서는 accelerated로 분류되는 animation이 두 번째 thread로 넘겨집니다. AcceleratedEffect — animation의 timing과 keyframe을 담는 graph — 는 scrolling thread에서 evaluate될 수 있어, animation이 main thread 작업과 독립적으로 계속 진행됩니다. 동시에 같은 effect data는 style resolution과 test introspection을 위해 main thread에서도 계속 접근 가능한 상태로 남습니다. 따라서 이 graph 내부의 모든 ref-counted type은 thread-safe refcounting을 사용해야 합니다. 두 thread가 동시에 해당 객체에 대해 transient reference를 획득하고 해제하기 때문입니다.

관전 포인트: 하나의 easing curve를 공유하는 accelerated animation을 여러 개 실행하는 페이지는 두 thread가 같은 객체의 refcount 업데이트를 놓치도록 유도할 수 있습니다. 그 결과 live holder가 virtual-dispatch를 시도하려는 시점에 polymorphic C++ 객체가 먼저 해제되는 상황이 발생합니다.

macOS에서는 accelerated effect가 main thread와 scrolling thread 양쪽에서 접근될 수 있으므로, TimingFunction class도 AcceleratedEffectAcceleratedEffectValues가 사용하는 다른 모든 ref-counted type과 마찬가지로 ThreadSafeRefCounted를 사용해야 합니다. (commit message에 따르면 이 fix는 bug 분석 과정에서 LLM이 제안했고, 저자가 이를 검증했습니다.)

Source/WebCore/platform/animation/TimingFunction.h

#include <wtf/NeverDestroyed.h>
#include <wtf/Ref.h>
-#include <wtf/RefCounted.h>
+#include <wtf/ThreadSafeRefCounted.h>
#include <wtf/Vector.h>
...
-class TimingFunction : public RefCounted<TimingFunction> {
+class TimingFunction : public ThreadSafeRefCounted<TimingFunction> {
public:
virtual Ref<TimingFunction> clone() const = 0;

LayoutTests/webanimations/threaded-animations/timing-function-threading-check.html

+ // cubic-bezier easing을 사용해 TimingFunction::transformProgress()가
+ // 사소하지 않은 작업을 수행하도록 만들고, main thread와 scrolling thread가
+ // 동일한 TimingFunction에 대해 transient ref를 동시에 보유하는 window를 넓힙니다.
+ const easing = "cubic-bezier(0.1, 0.7, 1.0, 0.1)";
+ const animations = [
+ target.animate({ translate: ["100px", "200px"] }, { duration, easing }),
+ target.animate({ translate: ["300px", "400px"] }, { duration, easing, composite: "add" }),
+ target.animate({ scale: [1, 2] }, { duration, easing }),
+ target.animate({ scale: [3, 4] }, { duration, easing, composite: "add" })
+ ];
+ await Promise.all(animations.map(animation => animationAcceleration(animation)));
+ // scrolling thread가 동시에 effect를 적용하는 동안 main thread에서 animation
+ // stack을 반복적으로 resolve합니다. 두 경로 모두 AnimationEffectTiming::resolve()를
+ // 호출하며, 이는 effect의 TimingFunction을 protect합니다. 이 과정에서 RefCounted
+ // threading check가 걸리면 안 됩니다.
+ for (let i = 0; i < 50; ++i)
+ await UIHelper.remoteAnimationStackForElement(target);

실제 프로덕션 코드는 한 줄만 변경됩니다. base class 선언이 class TimingFunction : public RefCounted<TimingFunction>에서 class TimingFunction : public ThreadSafeRefCounted<TimingFunction>로 바뀌고, include도 <wtf/RefCounted.h>에서 <wtf/ThreadSafeRefCounted.h>로 교체됩니다. 이 변경으로 TimingFunction과 그 서브클래스 — LinearTimingFunction, CubicBezierTimingFunction, StepsTimingFunction, SpringTimingFunction — 의 reference count가 atomic해지며, RefCounted가 강제하던 owning-thread 제약도 함께 사라집니다. Locking 추가나 ownership 구조 변경, API 변경은 없습니다. 나머지 변경은 새로운 layout test로, cubic-bezier(0.1, 0.7, 1.0, 0.1) easing을 공유하는 4개의 accelerated animation(그 중 2개는 composite: "add")을 시작하고 acceleration을 기다린 뒤, UIHelper.remoteAnimationStackForElement(target)를 50회 반복 호출합니다. 이를 통해 main thread의 timing resolution이 scrolling thread가 같은 effect를 적용하는 동안 함께 실행되도록 만듭니다.

단일 thread ownership을 전제로 하는 refcount를 가진 refcounted 객체가 여러 thread에 걸쳐 공유되는 패턴입니다. 이 경우 동시에 발생하는 acquire/release가 업데이트를 놓칠 수 있고, live holder가 아직 참조 중인 상태에서 객체가 해제될 수 있습니다.

RefCounted<T> vs. ThreadSafeRefCounted<T>. RefCounted<T>는 reference-counted 객체를 위한 WTF의 base class로, ref()/deref()가 일반적인 non-atomic 카운터를 증감시키고 카운트가 0이 되면 객체가 destroy됩니다. 이 클래스의 계약 조건은 refcount 변경이 단일 owning thread에서만 일어난다는 것이며, debug/ASan build에서는 이 계약을 검증하는 check가 포함되어 있습니다. ThreadSafeRefCounted<T>는 그 대응 버전으로, 카운터가 atomic read-modify-write 연산으로 변경되기 때문에 ref()/deref()가 어떤 thread에서 호출되어도 안전합니다.

Non-atomic increment. ++count는 load / add / store 순서로 컴파일됩니다. 같은 위치에 대해 두 thread가 이 연산을 동시에 수행하면, 둘 다 같은 값을 load한 뒤 둘 다 value+1을 store하는 상황이 발생할 수 있습니다.

Ref / RefPtrprotect(...) idiom. 이 smart pointer들은 생성 시 ref()를, 소멸 시 deref()를 호출합니다. 어떤 호출을 감싸며 임시로 생성되는 local RefPtr은 오직 그 호출이 진행되는 동안 callee의 receiver를 살아있게 유지하기 위한 용도입니다. 이런 사이트 각각이 그 순간 실행 중인 어떤 thread에서든 발생할 수 있는 acquire/release 쌍입니다.

TimingFunction. Animation의 easing curve(linear, cubic-bezier(...), steps(...), spring)를 나타냅니다. Virtual type(), clone(), operator==를 가진 abstract polymorphic base이며, transformProgress(progress, duration)type()을 switch하여 linear progress를 curve를 따라 매핑합니다.

Threaded animations. ENABLE(THREADED_ANIMATIONS) 하에서, accelerated animation은 AcceleratedEffect graph로 표현됩니다. 이 graph는 timing과 Vector<Keyframe>으로 구성되며, 각 keyframe이 RefPtr<TimingFunction>을 갖고 있고, scrolling thread에서 evaluate됩니다. AcceleratedEffect는 추가로 RefPtr<TimingFunction> m_defaultKeyframeTimingFunction을 저장합니다. 같은 data는 style, getComputedStyle, 그리고 UIHelper.remoteAnimationStackForElement() 같은 introspection hook을 위해 main thread에서도 계속 접근 가능합니다.

Web Animations composite: "add". 한 element 위에서 동시에 실행되는 여러 accelerated effect의 stack을 구성하는 방식으로, 해당 animation의 effect가 기존 값을 대체하는 대신 그 위에 더해지는 형태입니다.

이번 결함은 non-atomic reference count에서 발생하는 data race로, logic이나 bounds 오류가 아니라 lifetime/UAF 계열의 결함에 해당합니다.

  Main thread                      Scrolling thread
  ---------------                  ----------------
  ref(): load count = 2
                                   ref(): load count = 2
  add 1 -> 3
  store 3                          add 1 -> 3
                                   store 3   <- one increment lost (should be 4)
  ...                              ...
  deref(): 3 -> 2
  deref(): 2 -> 1
                                   deref(): 1 -> 0  -> delete TimingFunction
                                   protect(...)->transformProgress()
                                     ^ virtual dispatch through freed object

위 interleaving에서는 놓친 increment로 인해 count가 실제 live holder 수보다 낮아집니다. 이후 발생하는 deref()가 카운트를 0으로 관찰하면서 TimingFunction이 destroy되지만, 다른 thread의 RefPtr/Ref는 여전히 그 객체를 가리키고 있습니다. TimingFunction이 polymorphic이고 transformProgress()type()을 switch하기 때문에, 해제 이후 첫 사용은 해제된 객체의 vtable slot을 통한 virtual dispatch가 됩니다. 반대로 decrement를 놓치는 경우는 상대적으로 덜 위험한 방향으로, 단순한 leak으로 이어집니다.

이러한 공유 구조는 우연이 아니라 구조적입니다. Keyframe들이 RefPtr<TimingFunction>을 보유하고, timing-resolution 경로들이 transient protecting ref를 획득하며, commit message에 따르면 같은 AcceleratedEffect graph가 main thread와 macOS scrolling thread 양쪽에서 walk됩니다. 같은 header에 있는 NeverDestroyed<Ref<...>> singleton — LinearTimingFunction::identity()CubicBezierTimingFunction::defaultTimingFunction() — 은 두 thread가 모두 count를 변경할 수 있는 process-wide instance이며, 이들이 영구적으로 보유하는 Ref는 놓친 increment로 인해 count가 이미 0까지 내려간 상황을 막아주지 못합니다.

가장 먼저 드러난 것은 corruption이 아니라 tooling 신호였습니다. Bug title은 기존 layout test가 "ASan 하에서 crash할 수 있다"고 보고하고 있는데, 이는 RefCounted 내부의 debug/ASan-enabled owning-thread 검증 로직이 이미 존재하던 threaded-animations test에서 이 invariant 위반을 감지했다는 의미입니다. 새로 추가된 regression test는 자체 주석에서 concurrent 경로를 명시적으로 지목합니다. AnimationEffectTiming::resolve()가 양쪽 thread에서 effect의 TimingFunction에 대해 transient protecting ref를 획득한다는 내용입니다. 또한 cubic-bezier easing을 의도적으로 선택하여 transformProgress()가 두 thread가 동시에 ref를 보유하는 동안 사소하지 않은 작업을 수행하도록 만들어 window를 넓힙니다.

이 race를 안정적으로 이길 수 있는 attacker라면, polymorphic C++ 객체에 대한 use-after-free를 얻을 수 있고 그 이후 해당 객체는 virtual-dispatch됩니다. 유리한 heap 조건이 갖춰진다면 이는 control-flow hijack으로 이어질 수 있는 primitive의 형태에 해당합니다. 다만 현실적인 baseline 결과는 재현이 불안정한 renderer crash입니다. 어떤 형태로 확장되더라도 그 범위는 WebContent sandbox 내부에 머무르며, 추가적인 별도의 sandbox escape가 필요합니다.

이 vulnerability는 reference-counted 객체가 어떤 holder라도 reference를 유지하는 동안 계속 살아있어야 한다는 lifetime invariant를 깨뜨림으로써 WebContent process 내부의 memory safety를 약화시킵니다. 여기서 전제되는 model assumption은, 두 번째 thread — 여기서는 accelerated effect를 resolve하는 scrolling thread — 로 publish된 객체는 thread-safe ownership을 가져야 한다는 것입니다. Fix 이전에는 TimingFunction이 일반 web content로부터 접근 가능한 상태에서 이 assumption을 위반하고 있었습니다.

Insight: 이 fix는 synchronization을 추가하지 않고 ownership model만 순수하게 교정한 형태입니다. 이 방식이 옳으려면 TimingFunction instance가 한 번 공유된 이후로는 immutable이어야 합니다. Source 코드는 대체로 이를 뒷받침합니다. LinearTimingFunction::m_pointsconst Vector<Point>이고, CubicBezierTimingFunctionm_x1..m_y2const double입니다. 다만 CubicBezierTimingFunction::setTimingFunctionPreset()m_timingFunctionPreset을 변경하는 public non-const mutator이고, operator==가 이 필드를 읽습니다. 즉 이 class는 완전히 immutable하지 않으며, atomic refcounting만으로는 main thread의 setter와 concurrent하게 발생하는 이 필드에 대한 read를 커버하지 못합니다. 이는 이번 patch가 다루지 않는 별개의 문제입니다.