← All reports

[6] PathCG should use thread-safe scratchContext

MediumWebCore platform graphicsRace

Two locks, one CoreGraphics context, and they never excluded each other.

2917a66

Medium — 이전 fix에서 추가된 두 개의 lock은 서로를 배제하지 못했습니다. 그 결과 공유된 CoreGraphics context가 web content로부터 계속 동시에 변경 가능한 상태로 남아 있었습니다. 확인된 영향은 안정적으로 재현 가능한 renderer crash이며, 더 강력한 primitive로 이어지는지는 race 대상이 되는 graphics-state 필드가 heap 기반의 dash array인지에 달려 있습니다. 다만 closed-source인 CG 내부 구현으로는 이 부분이 확인되지 않습니다.

Stroke된 path에 대한 기하학적 질문에 답하려면 — 특정 point가 stroke 위에 있는지, stroke의 bounds가 무엇인지 — stroke parameter를 담고 있는 CoreGraphics context가 필요합니다. 그래서 WebKit은 화면에 그려지지 않는 일회용 "scratch" context를 유지합니다. 원래 이 context는 process 전역 singleton이었습니다. Main thread만이 stroke geometry를 계산했기 때문에 이 방식은 안전했습니다. 그런데 OffscreenCanvasWorker로 transfer하는 기능이 도입되면서 상황이 달라졌습니다. isPointInStroke()가 이제 worker thread에서 실행되는 반면, main thread는 paint를 위해 SVG stroke bounding box를 계산합니다. 이 경우, 한 번에 한 thread만 scratch context를 변경한다는 invariant가 반드시 성립해야 합니다.

관전 포인트: 하나의 page가 두 개의 thread를 동시에 동기화되지 않은 상태로 하나의 공유 CoreGraphics context에 접근하게 만들 수 있습니다. 그 결과 renderer가 이후 읽어들이는 graphics-state 구조체가 손상됩니다.

Bug 313935의 fix는 불완전했습니다. 해당 fix는 PathCG::strokeContains()PathCG::strokeBoundingRect()에 각각 static Lock을 추가하여 두 함수를 독립적으로 thread-safe하게 만들었습니다. 하지만 strokeContains()strokeBoundingRect()는 서로 다른 두 thread에서 동시에 호출될 수 있습니다. 이번 fix는 scratchContext()가 thread별 graphics context를 반환하도록 변경하여, 두 thread가 같은 context에 동시에 접근하지 못하도록 합니다.

Source/WebCore/platform/graphics/cg/PathCG.cpp

static inline CGContextRef scratchContext()
{
 
- static NeverDestroyed<RetainPtr<CGContextRef>> context = createScratchContext();
 
- return context.get().get();
+ static NeverDestroyed<ThreadSpecific<RetainPtr<CGContextRef>>> context;
+
+ auto& result = *context.get();
+ if (!result)
+ result = createScratchContext();
+
+ return result.get();
}
 
bool PathCG::strokeContains(const FloatPoint& point, NOESCAPE const Function<void(GraphicsContext&)>& strokeStyleApplier) const
{
ASSERT(strokeStyleApplier);
 
- static Lock scratchContextLock;
 
- Locker locker { scratchContextLock };
-
CGContextRef context = scratchContext();
 
CGContextSaveGState(context);
...
FloatRect PathCG::strokeBoundingRect(NOESCAPE const Function<void(GraphicsContext&)>& strokeStyleApplier) const
{
 
- static Lock scratchContextLock;
 
- Locker locker { scratchContextLock };
-
CGContextRef context = scratchContext();
 
CGContextSaveGState(context);

LayoutTests/fast/canvas/offscreen-isPointInStroke-svg-stroke-bounds-race-crash.html

+ const workerCode = `
+ self.onmessage = function(e) {
+ const ctx = e.data.getContext("2d");
+ let iter = 0;
+ while (true) {
+ const dashLen = 2 + (iter % 6);
+ const dashes = [];
+ for (let d = 0; d < dashLen; d++)
+ dashes.push(3 + ((iter * 7 + d * 13) % 30));
+ ctx.setLineDash(dashes);
+ ...
+ ctx.isPointInStroke(64, 64);
+ iter++;
+ }
+ };
+ `;
+ // 8개의 worker를 실행하여 strokeContains를 집중적으로 호출
+ for (let i = 0; i < 8; i++) {
+ const canvas = document.createElement("canvas");
+ const offscreen = canvas.transferControlToOffscreen();
+ const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));
+ worker.postMessage(offscreen, [offscreen]);
+ }
...
+ // stroke-box를 reference box로 갖는 clip-path는 매 paint마다 strokeBoundingBox()를 강제로 호출시킴
+ path.style.clipPath = "inset(0) stroke-box";
...
+ // CGContextSetLineDash가 서로 다른 array를 allocate하도록 dash pattern을 다양하게 변경
+ const d1 = 3 + ((seed * 7) % 20);

