← All reports

[6] ANGLE IndexRange: stored inclusive count replaced by a derived one

LowANGLE — gl::IndexRangeIntegerOverflow

56c55dd

Severity는 Low이며, 특히 실제로 우회 가능한 취약점은 아닙니다. Pre-image 상태에서도 inclusive cardinality를 uint64_t에 저장하고 있어, 최대 span이 정확하게 보고되고 bounds check가 생략되는 경우도 없습니다. 이번 패치가 제거하는 것은 구조적인 위험 요소입니다. 즉, endpoint와 나란히 별도로 저장되는 count 값이 있었고, "empty" 상태가 동일한 산술 연산으로도 만들어질 수 있는 값으로 인코딩되어 있었습니다.

N-bit 정수 타입에 대한 inclusive range는 자신의 cardinality를 표현하기 위해 N+1 bit가 필요합니다. 예를 들어 [0, 0xFFFFFFFF]는 0x100000000개의 원소를 담습니다. 이 산술적 사실은 WebGL의 indexed-draw bounds validation과 직결되는 부분으로, web content가 호출한 glDrawElements가 활성화된 attribute에 바인딩된 storage 범위 밖의 vertex를 절대 참조하지 못하도록 보장해야 합니다. ANGLE의 gl::IndexRange가 바로 이 span을 담는 value type입니다. Untrusted element-array buffer를 스캔해 생성되고, 캐시되며, 각 attribute의 element limit과 비교됩니다. 이때 isEmpty() predicate가 호출자에게 "여기에는 검증할 대상이 없다"는 신호를 전달하는 역할을 합니다.

관전 포인트: 이번 변경은 방어적 성격에 그칩니다. 최대 index span은 패치 이전에도 정확하게 보고되고 있었지만, empty 인코딩을 cardinality 산술로는 더 이상 합성할 수 없게 되었습니다. 추가된 테스트는 세 가지 index width 전체에 걸쳐 {0, 0xFFFFFFFF}에 대해 GL_INVALID_OPERATION을 고정적으로 검증합니다.

IndexRangeuint32_t start, uint32_t count로 index range를 표기했는데, 이 방식으로는 [0, 0xFFFFFFFF] 범위를 제대로 표현할 수 없습니다. 이를 start, end 표기로 전환하고, start > end인 경우를 empty range로 취급하도록 변경합니다.

Source/ThirdParty/ANGLE/src/common/mathutil.h

IndexRange(Undefined) {}
IndexRange() = default;
 
- IndexRange(uint32_t start, uint32_t end)
 
- : mStart(start), mEnd(end), mCount(static_cast<uint64_t>(end - start) + 1)
 
- {
 
- ASSERT(start <= end);
 
- }
 
- bool isEmpty() const { return mCount == 0; }
+ IndexRange(uint32_t start, uint32_t end) : mStart(start), mEnd(end) { ASSERT(mStart <= mEnd); }
+ bool isEmpty() const { return mStart > mEnd; }
...
// Number of vertices in the range.
 
- uint64_t vertexCount() const { return mCount; }
+ // Range: [0, 0] == 1
+ // Range: [0, 0xFFFFFFFF] == 0x100000000 (needs size_t).
+ size_t vertexCount() const
+ {
+ // Note: unsigned underflow ok on isEmpty() == true.
+ return static_cast<size_t>(mEnd) - mStart + 1u;
+ }
 
private:
 
- uint32_t mStart{0};
+ uint32_t mStart{1};
uint32_t mEnd{0};
-
 
- // Since the range is inclusive, mCount == 0 indicates an empty range
 
- uint64_t mCount{0};
+ friend bool operator==(const IndexRange &a, const IndexRange &b) noexcept = default;
};
 
-inline bool operator==(const IndexRange &a, const IndexRange &b)
-{
 
- return a.vertexCount() == b.vertexCount() &&
 
- ((a.vertexCount() == 0) || (a.start() == b.start()));
-}

Source/ThirdParty/ANGLE/src/tests/gl_tests/WebGLCompatibilityTest.cpp

