[2] PNGImageDecoder ICC transform overruns the frame buffer
The APNG geometry check authorised the compositing loop, not the transform after it.
High. APNG sub-frame를 게이팅하는 geometry check는 compositing loop만 승인할 뿐, 그 뒤에 이어지는 color transform은 승인 범위 밖에 있습니다. 게다가 transform의 length 계산식은 origin term 하나만큼 더 큽니다. 필요한 입력값이 모두 이미지 파일 안에 있으므로, 유일한 게이팅 조건은 해당 포트가 LCMS로 빌드되었는지 여부뿐입니다.
Image decoder는 attacker-controlled parsing surface의 전형적인 사례입니다. 무엇이 어디에 쓰이는지에 대한 geometry 전체가 untrusted byte에서 나오기 때문입니다. Animated PNG는 IHDR에서 canvas를 선언한 뒤, xOffset, yOffset, width, height를 담은 fcTL chunk를 가진 sub-frame들을 순서대로 나열합니다. Decoder는 각 sub-frame을 하나의 연속된 full-canvas pixel allocation에 compositing합니다. 이 파이프라인이 의존하는 전제는, parse 시점에 수행되는 프레임별 geometry check가 이후의 모든 pixel 연산을 해당 allocation 범위 안으로 제한한다는 것입니다.
관전 포인트: 임베디드 ICC profile을 가진 조작된 animated PNG는 GTK/WPE에서 decoded-image allocation의 끝을 넘어서는 heap read-modify-write를 유발할 수 있습니다. 이때 overrun 길이와 값 모두 attacker가 공급하는 color transform을 거쳐 attacker가 선택할 수 있습니다.
Commit message
Out-of-bounds write in
PNGImageDecoder::frameComplete()ICC transform
destinationRowstarts atpixelsStartingAt(rect.x(), y)and the loop above writesrect.width()pixels, butcmsDoTransformgetsrect.maxX()as its pixel count.- With a
TYPE_BGRA_8transform that is a 4-byte-per-pixel read-modify-write, so once2*rect.x() + rect.width()passes the canvas width it runs off each row, and on the last row (yOffset + height == height) past the pixel allocation. The onlyfcTLguard isxOffset + width <= width, which does not stop that; reachable from an animated PNG with an RGBiCCPchunk on ports built withUSE(LCMS)(GTK, WPE).Pass
rect.width()so the count matches the row-relative span, like the first-frame path inrowAvailable().
Source/WebCore/platform/image-decoders/png/PNGImageDecoder.cpp
LayoutTests/fast/images/animated-png-icc-transform-crash.html
LayoutTests/fast/images/resources/animated-png-icc-transform-crash.png
Patch Details
이번 변경은 production line 한 줄에 그칩니다. PNGImageDecoder::frameComplete()의 #if USE(LCMS) 블록 안에서, 방금 compositing된 APNG sub-frame에 임베디드 ICC profile을 적용하는 부분입니다. 행 단위 loop는 pixelsStartingAt(rect.x(), y)에서 destinationRow를 얻어 rect.width()개의 pixel을 씁니다. 그런데 이어지는 in-place cmsDoTransform() 호출은 LCMS pixel count로 rect.maxX() — 즉 rect.x() + rect.width() — 를 전달하고 있었습니다. 패치는 이 네 번째 인자를 rect.width()로 바꾸어, 실제로 쓰여지는 row-relative span과 일치시키고 rowAvailable()의 first-frame path와도 맞춥니다. 나머지 파일들은 부수적인 것들입니다. layout test와 그 expectation 파일, 그리고 두 번째 프레임이 xOffset + width == canvas width에 위치하고 임베디드 ICC profile을 가진 조작된 APNG입니다.
base pointer와는 다른 좌표 공간으로 표현된 length 인자로 인해, relative span length가 필요한 자리에 absolute end-coordinate가 사용된 패턴.
Background
APNG frame model.
Animated PNG는 IHDR에서 canvas 크기를 선언한 뒤 일련의 sub-frame들을 나열합니다. 각 sub-frame은 xOffset, yOffset, width, height를 담은 fcTL chunk를 가집니다. Sub-frame들은 하나의 full-canvas frame buffer로 compositing되므로, decoder는 canvas column xOffset에서 시작하는 height행짜리 band를 씁니다.
Frame buffer layout.
Decode된 프레임은 canvasWidth * canvasHeight pixel 크기의 연속된 4-bytes-per-pixel allocation입니다. pixelsStartingAt(x, y) 형태의 helper 함수는 이 allocation 안에서 row y, column x 위치의 pointer를 반환하므로, 연속된 행들은 별도의 padding이나 guard 없이 메모리상 인접해 있습니다.
IntRect accessors.
rect.width()는 rectangle의 span입니다. rect.x()는 canvas 좌표계 기준 rectangle의 왼쪽 끝입니다. rect.maxX()는 rect.x() + rect.width()와 같은 값으로, length가 아니라 absolute coordinate입니다. 둘 다 같은 타입을 반환하는데, 바로 이 점 때문에 호출 지점에서 서로 혼동되기 쉽습니다.
LCMS and cmsDoTransform.
Little-CMS는 일부 WebKit 포트에서 사용하는 color-management library입니다. cmsDoTransform(transform, input, output, pixelCount)은 소스 profile — 여기서는 이미지에 임베디드된 profile — 과 destination profile로 구성된 transform을 사용해 input에서 output으로 pixelCount개의 pixel을 변환합니다. input과 output에 같은 pointer를 전달하면 pixelCount * bytesPerPixel 바이트에 대한 in-place read-modify-write가 됩니다. pixel당 byte 폭은 transform이 생성될 때의 format에 의해 고정되며, 여기서는 premultiplied frame buffer용 4바이트 BGRA 계열 format입니다. ICC profile은 임의의 curve와 lookup table을 인코딩할 수 있으므로, transform이 적용하는 byte-to-byte mapping 자체가 이미지 파일 안의 데이터에 의해 결정됩니다.
iCCP chunk.
임베디드 ICC profile을 담는 PNG chunk입니다. Color management가 활성화되어 있으면 decoder가 이 chunk로부터 m_iccTransform을 구성합니다.
Analysis
이 버그는 rect.x() pixel만큼 어긋난 length로 인한 heap out-of-bounds write이며, transform 자체가 같은 span에 대한 read-modify-write이기 때문에 out-of-bounds read도 함께 동반합니다.
Row in the frame buffer (canvas width W):
col 0 x = rect.x() rect.maxX() W
| | | |
+--------------------+===================+---------------+
| written by the |
| loop: width px |
| |
|<---- cmsDoTransform gets maxX() = x + width ---->|
overrun: x px
Last row (yOffset + height == canvasHeight):
overrun leaves the allocation entirely ──► 4 * rect.x() bytes past end
Base pointer는 이미 rect.x()만큼 offset된 상태였는데, count는 row-relative가 아니라 canvas-absolute 값이었습니다. 그 결과 transform은 canvas column [rect.x(), 2*rect.x() + rect.width()) 범위를 건드리게 됩니다. 2*rect.x() + rect.width() > canvasWidth가 성립할 때마다 해당 row의 끝을 넘어서게 됩니다. fcTL chunk에 적용되는 유일한 검증은 xOffset + width <= canvasWidth입니다 (제공된 source excerpt는 geometry-validation 코드 앞에서 잘려 있어, 이 guard의 배타성 여부는 commit message를 따른 것입니다). 예를 들어 xOffset = W/2, width = W/2이면 이 조건은 만족되지만 2*xOffset + width = 1.5*W로 초과됩니다. Interior row의 경우 overrun은 같은 연속 allocation 안의 다음 row로 흘러들어가 in-bounds 상태에서 image content를 손상시킵니다. 반면 yOffset + height == canvasHeight인 sub-frame의 마지막 row에서는 처리 중인 row가 allocation의 마지막 row이므로, 같은 overshoot가 그대로 끝을 넘어갑니다. 최대 rect.x() pixel, 즉 4 * rect.x() 바이트의 heap read-modify-write가 발생합니다.
번들된 이미지의 구조를 따라가면, 구체적인 trigger는 다음과 같습니다.
- Canvas width
W, heightH로IHDR을 작성합니다. - RGB profile을 담은
iCCPchunk를 작성해frameComplete()에서m_iccTransform이 non-null이 되도록 합니다. acTL과 첫 번째 full-canvas frame을 작성합니다.- 두 번째
fcTL을xOffset = X,width = Wf로 작성하되,X + Wf <= W는 만족하면서(유일한 geometry guard를 통과)2*X + Wf > W가 되도록 하고,yOffset + height == H로 설정해 sub-frame의 마지막 row가 allocation의 마지막 row가 되게 합니다. - 이미지를 로드합니다.
<img src="evil.png">하나, CSS background, 혹은 canvas로의drawImage정도면frameComplete()에서 sub-frame이 compositing되기에 충분합니다.
마지막 row에서 destinationRow는 마지막 row의 column X를 가리키게 되고, compositing loop는 범위 안에서 Wf 개의 pixel을 씁니다. 그런데 cmsDoTransform에는 pixel count로 X + Wf가 전달되어, allocation 끝을 넘어선 X pixel을 읽고 다시 씁니다. (번들된 test의 expectation 텍스트는 수평 조건을 명시적으로 검증하지만, allocation-end overrun에 필요한 수직 배치는 commit message가 설명하는 last-row 케이스를 따른 것입니다.)
Attacker에게 가장 유리한 시나리오를 조건부로 서술하면 다음과 같습니다. Overrun 길이는 attacker가 프레임마다 선택하는 xOffset의 직접적인 함수입니다. 만약 allocator가 attacker가 groom 가능한 객체를 pixel allocation 바로 뒤에 배치한다면 — 큰 malloc-class buffer라면 있음직한 일이지만, frame backing store에 대한 구체적인 bin 동작은 제공된 context로는 확인되지 않습니다 — 서로 다른 xOffset 값으로 sub-frame decoding을 반복하는 것만으로 거리를 조절할 수 있는 overwrite가 가능할 수 있습니다. 또한 이 write는 attacker가 공급한 ICC profile을 거치는 read-modify-write이고, profile은 거의 임의의 lookup table을 인코딩할 수 있으므로, 각 overrun 위치에 쓰이는 값은 기존 byte를 단순히 뒤섞는 수준을 넘어 상당 부분 attacker가 통제할 수 있습니다. 이는 이 primitive를 blind smash보다는 controlled overwrite에 가깝게 만드는 요소입니다. 다만 이를 실제로 활용하려면, security-relevant object(length field, vector capacity, pointer 등)가 overrun 범위 안에 놓이도록 하는 heap grooming이 필요하고, 해당 offset의 기존 byte를 원하는 byte로 매핑하는 transform을 구성할 profile이 필요하며, 이는 다시 그 기존 byte를 알아내거나 강제할 수 있어야 함을 전제로 합니다. 그리고 같은 overrun이 span 안의 다른 모든 것도 함께 손상시킨다는 사실도 감수해야 합니다.
Last-row 조건 없이 2*rect.x() + rect.width() > W만 성립하는 interior-row 케이스는 allocation 안에 머물기 때문에 다음 row의 pixel만 손상시키며, 그 자체로는 memory-safety 문제라기보다 rendering artefact에 가깝습니다. 다만 편리한 oracle이 되기는 합니다. Script가 canvas를 통해 compositing된 프레임을 다시 읽어 miscount를 확인할 수 있기 때문입니다.
Discovery는 sub-frame compositing path에 대한 표적화된 pattern auditing으로 읽힙니다. Rectangle의 origin만큼 이미 offset된 pointer 옆에서 rectangle accessor가 length로 사용되었다는 점이 단서이며, commit message는 이 fix를 rowAvailable()의 first-frame path와 일관성을 맞추는 작업으로 설명합니다. 이는 두 경로를 나란히 비교하던 reviewer가 발견했을 법한 흐름입니다. Fuzzing으로 정확히 이 형태를 찾아내기는 상대적으로 어려워 보입니다. 이 형태에 도달하려면 세 가지 조건이 동시에 맞아떨어져야 하기 때문입니다. iCCP chunk, 2*xOffset + width > canvasWidth를 만족하는 nonzero xOffset의 sub-frame, 그리고 yOffset + height == canvasHeight가 LCMS 구성 빌드에서 모두 성립해야 합니다. 게다가 interior-row overrun은 조용히 진행되기 때문에 crash만을 오라클로 삼는 fuzzer라면 좀처럼 걸려들지 않을 것입니다. xOffset + width == canvasWidth 경계에 정확히 맞춰 만들어진 test PNG는, 축소된 fuzzer artefact라기보다는 auditor가 손수 만든 minimal reproducer로 읽힙니다.
<img>에 대한 image decoding은 WebContent process에서 일어나므로, 이 primitive는 renderer sandbox 안에 머물게 됩니다. 완전한 compromise로 이어지려면 별도의 escape가 여전히 필요합니다. 영향을 받는 코드는 USE(LCMS) 뒤에 있는데, 이는 GTK와 WPE 포트에 해당합니다. Apple 포트는 다른 color-management backend를 사용하며 이 분기를 컴파일하지 않습니다.
이 vulnerability는 image-decoding 경계에서 WebContent process 내부의 memory safety를 약화시킵니다. 이 경계에서는 geometry 전체와 color transform 자체가 모두 untrusted image byte에서 나옵니다. 여기서 위반되는 security model 전제는, fcTL validation이 모든 프레임별 pixel 연산을 frame buffer allocation 안에 가두기에 충분하다는 것입니다. 실제로는 그렇지 않습니다. ICC transform이 그 앞의 compositing loop와는 다른, 더 큰 span을 사용하기 때문입니다. 임베디드 RGB iCCP chunk를 가진 조작된 animated PNG를 서빙하는 attacker는 LCMS 기반 포트에서 decoded-image allocation의 끝을 넘어선 heap read-modify-write에 도달할 수 있습니다. 이때 overrun 길이와 transform semantics 모두 attacker가 선택할 수 있습니다. 이는 단순한 crash가 아니라 renderer 안에서 heap corruption으로 이어지는 출발점이 될 수 있습니다.
Insight
fcTL validation은 allocation 수준의 bound이지만, compositing path 안의 각 pixel 연산은 저마다 암묵적인 bound를 갖고 있습니다. 여기서는 같은 row 위의 두 연산이 서로 다른 좌표 공간을 전제로 하고 있었습니다. 이것이 image decoder에서 반복적으로 나타나는 위험 패턴입니다. Parse 시점의 geometry check 하나가 이후 모든 pointer/length 쌍에 대한 포괄적인 승인으로 취급되고, 그 check가 애초에 상정한 것과 다른 방식으로 length를 계산하는 연산이 있다면 조용히 그 check를 빠져나가게 됩니다. width()가 와야 할 자리에 maxX()가 쓰인 이번 단서는 쓰기 쉬운 실수입니다. 둘 다 같은 타입을 반환하는 IntRect accessor이고, 흔한 x() == 0 케이스에서는 두 값이 수치적으로 동일해서, offset이 없는 테스트 이미지로는 이 버그가 전혀 드러나지 않기 때문입니다.
Audit directions
-
상대적인 span 길이가 필요한 자리에 절대 end-coordinate가 사용되는 경우입니다. 이미 origin만큼 이동된 pointer에 이 값이 그대로 적용됩니다. 이 패턴이 위험한 이유는 origin이 0이면 정확히 no-op이 되기 때문입니다. 그래서 기본 형태의 테스트 입력으로는 이 경로가 절대 실행되지 않습니다. Narrow:
Source/WebCore/platform/image-decoders/와Source/WebCore/platform/graphics/에서maxX(),maxY(),bottom(),right()가 비교가 아니라 count/length 인자로 — 즉memcpy/memset/cmsDoTransform의 마지막 인자나 loop bound로 — 쓰이는 지점을 검색해야 합니다. 먼저 다른 APNG·GIF sub-frame compositing 경로들(GIFImageDecoder::haveDecodedRow,frameComplete)과 decoder에 남아 있는#if USE(LCMS)블록부터 확인하는 것이 좋습니다. Wider: 같은 클래스의 버그는 base pointer가 origin만큼 offset되고 size가 서로 다른 coordinate frame에서 계산되는 곳이면 어디서든 나타날 수 있습니다 — blit/scroll rect copy,ImageBuffer의 sub-rect read, tiled-layer damage-rect update,stride와width가 뒤섞이는 video frame plane copy가 그 예입니다. Widest: length 인자는 그것이 적용되는 pointer와 동일한 coordinate space로 표현되어야 합니다. 이 원칙은 rectangle type과 raw buffer API를 함께 사용하는 모든 코드베이스에 적용됩니다 — Skia의SkRect/SkPixmapsub-region draw, FFmpeg의 plane/linesize 처리,end와len이 모두usize인 Rust의 slice-plus-offset 패턴이 여기에 해당됩니다. Match tell은 모든 rung에서 동일합니다. base pointer를 만들어낸 라인을 찾아, origin term이 pointer와 count 양쪽 모두에 나타나는지 확인해야 합니다. 그렇다면 count 쪽이 잘못된 것입니다. -
동일한 pixel 연산을 수행해야 하는 fast/first-time 경로와 slow/incremental 경로 사이의 불일치입니다. 여기서는
rowAvailable()의 first-frame 경로가rect.width()를 사용한 반면,frameComplete()의 sub-frame 경로는rect.maxX()를 사용했습니다. 결국 올바른 코드와 버그가 있는 코드가 같은 파일 안에서 같은 작업을 처리하고 있었던 셈입니다. Narrow:PNGImageDecoder.cpp,GIFImageDecoder.cpp,JPEGImageDecoder.cpp,WEBPImageDecoder.cpp에서 각 first-frame/full-image 경로를 대응하는 animation/sub-frame 경로와 비교해야 합니다. pointer, count, stride 표현식이 sub-frame origin을 제외하면 문자 그대로 동일한지 확인하는 것이 핵심입니다. Wider: 같은 형태의 문제는 원래의 whole-object 경로 옆에 나중에 incremental 또는 partial-update 경로가 추가된 곳이면 어디서든 반복됩니다 — progressive decode와 complete decode, partial repaint와 full repaint, incremental style resolution과 full recalc가 그 예입니다. Widest: 동일한 연산에 대한 두 구현은 bounds arithmetic이 서로 일치해야 하며, partial-update variant가 추가되는 바로 그 순간이 두 구현이 어긋나기 시작하는 지점입니다. 이 원칙은 incremental 경로가 추가된 모든 코드베이스에 적용할 수 있습니다 — V8의 incremental marking과 full GC, streaming parser와 whole-buffer parser가 그 예입니다. Match tell: 두 경로가 동일한 값을 구문적으로 다른 표현식으로 계산하고 있다면, 그 차이는 의도된 origin 보정이거나 버그이며 제3의 가능성은 없습니다. -
사용 시점이 아니라 parse 시점에만 강제되는 chunk validation입니다.
fcTLguard는 단 한 번, 이를 의존하는 loop들과는 멀리 떨어진 곳에서 검사됩니다.cmsDoTransform호출 지점에서는 이 값을 다시 도출하는 코드가 전혀 없습니다. Narrow: PNG/GIF/WebP chunk header에서 파싱되는 각 geometry field에 대해, 모든 consumer를 추적하고 그 consumer 자신의 arithmetic이 parse 시점의 predicate로 커버되는지 확인해야 합니다. 특히 offset을 한 번 더 더하는 consumer가 있는지 주의 깊게 봐야 합니다. Wider: 같은 클래스의 문제는 size field가 한 번 validate된 뒤 서로 다른 arithmetic을 가진 여러 downstream consumer에서 사용되는 header-then-payload 형식 전반에서 나타날 수 있습니다 — font table, ICC profile tag table, media container box size가 그 예입니다. Widest: validation predicate는 그것이 작성될 때 전제로 한 arithmetic만을 정확히 승인합니다. 서로 다른 arithmetic을 사용하는 각 consumer는 자체적인 check를 갖거나, overrun을 표현할 수 없는 span-typed API를 사용해야 합니다. Match tell: bounds check와 그 수혜자가 함수 경계로 분리되어 있고, 수혜자의 index 표현식이 검사된 표현식으로부터 문자 그대로 도출되지 않는 경우입니다. 검사된 부등식을 consumer의 arithmetic에 대입해 증명을 얻을 수 없다면, 그 check는 해당 부분을 커버하지 못하는 것입니다. -
transform의 format과 일치하지 않는 count를 사용하는 in-place colour-management 호출입니다.
cmsDoTransform은 pixel count를 인자로 받는 반면, 주변 코드는 대체로 byte 단위로 계산을 진행합니다. 게다가 transform의 bytes-per-pixel은 호출 지점과 멀리 떨어진 생성 시점에 고정됩니다. Narrow:Source/WebCore/platform/전반에서cmsDoTransform과cmsCreateTransform을 검색하고, 각 호출에 대해 count가 pixel span인지, destination pointer가 가리키는 buffer가 생성 시점에 사용된 format 기준으로 최소count * bytesPerPixel바이트를 확보하고 있는지 확인해야 합니다. Wider: 같은 unit-mismatch 클래스는 length parameter가 element 단위인데 caller 주변 변수는 byte 단위인 API, 혹은 그 반대의 경우 전반에서 나타납니다 —wmemcpy,png_read_row의 row size,CGBitmapContext의 stride arithmetic, WebAudio resampler에서 audio frame count와 sample count가 혼동되는 경우가 그 예입니다. Widest: length parameter의 단위는 그 타입의 일부이며, context로부터 추측할 것이 아니라 호출 지점에서 명확히 확립되어야 합니다. Match tell: 변수명이나 도출 과정이 한 단위로 되어 있는 length 표현식이 다른 단위로 문서화된 parameter에 전달되는 경우입니다. 호출 지점이 같은 함수 내 다른 곳에서 상수를 곱하거나 나눠야 한다면, 곱하지 않은 그 호출이 의심 대상입니다.