← All reports

[2] NetworkBroadcastChannelRegistry crashes on a BroadcastChannel message with a null name

MediumWebKit NetworkProcessMemoryCorruption

e85a1ea

Medium 등급이며, fault의 형태 자체가 이 등급을 상한선으로 만듭니다. 무조건적으로 null인 base에서 고정 offset을 읽는 동작일 뿐, write도 없고 공격자가 주소를 선택할 수도 없기 때문입니다. 그럼에도 Low보다 높은 이유는 대상입니다. network process는 세션 전체가 공유하는 프로세스이고, 어떤 WebProcess에서 온 메시지 하나만으로도 이를 다운시킬 수 있습니다.

WebKit은 network 트래픽을 별도의, 더 높은 권한을 가진 프로세스에서 처리합니다. 이 프로세스는 cookie, credential, cache 상태를 보관하며, content process가 일단 compromise되면 이 프로세스가 받는 모든 메시지는 공격자의 영향을 받게 됩니다. BroadcastChannel은 같은 origin의 browsing context끼리 author가 지정한 채널 이름으로 메시지를 주고받게 해주는 web API입니다. peer들이 서로 다른 content process에 위치할 수 있기 때문에, network process는 (origin, channel name) 쌍을 key로 하는 registry를 통해 트래픽을 중개합니다. 이 registry가 의존하는 계약은, IPC로 전달된 channel name이 hash-table key로 사용 가능하다는 전제입니다.

관전 포인트: compromise된 WebProcess가 name이 null인 RegisterChannel 메시지 하나만 보내면, 세션의 모든 탭에 대해 network process가 항상 동일하게 종료됩니다.

NetworkBroadcastChannelRegistry는 IPC로 전달된 channel name을 registerChannel()(ensure() 경유)과 unregisterChannel() / postMessage()(find() 경유)에서 HashMap<String, ...>의 key로 사용합니다. compromise되었거나 malformed된 WebProcess는 name으로 null String을 보낼 수 있습니다. null String key를 조회하면 key를 해싱하는 과정에서 null StringImpl을 dereference하게 되는데(StringHash::hash()key.impl()->hash()를 호출), 이는 HashTable::validateKey()가 실행되기도 전에 network process를 crash시킵니다. 세 endpoint 모두에 MESSAGE_CHECK로 null name을 거부하도록 하여, 기존의 origin validation과 동일한 방식을 적용했습니다.

Source/WebKit/NetworkProcess/NetworkBroadcastChannelRegistry.cpp