+ GLint posLocation = glGetAttribLocation(program, "a_Position");
+ ASSERT_NE(-1, posLocation);
+ glEnableVertexAttribArray(posLocation);
constexpr float kVertexData[] = { 1.0f, ... };
glBufferData(GL_ARRAY_BUFFER, sizeof(kVertexData), kVertexData, GL_STREAM_DRAW);
+ glVertexAttribPointer(posLocation, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
...
+ constexpr GLuint kIndexData2[] = {
+ 0,
+ std::numeric_limits<GLuint>::max(),
+ };
+ glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(kIndexData2), kIndexData2, GL_DYNAMIC_DRAW);
+
+ glDrawElements(GL_LINES, 2, GL_UNSIGNED_INT, 0);
+ EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+ constexpr GLuint kIndexData3[] = {0, 1};
+ glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(kIndexData3), kIndexData3, GL_DYNAMIC_DRAW);
+ glDrawElements(GL_LINES, 2, GL_UNSIGNED_INT, 0);
+ EXPECT_GL_NO_ERROR();

Source/ThirdParty/ANGLE/src/common/utilities_unittest.cpp

+ EXPECT_EQ(ComputeIndexRange(i, vertices3, 3, false), gl::IndexRange(0, 0xffffffff));
+ EXPECT_TRUE(gl::IndexRange().isEmpty());
+ EXPECT_FALSE(gl::IndexRange(0, 0).isEmpty());
+ EXPECT_EQ(0u, gl::IndexRange().vertexCount());
+ EXPECT_EQ(static_cast<size_t>(0x100000000ull), gl::IndexRange(0, 0xffffffff).vertexCount());

이번 변경은 gl::IndexRange를 (start, end, stored-count) 구조에서 순수한 (start, end) 구조로 재구성하고, 이어서 unit test와 conformance test 양쪽의 커버리지를 확장합니다.

먼저 타입 자체를 살펴보면, 생성자는 더 이상 mCount를 계산해서 저장하지 않고 mStart/mEndASSERT(mStart <= mEnd)와 함께 보관합니다. isEmpty()mCount == 0 대신 mStart > mEnd로 판정하도록 바뀌었고, 기본 멤버 초기값도 mStart{0}, mEnd{0}, mCount{0}에서 mStart{1}, mEnd{0}으로 변경됩니다. 그 결과 기본 생성된 range는 생성자가 요구하는 순서 규칙 바깥에 위치하는, 정형화된 empty range가 됩니다. vertexCount()static_cast<size_t>(mEnd) - mStart + 1u로 파생 계산되는 size_t 값으로 다시 표현되며, 포함 범위 [0, 0xFFFFFFFF]의 원소 수가 0x100000000개라 32비트에 담기지 않는다는 점과, empty range에 대해서는 unsigned underflow가 의도적으로 0을 만들어낸다는 점을 명시하는 주석이 추가되었습니다. 기존의 수작업 operator==vertexCount()와, non-empty range의 경우 start()만 비교하던 방식이었는데, 이는 삭제되고 멤버 단위로 비교하는 defaulted friend bool operator==로 대체되었습니다.

테스트 측면에서는, utilities_unittest.cpp0xffffffff를 포함한 UnsignedInt 인덱스에 대한 ComputeIndexRange의 경계값 테스트와, 32비트 경계에서의 isEmpty()/vertexCount() 직접 검증이 추가되었습니다. Xcode 프로젝트에도 해당 파일이 unit test target에 추가되었습니다. WebGLCompatibilityTest.cpp에서는 테스트 대상 프로그램이 buffer로 뒷받침되는 vertex attribute를 갖도록 확장한 뒤, 인덱스 데이터 {0, 0xFFFFFFFF}로 draw하여 GL_UNSIGNED_INT/GL_UNSIGNED_SHORT/GL_UNSIGNED_BYTE 각 element type에서 GL_INVALID_OPERATION이 발생하는지, 그리고 {0, 1}에서는 GL_NO_ERROR가 나오는지를 검증합니다.

range의 카디널리티를 endpoint와 별도로 저장된 state로 중복시키면서, 동시에 'empty'를 그 카디널리티 계산 자체가 만들어낼 수 있는 값으로 인코딩한 패턴.

이 코드가 있는 위치. ANGLE은 WebKit에서 WebGL을 뒷받침하는 OpenGL ES / EGL translation layer로, GPU process에서 동작합니다. 따라서 이곳의 validation layer는 web에서 제어하는 draw parameter와 platform graphics backend 사이의 경계에 위치합니다.

