[5] Signed integer overflow (UB) in back/forward list index handling
Low. 극단적인 값들은 일반적인 wrapping build에서는 여전히 주변 range check에 의해 거부되므로, 실증된 primitive는 존재하지 않습니다. 그럼에도 등급을 부여받은 이유는 위치 때문입니다. renderer가 공급한 입력에 대한 UIProcess의 bounds check인데, 그 정확성이 language contract가 아니라 toolchain이 UB를 exploit하지 않는다는 우연에 의존하고 있었습니다.
세션 히스토리는 특권을 가진 parent process가 소유하며, renderer는 상대 offset으로 항목을 요청합니다. "뒤로 두 칸", "앞으로 한 칸" 같은 방식입니다. 이 signed offset에 대해 두 곳의 range check가 signed arithmetic을 수행했는데, 이 값은 untrusted 쪽에서 int32_t의 전체 domain에 도달 가능한 값이었습니다. 이 check들이 전제로 삼은 기대는, 상대 offset에 대한 일반적인 signed arithmetic이 수학적 산술처럼 동작한다는 것이었습니다. 이는 해당 타입의 경계값을 제외한 모든 입력에서 성립합니다.
관전 포인트: 침해된 renderer가 parent process에 INT32_MIN이나 INT32_MAX에 근접한 delta를 넘겨, 소스 코드로는 더 이상 동작이 결정되지 않는 bounds check를 유발할 수 있습니다. trap-instrumented build에서는 abort가 발생하고, assumption을 exploit하는 optimizer 상에서는 out-of-range index를 통과시키는 check가 될 수 있습니다.
Commit message는 이 사안을 직접적으로 명시하고 있습니다. index나 distance가 INT_MIN일 때, static_cast<unsigned>(-index) 표현식은 cast 이전에 signed arithmetic으로 negate를 수행하는데 이는 undefined behavior에 해당하며, 침해된 WebProcess가 BackForwardItemAtIndex IPC 메시지를 통해 INT32_MIN을 보내면 UIProcess에서 UB를 유발할 수 있습니다. 이 fix는 경계에서 INT_MIN을 거부하는 MESSAGE_CHECK를 추가했으며, defense in depth 차원에서 WebBackForwardList::itemAtIndex()와 BackForwardController::canGoBackOrForward() 양쪽 모두 negate 이전에 unsigned로 cast하도록 변경했습니다.
Source/WebKit/UIProcess/WebBackForwardList.cpp
Source/WebCore/history/BackForwardController.cpp
Patch Details
BackForwardController::canGoBackOrForward(int distance)에서 negative-distance 분기가 static_cast<unsigned>(-distance) <= backCount()에서 -static_cast<unsigned>(distance) <= backCount()로 변경되었습니다. 이제 negation이 unsigned로의 변환 이후에 일어납니다.
WebBackForwardList::itemAtDeltaFromCurrentIndex(int delta, AllowSkippingBackForwardItems)에서는, 기존의 결합된 guard if (!m_currentIndex || (int)*m_currentIndex + delta < 0)이 m_currentIndex에 대한 null check와, delta에 signed space에서 어떤 산술도 수행하지 않는 range check로 분리되었습니다. if (delta < 0 && -static_cast<unsigned>(delta) > *m_currentIndex) return nullptr; 형태이며, 의도를 명시한 주석도 함께 추가되었습니다. m_currentIndex는 std::optional<size_t> 타입이므로, 기존 코드는 size_t를 int로 narrowing한 뒤 renderer가 공급한 delta를 signed space에서 더하는 방식이었습니다. 새 코드는 unsigned magnitude를 size_t와 직접 비교하며, non-negative delta에 대해서는 아예 산술을 수행하지 않습니다.
IPC handler backForwardItemAtIndexForWebContent는 정의부와 WebBackForwardList.h의 private 선언부 양쪽에서 IPC::Connection& 파라미터를 맨 앞에 추가로 갖게 되었습니다. 이는 connection-aware validator macro를 사용할 수 있게 해주는 부분입니다. 이 함수의 첫 문장은 이제 MESSAGE_CHECK_COMPLETION_BASE(delta != std::numeric_limits<int32_t>::min(), connection, completionHandler(nullptr));로, delta가 itemAtDeltaFromCurrentIndex에 도달하기 전에 int32_t로 표현할 수 없는 negation을 갖는 유일한 입력값을 거부합니다. diff에는 test 파일이 포함되어 있지 않습니다.
Signed 값을 unsigned로 widening하기 전에 negate하는 패턴이라, bounds check 내부의 absolute-value 변환이 정확히 type minimum 지점에서 undefined가 됩니다.
Background
Signed integer overflow는 C++에서 undefined behavior입니다. two's-complement int의 range는 비대칭적입니다. INT_MIN은 -2147483648이고 INT_MAX는 2147483647이므로, -INT_MIN은 표현 가능한 결과가 없으며, 수학적 합이 range를 벗어나는 덧셈 역시 마찬가지로 undefined입니다. 컴파일러는 UB가 절대 발생하지 않는다는 가정 하에 최적화할 수 있는 권한을 갖는데, 이는 해당 입력에서 주변 코드의 동작이 소스 코드를 읽는 것만으로는 결정되지 않는다는 뜻입니다.
Unsigned arithmetic은 정의되어 있고 modular합니다. unsigned로의 변환과 그에 대한 산술은 표준에 의해 2^N 기준으로 wrap하므로, -static_cast<unsigned>(x)는 항상 well defined되어 있으며, negative x에 대해서는 x의 수학적 magnitude와 같습니다.
연산자 결합 순서. static_cast<unsigned>(-x)에서는 unary minus가 원래의 signed type인 x에 적용된 뒤 cast가 이루어집니다. 반면 -static_cast<unsigned>(x)에서는 변환이 먼저 일어나고 negation이 unsigned로 수행됩니다. 두 표기법은 type minimum 지점에서만 차이가 납니다.
WebKit의 multi-process 모델. WebProcess는 untrusted 웹 콘텐츠를 렌더링하며 잠재적으로 침해된 상태로 취급됩니다. UIProcess는 실제 세션 히스토리를 소유하는 특권 parent입니다. UIProcess 안의 WebBackForwardList는 IPC::MessageReceiver이며, WebProcess는 이를 통해 상대 offset으로 히스토리 항목을 요청합니다.
MESSAGE_CHECK 계열 매크로. 이 매크로들은 수신 process에서 IPC로 공급된 인자를 검증하며, check가 실패하면 송신 process가 오작동하는 것으로 취급됩니다. MESSAGE_CHECK_COMPLETION_BASE는 handler가 CompletionHandler를 소유하고 있어서 실패 시에도 이를 호출해야 하는 경우에 쓰이는 변형입니다. 여기서는 nullptr로 호출한 뒤 빠져나갑니다. Handler가 이 매크로들을 사용하려면 첫 번째 파라미터로 IPC::Connection&을 선언해야 하며, 이는 생성된 message dispatcher가 공급해 줍니다.
Back/forward list의 구조. WebBackForwardList는 BackForwardListItemVector m_entries와 std::optional<size_t> m_currentIndex를 갖습니다. delta -1은 이전 항목을, +1은 다음 항목을 의미하며, DefaultCapacity는 list를 100개 항목으로 제한합니다. WebCore 쪽에서는 BackForwardController::backCount()/forwardCount()가 unsigned count를 반환합니다.
Analysis
도달 가능한 전체 domain을 가진 offset에 대해 두 지점이 서로 다른 방식으로 signed arithmetic을 수행하고 있었습니다.
BackForwardController::canGoBackOrForward (WebCore, WebContent process)에서는 static_cast<unsigned>(-distance)의 unary minus가 int operand에 결합됩니다. 따라서 distance == INT_MIN일 때 negation이 int arithmetic으로 평가되고, -INT_MIN은 표현 불가능한 값이 됩니다.
WebBackForwardList::itemAtDeltaFromCurrentIndex (UIProcess)에서 도달 가능한 overflow는 INT32_MIN이 아닙니다. 작은 non-negative index(list는 100 근처로 제한됩니다)를 INT_MIN에 더해도 여전히 표현 가능한 범위 안에 머물며 negative sum을 만들어내므로, 기존 fix 이전의 guard도 이 경우는 올바르게 거부했습니다. 여기서 undefined인 경우는 큰 positive delta입니다. INT32_MAX + *m_currentIndex가 int를 overflow합니다. wrapping build에서는 합이 negative로 바뀌어 guard가 거부합니다 — fail-closed 방향이지만, 이 연산 자체는 UB이며 컴파일러는 "이런 일은 일어날 수 없다"고 가정할 권한을 갖습니다.
Before (UIProcess): After (UIProcess):
(int)*m_currentIndex + delta < 0 !m_currentIndex ──► nullptr
│ signed add, UB at delta < 0 ?
│ large positive delta ├─ yes ──► -(unsigned)delta > *m_currentIndex ──► nullptr
└──► reject / accept └─ no ──► no arithmetic on delta at all
이것이 단순히 wrong이 아니라 UB이기 때문에, 해당 입력에서의 구체적인 동작은 소스 코드로 고정되지 않습니다. optimizer가 "이런 일은 일어날 수 없다"는 assumption을 주변 비교문으로 역전파시켜, 예를 들어 -distance를 provably positive로 취급하고 unsigned count에 대한 <= 비교를 fold해버릴 수도 있습니다. wraparound가 있고 이런 folding이 없는 일반적인 two's-complement build에서는, -INT_MIN이 다시 INT_MIN으로 wrap되고 unsigned로의 변환은 0x80000000을 만들어냅니다. 이는 어떤 현실적인 backCount()보다도 훨씬 큰 값이므로 check는 여전히 거부하게 됩니다. 이 패치가 복원하는 것은, 두 range check 모두가 int32_t의 전체 domain에 걸쳐 정의된 동작을 갖도록 하는 부분입니다.
도달 가능성은 diff와 header에서 확인됩니다. backForwardItemAtIndexForWebContent는 WebBackForwardList.h의 // IPC messages 블록 아래, IPC::MessageReceiver를 상속하는 class에 선언되어 있으므로, 침해된 WebProcess가 임의의 int32_t로 이를 호출할 수 있습니다. WebCore 쪽의 canGoBackOrForward와 goBackOrForward는 WebContent process 안에서 실행됩니다. script가 유발하는 relative navigation이 이들의 distance 인자를 만들어내는 자연스러운 경로이지만, 제공된 BackForwardController.cpp context에는 이들을 웹에 노출된 API와 연결하는 binding layer는 포함되어 있지 않습니다.
Escalation은 제한적이며 build에 의존합니다. toolchain이 two's-complement wraparound로 덧셈을 lowering하고 UB에서 파생된 folding을 수행하지 않는다면, 합은 negative로 바뀌고 guard는 여전히 nullptr을 반환합니다. 상태 변화 없이 lookup이 거부되는 결과입니다. UIProcess build가 UBSan이나 -ftrapv로 instrumented되어 있다면 덧셈이 trap을 발생시킬 수 있고, parent process가 abort되어 침해된 renderer가 browser 전체에 대한 denial of service를 유발할 가능성이 있습니다. 반대로 optimizer가 canGoBackOrForward에서 distance < 0이 -distance > 0을 함의한다는 assumption을 exploit해 backCount()와의 비교를 true로 fold해버렸다면, 이 함수는 실제로는 허용되지 않는 navigation을 permissible로 보고할 수 있고, 이후 goBackOrForward 안의 itemAtIndex(distance)가 domain을 벗어난 index로 도달될 수 있습니다. 이것이 out-of-range list access로 이어질 수 있는지는 BackForwardClient::itemAtIndex 구현에 달려 있는데, 제공된 context에는 해당 구현이 포함되어 있지 않습니다. 이 경로는 WebContent process 안에 국한되며, 이 threat model에서 attacker는 이미 그 안에서 execution을 확보한 상태입니다.
이 vulnerability는 WebContent-to-UIProcess IPC boundary를 약화시킵니다. 구체적으로는, 침해된 renderer로부터 도착하는 정수가 UIProcess의 index arithmetic이 well defined인 domain 안에 머문다는 가정을 무너뜨립니다. renderer가 통제하는 입력에 대한 UIProcess bounds check의 유효성은 language contract가 아니라 compiler 동작에 의존하고 있었습니다. 별도로 존재하는 WebCore 쪽의 negation UB는 WebContent process 내부에 있으므로, 이 trap이 발생하더라도 이미 attacker가 통제하는 renderer만 abort시킬 뿐 어떤 boundary도 넘지 않습니다. corruption primitive는 실증되지 않았고, 두 지점 모두 일반적인 wrapping build에서는 여전히 극단값을 거부하므로, live escape path라기보다는 특권 process의 입력 경로에 대한 hardening으로 읽힙니다.
static_cast<unsigned>(-x) idiom은 "이 negative index의 magnitude를 취한다"는 것을 표현하는 거의 보편적인 방식이며, 정확히 하나의 입력에서만 wrong입니다. signed relative offset — delta, distance, scroll amount, seek position 등 — 을 unsigned나 size_t container size와 혼용하는 codebase는 이 idiom을 밀도 있게 쌓아가게 됩니다. sign-compare warning을 잠재우는 것이 바로 이 cast이고, warning을 잠재우는 그 cast 위치가 곧 UB를 유지하는 위치이기 때문입니다. 올바른 표기법은 더 낯설어 보이고, 그래서 더 드물게 쓰입니다. 두 번째 교훈은 UIProcess 쪽 rewrite의 형태에 있습니다. arithmetic을 고치는 대신, 패치는 arithmetic 자체를 제거합니다. "untrusted 값에 대해 산술을 하지 않고 range check를 수행한다"는 것은 "산술을 조심스럽게 한다"보다 더 강한 discipline입니다. 주변 타입이 바뀌더라도 재검증이 필요 없기 때문입니다.
Audit directions
-
Negation이 cast 안쪽에 있는 absolute-value 변환. 여기서 invariant는 signed magnitude를 unsigned로 변환할 때는 먼저 widening하고 나중에 negate해야 한다는 것입니다. signed domain 자체가 비대칭적이기 때문입니다. Narrow: WebCore와 WebKit에서
static_cast<unsigned>(-,static_cast<size_t>(-,static_cast<uint64_t>(-를 검색합니다. code-review에서 알아볼 수 있는 신호는 signed 변수에 대해 unary minus가 cast 괄호 안쪽에 위치하는 형태이며, operand가 type minimum에 도달할 수 있는 모든 hit이 후보입니다. Wider: 같은 부류의 문제가 다른 magnitude idiom 뒤에도 숨어 있을 수 있습니다.int에 대한abs()/labs()(INT_MIN에서 명시적으로 undefined), 두 signed index 차이에 대한std::abs, 그리고 직접 작성한x < 0 ? -x : x형태의 ternary 등입니다. 이런 패턴이.size(),count(), capacity 값에 대한<=/<비교와 인접해 있는지 검색해야 합니다. 이 조합이 바로 단순한 numeric 결함을 bounds-check 결함으로 바꾸는 지점이기 때문입니다. Widest: fixed-width two's-complement 정수를 쓰는 모든 언어가 대상입니다. Rust의i32::abs는 debug build에서i32::MIN일 때 panic합니다(checked_abs/unsigned_abs가 사용되고 있는지 확인이 필요합니다). Java의Math.abs(Integer.MIN_VALUE)는 조용히 negative 값을 반환합니다. 이 신호를 그대로 가져가면 됩니다. signed 값으로부터 magnitude를 계산해 즉시 length와 비교하는 표현식을 발견하면, untrusted 입력으로부터 type minimum에 도달할 수 있는지를 확인해야 합니다. -
IPC handler가 wire-format integer를 좁은 semantic domain의 값으로 잘못 취급하는 패턴. 이 클래스는 wire-format integer를 선언된 타입보다 더 좁은 semantic domain의 값으로 취급하는 handler를 가리킵니다. 좁게 보면, UIProcess 헤더의
// IPC messages주석 블록 아래 선언된 handler를 나열하는 작업부터 시작합니다.WebBackForwardList.h가 대표적이며, 여기에는backForwardGoToItem,backForwardAllItems,backForwardListContainsItem,backForwardListCounts가 열거되어 있습니다. 이 중int32_t/int64_t파라미터를 받으면서도 대응하는MESSAGE_CHECK가 없는 항목을 확인해야 합니다. Signed parameter가 index나 size 연산에 쓰이는데 첫 statement로 validator macro가 없는 형태가 바로 눈여겨봐야 할 지점입니다. 조금 더 넓게 보면, 같은 클래스는 타입의 전체 범위가 수용되지만 실제로는 일부 sub-range만 유의미한 모든 deserialization boundary에 적용됩니다.IPC::Decoder의 customdecode구현,SessionState/FrameStaterestore 경로, sandbox-message argument parsing이 여기 해당합니다. Decode된 scalar가 range predicate 이전에 곧바로 산술 연산으로 흘러 들어가는 형태를 주목해야 합니다. 가장 넓게 보면, wire-type이 semantic-type보다 넓은 이 패턴은 Chromium Mojo interface, protobufint32field, 그리고 IDL scalar range가 handler의 가정된 domain을 초과하는 모든 RPC layer에 동일하게 적용됩니다. Decode되는 각 integer마다 handler가 실제로 받아들이는 sub-range가 무엇인지 명시한 뒤, 첫 사용 이전에 그것을 강제하는 장치가 있는지 확인해야 합니다. -
자신의 scope를 부지불식간에 좁혀버린, 재작성된 validity predicate. 이 클래스는 부주의했던 원본이 side effect로 걸러내던 입력을, 고정된 predicate가 조용히 허용해버리는 패턴입니다. 좁게 보면,
WebBackForwardList::itemAtDeltaFromCurrentIndex에서 확인할 수 있습니다. 기존의(int)*m_currentIndex + delta < 0조건은 wrapping 상황에서 매우 큰 양수 delta까지도 함께 걸러냈지만, 새로운 guard는delta < 0일 때만 동작합니다. 이 함수의 나머지 부분과AllowSkippingBackForwardItems경로를 따라가, forward 방향이m_entries.size()를 기준으로 별도로 bound되어 있는지 확인해야 합니다.*m_currentIndex + delta를 index로 사용하면서 vector length와의 사전 비교가 없는 형태가 바로 확인해야 할 지점입니다. 조금 더 넓게 보면, 같은 종류의 drift는canX()predicate와doX()action이 script나 IPC로부터 각각 독립적으로 도달 가능한 모든 지점에서 나타납니다. History navigation, editing command의isEnabled/execute쌍, media의canPlayType/load쌍이 여기 해당합니다. Predicate를 참조하는 대신 자체적으로 bound를 다시 도출하는 action method가 있는지 찾아야 합니다. 가장 넓게 보면, guard와 guard 대상 operation이 각각 독립적으로 도달 가능한 이 구조는open()이전의 POSIXaccess(), web API의 permission check, bytecode VM의 validator/executor 분리에도 동일하게 적용됩니다. Predicate를 우회해 action을 직접 호출할 수 있다면, action은 predicate가 보장하던 모든 bound를 스스로 다시 검증해야 합니다. -
소스 코드가 아니라 build flag가 관찰되는 severity를 결정하는 UB. 좁게 보면, WebKit UIProcess target에 적용된 sanitizer와
-fno-strict-overflow/-ftrapv설정을 먼저 확인해야 합니다. 그런 다음, 이전 방향에서 발견된 미해결static_cast<unsigned>(-x)와 signed-addition hit들을 어느 process에 속해 있는지에 따라 재정렬해야 합니다. Trapping build 하에서 UIProcess에 있는 hit는 renderer가 유발 가능한 parent-process abort로 이어지는 반면, WebCore에 있는 동일한 hit는 이미 compromise된 renderer만 abort시킵니다. 조금 더 넓게 보면, 같은 논리가 runtime 효과가 flag에 의존하는 모든 UB class에 적용됩니다. Unaligned load, strict-aliasing violation,INT_MIN / -1division이 여기 해당하며, audit finding의 severity 서술에 "depends on the compiler"라는 표현이 포함되어 있는지가 주목해야 할 형태입니다. 가장 넓게 보면, privileged process에서 발견된 UB에 대해서는 hardening 설정이 severity의 상한을 결정하므로, ranking에 앞서 build configuration을 먼저 확인해야 합니다. 이 원칙은 sanitizer-hardened build나panic=abortbuild를 배포하는 모든 코드베이스에 적용되며, release에서는 wrap되던 arithmetic overflow가 debug에서는 abort되는 Rust 서비스에도 동일하게 성립합니다.