샌드박스 처리된 IFrame에서 안전하게 플레이하기

오늘날 웹에서 풍부한 환경을 구성하려면 거의 불가피하게 제어할 수 없는 구성요소와 콘텐츠를 삽입해야 합니다. 서드 파티 위젯은 참여도를 유도하고 전반적인 사용자 환경에서 중요한 역할을 할 수 있으며, 사용자 제작 콘텐츠가 사이트의 기본 콘텐츠보다 더 중요한 경우도 있습니다. 둘 중 하나를 선택하지 않는 것은 사실상 불가능하지만 둘 다 사이트에서 '나쁜 일'이 발생할 위험을 높입니다. 삽입하는 각 위젯(모든 광고, 모든 소셜 미디어 위젯)은 악의적인 의도를 가진 사용자의 잠재적인 공격 벡터입니다.

콘텐츠 보안 정책(CSP)을 사용하면 특히 신뢰할 수 있는 스크립트 및 기타 콘텐츠 소스를 허용 목록에 추가하여 이러한 두 가지 유형의 콘텐츠와 관련된 위험을 완화할 수 있습니다. 이는 올바른 방향으로 나아가는 중요한 한 걸음이지만 대부분의 CSP 지시어가 제공하는 보호는 바이너리 방식이라는 점에 유의해야 합니다. 리소스가 허용되거나 허용되지 않습니다. '이 콘텐츠 출처를 실제로 신뢰할 수 있을지는 모르겠지만 너무 예쁘네요. 브라우저님, 삽입해 주세요. 하지만 내 사이트가 다운되지 않도록 하세요.'

최소 권한

기본적으로 삽입된 콘텐츠에 작업을 수행하는 데 필요한 최소한의 수준의 기능만 부여할 수 있는 메커니즘을 찾고 있습니다. 위젯이 새 창을 띄울 필요가 없는 경우 window.open에 대한 액세스 권한을 삭제해도 문제가 되지 않습니다. Flash가 필요하지 않은 경우 플러그인 지원을 사용 중지해도 문제가 되지 않습니다. 최소 권한 원칙을 따르고 사용하려는 기능과 직접적으로 관련이 없는 모든 기능을 차단하면 최대한 안전하게 보호할 수 있습니다. 따라서 더 이상 일부 삽입된 콘텐츠가 사용해서는 안 되는 권한을 활용하지 않을 것이라고 맹목적으로 신뢰할 필요가 없습니다. 애초에 기능에 액세스할 수 없습니다.

iframe 요소는 이러한 솔루션을 위한 우수한 프레임워크를 만드는 첫 번째 단계입니다. iframe에 일부 신뢰할 수 없는 구성요소를 로드하면 애플리케이션과 로드하려는 콘텐츠 간에 어느 정도 분리가 이루어집니다. 프레임에 삽입된 콘텐츠는 페이지의 DOM이나 로컬에 저장된 데이터에 액세스할 수 없으며 페이지의 임의 위치에 그릴 수도 없습니다. 프레임의 윤곽선으로 범위가 제한됩니다. 하지만 이러한 구분은 실제로는 강력하지 않습니다. 포함된 페이지에는 여전히 성가시거나 악의적인 동작을 위한 여러 옵션이 있습니다. 자동재생 동영상, 플러그인, 팝업은 그중 일부에 불과합니다.

iframe 요소의 sandbox 속성은 프레임이 적용된 콘텐츠에 대한 제한을 강화하는 데 필요한 정보를 제공합니다. 브라우저에 특정 프레임의 콘텐츠를 권한이 낮은 환경에서 로드하도록 지시하여 필요한 작업을 실행하는 데 필요한 기능의 하위 집합만 허용할 수 있습니다.

신뢰하되 검증

트위터의 '트윗' 버튼은 샌드박스를 통해 사이트에 더 안전하게 삽입할 수 있는 기능의 좋은 예입니다. 트위터에서는 다음 코드를 사용하여 iframe을 통해 버튼을 삽입할 수 있습니다.

<iframe src="https://platform.twitter.com/widgets/tweet_button.html"
        style="border: 0; width:130px; height:20px;"></iframe>