Indexed drawing. glDrawElements(mode, count, type, offset)GL_ELEMENT_ARRAY_BUFFER에 bind된 buffer에서 offset부터 시작해 type(GL_UNSIGNED_BYTE/SHORT/INT) 형식의 인덱스를 count개 읽어들이고, 각 인덱스마다 활성화된 모든 vertex attribute array에서 대응하는 element를 가져옵니다.

gl::IndexRange. Indexed draw call이 참조하는 vertex 인덱스의 포함 범위 [start, end]를 나타내는 ANGLE의 value type이며, 해당 범위가 포괄하는 vertex 개수를 나타내는 vertexCount()도 함께 가집니다. common/utilities.cppComputeTypedIndexRange는 주어진 정수 폭의 인덱스를 count개 스캔하며 minIndex/maxIndex를 추적하고, gl::IndexRange(minIndex, maxIndex)를 반환합니다. Vertex가 하나도 없는 경우에는 기본 생성된 range를 반환합니다.

Primitive restart. 하나의 예약된 인덱스 값(std::numeric_limits<IndexType>::max())이 "이 vertex를 가져와라"가 아니라 "새 primitive를 시작하라"는 의미로 쓰이는 모드입니다. 이 모드가 켜져 있으면 ComputeTypedIndexRange는 min/max를 계산할 때 해당 값을 건너뛰는데, 이 때문에 같은 index buffer라도 플래그의 on/off에 따라 서로 다른 range가 나오게 됩니다.

Index range caching. gl::IndexRangeCache(std::map<IndexRangeKey, IndexRange>)와 gl::IndexRangeInlineCache는 element type, offset, count, primitive-restart 플래그를 key로 계산된 range를 캐싱하여, 비용이 큰 스캔이 index buffer 영역당 한 번만 실행되도록 합니다. Lookup은 IndexRangeKey::operator== / operator<로 key를 비교하고, 저장된 IndexRange payload를 복사해서 꺼내옵니다.

Vertex attribute element limits. VertexArray::updateCachedElementLimit()가 관리하는 VertexAttribute::getCachedElementLimit()는 활성화된 attribute가 자신이 bind된 buffer의 크기, stride, offset을 고려했을 때 몇 개의 element를 공급할 수 있는지를 기록합니다. Desktop GL과 달리 WebGL은 이 한계를 넘어서는 인덱스를 참조하는 draw를 undefined behaviour로 두지 않고, GL_INVALID_OPERATION으로 거부하도록 요구합니다.

ANGLE의 ASSERT. common/debug.h의 이 매크로는 assertion이 활성화된 빌드에서만 동작하고, 그렇지 않으면 컴파일 시 제거됩니다. 따라서 ASSERT로 표현된 precondition은 debug 빌드에서 계약을 문서화하는 역할을 할 뿐, release 빌드에서 런타임에 강제하지는 않습니다.

Inclusive versus half-open ranges. uint32_t 위의 포함 범위(inclusive range)에서 카디널리티는 end - start + 1이며, 이 값의 최댓값([0, 0xFFFFFFFF]의 경우 0x100000000)은 33비트를 필요로 합니다. size_t는 LP64 타겟에서는 64비트, ILP32 타겟에서는 32비트입니다.

먼저 무엇이 잘못되지 않았는지부터 짚어보겠습니다. 이 diff가 대체하는 이전 코드에서 mCountuint64_t로, static_cast<uint64_t>(end - start) + 1로 계산됩니다. start <= end인 한 이 연산은 정확합니다. 생성자 자체의 precondition 하에서는 uint32_t 뺄셈이 wrap될 수 없고, widening cast가 + 1 이전에 일어나기 때문입니다. 따라서 IndexRange(0, 0xFFFFFFFF).vertexCount()는 0x100000000이 되고, isEmpty()도 정확히 false를 반환합니다. 이 패치가 대체하는 코드에는 32비트 wrap-to-empty 문제가 존재하지 않습니다.