WebCore::scratchContext()는 scratch CGContextRef가 process 전역이 아니라 thread별로 존재하도록 재작성되었습니다. 기존 구현은 createScratchContext()로 한 번만 초기화되는 static NeverDestroyed<RetainPtr<CGContextRef>> 하나를 유지했습니다. 새 구현은 static NeverDestroyed<ThreadSpecific<RetainPtr<CGContextRef>>>를 유지하며, 각 thread가 처음 접근할 때 context를 지연 생성합니다 (auto& result = *context.get(); if (!result) result = createScratchContext();).

공유 context가 사라지면서, 이전에 strokeContains()strokeBoundingRect()에 각각 추가되었던 함수별 static Lock scratchContextLock / Locker locker { scratchContextLock } 쌍이 삭제되었습니다. 이제 두 함수 모두 scratchContext()를 호출한 뒤 곧바로 CGContextSaveGState(context)로 진행합니다. 이번 commit에는 regression test도 함께 추가되었습니다. 이 test는 8개의 worker를 생성해 OffscreenCanvas.isPointInStroke()를 dash pattern을 바꿔가며 빡빡한 루프로 호출하는 동시에, main thread에서는 clip-path: inset(0) stroke-box가 적용된 20개의 SVG <path> element를 반복적으로 변경하여 매 paint마다 strokeBoundingBox() 재계산을 강제합니다.

하나의 공유된 mutable resource에 대한 접근을 호출 지점별로 분리된 lock으로 직렬화하려 했으나, 그 결과 서로를 배제하지 못하는 독립된 critical section이 만들어진 패턴.

CGContextRef와 graphics state stack. CoreGraphics context는 line width, line dash pattern, cap, join, CTM 등을 담은 graphics state stack을 유지하며, 이는 CGContextSaveGState / CGContextRestoreGState로 조작됩니다. 여기에 더해 mutable한 current path도 함께 갖습니다. CGContextSetLineDash는 호출자가 전달한 dash array를 현재 graphics state로 복사해 넣습니다. 즉 gstate는 호출자가 지정한 dash 개수에 따라 크기가 달라지는 heap 기반 buffer를 갖게 됩니다.

Scratch context pattern. PathCG는 순수하게 기하학적인 질문에 답하기 위해 CGContext가 필요합니다. CGContextReplacePathWithStrokedPath는 현재 graphics state의 stroke 설정에 따라 current path를 stroke된 outline으로 변환합니다. 이런 목적이기 때문에 화면에 표시되지 않는 일회용 context를 유지하는 것입니다. createScratchContext()가 이 context를 생성하고, scratchContext()가 이를 반환합니다.

NeverDestroyed<T>ThreadSpecific<T>. NeverDestroyed<T>는 destructor가 절대 호출되지 않는 함수 지역 static을 위한 WTF wrapper로, 의도적으로 leak시키는 방식의 process 수명 singleton을 만듭니다. ThreadSpecific<T>는 thread-local storage를 기반으로 thread마다 하나씩의 T instance를 제공합니다. *threadSpecific은 호출한 thread 자신의 instance를 반환하며, 처음 접근 시 default-construct되고 thread 종료 시 소멸됩니다.

함수 지역 static Lock. 함수 body 내부에 선언된 Lock그 함수의 모든 호출에 걸쳐 공유되는 단일 object입니다. 하지만 다른 함수 내부에 선언된 별개의 Lock은 완전히 별도의 object가 됩니다.

Worker 안의 OffscreenCanvas. canvas.transferControlToOffscreen()OffscreenCanvas를 생성하며, 이를 transfer와 함께 WorkerpostMessage할 수 있습니다. 이후에는 worker thread가 2D context를 소유하게 되고, isPointInStroke()를 포함한 해당 API 호출은 main thread가 아닌 worker thread에서 실행됩니다.

stroke-box reference box. Reference box가 stroke-boxclip-path는 해당 element의 stroke bounding box를 기준으로 해석됩니다. 따라서 renderer는 paint 시점에 해당 element의 strokeBoundingBox()를 계산해야 합니다. dstroke-dasharray attribute를 변경하면 캐시된 값이 무효화되어 다음 frame에서 재계산이 강제됩니다.

