- Ladybird는 정상적인 웹 콘텐츠는 어느 정도 처리하지만, Google Project Zero의 DOM 퍼저 Domato를 돌리자 브라우저 엔진의 숨은 엣지 케이스가 빠르게 드러남
- JavaScript로 파서 규칙을 우회해 만든 DOM, window 없는 문서, 순환 SVG 참조처럼 현실적으로 가능한 비정상 입력에서 실제 버그 5개가 발견되고 수정됨
<th>의 table 조상 가정, DOMParser 문서의 window 가정, Element.before()의 형제 탐색 실수처럼 구현 내부의 암묵적 전제가 크래시나 무한 루프로 이어짐
- 제거된 iframe의
contentWindow 접근 문제는 Ladybird만의 결함이 아니라 HTML 명세의 browsing context 가정과도 맞물려 WHATWG HTML 이슈로 이어짐
- Domato 같은 퍼저는 정상 웹 페이지 테스트만으로는 잡기 어려운 보안·안정성 문제를 노출하며, Ladybird의 다음 과제는 지속 퍼징을 견딜 만큼 안정화한 뒤 자동 실행하는 것임
Domato로 Ladybird 스트레스 테스트
- Ladybird는 잘 구성된 웹 콘텐츠는 어느 정도 처리하지만, 보안 연구 도구로 이상 입력을 던져 어떤 문제가 나오는지 확인함
- 사용한 도구는 Google Project Zero의 DOM 퍼저 Domato임
- Domato는 대부분 유효하지만 이상한 HTML, CSS, JavaScript가 섞인 무작위 웹 페이지를 생성함
- 생성된 페이지를 Ladybird의 디버그 빌드에 로드하고 동작을 관찰함
- Domato README가 주요 브라우저에서 발견한 많은 버그를 내세우고 있어, Ladybird에서도 의미 있는 결함을 찾을 수 있다고 판단함
<th>가 <mfrac> 안에 있을 때의 널 포인터 역참조
- 첫 문제는 1초도 안 돼 발견됐고, 562KiB짜리 Domato 출력을 아래 형태로 줄일 수 있었음
<body>
<script>
let mfrac = document.createElement("mfrac");
mfrac.appendChild(document.createElement("th"));
document.body.appendChild(mfrac);
</script>
- UBSAN을 켠 Ladybird 빌드에서
HTMLTableCellElement.cpp의 table_containing_cell 호출이 널 포인터 역참조를 일으킴
- 원인은 Ladybird의
<th>와 <td> 구현이 DOM 트리 위쪽에 항상 <table>이 있다고 가정한 데 있었음
- HTML 파서는
<mfrac><th> 같은 마크업을 허용하지 않음
- 명세를 따르는 브라우저는 위 마크업을 로드하면 내부가 비어 있는
<mfrac> 하나를 만듦
- 하지만 JavaScript DOM API로 노드를 직접 만들면 파서 규칙 일부를 우회해
<mfrac> 안에 <th>를 넣을 수 있음
- 문제가 된 코드는
<table border=3>와 <table padding=5>가 테이블 박스뿐 아니라 각 셀에 CSS border와 padding을 적용하는 오래된 동작을 구현하는 데 쓰였음
- 수정은
<th>와 <td>가 항상 <table> 조상을 가진다는 가정을 제거하는 방식으로 이뤄짐
table_containing_cell(*this) 대신 first_ancestor_of_type<HTMLTableElement>()를 사용함
- 테이블 조상이 없으면 즉시 반환함
- 수정 커밋은 여기에 있음
window 없는 문서에서 <body> 이벤트 핸들러 할당
- 두 번째 문제도 1초 이내에 발견됐고, 472KiB짜리 Domato 출력은 다음 코드로 축약됨
<script>
var parser = new DOMParser();
var doc = parser.parseFromString("", "text/html");
var body = doc.createElement("body");
body.onblur = null;
</script>
- Ladybird는
GCPtr<Web::HTML::Window> 검증 실패로 중단됨
- 핵심은
<body>의 onfoo 이벤트 핸들러 속성이 가진 특수 동작임
- 오래된 웹 콘텐츠 호환성을 위해
document.body.onfoo 할당은 window.onfoo로 전달돼야 함
- 그러나
DOMParser로 만든 문서에는 window 객체가 없음
- Ladybird 내부 객체 모델은 모든 document가 항상 window를 가진다고 잘못 구조화돼 있었음
- 수정 후
Document::window()는 nullable 값을 반환하고, 여러 위치에서 null을 처리함
- window 없는 문서에서
document.body.onblur를 할당하면 다른 브라우저처럼 아무 일도 하지 않음
SVG <linearGradient>의 순환 참조
- 세 번째 문제는 SVG 그래디언트가 자기 자신을 참조할 때 발생한 무한 재귀였음
<svg>
<linearGradient id="oops" href="#oops"/>
<rect fill="url(#oops)" />
</svg>
- SVG는 HTML 안의 인라인 SVG와 외부 이미지 포맷 양쪽을 지원해야 하며, 그래디언트가 다른 그래디언트를 참조해 색을 상속할 수 있음
- Ladybird 구현은 그래디언트가 자기 자신을 참조하는 경우를 고려하지 않아 참조 체인을 따라가다 계속 루프를 돌았음
- 단순히 자기 자신을 참조하는 경우만 막으면 여러 단계에 걸친 순환 참조는 처리하지 못함
<svg>
<linearGradient id="lol" href="#lmao"/>
<linearGradient id="lmao" href="#even"/>
<linearGradient id="even" href="#lol"/>
<rect fill="url(#lol)" />
</svg>
- 올바른 처리는 방문한 그래디언트를 모두 추적하고, 이미 방문한 그래디언트를 다시 만나면 체인 추적을 중단하는 방식임
- Firefox는 이런 종류의 그래디언트에 대해 개발자 콘솔에 불만을 표시함
제거된 iframe의 window 속성 접근과 HTML 명세 버그
- 네 번째 문제는 iframe을 제거한 뒤 이전에 잡아둔
contentWindow에서 getSelection()을 호출할 때 발생함
<iframe></iframe>
<script>
window.onload = function() {
let iframe = document.querySelector("iframe")
let iframeWindow = iframe.contentWindow;
iframe.remove();
iframeWindow.getSelection();
}
</script>
- Ladybird는
WindowProxy.cpp에서 BrowsingContext에 대한 널 포인터 참조 바인딩 런타임 오류를 냄
- iframe이 DOM에서 제거되면 그 content document는 자신의 browsing context에서 분리됨
- window 객체의 속성을 가져오거나 설정할 때 HTML 명세 알고리듬
"check if an access between two browsing contexts should be reported"가 실행됨
- 이 알고리듬은 접근하는 window와 접근 대상 window의 browsing context를 검사함
- 명세는 속성 접근 시점에 두 window가 모두 연결된 browsing context를 가진다고 잘못 가정함
- HTML 명세에 대한 이슈가 열렸고, Ladybird에는 우선 null 체크가 추가됨
- Ladybird 작업 중 명세 버그를 찾으면 버그 리포트나 수정 제안으로 모두에게 명세를 개선할 수 있음
Element.before()의 무한 루프
- 다섯 번째 문제는 페이지 로딩이 끝나지 않고 CPU 100%를 사용하는 형태였음
<div id="one"></div><div id="two"></div>
<script>
two.before(one);
</script>
- 원인은
before() 구현에서 <div id="two">의 이전 형제 중 인자에 포함되지 않는 첫 형제를 찾는 로직의 실수였음
- 기존 루프는 매번
node->previous_sibling()을 다시 가져왔음
while (auto previous_sibling = node->previous_sibling()) {
// check if previous_sibling is one of the arguments
}
- 실제로는 형제 체인을 따라가며
previous_sibling->previous_sibling()으로 계속 이동해야 했음
for (auto sibling = node->previous_sibling(); sibling; sibling = sibling->previous_sibling()) {
// check if previous_sibling is one of the arguments
}
퍼징 결과와 다음 단계
- 이번 세션에서는 실제 버그 5개를 찾았고, 그중 하나는 HTML 명세 버그였으며 모두 수정됨
- 이상하고 예상 밖인 입력을 만나면 Ladybird가 매우 빠르게 무너지는 점이 드러남
- Domato 같은 퍼저는 소프트웨어를 더 견고하게 만들고 싶은 사람에게 유용한 자원임
- 다음 단계는 Ladybird가 지속적인 퍼징 입력을 견딜 수 있는 수준까지 안정화하는 것임
- 충분히 안정화되면 클라우드 어딘가에서 자동으로 실행해 더 많은 문제를 찾아낼 계획임