← All reports

[5] Signed integer overflow (UB) in back/forward list index handling

LowWebKit back/forward listIntegerOverflow

a6cd3ca

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

RefPtr<WebBackForwardListItem> WebBackForwardList::itemAtDeltaFromCurrentIndex(int delta, AllowSkippingBackForwardItems allowSkippingBackForwardItems) const
{
 
- if (!m_currentIndex || (int)*m_currentIndex + delta < 0)
+ if (!m_currentIndex)
+ return nullptr;
+
+ // Do range checks without doing math on delta to avoid overflow.
+ if (delta < 0 && -static_cast<unsigned>(delta) > *m_currentIndex)
return nullptr;
...
-void WebBackForwardList::backForwardItemAtIndexForWebContent(int32_t delta, FrameIdentifier frameID, CompletionHandler<void(RefPtr<FrameState>&&)>&& completionHandler)
+void WebBackForwardList::backForwardItemAtIndexForWebContent(IPC::Connection& connection, int32_t delta, FrameIdentifier frameID, CompletionHandler<void(RefPtr<FrameState>&&)>&& completionHandler)
{
+ MESSAGE_CHECK_COMPLETION_BASE(delta != std::numeric_limits<int32_t>::min(), connection, completionHandler(nullptr));
+
// FIXME: This should verify that the web process requesting the item hosts the specified frame.
if (RefPtr item = itemAtDeltaFromCurrentIndex(delta, AllowSkippingBackForwardItems::No)) {

Source/WebCore/history/BackForwardController.cpp

bool BackForwardController::canGoBackOrForward(int distance) const
{
if (!distance)
return true;
if (distance > 0 && static_cast<unsigned>(distance) <= forwardCount())
return true;
 
- if (distance < 0 && static_cast<unsigned>(-distance) <= backCount())
+ if (distance < 0 && -static_cast<unsigned>(distance) <= backCount())
return true;
return false;
}

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_currentIndexstd::optional<size_t> 타입이므로, 기존 코드는 size_tint로 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));로, deltaitemAtDeltaFromCurrentIndex에 도달하기 전에 int32_t로 표현할 수 없는 negation을 갖는 유일한 입력값을 거부합니다. diff에는 test 파일이 포함되어 있지 않습니다.

Signed 값을 unsigned로 widening하기 전에 negate하는 패턴이라, bounds check 내부의 absolute-value 변환이 정확히 type minimum 지점에서 undefined가 됩니다.

Signed integer overflow는 C++에서 undefined behavior입니다. two's-complement int의 range는 비대칭적입니다. INT_MIN-2147483648이고 INT_MAX2147483647이므로, -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 안의 WebBackForwardListIPC::MessageReceiver이며, WebProcess는 이를 통해 상대 offset으로 히스토리 항목을 요청합니다.

MESSAGE_CHECK 계열 매크로. 이 매크로들은 수신 process에서 IPC로 공급된 인자를 검증하며, check가 실패하면 송신 process가 오작동하는 것으로 취급됩니다. MESSAGE_CHECK_COMPLETION_BASE는 handler가 CompletionHandler를 소유하고 있어서 실패 시에도 이를 호출해야 하는 경우에 쓰이는 변형입니다. 여기서는 nullptr로 호출한 뒤 빠져나갑니다. Handler가 이 매크로들을 사용하려면 첫 번째 파라미터로 IPC::Connection&을 선언해야 하며, 이는 생성된 message dispatcher가 공급해 줍니다.

Back/forward list의 구조. WebBackForwardListBackForwardListItemVector m_entriesstd::optional<size_t> m_currentIndex를 갖습니다. delta -1은 이전 항목을, +1은 다음 항목을 의미하며, DefaultCapacity는 list를 100개 항목으로 제한합니다. WebCore 쪽에서는 BackForwardController::backCount()/forwardCount()unsigned count를 반환합니다.

도달 가능한 전체 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_currentIndexint를 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에서 확인됩니다. backForwardItemAtIndexForWebContentWebBackForwardList.h// IPC messages 블록 아래, IPC::MessageReceiver를 상속하는 class에 선언되어 있으므로, 침해된 WebProcess가 임의의 int32_t로 이를 호출할 수 있습니다. WebCore 쪽의 canGoBackOrForwardgoBackOrForward는 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입니다. 주변 타입이 바뀌더라도 재검증이 필요 없기 때문입니다.