← All reports

[4] WebGL draft-extension setting never reached the GPU process

MediumWebKit GPU processLogicError

The GPU process let the renderer vote on which draft extensions it could use.

0d94848

Medium — 이 자체로 메모리를 손상시키는 버그는 아닙니다. 다만 이 코드는 renderer가 더 높은 권한을 가진 process 안에서 얼마나 많은 ANGLE 및 driver 코드에 접근할 수 있는지를 결정하는 역할을 하며, 그 결정권이 IPC의 신뢰되지 않은 쪽에 놓여 있었다는 점이 문제입니다.

WebGL은 페이지를 실행하는 process에서 동작하지 않습니다. WebContent에 있는 RemoteGraphicsContextGL이 IPC를 통해 WebGL 호출을 sandbox 처리된 GPU process 안의 GraphicsContextGLANGLE로 전달하면, 이 객체가 renderer를 대신해 ANGLE과 플랫폼 GL driver를 구동합니다. ANGLE extension 중 일부는 draft로 표시되어 있는데, 아직 spec이 바뀔 수 있는 상태이기 때문에 WebKit은 이를 명시적인 preference 뒤에 게이트해 둡니다. 즉 어떤 context가 어떤 extension을 활성화할 수 있는지는 신뢰된 설정 값에 의해서만 결정되어야 하며, 호출하는 process가 무엇을 요청하든 그대로 허용되어서는 안 됩니다.

관전 포인트: 침해된 WebContent process가 GPU process에 draft ANGLE extension을 노출해 달라고 요청하면, 사용자나 policy가 draft extension을 활성화했는지 여부와 무관하게 그대로 노출되어버립니다.

Expose ANGLE extensions related to WebGL draft extensions only if the setting is on.

Add the GraphicsContextGLAttributes.supportWebGLDraftExtensions property since that is what controls the GraphicsContextGLANGLE. Validate this property against the real settings.

Source/WebKit/GPUProcess/GPUConnectionToWebProcess.cpp