이 vulnerability는 공유된 mutable state에 대한 data race이며, CoreGraphics 자체의 allocation 내부에 memory-unsafety 결과를 초래합니다.

  Worker thread                          Main/rendering thread
  ─────────────                          ─────────────────────
  strokeContains()                       strokeBoundingRect()
    Locker { lockA }  ◄── distinct ──►     Locker { lockB }
    ctx = scratchContext() ─┐           ┌─ ctx = scratchContext()
                            ├── SAME ───┤
    CGContextSaveGState(ctx)│  CGContextRef │  CGContextSaveGState(ctx)
    CGContextSetLineDash(...)             CGContextSetLineDash(...)
    CGContextReplacePathWithStrokedPath   CGContextReplacePathWithStrokedPath
    CGContextRestoreGState(ctx)           CGContextRestoreGState(ctx)
           └──── interleaved gstate push/pop and dash-buffer writes ────┘

두 함수 모두 context를 광범위하게 변경합니다. CGContextSaveGState, stroke-style applier(line width, CGContextSetLineDash를 통한 dash, join, cap), path 구성, 그리고 CGContextReplacePathWithStrokedPath가 여기에 해당합니다. Bug 313935에 대한 이전 fix는 이를 직렬화하려는 시도로 두 함수 각각의 내부에 static Lock scratchContextLock을 선언했습니다. 함수 지역 static은 각각 별개의 Lock object이기 때문에, 두 critical section은 서로 배타적인 관계가 아니었습니다. strokeContains()가 lock A를 쥐고 있는 동안 strokeBoundingRect()는 lock B를 쥐었고, 어느 쪽도 다른 쪽을 배제하지 못했습니다. 원래 성립해야 했던 invariant — 한 번에 한 thread만 공유된 scratch context를 변경한다 — 는 각 함수 내부에서만 pairwise로 강제되었을 뿐, resource 전체에 대해서는 강제되지 않았습니다.

CGContext의 graphics-state stack과 current path는 non-atomic한 mutable 구조체입니다. Interleave된 push/pop 쌍은 다른 thread가 push한 gstate를 pop해버릴 수 있고, heap allocation 기반의 gstate 필드 — 가장 눈에 띄는 예로 CGContextSetLineDash가 복사해 넣는 line-dash array — 는 한 thread가 여전히 참조하고 있는 동안 다른 thread에 의해 덮어써지거나 해제될 수 있습니다. Regression test가 dash array의 길이와 내용을 양쪽에서 의도적으로 다르게 변화시키는 것도 바로 이 지점을 겨냥한 것으로 보입니다. 이번 fix는 lock의 범위를 넓히는 대신 공유 resource 자체를 제거하는 방식을 택했습니다. 각 thread가 ThreadSpecific을 통해 자신만의 context를 갖게 되므로, disjoint critical section 문제 자체가 해소되고 lock은 더 이상 필요하지 않게 됩니다.

Regression test 자체가 trigger 방법을 그대로 보여주며, 특별한 권한 없이 web에서 완전히 도달 가능합니다.

  1. createOffscreenCanvasWorkers()는 8개의 canvas를 만들고 각각에 transferControlToOffscreen()을 호출한 뒤, 그 handle을 새로 생성한 WorkerpostMessage합니다. 각 worker는 종료 조건이 없는 while (true) 루프에 진입하여 15개 segment로 이루어진 bezier path를 구성하고, 2에서 7 사이를 순환하며 반복마다 길이가 달라지는 dash count로 ctx.setLineDash(dashes)를 호출한 뒤 ctx.isPointInStroke(64, 64)를 호출합니다.
  2. isPointInStroke 호출은 PathCG::strokeContains()로 이어집니다. 기존 코드에서는 이 함수가 자체 함수 지역 lock을 획득하고, 공유된 CGContextRef를 가져온 뒤 CGContextSaveGState와 stroke-style applier를 실행합니다. 이 과정에서 worker의 dash array가 공유 gstate로 밀어넣어집니다.
  3. Main thread에서는 createSVGPaths()가 각각 clip-path: inset(0) stroke-box 스타일이 적용된 20개의 <path> element를 생성합니다. 이로 인해 매 paint마다 strokeBoundingBox() 계산이 강제됩니다.
  4. animationLoop()requestAnimationFrame마다 모든 path의 dstroke-dasharray를 다시 씁니다. 이렇게 하면 캐시된 stroke bounds가 무효화되고, 다음 paint에서 PathCG::strokeBoundingRect()가 호출됩니다. 이 함수는 다른, 서로 무관한 lock을 획득한 뒤 같은 공유 context를 변경합니다.