무엇을 차단할 수 있는지 알아보려면 버튼에 필요한 기능을 신중하게 살펴보겠습니다. 프레임에 로드된 HTML은 트위터 서버의 JavaScript를 실행하고 클릭 시 트윗 인터페이스로 채워진 팝업을 생성합니다. 이 인터페이스는 트윗을 올바른 계정에 연결하기 위해 트위터 쿠키에 액세스해야 하며 트윗 양식을 제출할 수 있어야 합니다. 대략 이 정도입니다. 프레임은 플러그인을 로드할 필요가 없으며 최상위 창이나 기타 여러 기능을 탐색할 필요가 없습니다. 이러한 권한은 필요하지 않으므로 프레임의 콘텐츠를 샌드박스 처리하여 삭제하겠습니다.

샌드박스는 허용 목록을 기반으로 작동합니다. 먼저 가능한 모든 권한을 삭제한 다음 샌드박스 구성에 특정 플래그를 추가하여 개별 기능을 다시 사용 설정합니다. 트위터 위젯의 경우 JavaScript, 팝업, 양식 제출, twitter.com의 쿠키를 사용 설정하기로 결정했습니다. 다음 값으로 iframesandbox 속성을 추가하면 됩니다.

<iframe sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
    src="https://platform.twitter.com/widgets/tweet_button.html"
    style="border: 0; width:130px; height:20px;"></iframe>

이상입니다. 프레임에 필요한 모든 기능을 제공했으며 브라우저는 sandbox 속성 값을 통해 명시적으로 부여하지 않은 권한에 대한 액세스를 거부합니다.

기능에 대한 세분화된 제어

위의 예에서 가능한 몇 가지 샌드박스 플래그를 확인했습니다. 이제 속성의 내부 작동 방식을 좀 더 자세히 살펴보겠습니다.

빈 샌드박스 속성이 있는 iframe이 있으면 프레임된 문서가 완전히 샌드박스 처리되어 다음과 같은 제한사항이 적용됩니다.

  • 프레임이 있는 문서에서는 JavaScript가 실행되지 않습니다. 여기에는 스크립트 태그를 통해 명시적으로 로드된 JavaScript뿐 아니라 인라인 이벤트 핸들러와 javascript: URL도 포함됩니다. 즉, 사용자가 직접 스크립트를 사용 중지한 것처럼 noscript 태그에 포함된 콘텐츠가 표시됩니다.
  • 프레임이 적용된 문서는 고유한 출처에 로드되므로 모든 동일 출처 검사가 실패합니다. 고유한 출처는 자체는 물론 다른 출처와도 일치하지 않습니다. 이는 다른 영향 중에서도 문서가 출처의 쿠키 또는 기타 저장소 메커니즘(DOM 저장소, 색인 생성 DB 등)에 저장된 데이터에 액세스할 수 없음을 의미합니다.
  • 프레임이 있는 문서는 새 창이나 대화상자를 만들 수 없습니다 (예: window.open 또는 target="_blank"를 통해).
  • 양식을 제출할 수 없습니다.
  • 플러그인이 로드되지 않습니다.
  • 프레임이 적용된 문서는 최상위 상위 요소가 아닌 자체적으로만 탐색할 수 있습니다. window.top.location를 설정하면 예외가 발생하고 target="_top"가 포함된 링크를 클릭해도 아무런 효과가 없습니다.
  • 자동으로 트리거되는 기능 (자동 포커스 형식 요소, 자동 재생 동영상 등)은 차단됩니다.
  • 포인터 잠금을 가져올 수 없습니다.
  • 프레임이 적용된 문서에 포함된 iframes에서 seamless 속성은 무시됩니다.

이는 매우 엄격하며 완전히 샌드박스 처리된 iframe에 로드된 문서는 실제로 거의 위험하지 않습니다. 물론 큰 도움이 되지는 않습니다. 일부 정적 콘텐츠의 경우 전체 샌드박스로도 충분할 수 있지만 대부분의 경우 약간 완화하는 것이 좋습니다.

플러그인을 제외하고 이러한 각 제한은 샌드박스 속성 값에 플래그를 추가하여 해제할 수 있습니다. 플러그인은 샌드박스 처리되지 않은 네이티브 코드이므로 샌드박스 처리된 문서에서는 플러그인을 실행할 수 없지만 그 밖의 모든 작업은 가능합니다.

  • allow-forms: 양식 제출을 허용합니다.
  • allow-popups: 팝업을 허용합니다 (충격!).
  • allow-pointer-lock는 포인터 잠금을 허용합니다.
  • allow-same-origin를 사용하면 문서가 출처를 유지할 수 있습니다. https://example.com/에서 로드된 페이지는 해당 출처의 데이터에 계속 액세스할 수 있습니다.
  • allow-scripts는 JavaScript 실행을 허용하고 기능이 자동으로 트리거되도록 허용합니다 (JavaScript를 통해 구현하는 것이 간단하므로).
  • allow-top-navigation를 사용하면 문서가 최상위 창을 탐색하여 프레임을 벗어날 수 있습니다.