void GPUConnectionToWebProcess::createGraphicsContextGL(RemoteGraphicsContextGLIdentifier identifier, WebCore::GraphicsContextGLAttributes attributes, ...)
{
MESSAGE_CHECK(!isLockdownModeEnabled());
+ MESSAGE_CHECK(!attributes.supportWebGLDraftExtensions || m_sharedPreferencesForWebProcess.webGLDraftExtensionsEnabled);

Source/WebCore/platform/graphics/angle/GraphicsContextGLANGLE.cpp

for (auto& extensionString : m_allRequestableExtensions) {
- if (auto extension = extensionEnum(extensionString))
+ if (auto extension = extensionEnum(extensionString)) {
+ if (*extension == GCGLExtension::ANGLE_base_vertex_base_instance && !attributes.supportWebGLDraftExtensions)
+ continue;
m_requestableExtensions.add(*extension);
+ }
}

Source/WebCore/html/canvas/WebGLRenderingContextBase.cpp

glAttributes.isWebGL2 = isWebGL2;
+ glAttributes.supportWebGLDraftExtensions = scriptExecutionContext.settingsValues().webGLDraftExtensionsEnabled;

Source/WTF/Scripts/Preferences/UnifiedWebPreferences.yaml

WebGLDraftExtensionsEnabled:
...
+ sharedPreferenceForWebProcess: true

이번 변경은 IPC 경계를 넘어 설정 값을 전달하는 per-context attribute를 추가합니다. 동시에 신뢰된 preference 값을 GPU process 쪽에도 복제해, 수신 측이 비교할 기준 값을 가질 수 있도록 합니다. 마지막으로 두 군데의 enforcement 지점이 추가됩니다.

새로 추가된 supportWebGLDraftExtensions boolean은 GraphicsContextGLAttributes에 합류하며(GraphicsContextGLAttributes.h에 선언되고 WebGL.serialization.in에서 직렬화됩니다), WebGLRenderingContextBase::resolveGraphicsContextGLAttributes에서 scriptExecutionContext.settingsValues().webGLDraftExtensionsEnabled 값으로 채워집니다. GPU process가 동일한 값의 신뢰된 사본을 갖도록, UnifiedWebPreferences.yamlWebGLDraftExtensionsEnabled 항목에 sharedPreferenceForWebProcess: true가 추가되어 m_sharedPreferencesForWebProcess로 복제됩니다.

Enforcement는 두 지점에 자리잡습니다. GPUConnectionToWebProcess::createGraphicsContextGL에는 WebContent가 전달한 attribute가 true인데 GPU 쪽의 shared preference가 false인 경우 IPC를 거부하는 MESSAGE_CHECK가 추가되었습니다. GraphicsContextGLANGLE::initialize 내부에서는 ANGLE의 requestable-extension 목록을 m_requestableExtensions로 필터링하는 루프가, attribute가 설정되어 있지 않은 한 GCGLExtension::ANGLE_base_vertex_base_instance를 건너뛰도록 바뀌었습니다.

보안과 직결된 설정 값이 GPU-process 쪽 context 생성 IPC까지 전달되지 않아, process 경계를 넘어서는 신뢰된 preference가 강제되지 못한 패턴입니다.

GPU process와 ANGLE. GPU process는 WebContent를 대신해 WebGL과 WebGPU를 호스팅하는 별도의 sandbox process입니다. ANGLE은 WebKit의 WebGL 구현을 떠받치는, OpenGL-ES를 플랫폼 GL 위에서 변환해주는 계층으로, 자체적으로 GL_ANGLE_*, GL_EXT_* extension 집합을 노출합니다. 이 중 일부는 draft 상태이며, 이번 commit의 근거에 따르면 spec이 바뀔 수 있을 뿐 아니라 이미 출시된 extension보다 hardening이 덜 되어 있는 것으로 취급됩니다.

Requestable extensions. ANGLE은 GL_REQUESTABLE_EXTENSIONS_ANGLE을 통해 런타임에 활성화 가능한 extension 목록을 반환합니다. WebKit은 이 목록을 m_allRequestableExtensions로 복사한 뒤 m_requestableExtensions로 필터링하며, 이 필터링된 집합에 속한 항목만 getExtension으로 활성화될 수 있습니다.

MESSAGE_CHECK. 조건이 실패하면 connection을 종료시키는 WebKit IPC validator macro입니다. 신뢰도가 낮은 process에서 넘어온, 형식이 잘못되었거나 공격자가 제어 가능한 값을 거부하는 데 사용되는 trust boundary의 표준 도구입니다.

sharedPreferenceForWebProcess. UnifiedWebPreferences.yaml의 플래그로, preference 값을 UI process에서 GPU process의 m_sharedPreferencesForWebProcess로 복제되게 만듭니다. 이 메커니즘이 존재하는 이유는 GPU process가 WebContent의 주장과 무관하게 설정 값의 신뢰된 사본을 자체적으로 보유할 수 있도록 하기 위해서입니다. 이 메커니즘이 없다면 수신 측 process는 들어오는 attribute를 검증할 기준 값 자체를 갖지 못하는데, 이번 commit이 메우는 지점이 바로 이 공백입니다.

근본 원인은 잘못된 check가 아니라 연결 자체가 빠져 있었다는 데 있습니다. GraphicsContextGLANGLE::initializeWebGLDraftExtensionsEnabled에 대한 필터링 없이 ANGLE의 requestable-extensions 문자열 전체로 m_requestableExtensions를 채웠습니다. 설정 자체는 존재했지만, 이 값을 GraphicsContextGLAttributes까지 전달하는 상태가 없었기 때문에 GPU-process 쪽 context 생성 과정에는 게이트할 근거가 아예 없었던 셈입니다.

  WebContent (untrusted)          IPC boundary          GPU process
  ──────────────────────          ────────────          ───────────
  Settings.webGLDraft            [attributes]           createGraphicsContextGL
  ExtensionsEnabled                    │                   │
      │  (consulted here only)         │                   │  before: no gate
      └──► GraphicsContextGL ──────────┼──────────────────►│
             Attributes                │                   ▼
                                                    GraphicsContextGLANGLE::initialize
                                                      m_allRequestableExtensions
                                                        └─ no filter ──► ANGLE_base_vertex_base_instance
                                                                           reachable from any renderer

위 다이어그램에 나타난 세 지점에서 이 공백이 메워집니다. 먼저 attribute가 경계를 넘어 설정 값을 전달하게 됩니다. 이어서 MESSAGE_CHECK가 attribute 값이 GPU 쪽의 신뢰된 사본과 어긋날 때 IPC를 거부합니다. 마지막으로 ANGLE init 필터가 attribute가 false인 경우 draft extension을 걸러냅니다. WebContent 쪽에 기존에 어떤 형태로든 게이트가 있었는지는 제공된 context에 포함되어 있지 않습니다. diff는 새로운 plumbing이 생겼다는 사실만 보여줄 뿐, 다른 어딘가에 있던 이전 check의 부재를 입증하는 것은 아닙니다.

이 변경은 attack surface를 새로 추가하는 것이 아니라 오히려 줄이는 방향의 수정입니다. GPU process에서 요청 가능한 extension 집합이 기본적으로 더 좁아지기 때문입니다. 패치 이전에는 ANGLE_base_vertex_base_instance가 설정 값과 무관하게 enableExtension/getExtension 경로를 통해 어떤 WebContent process에서도 도달 가능했으며, 새로 추가된 MESSAGE_CHECK는 WebContent가 일방적으로 이 값을 opt-in할 수 있었던 IPC 경로를 차단합니다.

여기에 독립적으로 성립하는 exploit은 존재하지 않습니다. 실질적으로 의미 있는 시나리오는 체인 형태입니다. 먼저 공격자가 임의의 script 실행이나 제한적인 renderer 측 침해를 확보하고, 필터링되지 않은 IPC를 이용해 GPU process가 ANGLE_base_vertex_base_instance를 노출하도록 만든 뒤, 이 draft extension이 활성화된 상태에서만 도달 가능한 버그를 공격하는 흐름입니다. 패치 이전에는 IPC가 호출자를 가리지 않고 받아들였지만, 패치 이후에는 공격자가 preference 자체를 뒤집거나 — 이는 별도의 경계입니다 — 해당 code path에 도달하는 다른 방법을 찾아야 합니다. 이 버그 자체는 primitive가 아니라 확장된 reachability를 제공할 뿐입니다. 새로 도달 가능해진 코드에 자체적인 버그가 있어야만, 예를 들어 indexed draw call에서 baseVertex/baseInstance의 validation이 부족해 vertex buffer의 인덱싱이 잘못되는 경우가 있어야만 실제 primitive로 이어질 가능성이 생깁니다. 이런 버그를 통해 체인이 성공한다면 sandbox 내부에서의 GPU-process 침해로 이어질 수 있고, 완전한 escape에는 GPU-process에서 system으로 넘어가는 추가 버그가 여전히 필요합니다. Lockdown Mode는 기존에 있던 MESSAGE_CHECK(!isLockdownModeEnabled())로 독립적으로 강제되며 이번 변경의 영향을 받지 않습니다.

발견 경로는 fuzzing보다는 내부 검토에 가까워 보입니다. "설정이 IPC의 한쪽에서만 강제된다"는 전형적인 패턴은 누군가 Settings 토글과 그에 대응하는 MESSAGE_CHECK를 체계적으로 대조해 나가는 과정에서 드러나는 종류이며, Apple 내부 radar와 접근이 제한된 bugs.webkit.org 항목 역시 내부 제보와 부합하는 정황입니다.

이 vulnerability는 침해된 WebContent process에 대한 GPU-process trust boundary를 약화시킵니다. 보안 모델은 draft ANGLE extension이 명시적인 설정 뒤에 게이트되어 있다고 전제하지만, 패치 이전에는 IPC 계층에 그 게이트가 존재하지 않았습니다. 따라서 어떤 WebContent라도 ANGLE_base_vertex_base_instance를 요청해 GPU process 내부에서 관련 draw command를 실행할 수 있었고, 이는 GPU-process 침해 체인에 활용될 수 있는 표면을 넓히는 결과로 이어졌습니다.

Insight: 여기서 사용된 mitigation 형태 — 신뢰된 값을 복제하는 sharedPreferenceForWebProcess: true와, 그 사본을 기준으로 IPC 진입점에서 검증하는 MESSAGE_CHECK의 조합 — 은 다른 draft, experimental, 또는 privileged WebGL/WebGPU/Media 기능 게이트에도 그대로 적용할 만한 깔끔한 템플릿입니다. 반복적으로 나타나는 실패 패턴은, 많은 WebKit 기능이 노출 여부를 결정하는 설정을 갖고 있음에도 불구하고 그 설정을 WebContent 쪽에서만 참조하고, 경계를 넘어오는 attribute struct 자체는 수신 process가 그대로 신뢰해버린다는 점입니다.

7842f48

Low — 어느 쪽이든 payload는 0이므로 저장되는 bit 자체는 올바릅니다. 문제가 되는 것은 B3의 type bookkeeping이며, release assert가 이를 감지해 프로세스를 종료시킵니다. 다만 안정적으로 재현되고 원격에서 유발 가능하다는 점 때문에 severity가 더 낮아지지는 않습니다.

JSC는 WebAssembly를 여러 tier를 거쳐 컴파일하며, 그중 최상위 tier가 OMG입니다. OMG는 Wasm bytecode를 B3로 낮추는 optimizing JIT이며, B3는 FTL JavaScript 컴파일러와 공유하는 mid-level IR입니다. B3의 모든 value는 명시적인 type을 가지고, optimization pass들은 이 type이 서로 일치한다는 전제에 의존합니다. 예를 들어 방금 기록된 슬롯을 다시 읽는 load가 있으면, CSE pass는 해당 load를 저장된 값으로 대체할 수 있는데, 이때 두 값의 type이 일치해야만 가능합니다. Wasm GC의 struct.new_defaultarray.new_default는 객체를 할당하고 모든 field를 0으로 초기화하므로, IR generator는 각 field마다 zero constant를 생성해야 합니다. 이때 해당 field에 선언된 type을 그대로 따라야 합니다.

관전 포인트: 어떤 페이지든 몇 줄짜리 Wasm module을 서빙하는 것만으로, 이를 로드하는 모든 탭의 WebContent process를 항상 동일한 방식으로 종료시킬 수 있습니다.

struct.new_default wasm 명령어는 현재 f32 type에 대해 Int32(0)을 기록합니다. store-to-load forwarding이 발생하면, 기대되는 type이 i32가 아니라 f32이기 때문에 B3::Value::replaceWithIdentity에서 RELEASE_ASSERT가 실패합니다. 이 패치는 OMGIRGenerator::addStructNewDefaultOMGIRGenerator::addArrayNewDefault를 수정하여, int type으로 기본 처리하는 대신 올바른 type의 constant를 생성하도록 합니다.

Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

// addArrayNewDefault
initValue = m_currentBlock->appendNew<WasmConstRefValue>(m_proc, origin(), JSValue::encode(jsNull()));
else if (elementType.elementSize() == 16)
initValue = constant(V128, v128_t { });
- else if (elementType.elementSize() <= 4)
- initValue = constant(Int32, 0);
else
- initValue = constant(Int64, 0);
+ initValue = constant(toB3Type(elementType.unpacked()), 0);
 
// addStructNewDefault
initValue = m_currentBlock->appendNew<WasmConstRefValue>(m_proc, origin(), JSValue::encode(jsNull()));
else if (typeSizeInBytes(fieldType) == 16)
initValue = constant(V128, v128_t { });
- else if (typeSizeInBytes(fieldType) <= 4)
- initValue = constant(Int32, 0);
else
- initValue = constant(Int64, 0);
+ initValue = constant(toB3Type(fieldType.unpacked()), 0);

JSTests/wasm/stress/new_default-f32.js

+ // (type (struct (field f32))) ; struct.new_default 0; struct.get 0 0; global.set 0
+ for (let i = 0; i < wasmTestLoopCount; ++i) fn();
+ // (type (array f32)) ; array.new_default 0; array.get 0; global.set 0
+ for (let i = 0; i < wasmTestLoopCount; ++i) fn();

기존에는 OMGIRGenerator::addArrayNewDefaultOMGIRGenerator::addStructNewDefault 모두, zero-initializer의 B3 type을 field의 byte size로부터 결정했습니다. 두 개의 분기로 나뉘어, size <= 4이면 constant(Int32, 0)을, 나머지 경우에는 constant(Int64, 0)을 생성하는 구조였습니다. 이 패치는 각 쌍을 하나의 initValue = constant(toB3Type(<wasmType>.unpacked()), 0) 호출로 합치고, type을 byte size가 아니라 선언된 Wasm value type으로부터 도출하도록 변경했습니다. 앞쪽의 reference type 분기와 16바이트 V128 분기는 그대로 유지되었습니다. 회귀 테스트는 두 개의 module을 구성합니다. 하나는 f32 field를 하나 가진 struct이고, 다른 하나는 f32의 array입니다. 각각 struct.new_default/array.new_default를 호출한 뒤 곧바로 field나 element를 읽고, 함수를 hot loop(wasmTestLoopCount)에서 반복 실행시켜 OMG tier로 승격시킵니다.

JIT IR 생성 과정에서의 type-tagging 불일치 — byte-size 휴리스틱으로 B3 constant를 생성하고, 선언된 Wasm value type을 반영하지 않은 패턴.

B3와 그 type invariant. B3는 JSC의 mid-level optimizing IR로, FTL JavaScript JIT과 OMG Wasm JIT이 공유합니다. 모든 B3 ValueInt32, Int64, Float, Double, V128 등의 Type을 가지며, IR 레벨의 invariant는 연산의 operand들이 서로 같은 type을 가질 것을 요구합니다.

CSE와 store-to-load forwarding. Common subexpression elimination과 store-to-load forwarding은 B3의 optimization으로, 방금 기록한 주소에서의 load를 감지해 저장된 값으로 대체합니다. 이 대체는 Value::replaceWithIdentity를 통해 이루어지며, source와 target의 B3 type이 같아야만 가능합니다.

RELEASE_ASSERT. Debug build와 release build 양쪽에서 모두 동작하는 WebKit assertion macro로, 조건이 false이면 프로세스를 abort시킵니다.

OMG와 Wasm GC의 default constructor. OMG는 BBQ 위에 위치한 JSC의 optimizing Wasm JIT tier이며, OMGIRGenerator가 Wasm bytecode를 순회하며 B3 IR을 생성합니다. struct.new_defaultarray.new_default는 struct나 array를 할당하고 모든 field 또는 element를 0으로 초기화하는 Wasm GC 명령어입니다.

Wasm value type과 toB3Type. Wasm value type에는 i32, i64, f32, f64, v128, 그리고 reference type이 포함됩니다. f32는 4바이트, f64는 8바이트를 차지합니다. toB3Type은 Wasm value type을 대응하는 B3 type으로 매핑하는 helper로, f32Float로, f64Double로 변환됩니다.

이 버그는 IR 생성 시점의 type-tagging 오류에 해당합니다. OMG가 initializer의 B3 type을 선언된 type이 아니라 storage size로부터 도출한 것이 원인입니다.

  Wasm decl        old size branch       B3 constant    field's B3 type
  ──────────       ───────────────       ───────────    ───────────────
  i32 (4B)         size <= 4             Int32(0)       Int32     ✓
  f32 (4B)         size <= 4             Int32(0)       Float     ✗ mismatch
  i64 (8B)         else                  Int64(0)       Int64     ✓
  f64 (8B)         else                  Int64(0)       Double    ✗ mismatch

  struct.new_default          struct.get
  ──────────────────          ──────────
  WasmStructSet(field,  ────► WasmStructGet(field) : Float
     Int32(0))                     │
                                   ▼  B3 CSE: store-to-load forwarding
                            replaceWithIdentity(Int32 → Float)
                                   └─ RELEASE_ASSERT → abort

4바이트짜리 f32 field의 경우, size 휴리스틱은 Int32 B3 value를 생성해 WasmStructSet/WasmArraySet에 전달합니다. 하지만 해당 field의 선언된 B3 type은 실제로는 Float입니다. else 분기 역시 f64에 대해 Int64Double 대신 생성하는 동일한 패턴의 mismatch를 만들어냅니다. 저장된 값을 다시 참조하는 코드가 없는 한 이 mismatch는 잠재된 상태로 남습니다. 문제는 B3의 CSE가 같은 struct field나 array slot에서 이후의 load를 발견하고, Value::replaceWithIdentity를 통해 저장된 값을 forwarding하려는 순간 드러납니다. load의 type은 Float인데 replacement는 Int32이므로 type equality 요구 조건이 깨지고, OMG 컴파일 중 RELEASE_ASSERT가 발생해 WebContent process가 종료됩니다. replaceWithIdentity가 정확한 assertion 지점이라는 사실은 commit message에서 인용한 내용이며, B3 CSE의 구현 자체는 제공된 context에 포함되어 있지 않습니다. 이 패치는 type을 toB3Type(<wasmType>.unpacked())를 통해 경유시킴으로써, 저장된 값의 type이 slot에 선언된 type과 일치해야 한다는 invariant를 복원합니다.

회귀 테스트는 사실상 PoC와 동일하며, 그 구조가 핵심입니다.

  1. f32 field 하나를 가진 struct type을 선언합니다(또는 f32의 array).
  2. struct.new_default / array.new_default를 호출합니다.
  3. 곧바로 struct.get / array.get으로 field를 다시 읽습니다.
  4. 결과를 관찰 가능한 곳에 저장합니다 — 테스트에서는 global.set 0을 사용합니다.
  5. export된 함수를 촘촘한 loop 안에서 반복 호출해 OMG 승격을 유도합니다.

3단계가 바로 CSE가 2단계의 store로부터 forwarding을 시도하는 load에 해당합니다. 4단계와 5단계는 이 시퀀스를 유지하면서 optimizing tier까지 도달시키기 위해 존재합니다. 웹에서 전달되는 경우, 몇 바이트짜리 데이터와 loop 하나를 담은 WebAssembly.instantiate 호출만으로 충분합니다. 어느 origin에서든 renderer를 안정적으로 crash시킬 수 있는 형태입니다.

이 변경에는 downstream memory-safety primitive로 이어진다는 architectural 근거가 없습니다. Int32(0)Float(0.0), 그리고 Int64(0)Double(0.0)의 bit pattern은 동일하므로, assertion이 없었다 하더라도 저장되는 byte 자체는 올바릅니다. 여기서 문제가 되는 것은 IR 레벨의 type confusion 그 자체이며, RELEASE_ASSERT는 이를 abort로 감지해낼 뿐입니다. type-confused B3 graph가 원론적으로는 이후 optimization pass를 잘못된 방향으로 유도할 가능성은 있지만, patch나 주변 코드에서 그런 downstream miscompilation은 확인되지 않습니다. 이 코드는 WebContent process 안에서 동작하므로, 여기서 발생하는 crash는 문제가 된 탭을 호스팅하는 renderer를 종료시킬 뿐이며 sandbox escape로 이어지지는 않습니다.

trigger 형태 — f32 field 하나짜리 작은 module과 hot loop 조합 — 은 GC type에 국한된 Wasm grammar fuzzer가 전형적으로 만들어내는 패턴이기도 합니다. 그래서 fuzzing이 가장 유력한 발견 경로로 보입니다. Variant analysis 역시 현실적인 경로입니다. 한 번 B3 type-mismatch assertion crash가 알려지면, OMGIRGenerator에서 constant(Int32, 0)constant(Int64, 0)을 검색하는 것만으로 이 두 지점이 곧바로 드러나기 때문입니다.

이 vulnerability는 WebContent process의 availability를 약화시킵니다. Wasm trust boundary는 adversarial 여부와 무관하게 well-formed module이라면 OMG를 거쳐 renderer를 abort시키지 않고 컴파일된다는 전제를 두고 있습니다. 패치 이전에는 struct.new_defaultarray.new_defaultf32/f64 type에 사용하는 단 한 줄짜리 module만으로도 탭이 항상 동일하게 종료되었습니다.

Insight: 이 버그 class는 JIT IR generator가 source language에 선언된 type 대신 byte-size나 storage-size 휴리스틱으로 IR type을 결정하는 곳이라면 어디서든 재발할 수 있습니다. if (size == 4) Int32 else Int64 형태의 분기는 integer field에 대해서는 조용히 동작하지만, 같은 크기의 floating-point나 vector field가 등장하는 순간 깨집니다. size로부터 type을 다시 도출하는 대신 toB3Type(declaredType) helper를 경유시키는 것이 정석적인 대응입니다.