XSS 경험담 2개

와 웹해킹! example.me에 있는 A사를 분석하며 특이하다 싶은 두 가지가 있었어요.

프롤로그

들어가기 전에 CTF식으로 문제를 내보았어요.

1: simplexss
2: www - 시작점

여러분의 사이트를 방문한 후 simplexss.0e1.kr로 접속 시 아래와 같이 뜨면 됩니다.

challenge-goal

이제 설명을 시작해볼게요.

1. 이걸 어떻게 막냐! document.domain

A사의 많은 *.example.me 사이트가 document.domain = 'example.me'; 를 사용했어요. 메인 페이지가 안 그렇더라도, 그런 페이지가 하나씩은 다 있었습니다. 이 경우 JavaScript 상으로 두 사이트 간 보안의 경계가 없어집니다. 이는, 흔히 CORS라 부르는 표준인데요,

infographic

표준의 해당 부분에 대해 잠시 소개드리자면, 어떤 웹 페이지 주소의 도메인/포트/프로토콜 등을 origin이라 합니다. / 뒤의 '경로' 부분은 포함되지 않습니다. Origin이 다른 창의 내용을 접근할 때 오류가 발생합니다.

// 0e1.kr <-> mysite.0e1.kr
<iframe src="https://mysite.0e1.kr" id="x"></iframe>

<script>
  x.onload = () => {
    x.contentWindow.alert(1);

`VM207:1 Uncaught DOMException: Blocked a frame with origin
"https://0e1.kr" from accessing a cross-origin frame.
    at <anonymous>:1:9`
  }
</script>

하지만 document.domain이 같은 경우, 다른 서브도메인에서 XSS를 찾아도, 해당 서브도메인에 동일한 영향을 미치게 됩니다.

vulnerable.example.me에서 아래의 코드가 XSS로 인해 실행된다고 가정합니다.

<iframe src="https://blog.example.me/myfolder/" id="x"></iframe>

<script>
document.domain = 'example.me';

x.onload = () => {
  var ctor = x.contentWindow.Function();
  var func = new ctor(to_execute);
  func();
}

var to_execute = () =>
  alert(location.hostname); // serv1.example.me
</script>

blog.example.me는 document.domain이 exmaple.me라고 해보죠.

<script> document.domain = 'example.me'; </script>

공격자는 vulnerable.exp.me를 거쳐 blog.exp.me 등을 접근할 수 있다는 이야기가 되지요.
A사의 서비스들은 대부분 XSS 처리를 제대로 하지만, 처리가 잘 안 되어있는 서브도메인을 찾은 후, document.domain을 바꿔주면 동일한 효과를 낼 수 있습니다.

사실, document.domain을 통일시키는 이 패턴의 경우 보안 모델을 세울 때 각 서비스 간의 security boundary를 설정하지 않았다면 문제가 덜 되지만 (가령 세션 쿠키를 공유한다면 별 영향이 없습니다), 제가 아는 바로는 후술할 문제가 있습니다.

2. JSONP의 반격: Stored XSS 만들어내기

또한, 지금은 HTML5 시대입니다. JSONP injection은 응답에 개인정보가 있을 때에 한해 취약점으로 여겨지기도 하지만, 여기서는 다른 부분을 언급하려 합니다.

JSONP

위의 글에 잘 설명되어있지만, JSONP는 xhr, fetch 등이 오리진 문제로 사용 불가능한 경우 JSON 등의 응답을 받기 위해 고려된 데이터 전달 방식 중 하나입니다. 데이터를 받아올 때는 script src로, 해당 스크립트에서는 지정된 함수 명으로 데이터를 전달합니다. 예를 들면,

// 요청은 이렇게
<script src="/getInfo.php?callback=set_name"></script>
`응답부에서는 이렇게`
set_name({"result": "success", "id": "...", "userInfo": { ... }});

이제부터 응답 시 callback값을 잘 필터링해야한다는 것을 설명드릴거에요. 그 전에...

ServiceWorker

AppCache에 한계를 느낀 웹 개발자들은 프로그래밍 가능한 캐시, ServiceWorker라는 스펙을 만들어 냈습니다. "요청에 따라 스크립트로 캐시와 응답을 제어할 수 있는 기능을 만들자!"
트위터, 구글, 크롬의 새 탭 페이지 (chrome://newtab) 에서도 쓰고 있어요.

service-worker

// 등록
var registration = await navigator.serviceWorker.register('/sw.js');
console.log('Registered with scope:', registration.scope);

// sw.js
onfetch = (event) => {

  const responder = async () => {
    return new Response('cached ' + event.request.url);
  }

  if(event.request.url.endsWith('&should_cache'))
    event.respondWith(responder)

  return;
}

확실히 캐시 용도로 작성하진 않아보이지만, 다분히 공격쪽 예제입니다.
등록 조건은 아래와 같습니다.

  1. HTTPS, localhost, file:///
  2. 등록하는 쪽과 스크립트의 origin이 같아야 함
  3. MIME type이 javascript여야 함

https 사이트의 JSONP는 위의 조건을 만족하므로, 고쳐야 할 버그입니다.

다시, JSONP Injection

가령 /getId.php?callback=window.setNameCallback라 해봅시다.

window.setNameCallback({result: "SUCCESS", data: ["jinmo123"]})

만약 callback=alert(1)을 넣었는데 아래와 같다면? 어떨까요?

alert(1)({result: "SUCCESS" ...})

1번. 저희가 원하는 내용을, 2. 같은 오리진에서, 3. javascript MIME type으로 가져올 수 있습니다. 서비스 워커로 쓰일 스크립트를 출력시켜봅시다.

// callback= ... (see below)
onfetch = function() {
  event.respondWith(new Promise(resolve =>
    resolve(new Response("hacked"))
  ));
} //

그리고 그 주소를 아래와 같이 등록시켜줍니다.

await navigator.serviceWorker.register('/getId.php?callback=onfetch=...')

이제 해당 사이트를 방문할 때마다 응답이 hacked로 뜨게 됩니다. 브라우저를 껐다 켜도 뜨므로, 제거하려면 크롬의 경우 chrome://serviceworker-internals, 또는 쿠키 및 기타 사이트 데이터 삭제를 해주셔야 합니다.

해당 사이트에서 JSONP 부분의 취약점을 고친 경우 24시간 뒤에는 자동으로 사라집니다. Reflective가 Stored로 바뀌었군요! 유지도 됩니다. 피싱 사이트 없는 피싱인 셈입니다.

결산

서버사이드 취약점들은 너무 구체적으로 서술해야 하는 케이스를 제외하면, 제가 찾은 것 중에는 별로 특이한 게 없고 해서 일반적인 클라이언트 사이드 취약점을 써보았어요.

document.domain 을 통일시키면 해당되는 서브도메인은 모든 서브도메인의 XSS에 취약합니다.
또한 JSONP 스크립트 삽입 취약점도 조건부로 서비스워커 설치에 이용될 수 있습니다.

저는 위의 스크린샷을 다시 한번 불러오면서, 글을 맺겠습니다.

challenge-goal

p.s. 이 외에도 서비스 워커가 작용하는 범위에 대한 조건에 관한 코드가 있습니다.
심심할 때 읽어보세요!