WebTiles에서 사용자 제공 HTML/CSS/JS를 안전하게 받아 실행하기 위해 타일 구조, Shadow DOM, CSS 정화, JS-Interpreter 기반 샌드박스를 설계하고 운영하며 겪은 탈출 사례와 대응을 정리한다.
더 나은 이해를 위해 먼저 WebTiles를 확인해 보세요.
오랫동안, 누구나 함께 협업할 수 있는 웹페이지를 만들고 싶다는 꿈이 있었다. 수년 동안 이 문제를 꽤 많이 고민했다. 늘 가장 큰 장애물은 다음과 같았다:
첫 번째 문제를 해결하기 위해, 페이지를 250x250 타일 그리드로 나누기로 했다. 페이지를 감당할 수 없는 혼란으로 만들지 않으면서도 전반적으로 가장 단순한 해결책처럼 보였다. 사람들에게 일종의 자유 캔버스를 주는 건 엄청 멋질 테지만, 아쉽게도 이를 안전하게—그리고 어떤 방식으로든 성능 있게—가능하게 하는 시스템을 끝내 떠올리지 못했다.

마우스로 캔버스를 잡아 끌어서 여기저기 움직일 수 있게 만들었다. 위 GIF는 카메라의 초기 테스트 중 하나다.

결국 보기 좋은 타일을 만들게 됐다. 어느 타일이든 클릭해서 활성화할 수 있는데, 그러면 타일 안의 HTML 요소를 실제로 클릭할 수 있고, CSS 애니메이션을 실행하거나 JS 코드를 돌리는 등도 가능해진다.
각 타일의 파일 크기를 제어하기 위해 파일용 간단한 대시보드와 에디터를 구현했다. 파일 유형마다 최대 크기가 달랐다. 또한 이미지/비디오/오디오에는 길이와 해상도에 대한 기본적인 검증도 넣었다.
HTML부터 시작하자. 격리(isolation)를 할 때 가장 먼저 떠오르는 선택지는 모든 것을 iframe에 넣는 것이다. 하지만 이건 안 된다. iframe은 로드에 몇 초가 걸릴 뿐 아니라, 10개 정도를 넘어가면 페이지를 한동안 완전히 얼려버린다. 그리고 캔버스는 크기 때문에, 요소를 계속 언렌더/렌더 해야 하는데 그러면 페이지는 그냥 쓸 수 없게 된다.
정화된(sanitized) HTML을 페이지에 직접 렌더링하는 방법도 생각해봤지만, 그러면 겹치는 id, 충돌하는 스타일 등 수많은 문제가 생긴다. 그때 이 문제에 딱 맞는 도구가 있다는 걸 깨달았다: Shadow DOM. 이걸 쓰면 이런 문제들이 완전히 사라진다. 모든 요소가 각자의 컨텍스트로 격리되어 페이지의 나머지 부분과 서로 간섭하지 않는다. 물론 여전히 페이지를 넘쳐흐르게 만들 수는 있는데, 그건 CSS 파트의 문제다.
그래도 HTML 정화는 해야 한다. 이를 위해 node-html-parser를 사용했고, 모든 요소를 훑으면서 제거하거나 변환했다. 수행되는 작업은 다음과 같다:
img 태그로 svg 파일을 쓰는 것은 여전히 가능하다. iframe은 순전히 성능 이유로 금지했다.src, href(a 태그 제외), poster 속성을 정화하여 사용자가 자신의 계정에 업로드한 파일만 사용 가능하게 함.a 태그에 target="_blank" 추가.width, height 속성을 최대 4096px로 제한해 거대한 미디어가 성능 문제를 유발하는 것을 방지.video, audio 태그에서 autoplay 제거 및 preload="metadata" 추가.input, textarea에 autocomplete="off"를 추가해 비밀번호 자동완성을 방지.link 태그를 연결된 파일 기반의 style 태그로 변환. 내부 CSS는 정화됨.script 태그에 src가 있으면 파일에서 읽어 인라인 스크립트로 변환.script 태그에 type="text/tilescript"를 추가해 브라우저가 일반 JS로 실행하지 않게 함.style 태그는 CSS 정화를 수행.전반적으로 이는 효과적이었고 잘 동작했다. 그러다 누군가 실제로 금지된 요소 몇 개를 집어넣는 데 성공하기 전까지는. 조사해보니, 손상된 HTML이었고 정화 과정을 거치면 시작할 때와는 다른 요소를 포함하게 되는 형태였다. 해결책은 파서를 여러 번 돌리고 HTML이 더 이상 변하지 않을 때 멈추는 것이었다. 3번 루프 이후에도 HTML이 계속 바뀌면 아예 제거했다.
또한 a 태그에는 사람들에게 타일 안에서 여러 페이지를 둘 수 있게 하는 특별 동작을 구현했다. 클릭 시 href가 상대 경로 페이지라면, 타일은 새 경로로 다시 렌더링된다.
CSS는 꽤 단순했는데, Shadow DOM이 이미 여기서 큰 역할을 해주기 때문이다. 각 Shadow DOM은 overflow: hidden이 있는 컨테이너 요소 안에 놓였고, 비교적 새로운 속성인 contain도 사용했다. 값은 strict로 설정했는데, 이는 어떤 탈출도 막고 성능에도 도움이 되길 기대했다.
요소가 아주 많은 웹사이트라 will-change가 완벽할 거라고 느꼈지만, 결과적으로는 성능을 훨씬 나쁘게 만들었고, 이전에는 본 적 없는 랜덤 렌더링 문제도 일으켰다. 그리고 그렇다, 변화가 일어날 적절한 요소에 사용했다고 확신한다.
Shadow DOM 안에 CSS를 두는 것의 한 가지 한계는, 이유는 모르겠지만 font-face가 동작하지 않는다는 점이다. 이를 위해 CSS를 파싱해서 폰트 선언을 Shadow DOM 밖에 추가해야 했다. 타일이 언렌더될 때는 해당 선언을 제거했다.
CSS 정화에는 정규식으로 CSS를 파싱하고 싶은 유혹을 참고 lightningcss 라이브러리를 사용했다. 이는 모든 토큰을 매우 효율적으로 순회할 수 있다. 문서는 끔찍했지만, 많은 시행착오 끝에 해냈다. 목록은 더 짧다:
import 선언 제거.animation-play-state 속성 제거. 기본적으로 *에 animation-play-state: paused가 설정되어 있어 모든 CSS 애니메이션이 한꺼번에 재생되는 것을 막기 때문이다. 타일을 클릭하면 활성화된다.url() 함수는 src 속성과 같은 방식으로 정화. 사용자 업로드 파일만 허용.px 등)을 최대 4096px로 제한해 성능 문제를 방지.그리고 또 한 번, 탈출은 딱 한 번만 일어났다. 이걸 몰랐는데, :host가 Shadow DOM을 탈출할 수 있다고 하더라. 그래서 :host가 포함된 모든 선언을 제거했다.
이제 가장 흥미로운 부분이다... 마지막 순간까지도 이게 가능할지 정말 몰랐다. 그래도 꼭 구현하고 싶었다. 타일이 HTML+CSS만 허용되는 것보다 훨씬, 훨씬 더 재미있어질 테니까.
페이지에는 인라인 스크립트 실행을 막는 Content Security Policy가 설정돼 있다. 안전하지 않은 코드를 실행하기 위한 여러 접근을 꽤 오래 시도해봤다.
전반적으로 JS-Interpreter는 완벽한 선택이 됐다. 느리다는 점이 오히려 WebTiles 같은 프로젝트에는 딱 맞았다. 모두가 코드를 최대한 최적화하고 제한된 메모리 안에서 작업해야 했다. 그리고 타일을 처음 클릭할 때만 인터프리터를 초기화하면 됐다. 클릭을 해제하면 실행은 사용자가 그 타일을 다시 클릭할 때까지 단순히 일시정지된다. 동시에 활성화될 수 있는 타일은 1개뿐이었다.
또한 초당 step 함수를 호출하는 횟수를 바꾸는 것만으로 인터프리터 속도를 조절할 수도 있었다. 메모리 관리는 500 step마다 전체 사용 메모리를 확인하는 방식으로 했다.
가장 어려운 부분은 공통 API를 JS로 구현해야 했다는 점이다—DOM, Canvas, XMLHttpRequest, Events, localStorage, DOMParser, console, atob/btoa, alert/prompt/confirm 등. 말 그대로 지루했다. 각 API를 재구현해야 했는데, 대부분은 기존 API에 대한 래퍼를 작성하고 interpreter.createNativeFunction으로 연결하는 수준이었다.
구현된 API의 흥미로운 세부 사항들:
innerHTML 설정은 허용하지 않았다. 이를 정화하는 건 거의 불가능하다. 사람들은 document.createElement 같은 직접 함수들을 사용해야 했다.src, href 같은 모든 속성 setter에는 HTML 섹션에서 언급한 것과 같은 검사가 들어갔다.document.createElement는 HTML 섹션과 동일한 태그 블랙리스트를 적용했다. 또한 500개 요소 제한에 도달했는지도 확인했다.play() 호출은 먼저 해당 요소를 DOM에 삽입해야만 가능하게 했다. 그래야 사용자가 타일에서 벗어났을 때 모든 소리를 실제로 일시정지할 수 있다. DOM에 삽입하지 않고 재생하면, 요소에 대한 참조가 없는 한 멈출 방법이 없다.localStorage는 내부적으로 IndexedDB를 사용했다. JS-Interpreter에는 createAsyncFunction이라는 꽤 멋진 함수가 있어서, 비동기 네이티브 함수를 실행하면서도 인터프리터에는 동기처럼 보이게 할 수 있다.XMLHttpRequest는 외부 리소스로의 네트워크 요청만 허용했다(WebTiles API를 호출하지 못하게 하기 위해). 네트워크 요청을 아예 넣을지 말지 좀 망설였지만, IP 유출은 기능 추가의 가치가 있다고 느꼈다(사람들이 실제로 이걸로 소셜 미디어를 만들기도 했다). 그리고 요청은 타일을 클릭했을 때만 나가므로, 사실상 웹사이트를 방문하는 것과 비슷하다.JS 지원을 추가할 수 있어서 정말 기뻤다. WebTiles를 훨씬, 훨씬 더 흥미롭게 만들었다. 사람들은 게임, 앱, 그리고 다른 멋진 작은 것들을 만들었다.

