← All reports

[3] HTMLDialogElement: invoker `value()` reference freed by a `beforetoggle` listener

HighWebCore HTML — HTMLDialogElementUAF

The dialog handed back a return value from memory the page just freed.

7075344

High. 2단계 accessor chain을 거쳐 callee에 DOM attribute storage로의 alias가 전달됩니다. 여기에 callee 자신이 발생시키는 cancelable pre-event가 페이지 스크립트에 동기적인 free 지점을 제공합니다. Free-to-use window가 공격자가 제어하는 JS callback으로 경계가 지어지기 때문에, reclaim grooming이 우발적이 아니라 손쉽게 구성 가능한 형태가 됩니다.

WebKit의 문자열 타입은 refcount 기반으로 관리되며, const String& 파라미터는 소유권을 갖는 복사본이 아니라 단순한 borrow에 해당합니다. 이는 의도된 효율성 선택이지만, callee가 페이지 스크립트로 제어를 넘기는 순간까지만 유효합니다. HTML의 declarative command invoker 기능을 이용하면 commandcommandfor 속성을 가진 <button>이 다른 요소의 상태 머신을 구동할 수 있고, 이때 버튼의 value 속성이 dialog의 close command에 대한 반환값을 제공합니다. Dialog와 popover는 open/closed 전환을 확정하기 전에 cancelable beforetoggle 이벤트를 발생시킵니다. 따라서 상태 전환 루틴 중간에는 동기적인 재진입 지점이 존재하게 되고, 이 시점까지 유지되고 있는 borrow된 DOM reference는 반드시 살아있어야 합니다.

관전 포인트: 페이지는 close() 내부의 정확히 선택된 시점에서 공격자가 크기와 내용을 모두 통제하는 heap allocation을 해제시킬 수 있고, 엔진은 이를 다시 읽어 dialog.returnValue에 저장하게 되며, 스크립트는 이 값을 그대로 읽어낼 수 있습니다.

HTMLDialogElement::handleCommandInternal에서 invoker의 String 값을 local 변수에 저장하도록 수정하여 버그를 해결합니다.

Source/WebCore/html/HTMLDialogElement.cpp