이를 고려하여 위의 트위터 예시에서 특정 샌드박스 플래그 세트가 사용된 이유를 정확하게 평가할 수 있습니다.

  • 프레임에 로드된 페이지가 사용자 상호작용을 처리하기 위해 일부 JavaScript를 실행하므로 allow-scripts가 필요합니다.
  • allow-popups는 버튼을 누르면 새 창에 트윗 양식이 표시되므로 필요합니다.
  • 트윗 양식을 제출할 수 있어야 하므로 allow-forms가 필요합니다.
  • allow-same-origin는 필수입니다. 그렇지 않으면 twitter.com의 쿠키에 액세스할 수 없으며 사용자가 로그인하여 양식을 게시할 수 없기 때문입니다.

프레임에 적용된 샌드박스 플래그는 샌드박스에서 생성된 모든 창 또는 프레임에도 적용된다는 점이 중요합니다. 즉, 양식이 프레임이 팝업되는 창에만 있더라도 프레임의 샌드박스에 allow-forms를 추가해야 합니다.

sandbox 속성이 있으면 위젯은 필요한 권한만 가져오고 플러그인, 상단 탐색, 포인터 잠금과 같은 기능은 계속 차단됩니다. 부작용 없이 위젯 삽입의 위험을 줄였습니다. 관련된 모든 당사자에게 이익이 됩니다.

권한 분리

권한이 낮은 환경에서 신뢰할 수 없는 코드를 실행하기 위해 서드 파티 콘텐츠를 샌드박스 처리하는 것은 분명히 유용합니다. 하지만 자체 코드는 어떨까요? 자신을 믿으시나요? 그렇다면 샌드박스를 왜 신경 써야 할까요?

반대로 질문을 드리겠습니다. 코드에 플러그인이 필요하지 않은데 왜 플러그인에 대한 액세스 권한을 부여하나요? 잘하면 사용하지 않는 권한이지만, 나쁘게 보면 공격자가 침입할 수 있는 잠재적인 경로가 될 수 있습니다. 모든 코드에는 버그가 있으며 거의 모든 애플리케이션은 어떤 식으로든 악용에 취약합니다. 자체 코드를 샌드박스 처리하면 공격자가 애플리케이션을 성공적으로 도용하더라도 애플리케이션 출처에 대한 전체 액세스 권한이 부여되지 않으므로 애플리케이션에서 할 수 있는 작업만 할 수 있습니다. 여전히 좋지 않지만 최악의 상황은 아닙니다.

애플리케이션을 논리적 부분으로 나누고 가능한 한 최소한의 권한으로 각 부분을 샌드박스 처리하여 위험을 더욱 줄일 수 있습니다. 이 기법은 네이티브 코드에서 매우 일반적입니다. 예를 들어 Chrome은 로컬 하드 드라이브에 액세스하고 네트워크 연결을 설정할 수 있는 높은 권한의 브라우저 프로세스와 신뢰할 수 없는 콘텐츠 파싱의 무거운 작업을 실행하는 여러 권한이 낮은 렌더러 프로세스로 나뉩니다. 렌더러는 디스크를 터치할 필요가 없습니다. 브라우저가 페이지를 렌더링하는 데 필요한 모든 정보를 제공합니다. 영리한 해커가 렌더러를 손상시키는 방법을 찾더라도 렌더러는 그 자체로 큰 관심을 가질 만한 작업을 할 수 없으므로 큰 소득을 얻지 못합니다. 모든 높은 권한 액세스는 브라우저 프로세스를 통해 라우팅되어야 합니다. 공격자가 시스템의 여러 부분에서 여러 가지 취약점을 찾아야 피해를 줄 수 있으므로 성공적인 계정 도용의 위험이 크게 줄어듭니다.

eval()를 안전하게 샌드박스 처리

