[3] Pasteboard file-type blocklist bypassed by UTI aliasing
The blocklist compared strings while AppKit compared identities.
Medium. 무엇이 같은 type인지에 대해 blocklist와 NSPasteboard의 판단이 서로 달랐습니다. 그 결과 정확한 문자열 비교로 닫아두려 했던 file-URL 슬롯에 세 갈래의 대체 표기가 도달했습니다. 다만 "attacker가 pasteboard를 제어한다"는 수준을 넘어서려면, 사용자가 file URL을 처리하는 애플리케이션에 붙여넣는 과정이 필요합니다.
Pasteboard에 무언가를 기록하는 동작은 권한을 넘기는 과정에 해당합니다. sandbox 안의 renderer가 더 높은 신뢰 수준의 UI process에 요청해, 특정 type 이름으로 데이터를 시스템 pasteboard에 선언하도록 만들기 때문입니다. 그리고 그 type 이름 중 일부는 "이것은 디스크상의 파일"이라는 의미를 갖습니다. WebContent process가 어떤 type 이름을 선언할 수 있는지 결정하는 필터가 canWritePasteboardType()이며, 내부적으로 세 개 항목짜리 blocklist인 isFilePasteboardType()을 참조합니다. 문제는 필터 반대편의 AppKit입니다. AppKit은 pasteboard type을 불투명한 문자열로 다루지 않고, Uniform Type Identifier 체계를 통해 이름을 정규화합니다. 따라서 서로 다른 여러 표기가 결국 동일한 pasteboard 슬롯을 가리키게 됩니다.
관전 포인트: 장악된 renderer가 blocklist가 인식하지 못하는 이름을 이용해, 원하는 file URL을 사용자의 pasteboard에 올려둘 수 있다는 점입니다. 이후 파일을 받아들이는 애플리케이션에서 붙여넣기가 일어나면, attacker가 고른 경로를 대상으로 동작하게 됩니다.
Patch Details
패치 이전의 isFilePasteboardType()은 NSFilenamesPboardType, NSFilesPromisePboardType, UTTypeFileURL.identifier(public.file-url)에 대해 대소문자를 구분하는 isEqualToString: 비교만 수행했습니다. 이번 패치는 문자 그대로의 비교를 세 가지 축의 정규화로 변경하고, conformance 검사를 추가했습니다.
먼저 비교 자체가 caseInsensitiveCompare:로 바뀌었고, 새로 추가된 IsFilePasteboardTypeCaseVariants unit test가 이를 검증합니다. 두 번째로, 입력값을 typeWithIdentifier:를 통해 UTI로 해석하는 동시에 com.apple.nspboard-type tag로도 조회하고, UTType.tags[@"com.apple.nspboard-type"]에 두 legacy 이름이 들어 있는지 확인합니다. 선언된 UTI가 없는 legacy NSPboardType을 대신해 시스템이 생성한 dyn.* identifier는 바로 이 경로에서 걸러집니다. 세 번째로, "CorePasteboardFlavorType 0x6675726C" 형태의 OSType four-character-code wrapper를 파싱합니다. 여덟 자리 16진수를 다시 4바이트 tag 문자열(0x66 0x75 0x72 0x6C = 'furl')로 되돌린 뒤, com.apple.ostype tag class를 통해 해석합니다. 여기에 typeConformsToFilePasteboardType()라는 helper가 새로 추가되면서, file-URL 판정이 identifier 일치에서 conformsToType:UTTypeFileURL 검사로 넓어졌습니다. 이제 identifier가 정확히 일치하는 경우뿐 아니라, public.file-url에 conform하기만 하는 UTI도 거부됩니다.
한편 setStringForType() 안의 블록 하나도 함께 삭제되었습니다. 해당 블록은 NSURLPboardType(canWritePasteboardType()이 허용하는 "Apple URL pasteboard type")으로 들어온 쓰기를 UTTypeFileURL 슬롯에 그대로 복제하던 코드입니다. 복제 조건은 pasteboard가 이미 public.file-url을 광고하고 있고, 해당 URL이 file: URL인 경우였습니다. 새로 추가된 SetStringForTypeRejectsFileURL API test는 "Apple URL pasteboard type"으로 file:///etc/passwd를 기록했을 때 public.file-url 항목이 생성되지 않는지 확인합니다.
Input spelling Before After
────────────── ────── ─────
"public.file-url" blocked blocked
"PUBLIC.FILE-URL" ALLOWED (case-sensitive) blocked (caseInsensitiveCompare)
"dyn.ah62d4rv4gu8y6y4grf0..." ALLOWED (no literal hit) blocked (nspboard-type tag)
"CorePasteboardFlavorType
0x6675726C" ('furl') ALLOWED (no literal hit) blocked (com.apple.ostype tag)
UTI conforming to public.file-url ALLOWED (identity only) blocked (conformsToType:)
blocklist는 원시 문자열을 비교하는데 그 결과를 사용하는 쪽은 정규화된 identity를 비교하므로, 금지된 type의 모든 대체 표기가 열린 경로로 남는 패턴.
Background
이 코드가 있는 위치.
PlatformPasteboardMac.mm은 NSPasteboard 위에 얇게 얹힌 WebCore wrapper입니다. UI process의 WebPasteboardProxy가 WebContent에서 올라오는 pasteboard IPC를 처리할 때 이 wrapper를 사용합니다. renderer는 장악되어 있을 수 있으므로, canWritePasteboardType()이 해당 renderer가 UI process 소유의 pasteboard에 어떤 type을 선언하거나 기록할 수 있는지 걸러내는 capability filter 역할을 맡습니다. getPathnamesForType() 역시 파일 경로를 다시 읽어올 때 같은 predicate를 가드로 사용합니다.
Uniform Type Identifier.
UTI는 데이터 type의 이름을 reverse-DNS 형식 문자열로 표현한 것이며, conformance 계층 구조를 이룹니다. 예를 들어 public.png는 public.image에 conform하고, 다시 public.image는 public.data에 conform합니다. 이런 계층이 존재하는 덕분에, 이를 사용하는 쪽은 모든 이미지 포맷을 일일이 나열하지 않고도 "이것이 이미지인가?"를 물어볼 수 있습니다. 다만 blocklist 입장에서 보면, identifier 일치를 검사하는 것은 conformance를 검사하는 것보다 훨씬 좁은 질문에 답하는 셈입니다.
Legacy pasteboard type과 tag class.
pasteboard type 이름은 UTI보다 먼저 존재했습니다. NSFilenamesPboardType이나 OSType four-character code는 그 이전 세대의 명명 방식이며, UTI 체계는 tag class를 통해 이들을 연결합니다. 즉 UTType은 com.apple.nspboard-type이나 com.apple.ostype tag로 조회할 수도 있고, 반대로 해당 tag 값을 되물어볼 수도 있습니다. legacy NSPboardType 문자열에 선언된 UTI가 아예 없는 경우에는, 시스템이 원래 이름을 인코딩한 dyn.* identifier를 생성합니다. 결과적으로 하나의 pasteboard 슬롯에 똑같이 유효한 표기가 여러 개 존재하게 되고, 그중 무엇이 보이는지는 어떤 API로 물어봤는지에 달려 있습니다.
File promise와 file URL.
NSFilenamesPboardType은 경로를 담고, NSFilesPromisePboardType은 파일을 생성해 주겠다는 약속을 담으며, public.file-url은 file: scheme의 URL을 담습니다. 세 가지 모두 수신 애플리케이션에 "이것은 파일 시스템상의 무언가를 가리킨다"는 의미를 전달합니다. 세 이름이 모두 blocklist에 올라 있는 이유이기도 합니다. 그래서 정작 중요한 질문은, 그 외에 또 무엇이 같은 대상으로 해석되는가입니다.
Analysis
root cause는 검증하는 쪽과 그 결과를 사용하는 쪽이 서로 다른 identity 기준을 갖는다는 점입니다. 전형적인 canonicalization bypass에 해당합니다. 금지된 세 이름의 정확한 표기 자체는 패치 이전에도 이미 거부되고 있었고, 새 test에는 regression 확인용으로 포함되어 있습니다. 문자 그대로의 비교가 잡아내지 못한 것은, AppKit이 결국 같은 pasteboard 슬롯으로 해석하는 세 갈래의 인코딩 계열이었습니다.
세 가지 중 가장 단순한 것은 대소문자입니다. 패치 이전의 비교 세 개는 모두 대소문자를 구분했기 때문에, PUBLIC.FILE-URL이나 Public.File-Url, nsfilenamespboardtype, APPLE FILES PROMISE PASTEBOARD TYPE 같은 표기가 그대로 통과했습니다. 반면 public.file-url 같은 UTI identifier는 대소문자를 구분하지 않고 비교되므로, 플랫폼은 이들 표기를 하나의 type으로 취급합니다. AppKit의 legacy com.apple.nspboard-type 이름 조회도 같은 방식으로 동작하는지는 WebKit 코드가 결정할 문제가 아니라 플랫폼 쪽 영역에 속합니다. 새로 추가된 IsFilePasteboardTypeCaseVariants test가 그렇게 단정하고 있을 뿐입니다. 어느 쪽이든 fix는 보수적인 쪽을 택해, 항상 대소문자를 구분하지 않고 비교합니다.
문자열 기반 blocklist를 가장 확실하게 무너뜨리는 쪽은 dynamic UTI 계열입니다. attacker가 넘기는 문자열이 차단 대상 이름과 부분 문자열조차 공유하지 않기 때문입니다. 새 unit test는 dyn.ah62d4rv4gu8y6y4grf0gn5xbrzw1gydcr7u1e3cytf2gn를 NSFilenamesPboardType의 dynamic UTI로 표기하고 있습니다. 이 특정 인코딩 값은 test 자체의 주장이며, 여기서는 그대로 인용합니다. fix는 문자열 비교를 포기하고, 넘겨받은 값이 무엇이든 typeWithIdentifier:로 해석한 뒤 그 UTType에서 com.apple.nspboard-type tag를 조회합니다. 소스 파일이 적어둔 표기가 아니라 플랫폼이 정의한 방식으로 identity를 검사하는 셈입니다.
OSType wrapper도 한 단계 아래에서 같은 원리로 동작합니다. "CorePasteboardFlavorType 0x6675726C" 안에는 four-character code 'furl'이 들어 있고, 새 test의 주석은 이 com.apple.ostype tag가 public.file-url로 대응된다고 기록하고 있습니다. 16진수를 파싱해 tag를 복원한 뒤 com.apple.ostype class로 해석하면, 앞의 경우와 동일한 방식으로 이 인코딩도 막히게 됩니다.
setStringForType()에 있던 두 번째 결함은 같은 파일에 있지만 성격이 다른 버그이므로 따로 볼 필요가 있습니다. 여기서는 capability 검사가 들어온 type을 대상으로 수행된 반면, 데이터는 다른 type 아래에 기록되었습니다. 구체적으로는 허용 대상인 NSURLPboardType으로 선언된 쓰기가 금지 대상인 UTTypeFileURL 슬롯으로 복제되는 동작이었습니다. 복제 조건은 pasteboard가 이미 public.file-url을 광고하고 있고 URL이 file: scheme인 경우였습니다. 가장 좁은 의미의 confused deputy에 해당합니다. 필터와 실제 쓰기가 서로 다른 객체를 대상으로 삼고 있으므로, isFilePasteboardType() 쪽을 아무리 강화해도 이 경로는 잡히지 않습니다.
exploitability를 결정하는 것은 memory safety 조건이 아니라 pasteboard 항목 하나가 무엇을 할 수 있는가입니다. 확인된 primitive는 처음 인상보다 좁습니다. 파일을 가리키는 금지 type을 정확히 일치하지 않는 표기로 canWritePasteboardType()에 통과시키는 것까지입니다. 그 선언이 실제로 public.file-url이나 NSFilenamesPboardType 슬롯을 차지하게 되는 부분은 AppKit의 canonicalization 동작이며, fix의 tag class 조회와 새 test가 바로 이 동작을 전제로 합니다. 다만 renderer가 고른 내용이 그 슬롯에 실제로 들어가려면, 선언 이후 같은 type으로 데이터 쓰기가 한 번 더 이어져야 합니다. 여기서 실질적인 피해로 넘어가려면 사용자가 file URL을 처리하는 애플리케이션에 붙여넣어야 하고, 최종적으로 얻는 capability는 그 애플리케이션이 attacker가 고른 경로로 무엇을 하느냐에 달려 있습니다.
결국 이번 vulnerability는 sandbox 안의 renderer가 시스템 pasteboard에 무엇을 선언할 수 있는지 걸러내는 UI process의 역할을 약화시킵니다. 이 경계가 존재하는 이유는 pasteboard 내용이 브라우저를 벗어나 임의의 다른 애플리케이션으로 건너가기 때문입니다.
Audit directions
- 검증하는 쪽과 사용하는 쪽의 identity 차이. 보안 판단은 문자열을 그대로 비교하는데 그 문자열을 실제로 다루는 컴포넌트는 정규화를 수행하는 지점이 있다면 — UTI, MIME type, scheme 이름, 파일 확장자, 헤더 필드 이름 등 — 두 동등성 기준 사이의 간극이 곧 버그입니다.
PlatformPasteboardMac.mm에 남아 있는 문자 그대로의 비교와 iOS 쪽 pasteboard 대응 코드부터 시작하고, 이후 플랫폼이 정의한 identifier를isEqualToString:으로 검사하는 WebKit predicate 전반으로 범위를 넓혀 보십시오. 코드 리뷰에서의 신호는 이렇습니다. WebKit 내부 type이 아니라 플랫폼 type을 가리키는 상수를isEqualToString:이나==로 비교하는 코드입니다. - 검사하는 이름과 기록하는 이름이 다른 경우. capability filter가 의미를 가지려면, 검증한 객체와 실제로 기록되는 객체가 같아야 합니다. 이번에 삭제된
setStringForType()의 복제 블록이 전형적인 형태입니다. type A를 검증해 놓고 type B로 저장했습니다. pasteboard 쓰기 경로에서 capability 검사에 쓰인 type과 저장에 쓰인 type이 갈라지는 지점이 있는지 점검하고, 같은 질문을 renderer가 호출자 제공 key로 기록할 수 있는 다른 UI process 리소스까지 확장해 보십시오. - Blocklist에서의 identity와 conformance. identifier 일치를 기준으로 만든 blocklist는 모든 하위 type을 놓칩니다.
typeConformsToFilePasteboardType()변경이 바로 그 부분을 교정합니다. UTI conformance, 클래스 계층, scheme 계열처럼 계층 구조가 존재하는 곳이라면, 거부 판단이 conformance가 필요한 자리에서 단순 일치 비교를 쓰고 있지는 않은지 확인해 보십시오. 반대로 allow-list에서는 안전한 방향이 정반대라는 점도 함께 기억해 둘 필요가 있습니다.