이전 코드의 문제는 구조적인 것입니다. (a) 카디널리티가 [mStart, mEnd]와 항상 일치해야 하는 중복 state로 존재한다는 점, (b) emptiness가 도메인 바깥의 endpoint 쌍이 아니라 그 산술 연산 자체가 만들어낼 수 있는 값인 mCount == 0으로 인코딩된다는 점, (c) 기본 생성된 range(mStart{0}, mEnd{0})가 endpoint 멤버만 놓고 보면 유효한 단일 vertex range [0, 0]과 구조적으로 동일하다는 점입니다. 이 둘을 구분해주는 것은 오직 mCount가 갖는 추가 비트뿐이었으므로, mStart/mEnd만 읽는 consumer나 향후 count의 폭을 좁히는 변경이 생기면 두 값이 그대로 합쳐질 수 있었습니다.

이 fix가 차단하는 것은 바로 이 두 결함이 열어두던 위험입니다. ComputeTypedIndexRange()는 element-array buffer를 스캔해 gl::IndexRange(minIndex, maxIndex)를 반환합니다. Primitive restart가 꺼진 상태에서 UnsignedInt 내용이 {0, 0xFFFFFFFF}라면 이는 가능한 최대 포함 범위이고, 그 카디널리티는 33비트를 필요로 합니다. 이 카디널리티를 32비트 값에 그대로 담아내는 어떤 표현 방식이든 결과는 0이 될 것이고, isEmpty() { return mCount == 0; }와 같은 predicate 아래에서는 가능한 가장 넓은 인덱스 범위가 vertex가 전혀 없는 상태로 잘못 해석될 수 있었습니다. 이번 패치는 이를 구조적으로 제거합니다. vertexCount()가 이제 파생값이므로 어떤 consumer도 endpoint와 어긋나는 count를 관찰할 수 없고, emptiness는 mStart > mEnd로 옮겨졌으며, 기본 초기값도 생성자 계약이 허용하는 순서 규칙 바깥에 위치하게 되었습니다. 이 계약은 ASSERT(mStart <= mEnd)로 표현되는데, assertion이 꺼진 빌드에서는 컴파일 시 제거됩니다. 따라서 이 분리는 컨벤션과 debug tripwire의 조합일 뿐, construction 시점에 보장되는 것은 아닙니다. 같은 유보 사항이 start()/end()ASSERT(!isEmpty()) 가드에도 그대로 적용됩니다.

operator== 교체 역시 같은 맥락의 중복 제거 작업입니다. 기존 comparator는 타입을 (vertexCount(), start())로 투영해서 비교했고, endpoint 멤버가 무엇이든 상관없이 count가 0인 range는 모두 같다고 취급했습니다. 즉 state 자체가 아니라 파생된 view를 비교하고 있었던 셈입니다. 다만 이 operator는 index-range 캐시의 hit 여부를 결정하는 요소가 아니라는 점에 유의할 필요가 있습니다. libANGLE/IndexRangeCache.h를 보면, IndexRangeInlineCache::get()IndexRangeKey::operator==를 비교하고 IndexRangeCachestd::mapIndexRangeKey::operator<로 정렬합니다. IndexRange는 그저 복사되어 나오는 payload일 뿐입니다. 따라서 IndexRange::operator==가 관여하는 것은 추가된 unit test의 assertion과 같은 값 비교이지, lookup의 정확성이 아닙니다.

새로 추가된 conformance test case는 이 표현 방식이 지원해야 하는 end-to-end 동작을 고정시켜 검증합니다. (1) a_Position attribute를 가진 program에 glEnableVertexAttribArray(posLocation), glVertexAttribPointer(posLocation, 4, GL_FLOAT, GL_FALSE, 0, nullptr)를 적용하고, GL_ARRAY_BUFFER에 float 12개, 즉 vec4 vertex 3개를 담아 attribute가 3개의 element를 공급할 수 있도록 구성합니다. (2) primitive restart가 꺼진 상태에서 {0, 0xFFFFFFFF}GL_UNSIGNED_INT 인덱스 데이터로 업로드합니다. (3) glDrawElements(GL_LINES, 2, GL_UNSIGNED_INT, 0)를 호출하며 GL_INVALID_OPERATION이 발생해야 함을 검증합니다. GL_UNSIGNED_SHORT를 offset 2에서, GL_UNSIGNED_BYTE를 offset 3에서 재해석하는 변형 테스트도 같은 바이트를 각각 {0, 0xFFFF}, {0, 0xFF}로 재해석하며, 이 값들 역시 3개짜리 attribute의 한계를 넘으므로 거부되어야 합니다.