void NetworkBroadcastChannelRegistry::registerChannel(IPC::Connection& connection, const WebCore::ClientOrigin& origin, const String& name)
{
MESSAGE_CHECK(isValidClientOrigin(origin), connection);
+ MESSAGE_CHECK(!name.isNull(), connection);
 
auto& channelsForOrigin = m_broadcastChannels.ensure(origin, [] { return NameToConnectionIdentifiersMap { }; }).iterator->value;
auto& connectionIdentifiersForName = channelsForOrigin.ensure(name, [] { return Vector<IPC::Connection::UniqueID> { }; }).iterator->value;
...
void NetworkBroadcastChannelRegistry::unregisterChannel(IPC::Connection& connection, const WebCore::ClientOrigin& origin, const String& name)
{
MESSAGE_CHECK(isValidClientOrigin(origin), connection);
+ MESSAGE_CHECK(!name.isNull(), connection);
...
auto connectionIdentifiersForNameIterator = channelsForOriginIterator->value.find(name);
...
void NetworkBroadcastChannelRegistry::postMessage(IPC::Connection& connection, const WebCore::ClientOrigin& origin, const String& name, WebCore::MessageWithMessagePorts&& message, CompletionHandler<void()>&& completionHandler)
{
MESSAGE_CHECK_COMPLETION(isValidClientOrigin(origin), connection, completionHandler());
+ MESSAGE_CHECK_COMPLETION(!name.isNull(), connection, completionHandler());

LayoutTests/ipc/register-broadcast-channel-malformed-client-origin-crash.html

+<!-- webkit-test-runner [ IPCTestingAPIEnabled=true ] -->
+ import('./coreipc.js').then(({ CoreIPC }) => {
+ const channelIdentifier = 0;
+ const hostString = unescape('nn%01%23KpUJ%21%09%3CC%3Ad0E%12seZVh%3BZ-%5D@%00E%3D%3Ctbl-...');
+ CoreIPC.Networking.NetworkBroadcastChannelRegistry.RegisterChannel(channelIdentifier, {
+ origin : { topOrigin : { ... }, clientOrigin : { ... } },
+ name : null
+ });
+ });

LayoutTests/ipc/coreipc.js

case 'String':
+ if (argument === null)
+ return {value: null, type: 'String'};
if (typeof argument != 'string') {
throw new SerializationError(`Primitive value is not a string`);
}

세 개의 IPC entry point 각각에 name 인자에 대한 입력 검증이 딱 한 줄씩 추가되었습니다. 두 개의 동기 handler에는 MESSAGE_CHECK(!name.isNull(), connection)가, 여전히 reply를 처리해야 하는 postMessage에는 MESSAGE_CHECK_COMPLETION(!name.isNull(), connection, completionHandler())가 각각 추가되었습니다. 새로 추가된 check들은 기존의 isValidClientOrigin(origin) check 바로 다음 위치에 배치되었습니다. 그 외 production 로직은 변경되지 않았습니다. ensure(name, ...) insert와 find(name) lookup은 그대로 유지됩니다. coreipc.jsArgumentSerializer는 JS의 nullString 자리에 전달될 때 SerializationError를 던지는 대신 null String으로 직렬화되도록 확장되었으며, 이를 통해 regression test가 name: null을 실제로 전송할 수 있게 되었습니다.

hash function이 sentinel 값은 결코 도달하지 않는다고 전제하고 있는 상황에서, 표현은 가능하지만 hash-table key로는 illegal한 cross-process string 인자에 대한 boundary validation이 누락된 패턴.

  Before:                                   After:
  RegisterChannel(origin, name)             RegisterChannel(origin, name)
    |- MESSAGE_CHECK(validOrigin)             |- MESSAGE_CHECK(validOrigin)
    |                                         |- MESSAGE_CHECK(!name.isNull())
    `- ensure(name, ...)                      |     `-> terminate sender
         `- StringHash::hash(name)            `- ensure(name, ...)
              `- name.impl()->hash()               `- impl() guaranteed non-null
                   ^ impl() == nullptr
                     -> fault, network process dies

이 코드가 있는 위치. NetworkBroadcastChannelRegistry는 서로 다른 WebProcess에 존재하는 same-origin BroadcastChannel instance들이 서로를 찾을 수 있게 해주는 broker입니다. (origin, channel name) 쌍을 구독 중인 connection에 매핑하는 HashMap<ClientOrigin, HashMap<String, Vector<IPC::Connection::UniqueID>>>를 유지하며, postMessage payload를 다른 모든 구독자에게 중계합니다. RegisterChannel, UnregisterChannel, PostMessage를 받으며, 각각 (ClientOrigin origin, String name, ...)을 포함합니다.

null String과 empty String의 차이. WTF StringRefPtr<StringImpl>을 보유합니다. null Stringimpl() == nullptr이고, empty String은 유효한 zero-length StringImpl을 가리킵니다. String::isNull()이 이 둘을 구분하며, IPC decoder는 둘 중 어느 쪽이든 정상적으로 생성할 수 있습니다. 즉 null도 wire 상에서 표현 가능한 값입니다.

StringHash와 hash-table traits. StringHash::hash(const String&)key.impl()->hash()를 계산합니다. WTF의 HashTraits<String>는 null String을 테이블의 empty value로 사용하기 때문에, 계약상 null String은 애초에 legal한 key가 될 수 없고 hash function 역시 null check를 갖고 있지 않습니다. HashMap::ensure()HashMap::find() 모두 어떤 bucket에 접근하기 전에 key를 먼저 해싱합니다. HashTable::validateKey()는 empty 또는 deleted key 값의 사용을 잡아내는 debug-build 전용 check입니다.

MESSAGE_CHECK / MESSAGE_CHECK_COMPLETION. WebKit의 IPC validation macro입니다. 조건이 false이면 로그를 남기고 보낸 측 connection의 WebProcess를 종료시키며, malformed 메시지를 compromise의 증거로 취급합니다. _COMPLETION variant는 추가로 reply handler를 호출하여, 비동기 메시지가 completion을 dangling 상태로 남기지 않도록 합니다.

IPC testing API. compromise된 WebProcess를 흉내 내어 layout test가 raw IPC 메시지를 직접 구성할 수 있도록 window.IPC를 노출하는 test 전용 기능입니다(IPCTestingAPIEnabled=true). LayoutTests/ipc/coreipc.js는 이 기능의 JS 측 argument serializer입니다.

근본 원인은 IPC boundary에서 강제되지 않은 type invariant에 있습니다. hash key로 사용되는 String은 non-null이어야 한다는 조건이 있지만, 이를 강제하는 장치가 없었습니다. handler는 ClientOrigin 인자는 검증했지만 name에는 어떤 제약도 두지 않았고, 이를 곧바로 key로 사용했습니다. name이 null이면 impl()은 nullptr을 반환하고, 그 null base에서 hash 필드를 읽는 동작이 fault를 일으켜 프로세스가 종료됩니다. 이 fault는 validateKey()(debug 전용 illegal-key assertion)가 무언가를 보고하기도 전에 발생하므로, release build에서 관찰되는 동작은 assertion이 아니라 hard crash입니다. hashing이 null-safe하더라도 이 lookup 자체는 여전히 illegal합니다. empty value를 key로 insert하는 행위가 테이블의 empty-bucket bookkeeping을 깨뜨리기 때문입니다. 따라서 이번 fix는 lookup을 관대하게 만드는 대신 boundary에서 값을 거부하는 방식을 택했습니다.

일반 web content로부터는 도달이 불가능한 경로입니다. BroadcastChannel binding이 channel name을 DOMString으로 coerce하기 때문에, 일반적인 WebCore 경로로는 null String을 만들어낼 수 없습니다. 공격자는 이미 WebProcess→NetworkProcess connection 위에서 임의의 IPC를 보낼 수 있는 위치에 있어야 합니다. WebProcess code execution을 확보했거나, 이번 regression test처럼 IPC testing API를 사용하는 경우입니다. 그런 위치에 있다면 트리거는 메시지 하나로 충분합니다. 문법적으로 유효한 ClientOrigin(test에서는 opaque topOrigin과, host가 garbage 값인 SecurityOriginData::Tuple clientOrigin을 사용하는데, 둘 다 null이 아니므로 isValidClientOrigin을 통과합니다)과 함께 name: null을 담아 RegisterChannel을 보내면 됩니다. unregisterChannelpostMessagefind(name)을 통해 같은 fault에 도달합니다.

프로세스 종료를 넘어서는 확장이 이루어지려면 null page가 network process에 매핑 가능해야 하고, fault가 발생하는 offset이 공격자에 의해 결정될 수 있어야 합니다. 두 조건 모두 성립하지 않습니다. offset은 StringImpl 내부의 compile-time member offset이고, base는 무조건 nullptr이기 때문입니다. read, write, type-confusion 중 어떤 primitive도 얻을 수 없으며, bucket 상태가 변경되기 전에 fault로 abort됩니다.

이 vulnerability는 WebContent→Network process 사이의 trust boundary 중 input-validation 계층을 약화시킵니다. WebKit의 IPC 보안 모델은, 더 높은 권한을 가진 프로세스의 모든 handler가 compromise될 수 있는 WebProcess로부터 온 인자를 데이터 구조에 넘기기 전에 완전히 검증해야 한다는 전제를 갖고 있습니다. 특히 그 데이터 구조의 invariant가 wire format이 허용하는 범위보다 좁을 때는 더욱 그렇습니다. 이 경우 공격자가 얻을 수 있는 이론적인 이득은 availability 저해입니다. 브라우저 세션 전체가 공유하는 프로세스를 안정적으로 종료시켜, 모든 탭에 지장을 주고 network 상태의 teardown과 재시작을 강제할 수 있습니다.

Insight: 이 사례는 더 넓은 WTF 계약 불일치의 한 boundary 사례에 해당합니다. IPC String decoder의 value domain(null | empty | non-empty)은 HashMap<String, ...>이 key로 허용하는 domain(non-null만)보다 명백히 넓은데, StringHash::hash()는 hash-table 계약상 null이 empty value로 선언되어 있다는 이유로 의도적으로 null check를 생략하고 있습니다. 이런 종류의 버그는 release-build 테스트나 ASan에서도 체계적으로 드러나지 않습니다. null-base load는 heap error가 아니라 단순한 segfault이고, hashing이 먼저 일어나기 때문에 debug 전용 validateKey()도 결코 발동하지 않습니다. 바로 이 파일에 이미 존재하던 isValidClientOrigin() check는, 같은 원칙을 다른 인자에 올바르게 적용한 사례입니다. 단지 이 원칙이 모든 hash-key 인자로 확장되지 않았을 뿐입니다.