if (isOpen()) {
if (command == CommandType::Close) {
 
- close(invoker.value().string(), &invoker);
+ String value = invoker.value().string();
+ close(value, &invoker);
return true;
}
if (command == CommandType::RequestClose) {
 
- requestClose(invoker.value().string(), &invoker);
+ String value = invoker.value().string();
+ requestClose(value, &invoker);
return true;
}
} else {

LayoutTests/fast/html/dialog-close-from-button-crash.html

+<dialog id=dialog open>
+ <button command=close commandfor=dialog>Close</button>
+</dialog>
+<script>
+window?.GCController?.collect();
+const button = document.querySelector('button');
+button.setAttribute('value', 'PA' + 'SS');
+document.querySelector('dialog').addEventListener('beforetoggle', (event) => {
+ button.removeAttribute('value');
+});
+button.click();
+document.write(dialog.returnValue);
+</script>

기존에는 HTMLDialogElement::handleCommandInternal의 두 command 분기 모두에서 invoker.value().string()close()requestClose()const String& 인자로 그대로 전달하고 있었습니다. HTMLButtonElement::value()HTMLButtonElement.hconst AtomString& NODELETE value() const로 선언되어 있고, AtomString::string()은 감싸고 있는 String을 reference로 그대로 반환합니다. 즉 callee의 파라미터는 소유권을 가진 복사본이 아니라, 버튼의 살아있는 attribute storage로 이어지는 alias였던 셈입니다. 패치는 각 분기에 local 변수 String value = invoker.value().string();을 도입하여 — 기반 StringImpl에 대해 자체적으로 reference를 갖는 String을 만들어냅니다 — 이 local 변수를 대신 전달하도록 변경했습니다. 다른 production 코드는 변경되지 않았으며, commit의 나머지 부분은 regression test와 그 expectation 파일입니다. 이 테스트는 beforetoggle listener에서 버튼의 value 속성을 제거한 뒤 dialog.returnValue를 출력합니다.

Re-entrancy 경계를 넘어선 지점에서 callee의 reference 파라미터가 caller 소유의, 스크립트로 변경 가능한 storage에 바인딩되어 있고, callee 내부의 재진입 지점이 그 storage를 해제할 수 있는 패턴.

String types and ownership. WebKit의 문자열은 공유되는 StringImpl 버퍼를 중심으로 refcount 방식으로 관리됩니다. AtomString은 스레드별 atom table에 존재하는 interned AtomStringImpl을 감싸는 형태이며, AtomString::string()은 감싸인 String을 reference로 노출합니다. String을 복사하면 추가 reference를 얻게 되지만, const String&을 받는 것만으로는 reference가 늘어나지 않습니다.

Element attribute storage. Element의 속성들은 AtomString 값을 담고 있는 Attribute 레코드로 보관됩니다. HTMLButtonElement::value()const AtomString&을 반환하는데, 이는 해당 storage로 곧바로 이어지는 reference입니다. removeAttribute()가 호출되면 이 레코드가 파괴되면서 reference도 해제됩니다.

Command invokers. commandcommandfor 속성을 가진 <button>은 활성화 시 대상 요소에 declarative command를 전달하며, 이 경로는 Element::handleCommandInternal을 거칩니다. HTMLDialogElement는 이를 bool handleCommandInternal(HTMLButtonElement& invoker, const CommandType&) final로 오버라이드합니다. 버튼의 value 속성은 closerequest-close command의 반환값을 제공합니다.

beforetoggle. Dialog와 popover가 open/closed 상태 전환을 확정하기 전에 발생시키는 cancelable ToggleEvent로, C++ 상태 전환 루틴의 중간에 페이지 스크립트가 개입할 수 있는 동기적인 지점을 만들어냅니다. HTMLDialogElement::show()showModal()에서 이러한 "dispatch 이후 recheck" 구조를 확인할 수 있습니다.

m_returnValue. HTMLDialogElementString 멤버로, setReturnValue(String&&)에 의해 설정되고 const String& returnValue() const를 통해 스크립트에 노출됩니다. IDL은 이를 dialog.returnValue 프로퍼티로 드러냅니다.

Re-entrancy. 네이티브 코드가 DOM 이벤트를 발생시켜 JavaScript를 호출하는 모든 지점을 가리키며, 이 시점에서 스크립트는 반환 전에 임의의 C++ 기반 DOM 상태를 동기적으로 변경할 수 있습니다.

근본 원인은 use-after-free입니다. handleCommandInternal은 callee의 const String& 파라미터를 invoker 버튼의 value 속성이 소유한 storage에 직접 바인딩하고 있었습니다. HTMLButtonElement::value()AtomString::string() 모두 복사나 소유권을 갖는 reference 획득을 수행하지 않습니다. 즉 이 체인은 element의 attribute list로 이어지는 2단계 alias에 불과합니다. 여기서 누락된 invariant는, 스크립트로 변경 가능한 DOM attribute storage로의 reference가 JavaScript로 재진입할 수 있는 호출보다 더 오래 살아있어서는 안 된다는 점입니다.

  main thread, inside close():

  handleCommandInternal
    └─ close(invoker.value().string(), &invoker)   ← borrow, refcount unchanged
         │
         ├─ dispatch beforetoggle ───────────────► listener JS
         │                                            │
         │                        button.removeAttribute('value')
         │                          └─ Attribute destroyed
         │                               └─ last ref on AtomStringImpl dropped
         │                                    └─ unregistered from atom table, freed
         │   ┌────────────────────────────────────────┘
         ▼   ▼
       consume the string parameter
         └─ m_returnValue = <freed StringImpl>   ← UAF read + refcount write

close()는 상태 변화를 확정하기 전에 cancelable beforetoggle ToggleEvent를 발생시킵니다. 이는 제공된 소스에서 show()showModal()에서도 확인되는 동일한 패턴이며, regression test에서 실제로도 확인됩니다. 이 테스트에서는 button.click()이 유발하는 close() 호출 도중 beforetoggle listener가 실행됩니다. 이 listener 안에서 스크립트는 button.removeAttribute('value')를 호출합니다. Attribute가 파괴되면 해당 AtomString의 reference가 해제되는데, 테스트가 값을 리터럴이 아니라 런타임에 'PA' + 'SS'로 조합해 만들었기 때문에 — 리터럴이었다면 다른 곳에서도 interned되어 유지되었을 것입니다 — 이 해제가 마지막 reference를 떨어뜨리게 됩니다. 결과적으로 AtomStringImpl은 atom table에서 등록 해제되고 실제로 해제됩니다. close()가 재개되면, 여전히 들고 있던 reference는 이미 해제된 StringImpl을 가리키게 되고, m_returnValue로의 대입은 해제된 header를 읽는 동시에 그 refcount를 증가시키는 동작이 됩니다. dispatch 이후 close() 내부에서 파라미터를 정확히 어느 줄에서 사용하는지는 추정에 해당합니다. HTMLDialogElement.cpp에 대해 제공된 소스 context가 close() 이전에서 잘려 있기 때문입니다. 다만 보고된 crash가 발생하려면 그 사용 지점이 dispatch 이후에 위치해야 합니다.

도달 가능성을 확보하는 데 특별한 조건은 필요하지 않습니다. 열려 있는 <dialog>, 그 안의 <button command=close commandfor=dialog value=...>, beforetoggle listener, 그리고 스크립트로 유발한 .click()이면 충분합니다. 테스트에는 user gesture나 feature flag가 등장하지 않습니다. 단계별로 살펴보면 다음과 같습니다.

  1. button.setAttribute('value', 'PA' + 'SS')는 런타임에 조합된 값을 interning합니다. 그 결과 생성되는 AtomStringImpl은 기존 리터럴 atom이 아니라 버튼의 attribute record에서만 참조됩니다.
  2. button.click()handleCommandInternal에 도달하며, 패치 이전에는 이곳에서 close()const String& 파라미터를 해당 attribute storage에 곧바로 바인딩했습니다.
  3. close()beforetoggle을 발생시킵니다.
  4. listener의 button.removeAttribute('value')Attribute를 파괴하면서 마지막 reference를 해제하고, AtomStringImpl이 실제로 해제됩니다.
  5. close()가 재개되고, m_returnValue를 채우는 과정에서 이미 dangling 상태가 된 reference를 사용합니다.

이를 실질적인 primitive로 발전시키려면, 공격자는 listener 내부의 window에서 해제된 allocation을 다시 확보해야 합니다. 값의 길이는 전적으로 공격자가 정할 수 있으므로 target size class를 선택할 수 있고, listener 자체가 free와 use 사이에 임의의 JS를 실행할 수 있는 이상적인 grooming 지점이 됩니다. 만약 이를 재확보하는 allocation이 공격자가 제어하거나 민감한 heap 데이터를 담고 있다면, 이후 m_returnValue로 이어지는 String 복사 과정에서 재확보된 바이트로부터 length와 buffer pointer를 읽어들이게 되고, 이어서 dialog.returnValue를 읽는 스크립트가 out-of-bounds 혹은 stale한 heap 내용을 확인하게 될 가능성이 있습니다. 별개로, 이 복사 과정은 이미 해제된 header에 대해 refcount 증가를 수행하므로 현재 그 슬롯을 차지하고 있는 다른 객체를 손상시킬 가능성도 있고, 이후 m_returnValue가 해제될 때 재확보된 메모리에서 파생된 pointer를 감소시키고 해제하는 결과로 이어질 가능성도 있습니다. 이런 확장 단계들은 모두 공격자가 controlled contents로 reclaim에 성공하는 것을 조건으로 합니다. 테스트가 실제로 겨냥하는, 즉시 관찰 가능한 효과는 ASAN으로 탐지 가능한 use-after-free read입니다.

이 vulnerability는 WebContent process 내부의 메모리 안전성을 약화시킵니다. 이 취약점이 깨뜨리는 보안 모델 가정은, DOM에서 내부 상태 머신 호출로 전달된 문자열이 그 호출이 지속되는 동안 계속 살아있어야 한다는 것입니다. 패치 이전에는 beforetoggle listener에서 실행되는 평범한 페이지 스크립트만으로도 callee가 여전히 alias를 들고 있는 상태에서 마지막 reference를 떨어뜨릴 수 있었습니다. 페이지를 제어하는 공격자라면 정확히 선택한 시점에 공격자가 크기와 내용을 모두 통제하는 heap allocation을 해제시킬 수 있고, 엔진이 이후 이를 읽어 m_returnValue에 해당 값을 가리키는 String을 저장하도록 만들 수 있습니다. 이 값은 스크립트에서 읽을 수 있는 값이므로, 성공적인 exploitation은 direct control-flow hijack보다는 heap contents 노출과 refcount 조작이라는 발판을 확보하는 형태로 이어질 가능성이 가장 높습니다.

여기서 위험한 패턴은 소유권을 전혀 구체화하지 않는 accessor chain입니다. invoker.value()const AtomString&을 반환하고 .string()const String&을 반환하므로, 값을 전달하는 것처럼 보이는 호출 지점이 실제로는 DOM attribute storage로 이어지는 2단계 alias를 전달하고 있는 셈입니다. WebCore의 callee들은 정확히 이런 효율성을 위해 const String&을 흔히 받아들이며, 이는 callee가 이벤트를 발생시키기 전까지는 아무 문제가 없습니다. HTMLButtonElement.h는 이미 value()NODELETE를, HTMLDialogElement.hreturnValue()LIFETIME_BOUND를 각각 주석으로 달아두고 있어, 코드베이스가 이런 accessor들의 lifetime hazard를 인지하고 있음을 보여줍니다. 다만 이 annotation들은 temporary나 직접적인 escape는 잡아내지만, 중간에 스크립트 재진입이 끼어드는 liveness 문제까지는 잡아내지 못합니다. Declarative command-invoker 기능은 attribute 값을 popover와 dialog 상태 머신에 곧바로 흘려보내는 비교적 최근에 추가된 표면이며, 이 상태 머신들은 애초에 전환 도중 beforetoggle을 발생시키도록 설계되어 있으므로, 이런 조합 자체가 구조적으로 버그가 발생하기 쉬운 형태입니다.