[5] Tracker lookup tables raced between the WebPrivacy and resolver threads
Medium. process 전역 테이블 두 개가 networking stack의 resolver thread에서 조회되는 동안, 다른 thread가 같은 테이블을 비우고 다시 채웁니다. 한쪽 경로는 여기에 더해 내부 const char*를 외부로 내보냈고, 다른 경로는 non-atomic refcount 해제를 lock 바깥에서 수행했습니다. 다만 이를 controlled free로 끌고 가려면 lookup이 진행되는 창 안으로 refresh가 정확히 들어와야 하는데, 그 스케줄은 attacker가 정할 수 없습니다.
thread 두 개가 테이블 하나를 공유하는 구조에서 use-after-free로 이어지는 전형적인 경로가 shared container의 data race입니다. 한쪽 thread가 backing store를 reallocate하는 동안 다른 쪽이 그 안을 인덱싱하는 상황이 대표적입니다. WebKit의 Advanced Privacy Protections는 알려진 tracker IP 대역과 registrable domain 목록을 Network process 안에 process 전역 테이블로 유지하며, 이 데이터는 Apple의 WebPrivacy framework에서 공급됩니다. 한편 이 테이블들은 NSURLSession 뒤의 nw_context_t에 lookup callback으로도 등록됩니다. 그래서 system networking stack은 자체 resolver thread에서 테이블을 조회하고, WebKit은 WebPrivacy thread에서 같은 테이블을 갱신하게 됩니다.
관전 포인트: tracker 테이블 refresh가 들어오는 시점에 맞춰 network traffic을 계속 발생시킬 수 있다면, resolver thread가 이미 해제되었거나 줄어든 vector를 binary search하도록 유도할 수 있습니다. 또한 마지막 소유자가 사라진 buffer를 가리키는 byte pointer를 따라가게 만드는 것도 가능합니다.
Patch Details
WebPrivacyHelpers.mm에 있던 서로 다른 결함 세 가지가 하나의 patch로 함께 닫혔습니다. 이때 fix가 취한 형태는 bug 못지않게 참고할 만합니다.
먼저 주소 테이블부터 살펴보면, TrackerAddressLookupInfo의 version4List()와 version6List()는 둘 다 NeverDestroyed<Vector<TrackerAddressLookupInfo>>이며 lock이 전혀 없었습니다. populateIfNeeded()의 completion handler는 version4List().clear(); version6List().clear();를 호출한 뒤 테이블을 다시 채웠고, 같은 시점에 find()는 동일한 vector를 binary search했습니다. patch는 새로 추가된 trackerLookupLock() 아래에서 refresh와 lookup을 직렬화합니다. 여기에 더해 matchingInfo(), version4List(), version6List()에 WTF_REQUIRES_LOCK annotation을 붙여 이 요구사항을 타입 시스템에 기록했습니다.
domain 테이블은 NeverDestroyed<MemoryCompactRobinHoodHashMap<String, TrackerDomainLookupInfo>> 타입으로, 자체 domainListLock을 가지고 있었습니다. 다만 find()가 static const TrackerDomainLookupInfo find(String host)로 선언되어 entry를 값으로 반환했습니다. 그 결과 복사본과 그 안의 refcount 기반 string buffer가 호출자와 함께 critical section을 빠져나갔습니다. patch는 matchingInfo()를 private으로 바꾸고, find()가 내부에서 lock을 잡은 뒤 entry를 반환하는 대신 NOESCAPE callback에 전달하도록 구조를 변경했습니다.
setTrackerLookupCallback 블록의 두 경로는 모두 info.owner().legacyCStringPointer()와 info.host().legacyCStringPointer()를 const char** out-parameter로 내보냈습니다. 테이블이 소유한 저장소를 직접 가리키는 raw pointer입니다. 수정 이후 callback이 내보내는 값은 thread_local UTF8CString 복사본을 가리키는 pointer입니다. 이 복사본은 WTF의 CString / CStringWithEncoding에 새로 추가된 isolatedCopy()로 생성됩니다.
WebPrivacy thread Resolver thread (nw_context callback)
───────────────── ────────────────────────────────────
populateIfNeeded() completes
version4List().clear() ──┐ find(address)
append(...) (realloc) │ list.isEmpty() (read pre-clear)
append(...) │ list[mid], list[upper] ← freed/shrunken
└──► info.containsAddress()
reads m_network, m_netMaskLength
*owner = info->owner()
.legacyCStringPointer() ← escapes
한 thread에서 읽고 다른 thread에서 다시 만드는 process 전역 가변 테이블, 그리고 critical section을 함께 빠져나간 내부 pointer와 non-atomic refcount.
Background
이 코드가 있는 위치.
Source/WebKit/Platform/cocoa/WebPrivacyHelpers.mm는 Apple WebPrivacy framework의 tracker 데이터를 Network process 안 WebKit networking 경로로 연결하는 역할을 합니다. 알려진 tracker IP 대역과 registrable domain을 process 전역 테이블로 유지하고, isKnownTrackerAddressOrDomain과 isRequestBlockable을 통해 WebKit policy 코드에 노출합니다. 또한 NSURLSession을 뒷받침하는 nw_context_t에 lookup callback을 등록해, system networking stack이 연결의 출처를 표기하고 필요하면 차단할 수 있도록 합니다.
설계상 두 개의 thread.
commit message에 따르면 setTrackerLookupCallback 블록은 networking stack의 resolver thread에서 실행되고, 테이블을 채우고 갱신하는 작업은 WebPrivacy thread에서 이루어집니다. 두 thread 모두 WebKit의 main thread가 아니며, 한쪽이 다른 쪽의 스케줄을 제어하지도 않습니다. 둘이 공유하는 것은 테이블뿐입니다.
Vector의 재할당.
WTF의 Vector는 원소를 하나의 연속된 buffer에 담습니다. clear()는 원소를 파괴하고, 뒤이은 append() 호출들이 테이블을 다시 채우면서 buffer가 커집니다. 이때 buffer 확장은 새 블록을 할당하고 원소를 옮긴 뒤 기존 블록을 해제하는 과정을 의미합니다. 어떤 buffer를 기준으로 계산한 index는 다른 buffer에서는 아무 의미가 없으며, 해제된 쪽을 읽으면 그대로 해제된 heap을 읽는 셈입니다.
CString, CStringBuffer, 그리고 refcount의 atomicity.
CString은 RefPtr<CStringBuffer> m_buffer를 보유하며, 선언은 class CStringBuffer final : public RefCounted<CStringBuffer>입니다. ThreadSafeRefCounted가 아니라 평범한 RefCounted입니다. 따라서 증가와 감소는 atomic하지 않은 일반 메모리 연산이며, 특정 buffer에 대한 모든 참조가 하나의 thread 안에 머무는 한 이 방식이 정확하고 빠릅니다. legacyCStringPointer()는 그 buffer의 바이트를 가리키는 raw const char*를 반환하는데, 소유권은 해당 CString을 들고 있는 쪽에 있습니다.
isolatedCopy()와 NOESCAPE.
isolatedCopy()는 원본과 backing allocation을 공유하지 않는 값을 생성하는 WTF의 관용 패턴입니다. 덕분에 thread 경계를 안전하게 넘어갈 수 있으며, thread-safety 관점의 deep copy에 해당합니다. 한편 callback parameter에 붙은 NOESCAPE는 호출 대상이 callable을 호출 이후까지 보관하지 않는다는 선언입니다. 이 보장이 있으므로 callback이 실행되는 동안 lock을 계속 잡고 있어도, 이후 lock 없이 호출될 위험을 고려하지 않아도 됩니다.
Analysis
root cause는 동일한 invariant가 세 겹으로 깨진 것입니다. 공유 가변 상태는 lock 아래에서 접근해야 하고, 그 lock은 접근 결과를 사용하는 모든 구간까지 덮어야 한다는 invariant입니다. 세 갈래는 각각 다른 방향으로 확장되는데, fix가 lock 하나를 추가하는 데 그치지 않고 셋을 모두 다룬 이유이기도 합니다.
첫 번째 갈래: 동기화 없는 container 접근. 패치 이전 find()는 list.isEmpty()를 읽은 뒤 list[mid], list[upper], list[lower]를 인덱싱했습니다. 이때 다른 thread의 clear()가 진행되면 해당 entry들이 파괴되고, 테이블을 다시 채우는 append 시퀀스는 backing store를 reallocate합니다. 어느 쪽이든 인덱싱은 해제되었거나 줄어든 메모리를 읽는 동작이 됩니다. 이어서 containsAddress()가 그 블록을 현재 차지하고 있는 내용에서 m_network와 m_netMaskLength를 읽게 됩니다. 이 부분이 bug의 out-of-bounds / use-after-free 측면에 해당합니다. 재발을 막는 장치는 WTF_REQUIRES_LOCK annotation인데, 요구사항이 기억에 의존하지 않고 컴파일 단계에서 검사되기 때문입니다.
두 번째 갈래: 밖으로 빠져나가는 내부 pointer. *owner = info->owner().legacyCStringPointer()는 매칭된 entry의 CStringBuffer 내부를 가리키는 const char*를 networking stack에 전달했습니다. 문제는 호출이 끝난 뒤 그 entry를 살려 두는 장치가 전혀 없다는 점입니다. 그래서 다음 refresh가 entry를 파괴하면서 buffer에 대한 마지막 reference까지 해제하는데, 이 시점에도 networking stack은 여전히 같은 pointer를 보유하고 있습니다. fix는 주석으로 위험을 설명하고 넘어가는 방식을 택하지 않았습니다. matchingInfo()를 private으로 돌리고, entry를 lock이 잡힌 NOESCAPE callback 안에 가둬 두며, 내부 pointer 대신 thread_local 복사본을 내보내는 방식으로 실수 자체가 불가능하도록 만들었습니다.
세 번째 갈래: thread를 넘나드는 non-atomic refcount. domain 경로의 값 반환은 domainListLock 아래에서 entry를 복사하기는 했습니다. 다만 그 복사본이 파괴되는 시점은 Locker가 scope를 벗어난 이후, 호출자 쪽이었습니다. 그 결과 복사본의 CStringBuffer와 StringImpl 해제가, 같은 객체에 대한 WebPrivacy thread의 list().set(...) 해제와 동기화 없이 맞붙게 됩니다. 두 타입 모두 평범한 RefCounted이므로 refcount 갱신은 atomic하지 않습니다. 갱신 하나가 유실되면 buffer가 누수되거나, 다른 thread가 아직 reference를 들고 있는 상태에서 해제됩니다. fix가 단순히 CString을 복사하는 대신 thread마다 새 buffer를 만드는 isolatedCopy()를 선택한 이유가 여기에 있습니다. 단순 복사였다면 buffer를 공유하게 되어 race가 그대로 남았을 것입니다.
domain 경로에서의 두 번째 갈래에 대해서는 commit message의 설명을 한 가지 다듬을 필요가 있습니다. CStringBuffer는 refcount로 관리되고, 반환된 복사본은 살아 있는 map entry와 buffer를 공유했습니다. 따라서 if 문이 끝나면서 임시 객체가 파괴되는 순간에 바이트가 곧바로 해제된다고 보기는 어렵습니다. 그 시점에 사라진 것은 바이트를 살려 두던 ownership 보장입니다. 실제 메모리 회수는 다음 refresh 때 일어났고, 세 번째 갈래에서 증가 연산이 유실된 경우라면 그보다 이른 시점이었을 가능성도 있습니다.
exploitability는 attacker가 소유하지 못한 스케줄에 묶여 있습니다. refresh 주기는 페이지 활동이 아니라 WebPrivacy framework가 결정하기 때문입니다. 결국 window를 맞히려면 lookup을 계속 발생시켜 놓고, 그중 한 번 안으로 refresh가 들어오기를 기다려야 합니다. 회수된 블록에 어떤 데이터가 들어오는지도 Network process의 allocator가 반환하는 내용에 좌우됩니다. 이 bug가 자체적으로 확보하는 primitive는 해제되었거나 재할당된 테이블 메모리에 대한 read, 그리고 다른 thread와 경합하는 non-atomic refcount 갱신까지입니다. 그 이상으로 확장하려면 회수된 블록을 무엇으로 채울지 제어할 수 있어야 하는데, 이번 변경 범위에서는 그 조건이 확인되지 않습니다.
WebKit이 소유하지 않은 thread에서 실행되는 code path, 그리고 privacy policy 판단에 참여하는 자료구조 위에서 발생하는 문제입니다. 결과적으로 Network process의 내부 일관성이 약화됩니다.
Audit directions
- lock 구간 밖으로 값을 반환하는 lookup. lock을 잡고 entry를 복사한 뒤 그 복사본을 반환하는
find()는, entry의 destructor와 그 안에서 수행되는 모든 refcount 해제를 critical section 바깥으로 옮겨 놓은 셈입니다. 호출 지점에서는 이 패턴이 전혀 드러나지 않고, 정의부만 보면 오히려 안전하게 작성된 코드처럼 보입니다.WebPrivacyHelpers.mm의 나머지 lookup helper를 점검하고, 범위를 넓혀 multithread process에서 process 전역 container를 다루는static T find(...)형태를 함께 살펴볼 필요가 있습니다. 코드 리뷰에서의 단서는, 본문에서Locker를 생성하면서 반환 타입은 값인 함수입니다. - callback 경계를 넘어 전달되는 내부 pointer.
legacyCStringPointer(),characters(),data(),span().data()는 모두 다른 객체가 소유한 주소를 반환합니다. 이 주소를const char**out-parameter에 담아 framework가 보관하도록 넘기면, 아무도 문서화하지 않은 lifetime 계약이 생깁니다. system framework callback으로 넘어가는 raw byte pointer를 찾아보되, 특히 원본 객체가 다른 thread에서 갱신되는 테이블에 들어 있는 경우를 우선 확인하면 됩니다. 그다음 이번 fix의 형태, 즉thread_local복사본과isolatedCopy()조합을 해당 지점에도 적용할 수 있는지 판단할 필요가 있습니다. - 두 thread에서 도달 가능한 평범한
RefCounted객체.RefCounted와ThreadSafeRefCounted의 구분은 안전성을 떠받치는 핵심이지만, 어떤 컴파일러 진단도 이를 강제하지 않습니다. system framework callback thread에서 조회되는 process 전역 테이블에 어떤 타입이 저장되는지 나열하고, 각각의 refcount base class를 확인하는 작업이 필요합니다. 이 자리에 가장 자주 등장하는 조합은CString/CStringBuffer와String/StringImpl입니다. - 주석에만 존재하는 lock 요구사항. 이번에 추가된
WTF_REQUIRES_LOCKannotation은 fix에서 오래 남는 쪽 절반에 해당합니다. 전역 accessor에 "lock을 잡고 호출할 것"이라는 설명만 있고 annotation이 없다면, 모든 호출 지점을 아직 점검되지 않은 상태로 간주하는 편이 안전합니다. 호출 지점을 하나씩 읽어 나가기보다 annotation을 먼저 붙이는 쪽을 권합니다. 사람이 놓쳤을 지점은 컴파일러가 찾아냅니다.