← All reports

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

LowWebKit back/forward listIntegerOverflow

a6cd3ca

Low. The pathological values are still rejected by the surrounding range checks on any conventional wrapping build, so there is no demonstrated primitive. What earns it a rating at all is location: a UIProcess bounds check on renderer-supplied input whose correctness rested on the toolchain declining to exploit the UB rather than on the language contract.

Session history is owned by the privileged parent process, and the renderer asks for entries by relative offset — "two back", "one forward". Two range checks over that signed offset did signed arithmetic on a value whose full int32_t domain is reachable from the untrusted side. The expectation those checks encoded is that ordinary signed arithmetic on a relative offset behaves like mathematical arithmetic, which holds for every input except the edges of the type.

The angle: a compromised renderer can hand the parent process an INT32_MIN or near-INT32_MAX delta and drive a bounds check whose behavior the source no longer determines — an abort on trap-instrumented builds, and on an optimizer that exploits the assumption, a check that admits an out-of-range index.

The commit message states the case directly: when index or distance is INT_MIN, the expression static_cast<unsigned>(-index) negates in signed arithmetic before casting, which is undefined behavior, and a compromised WebProcess can send INT32_MIN via the BackForwardItemAtIndex IPC message to trigger UB in the UIProcess. The fix adds a MESSAGE_CHECK rejecting INT_MIN at the boundary and, as defense in depth, casts to unsigned before negating in both WebBackForwardList::itemAtIndex() and BackForwardController::canGoBackOrForward().

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;
}

In BackForwardController::canGoBackOrForward(int distance), the negative-distance branch changes from static_cast<unsigned>(-distance) <= backCount() to -static_cast<unsigned>(distance) <= backCount() — the negation now happens after the conversion to unsigned.

In WebBackForwardList::itemAtDeltaFromCurrentIndex(int delta, AllowSkippingBackForwardItems), the combined guard if (!m_currentIndex || (int)*m_currentIndex + delta < 0) splits into a null check on m_currentIndex followed by a range check that performs no arithmetic on delta in signed space: if (delta < 0 && -static_cast<unsigned>(delta) > *m_currentIndex) return nullptr;, with a comment stating the intent. m_currentIndex is std::optional<size_t>, so the old code narrowed a size_t to int and then added the renderer-supplied delta in signed space; the new code compares an unsigned magnitude against the size_t directly and, for non-negative delta, performs no arithmetic at all.

The IPC handler backForwardItemAtIndexForWebContent gains a leading IPC::Connection& parameter — in both the definition and the private declaration in WebBackForwardList.h — which is what lets it use the connection-aware validator macro. Its first statement is now MESSAGE_CHECK_COMPLETION_BASE(delta != std::numeric_limits<int32_t>::min(), connection, completionHandler(nullptr));, rejecting the single input value whose negation is not representable in int32_t before delta reaches itemAtDeltaFromCurrentIndex. There are no test files in the diff.

Negating a signed value before widening it to unsigned, so the absolute-value conversion inside a bounds check is undefined at exactly the type minimum.

Signed integer overflow is undefined behavior in C++. For a two's-complement int, the range is asymmetric: INT_MIN is -2147483648 while INT_MAX is 2147483647, so -INT_MIN has no representable result, and an addition whose mathematical sum leaves the range is likewise undefined. Compilers are permitted to optimize on the assumption that UB never occurs, which means the surrounding code's behavior at those inputs is not determined by reading the source.

Unsigned arithmetic is defined and modular. Conversions to and arithmetic on unsigned wrap modulo 2^N by the standard, so -static_cast<unsigned>(x) is always well defined and, for negative x, equals the mathematical magnitude of x.

Operator binding. In static_cast<unsigned>(-x) the unary minus applies to x in its original signed type and the cast happens afterward; in -static_cast<unsigned>(x) the conversion happens first and the negation is unsigned. The two spellings differ only at the type minimum.

WebKit's multi-process model. The WebProcess renders untrusted web content and is treated as potentially compromised. The UIProcess is the privileged parent that owns real session history. WebBackForwardList in the UIProcess is an IPC::MessageReceiver; the WebProcess asks it for history entries by relative offset.

MESSAGE_CHECK family. These macros validate IPC-supplied arguments in the receiving process; when a check fails, the sending process is treated as misbehaving. MESSAGE_CHECK_COMPLETION_BASE is the variant used when the handler owns a CompletionHandler that must still be invoked — here with nullptr — before bailing out. Handlers opt into these by declaring an IPC::Connection& first parameter, which the generated message dispatcher supplies.