이 패치가 방어하는 시나리오는 조건부이며 이번 diff에서 직접 드러나지는 않습니다. 만약 이 타입의 어떤 구현이든 포함 카디널리티를 32비트 폭에 그대로 담아냈다면 — 예를 들어 ILP32 타겟에서의 파생 size_t 표현이 그러했을 것처럼 — 가능한 최대 범위는 count가 0으로 평가되었을 것이고, isEmpty()vertexCount() == 0에 의존하는 consumer는 가능한 가장 넓은 인덱스 범위에 대해 "검증할 것이 없다"는 경로를 탈 수도 있었습니다. 다만 이것이 실제로 성립하려면 그런 폭이 좁혀진 구성이 shipping 빌드에 존재해야 하고, getCachedElementLimit()와의 element-limit 비교가 이 predicate로 guard되어야 합니다. 제공된 validationES.cpp 발췌는 잘려 있고 IndexRange consumer를 포함하지 않으므로, 두 조건 모두 이번 자료로는 확인되지 않습니다. 만약 이런 오판이 실제로 도달 가능했다면, 공격자가 얻을 수 있는 이론적 이득은 attribute stride로 스케일된, 페이지가 선택한 offset에서의 상대적인 out-of-bounds vertex fetch였을 것입니다. 그 내용이 shader input으로 흘러들어가고, rendering과 readPixels를 통해 회수 가능했을 수 있습니다. 즉 GPU process에서의 information-disclosure나 crash로 이어질 가능성이 있는 시나리오이지, write는 아닙니다. gl::IndexRange가 관여하는 것은 read-side 크기 비교이기 때문입니다.

이 value type이 위치한 지점은 WebGL의 필수적인 indexed-draw bounds validation입니다. Platform backend와 driver는 web content에 대한 trust boundary가 아니기 때문입니다. 이 코드의 security model이 전제하는 것은, 계산된 index range가 신뢰할 수 없는 index buffer를 32비트 인덱스 공간의 극단값까지 포함해 정확히 나타낸다는 것과, "empty"가 "카디널리티가 wrap된 상태"가 아니라 "vertex가 없는 상태"를 뜻해야 한다는 것입니다. 이번 commit은 실제로 존재하던 bypass를 닫았다기보다는 hardening 성격의 변경으로 보는 편이 적절합니다.

이전 코드에는 두 가지 design smell이 겹쳐 있었는데, 둘 다 이번 사례에서는 실제로 발현되지 않았지만 기억해둘 가치가 있습니다. 첫째, 포함 범위는 endpoint 타입이 제공하는 것보다 1비트 더 많은 카디널리티를 필요로 합니다. 따라서 자신의 길이를 endpoint와 같은 폭에 그대로 담아내는 inclusive-range 타입은 경계값 하나 차이로 wrap될 위험을 안고 있습니다. 실제로 이 패치의 주석에서도 저자가 바로 이 부분을 고민한 흔적이 드러납니다. 둘째, 더 위험한 것은 sentinel aliasing입니다. mCount == 0이 실제 카디널리티와 "여기 아무것도 없음" 마커라는 두 역할을 동시에 수행했기 때문에, 가장 out-of-bounds한 range에 대해 wrap된 count가 가장 안전한 상태로 잘못 해석될 수 있었습니다. Validation 코드가 if (range.isEmpty()) skip_the_check; 같은 shortcut을 취하는 곳이라면 어디든, 산술 연산이 만들어낼 수 있는 sentinel은 그 shortcut을 잠재적인 bypass 후보로 바꿔놓습니다. 이 패치가 다른 축에서 새로 도입한 유의 사항 하나는, vertexCount()가 이제 uint64_t가 아니라 size_t를 반환한다는 점입니다. ILP32 빌드에서는 최대 range의 카디널리티가 0으로 평가되는 반면 isEmpty()는 false를 반환하게 됩니다. 즉 derived-count 설계는 LP64 타겟에서만 기존의 저장된 uint64_t와 동등하며, 이 값을 사용하는 모든 ANGLE 빌드 구성에 대해 이 점을 확인할 필요가 있습니다.