벽돌 깨기 게임.

지뢰찾기.

전체 3D 엔진!

실제로 동작하는 전체 소셜 미디어 클라이언트!
인터프리터는 몇 번 탈출당했고, 기본적으로 모든 탈출은 document.createElement 함수(그리고 script 태그를 append) 때문에 발생했다:
new String("script");를 입력했다. 이 때문에 정상성 검사(sanity check)가 문제를 발견하지 못했고 네이티브 함수로 그대로 전달됐다.createElement 함수를 DOMParser 쪽 변형으로 바꾸는 데 성공했는데, 그 쪽에는 정상성 검사가 없었다.결국 DOMParser 요소 전부에 대해 "오염(tainting)"을 추가해야 했다. 오염된 요소는 DOM에 삽입할 수 없게 했다. 이런 탈출들은 꽤 나빴지만, 치명적이진 않았다. JS는 악성 사용자의 타일을 클릭했을 때만 실행되기 때문이다.
가장 심각하면서도 동시에 흥미로운 샌드박스 탈출 사례는 누군가 만든 웜이다. 누군가가 감염된 타일을 클릭하면 전파되었고, 자신을 사용자의 메인 페이지에 추가한 뒤, 탈출한 코드를 실행하면서 계속 복제됐다.

의도치 않게, 내가 익스플로잇 중 하나를 수정하면서 웜의 확산을 막아버렸고, 다행히 약 70개 타일 정도로만 퍼졌다. 또한 웜은 의도치 않게 모든 JS와 CSS를 인라인으로 만들고, 들여쓰기를 제거했으며, 패치 이후 코드의 오류 때문에 JS가 동작하지 않게 만들었다.
코드를 분석할 때, 처음엔 흔한 난독화라고 생각했는데, 결과적으로는 몇 개의 함수/오퍼코드로 이루어진 실제 VM이었다:
0x00 read
0x01 call
0x02 sum
0x03 new
0x04 call with 2 args
0x05 call with 3 args
0x06 call with 4 args
0x07 read and call with 2 args (?)
이런 탈출은 기본적으로 더 엄격한 CSP를 처음부터 적용했다면 대부분 피할 수 있었을 거라고 생각한다. 허용할 스크립트를 도메인만이 아니라 폴더로도 지정할 수 있다는 걸 몰랐고, 도메인만 지원하는 줄 알았다. 실수도 있었지만, 사람들이 해낸 창의적인 탈출들을 보는 건 꽤 재미있었다.
반쯤은 농담인 제목이긴 하지만, 나는 실제로 사용자 제공 코드를 받아도 괜찮다고 생각한다. 다만 조심해야 한다.