샌드박스 및 postMessage API를 사용하면 이 모델의 성공을 웹에 적용하는 것이 매우 간단합니다. 애플리케이션의 일부는 샌드박스화된 iframe에 있을 수 있으며 상위 문서는 메시지를 게시하고 응답을 수신 대기하여 간에 통신을 중개할 수 있습니다. 이러한 구조를 사용하면 앱의 한 부분에서 악용사례가 발생해도 최소한의 피해만 발생합니다. 또한 명확한 통합 지점을 만들도록 강제하므로 입력과 출력의 유효성 검사에 주의해야 할 위치를 정확하게 알 수 있습니다. 작동 방식을 알아보기 위해 간단한 예를 살펴보겠습니다.

Evalbox는 문자열을 받아 JavaScript로 평가하는 흥미로운 애플리케이션입니다. 와, 그렇죠? 오랫동안 기다리셨던 기능입니다. 물론 이는 상당히 위험한 애플리케이션입니다. 임의의 JavaScript 실행을 허용하면 출처에서 제공하는 모든 데이터를 가져올 수 있기 때문입니다. 코드가 샌드박스 내에서 실행되도록 하여 나쁜 일™이 발생할 위험을 완화하므로 훨씬 더 안전합니다. 프레임의 콘텐츠부터 시작하여 코드를 안에서 밖으로 살펴보겠습니다.

<!-- frame.html -->
<!DOCTYPE html>
<html>
    <head>
    <title>Evalbox's Frame</title>
    <script>
        window.addEventListener('message', function (e) {
        var mainWindow = e.source;
        var result = '';
        try {
            result = eval(e.data);
        } catch (e) {
            result = 'eval() threw an exception.';
        }
        mainWindow.postMessage(result, event.origin);
        });
    </script>
    </head>
</html>

프레임 내부에는 window 객체의 message 이벤트에 후크하여 상위 요소의 메시지를 리슨하는 최소 문서가 있습니다. 상위 요소가 iframe의 콘텐츠에서 postMessage를 실행할 때마다 이 이벤트가 트리거되어 상위 요소가 실행하려는 문자열에 액세스할 수 있습니다.

핸들러에서 이벤트의 source 속성(상위 창)을 가져옵니다. 완료되면 이 이메일 주소를 사용하여 노력의 결과를 다시 보내드리겠습니다. 그런 다음 제공된 데이터를 eval()에 전달하여 까다로운 작업을 처리합니다. 샌드박스화된 iframe 내에서 금지된 작업은 DOM 예외를 자주 생성하므로 이 호출은 try 블록으로 래핑되었습니다. 이러한 예외를 포착하여 대신 친근한 오류 메시지를 보고합니다. 마지막으로 결과를 상위 창에 다시 게시합니다. 꽤 간단한 작업입니다.

상위 요소도 마찬가지로 간단합니다. 코드용 textarea, 실행용 button로 구성된 작은 UI를 만들고 샌드박스화된 iframe를 통해 frame.html를 가져와 스크립트 실행만 허용합니다.

<textarea id='code'></textarea>
<button id='safe'>eval() in a sandboxed frame.</button>
<iframe sandbox='allow-scripts'
        id='sandboxed'
        src='frame.html'></iframe>

이제 실행을 위해 연결하겠습니다. 먼저 iframe의 응답을 리슨하고 사용자에게 alert()합니다. 실제 애플리케이션은 덜 성가신 작업을 할 것입니다.

window.addEventListener('message',
    function (e) {
        // Sandboxed iframes which lack the 'allow-same-origin'
        // header have "null" rather than a valid origin. This means you still
        // have to be careful about accepting data via the messaging API you
        // create. Check that source, and validate those inputs!
        var frame = document.getElementById('sandboxed');
        if (e.origin === "null" &amp;&amp; e.source === frame.contentWindow)
        alert('Result: ' + e.data);
    });

다음으로 button의 클릭에 이벤트 핸들러를 연결합니다. 사용자가 클릭하면 textarea의 현재 콘텐츠를 가져와 실행을 위해 프레임에 전달합니다.

function evaluate() {
    var frame = document.getElementById('sandboxed');
    var code = document.getElementById('code').value;
    // Note that we're sending the message to "*", rather than some specific
    // origin. Sandboxed iframes which lack the 'allow-same-origin' header
    // don't have an origin which you can target: you'll have to send to any
    // origin, which might alow some esoteric attacks. Validate your output!
    frame.contentWindow.postMessage(code, '*');
}

document.getElementById('safe').addEventListener('click', evaluate);