Back/forward list shape. WebBackForwardList holds BackForwardListItemVector m_entries and std::optional<size_t> m_currentIndex; a delta of -1 means the previous entry, +1 the next, and DefaultCapacity caps the list at 100 entries. On the WebCore side, BackForwardController::backCount() / forwardCount() return unsigned counts.

Two sites did signed arithmetic on an offset whose full domain is reachable, in two different ways.

In BackForwardController::canGoBackOrForward (WebCore, WebContent process), the unary minus in static_cast<unsigned>(-distance) binds to the int operand, so for distance == INT_MIN the negation is evaluated as int arithmetic and -INT_MIN is not representable.

In WebBackForwardList::itemAtDeltaFromCurrentIndex (UIProcess), the overflow reachable is not INT32_MIN — adding a small non-negative index (the list caps near 100) to INT_MIN stays representable and yields a negative sum, so the pre-fix guard correctly rejected it. The undefined case here is a large positive delta: INT32_MAX + *m_currentIndex overflows int. On a wrapping build the sum turns negative and the guard rejects — fail-closed — but the operation is UB and the compiler is entitled to assume it cannot happen.

  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

Because these are UB rather than merely wrong, the concrete behavior at those inputs is not fixed by the source — an optimizer may propagate the "cannot happen" assumption backwards into the surrounding comparison, e.g. treating -distance as provably positive and folding the <= against an unsigned count. On a conventional two's-complement build with wraparound and no such folding, -INT_MIN wraps back to INT_MIN and the conversion to unsigned produces 0x80000000, far larger than any plausible backCount(), so the check would still reject. What the patch restores is that both range checks have defined behavior across the entire int32_t domain.

Reachability is established from the diff and header: backForwardItemAtIndexForWebContent is declared under the // IPC messages block in WebBackForwardList.h on a class deriving from IPC::MessageReceiver, so a compromised WebProcess can invoke it with any int32_t. The WebCore-side canGoBackOrForward and goBackOrForward run inside the WebContent process; script-driven relative navigation is the natural producer of their distance argument, though the supplied BackForwardController.cpp context does not include the binding layer that connects them to a web-facing API.

Escalation is bounded and build-dependent. If the toolchain lowers the addition with two's-complement wraparound and performs no UB-derived folding, the sum turns negative and the guard still returns nullptr — a rejected lookup with no state change. If the UIProcess build is UBSan- or -ftrapv-instrumented, the addition would trap and the parent process would abort, giving a compromised renderer a browser-wide denial of service. If instead an optimizer exploited the distance < 0 implies -distance > 0 assumption in canGoBackOrForward and folded the comparison against backCount() to true, that function could report a navigation as permissible when it is not, and the downstream itemAtIndex(distance) in goBackOrForward would then be reached with an out-of-domain index; whether that could reach an out-of-range list access depends on BackForwardClient::itemAtIndex implementations the supplied context does not include. That path is confined to the WebContent process, where the attacker already has execution in this threat model.

This vulnerability weakens the WebContent-to-UIProcess IPC boundary, specifically the assumption that integers arriving from a compromised renderer stay inside the domain where the UIProcess's index arithmetic is well defined. The validity of a UIProcess bounds check on renderer-controlled input rested on compiler behavior rather than on the language contract. The separate WebCore-side negation UB sits inside the WebContent process, so its trapping outcome would abort only the renderer the attacker already controls and crosses no boundary. No corruption primitive is demonstrated, and both sites still reject the pathological values on conventional wrapping builds, so this reads as hardening of a privileged-process input path rather than a live escape path.

The static_cast<unsigned>(-x) idiom is a near-universal way of writing "take the magnitude of this negative index," and it is wrong at exactly one input. Codebases that mix signed relative offsets — deltas, distances, scroll amounts, seek positions — with unsigned or size_t container sizes accumulate the idiom densely, because the cast is what silences the sign-compare warning, and the cast placement that silences the warning is also the placement that keeps the UB. The correct spelling looks stranger and is therefore rarer. The second lesson is in the shape of the UIProcess rewrite: rather than fixing the arithmetic, the patch removes the arithmetic. "Do range checks without doing math on the untrusted value" is a stronger discipline than "do the math carefully," because it does not need re-verification when the surrounding types change.