두 lock이 서로 다른 object이기 때문에, 2단계와 4단계는 context에 대한 상호 배제 없이 동시에 실행됩니다. Test에 달린 주석 — "CGContextSetLineDash가 서로 다른 array를 allocate하도록 dash pattern을 다양하게 변경" — 은 작성자가 이 race를 무해한 scalar 필드가 아니라 graphics state의 heap 기반 dash-array 필드 쪽으로 유도하고자 했음을 시사합니다.

직접 확인 가능한 primitive는 높은 신뢰도로 재현 가능한, 통제된 형태의 rendering process crash입니다. 이 이상으로 확장될 가능성도 존재합니다. Race를 벌이는 두 호출 지점 모두 공유 context에 대해 CGContextSaveGState(context)를 실행하므로, interleave된 push/pop 시퀀스가 graphics-state stack을 desynchronize시킬 가능성이 있습니다. 또한 양쪽에서 공격자가 임의로 지정한 dash count로 CGContextSetLineDash가 호출되면, 한 thread의 gstate dash buffer가 다른 thread가 여전히 참조하는 도중 교체되거나 해제될 가능성도 있습니다. 이 torn-gstate 조건이 성립한다면, 공격자가 dash-array 길이를 통해 크기를 조절할 수 있는 CoreGraphics 내부 allocation에 대한 use-after-free 또는 double-free에 해당할 수 있습니다. 다만 이를 실제로 성립시키려면 좁은 timing window를 반복적으로 이겨내야 하고, CG allocator를 grooming하는 과정도 필요합니다. CoreGraphics는 closed-source이고 graphics-state 및 dash-buffer allocation 동작을 이 변경사항만으로는 관찰할 수 없기 때문에, torn graphics state에서 실제 사용 가능한 heap primitive로 이어지는 단계는 하나의 projection에 해당합니다. Diff와 regression test가 실제로 exercise하는 CGContextSetLineDash / CGContextSaveGState 호출에 근거를 두고 있지만, 어느 쪽도 이를 직접 입증하지는 않습니다.

두 함수 모두 layout 및 canvas state와 함께 WebCore에서 실행되는 기하학적 query, 즉 WebContent process 내부에서 실행됩니다. Race를 벌이는 두 thread는 WebContent의 worker thread와 WebContent의 main/rendering thread입니다. Sandbox 경계는 넘지 않으므로, 공격자가 WebContent를 벗어나려면 별도의 escape가 여전히 필요합니다.

이 vulnerability는 rendering process 내부의 memory safety를 약화시킵니다. 공유된 CoreGraphics scratch context가 한 번에 한 thread에 의해서만 변경된다는 thread-confinement 가정이 깨지기 때문입니다. 여기서 위협받는 security-model 가정은, main rendering thread와 worker thread 양쪽에서 접근하는 process 전역 mutable object가 완전히 직렬화되어야 한다는 것입니다. Fix 이전에는 이 직렬화가 부분적으로만 이루어지고 있었습니다. Worker에서 isPointInStroke()를 구동하는 동시에 main thread에서 stroke-bounding-box 재계산을 강제하는 script를 공격자가 실행하면, 손상된 CoreGraphics 내부 state에 도달할 수 있습니다. 최소한 이는 rendering process에 대해 안정적으로 재현 가능한 crash를 허용하며, 손상된 state에 line-dash array와 같은 heap 기반 gstate 필드가 포함된다면 더 강력한 memory-corruption primitive로 이어질 가능성도 있습니다.

이번 사례는 불완전한 fix의 후속 조치를 보여주는 전형적인 예시이며, 그 실패 양상은 시사하는 바가 큽니다. 원래 patch는 lock을 resource 옆이 아니라 호출 지점에 배치했습니다. 함수 지역 static Lock object 두 개는 얼핏 보면 "scratch context가 lock으로 보호되고 있다"는 인상을 줍니다. 하지만 실제로는 하나의 object에 대한 두 개의 독립된 mutex일 뿐입니다. 이는 리뷰어가 Locker 라인을 보고 안심한 채 넘어가기 쉬운 전형적인 패턴입니다. 최종적으로 채택된 fix는 더 견고한 방식입니다. Lock의 범위를 resource 전체로 넓히는 대신, ThreadSpecific을 통해 공유 자체를 제거함으로써 synchronization 문제를 confinement 문제로 전환했습니다. 이 tradeoff는 "공유된 scratch object" 패턴이 multi-threaded reachability와 만날 때마다 기억해둘 만한 가치가 있습니다. 이 사례가 보여주는 더 넓은 흐름도 있습니다. OffscreenCanvas-in-workers와 같은 기능은 오랫동안 main-thread 전용이었던 전역 상태를 소급적으로 multi-threaded reachability로 끌어올립니다. 그리고 이런 전역 상태는 점검되기 전까지는 잠재적인 버그로 남아 있습니다.