정말 쉽죠? 매우 간단한 평가 API를 만들었으며, 평가되는 코드가 쿠키나 DOM 저장소와 같은 민감한 정보에 액세스할 수 없습니다. 마찬가지로 평가된 코드는 플러그인을 로드하거나 새 창을 띄우거나 기타 여러 가지 성가시거나 악의적인 활동을 할 수 없습니다.

모놀리식 애플리케이션을 단일 목적 구성요소로 분할하여 자체 코드에 동일한 작업을 실행할 수 있습니다. 위에서 작성한 것처럼 각 메시지는 간단한 메시지 API로 래핑할 수 있습니다. 권한이 높은 상위 창은 컨트롤러 및 디스패처 역할을 할 수 있으며, 각 모듈이 작업을 수행하는 데 필요한 최소한의 권한을 갖도록 메시지를 전송하고, 결과를 수신 대기하며, 각 모듈에 필요한 정보만 잘 제공되도록 합니다.

하지만 상위 요소와 동일한 출처에서 가져온 프레임 콘텐츠를 처리할 때는 매우 주의해야 합니다. https://example.com/의 페이지가 allow-same-originallow-scripts 플래그를 모두 포함하는 샌드박스로 동일한 출처의 다른 페이지를 프레이밍하는 경우 프레이밍된 페이지가 상위 요소까지 도달하여 샌드박스 속성을 완전히 삭제할 수 있습니다.

샌드박스에서 플레이하기

현재 샌드박싱은 다양한 브라우저(Firefox 17 이상, IE10 이상, 작성 시점의 Chrome)에서 사용할 수 있습니다(caniuse에는 최신 지원 표가 있음). 포함하는 iframessandbox 속성을 적용하면 표시되는 콘텐츠에 특정 권한을 부여할 수 있습니다. 콘텐츠가 올바르게 작동하는 데 필요한 권한 부여할 수 있습니다. 이를 통해 콘텐츠 보안 정책으로 이미 가능한 것 이상으로 서드 파티 콘텐츠 포함과 관련된 위험을 줄일 수 있습니다.

또한 샌드박스는 영리한 공격자가 자체 코드의 허점을 악용할 위험을 줄이는 강력한 기술입니다. 모놀리식 애플리케이션을 각각 독립형 기능의 작은 청크를 담당하는 일련의 샌드박스 서비스로 분리하면 공격자가 특정 프레임의 콘텐츠뿐만 아니라 컨트롤러도 손상시켜야 합니다. 이는 컨트롤러의 범위를 크게 줄일 수 있으므로 훨씬 더 어려운 작업입니다. 나머지 부분에 관해 브라우저에 도움을 요청하면 보안 관련 작업을 해당 코드 감사에 집중할 수 있습니다.

그렇다고 해서 샌드박스가 인터넷 보안 문제에 대한 완전한 해결책은 아닙니다. 깊이 있는 방어를 제공하며 사용자의 클라이언트를 제어할 수 없다면 아직 모든 사용자에게 브라우저 지원을 사용할 수 없습니다(예: 사용자 클라이언트를 제어하는 경우(예: 기업 환경)). 언젠가는… 하지만 지금은 샌드박스가 방어를 강화하기 위한 또 다른 보호 계층일 뿐, 전적으로 의존할 수 있는 완전한 방어 수단은 아닙니다. 그래도 레이어는 훌륭합니다. 이 방법을 사용하는 것이 좋습니다.

추가 자료

  • 'HTML5 애플리케이션의 권한 분리'는 소규모 프레임워크의 설계와 기존의 세 가지 HTML5 앱에 대한 애플리케이션을 통해 작동하는 흥미로운 논문입니다.

  • 샌드박스는 다른 두 가지 새로운 iframe 속성인 srcdocseamless와 함께 사용하면 더욱 유연해질 수 있습니다. 전자는 HTTP 요청의 오버헤드 없이 프레임을 콘텐츠로 채울 수 있고 후자는 스타일이 프레임된 콘텐츠로 전달되도록 허용합니다. 현재 둘 다 브라우저 지원이 매우 좋지 않지만 (Chrome 및 WebKit Nightly) 향후 흥미로운 조합이 될 것입니다. 예를 들어 다음 코드를 통해 도움말의 댓글을 샌드박스 처리할 수 있습니다.

        <iframe sandbox seamless
                srcdoc="<p>This is a user's comment!
                           It can't execute script!
                           Hooray for safety!</p>"></iframe>