← Posts

Security · Browser · XSS

XSS - 남의 스크립트가 내 페이지에서 돈다

데이터로 넣은 글자가 코드가 되는 순간 출처는 이미 같아져 있다. 동일 출처 정책이 왜 못 막는지, 방어가 왜 출력 자리에 있는지.

목차
  1. 게시글 제목에 넣은 태그가 실행됐다
  2. 데이터가 코드가 되는 순간
  3. 동일 출처 정책은 왜 이걸 못 막나
  4. 어디서 끼어드나
    1. 저장형 - 서버에 남아서 계속 터진다
    2. 반사형 - 요청에 담겨 왔다가 그대로 돌아간다
    3. DOM 기반 - 서버는 관여하지 않는다
  5. 막는 자리는 입력이 아니라 출력이다
  6. 프레임워크는 기본으로 막아준다 - 뚫는 문만 조심하면 된다
  7. 스크립트가 쿠키를 못 읽게: HttpOnly
  8. 방어는 한 겹으로 끝나지 않는다
  9. XSS가 뚫리면 다른 방어도 같이 무너진다
  10. 정리

앞 글의 CORS는 정책을 여는 공식 절차였다. 이번엔 정책을 열 필요조차 없는 쪽이다.

게시글 제목에 넣은 태그가 실행됐다

게시판을 하나 만들었다고 하자. 제목을 받아서 화면에 뿌린다.

js
document.querySelector('#title').innerHTML = post.title;

평소엔 잘 돈다. 그런데 누가 제목에 이런 걸 넣고 글을 올렸다.

plaintext
<img src=x onerror="alert('hi')">

목록을 여는 모든 사람의 브라우저에서 그 alert가 뜬다. 글쓴이 화면만이 아니다. 저장돼 있으니 이후에 그 글을 여는 사람 전부다.

여기서 놀랄 지점은 경고창이 아니다. 그 자리에 경고창 대신 다른 코드가 들어갔다면, 그 코드는 우리 페이지의 권한으로 돌았을 것이다. 로그인한 사용자의 화면에서, 우리 출처로.

데이터가 코드가 되는 순간

문제의 뿌리는 한 줄로 요약된다. 데이터로 넣은 글자를 브라우저가 코드로 읽었다.

브라우저에게 HTML은 그냥 글자열이다. <를 만나면 태그의 시작으로 해석하고, onerror=를 만나면 실행할 코드로 받아들인다. 우리는 “제목”이라는 값을 꽂았다고 생각했지만, 브라우저는 그 자리에 온 글자를 문서의 일부로 파싱했을 뿐이다.

diagramdiagram

이 구조가 낯익다면 맞다. 데이터를 문법의 일부로 섞어 넣어서 생기는 문제라는 점에서 SQL 인젝션과 같은 종류다. 문자열을 이어 붙여 문법을 만들면 값과 문법의 경계가 사라진다. XSS는 그 무대가 브라우저인 경우다.

XSS(Cross-Site Scripting) 라는 이름은 “남의 사이트 스크립트가 끼어든다”는 뜻인데, 이름 때문에 오히려 헷갈린다. 실제로 벌어지는 일은 이거다. 공격자가 쓴 코드가 피해자의 브라우저에서, 우리 사이트의 페이지 안에서 실행된다.

동일 출처 정책은 왜 이걸 못 막나

앞에서 브라우저가 출처를 갈라 격리한다고 했다. 그럼 이건 왜 안 막히나.

격리할 게 없기 때문이다. 그 스크립트는 https://shop.example.com 페이지 안에서 실행되고 있다. 브라우저 입장에서 그 코드의 출처는 https://shop.example.com이다. 공격자가 쓴 코드인지 우리가 쓴 코드인지 구분할 방법이 없다.

diagramdiagram

그래서 주입된 코드가 할 수 있는 일이 곧 우리 페이지가 할 수 있는 일 전부다.

  • 화면의 DOM을 읽고 고친다. 로그인 폼을 가짜로 바꿔도 주소창은 진짜 주소다.
  • localStorage에 넣어둔 토큰을 읽는다.
  • 우리 API를 호출한다. 동일 출처라서 CORS도 필요 없다. 쿠키는 알아서 실린다.

앞 글에서 CORS를 조여도 서버가 안전해지지 않는다고 했는데, 여기가 그 말이 가장 아프게 확인되는 자리다. XSS 앞에서 CORS 설정은 아무 의미가 없다. 공격 코드가 애초에 허용된 출처 안에 있다.

어디서 끼어드나

끼어드는 자리에 따라 셋으로 나눠 부른다. 이름을 외우는 게 목적이 아니라, 어느 지점을 점검해야 하는지가 셋이 다르다.

저장형 - 서버에 남아서 계속 터진다

앞의 게시판 예다. 입력이 DB에 저장되고, 그 글을 여는 모든 사람에게 재생된다. 피해 범위가 가장 넓고, 관리자가 목록을 열다 당하면 권한까지 같이 넘어간다.

반사형 - 요청에 담겨 왔다가 그대로 돌아간다

검색 결과 페이지가 "xxx에 대한 검색 결과"를 그대로 찍는 식이다. 쿼리스트링에 담긴 글자가 응답에 그대로 실려 돌아온다. 저장이 안 되니 피해자가 그 링크를 열어야 성립한다. 그래서 메일이나 메신저로 링크를 뿌리는 방식과 짝을 이룬다.

DOM 기반 - 서버는 관여하지 않는다

서버 응답은 멀쩡한데 브라우저에서 도는 자바스크립트가 값을 받아 DOM에 꽂으면서 생긴다.

js
// 주소의 해시를 그대로 화면에 넣는다
document.querySelector('#tab').innerHTML = location.hash.slice(1);

이 경우 서버 로그를 아무리 봐도 안 나온다. 프래그먼트(# 뒤)는 서버로 전송되지도 않는다. 점검 대상이 서버 템플릿이 아니라 프런트 코드라는 게 이 유형의 핵심이다.

막는 자리는 입력이 아니라 출력이다

가장 흔한 첫 대응이 “입력에서 <script>를 걸러내자”다. 이게 잘 안 되는 이유가 둘 있다.

첫째, 무엇을 걸러야 하는지가 입력 시점엔 안 정해진다. 같은 값이라도 HTML 본문에 들어갈 때, 태그 속성 안에 들어갈 때, 자바스크립트 문자열 안에 들어갈 때, URL 자리에 들어갈 때 위험한 글자가 서로 다르다. 어디에 쓰일지 모르는 상태에서 거르면 늘 모자라거나 넘친다.

둘째, 걸러내기는 목록 관리 싸움이다. 막을 것을 나열하는 방식은 나열에서 빠진 것 하나면 뚫린다. 그리고 정상 사용자의 값까지 망가뜨린다. 수식을 다루는 게시판에서 <를 지워버리면 그냥 버그다.

그래서 방어의 중심은 출력이다. 값을 화면에 꽂는 그 순간, 그 자리에서 특별한 뜻을 갖는 글자를 뜻 없는 글자로 바꿔준다. 이걸 이스케이프라고 한다.

글자바꾼 형태
<&lt;
>&gt;
&&amp;
"&quot;
'&#x27;

이렇게 바꾸면 브라우저는 <img ...>를 태그가 아니라 글자 그대로 그린다. 화면에는 사용자가 입력한 그 텍스트가 보이고, 실행은 안 된다. 값을 버리지 않으면서 코드가 되는 길만 끊는 것이다.

핵심은 꽂는 자리에 맞는 이스케이프라는 점이다. HTML 본문용 처리를 해놓고 그 값을 자바스크립트 코드 안에 넣으면 여전히 위험하다. 그래서 “어디에 넣는가”를 먼저 정하고 거기 맞는 방식을 쓴다.

값이 들어갈 자리 중에 유난히 위험한 곳이 몇 군데 있다. href·src 같은 URL 자리(스킴 자체가 코드가 될 수 있다), 인라인 이벤트 핸들러 속성, <script> 블록 안. 이 셋은 이스케이프로 해결하려 들기보다 사용자 값을 아예 안 넣는 구조로 바꾸는 게 낫다. URL이라면 허용할 스킴을 목록으로 정해 검사한다.

프레임워크는 기본으로 막아준다 - 뚫는 문만 조심하면 된다

여기까지 읽고 “그럼 매번 손으로 치환하나” 싶겠지만, 요즘 프레임워크는 대부분 기본값이 이스케이프다.

jsx
// React - 이 값은 텍스트로 들어간다. 태그로 해석되지 않는다
<h1>{post.title}</h1>

Vue의 {{ }}, 서버 템플릿 엔진들의 기본 출력도 같은 방향이다. 아무것도 안 하면 안전한 쪽으로 설계돼 있다.

그래서 실무의 XSS는 대개 “이스케이프를 깜빡했다”가 아니라 그 기본값을 일부러 끈 자리에서 나온다.

뚫는 문안전한 대안
element.innerHTML = 값element.textContent = 값
React의 dangerouslySetInnerHTML그냥 자식으로 렌더
Vue의 v-html{{ }} 보간
템플릿 엔진의 이스케이프 해제 문법기본 출력

React가 저 속성 이름을 dangerously...로 지은 건 농담이 아니다. 코드 리뷰에서 이 이름들을 검색하는 것만으로 대부분의 XSS 후보를 모을 수 있다.

그런데 진짜로 HTML을 받아야 하는 기능이 있다. 리치 텍스트 에디터로 쓴 글을 저장했다가 서식 그대로 보여주는 경우다. 이럴 땐 이스케이프가 답이 아니다. 서식이 다 깨지니까.

이 경우엔 정화(sanitize) 를 쓴다. 허용할 태그와 속성만 남기고 나머지를 제거하는 방식이다. 직접 만들면 거의 반드시 빠뜨리는 게 생기므로 검증된 라이브러리를 쓰고, 허용 목록은 좁게 유지한다.

스크립트가 쿠키를 못 읽게: HttpOnly

주입된 코드가 노리는 것 중 하나가 세션 쿠키다. 그걸 가져가면 로그인 상태를 그대로 흉내 낼 수 있다.

기본 쿠키는 자바스크립트에서 document.cookie로 읽힌다. 여기에 속성 하나를 붙이면 그 통로가 막힌다.

http
Set-Cookie: session=abc123; HttpOnly; Path=/

HttpOnly가 붙은 쿠키는 브라우저가 요청에는 계속 실어 보내지만, 스크립트에는 안 보여준다. 이름이 좀 헷갈리는데 “HTTP를 통해서만 쓰이는 쿠키”라는 뜻이다.

그래서 세션을 쿠키에 담는다면 HttpOnly는 사실상 기본으로 켜는 값이다. 그리고 이 관점에서 보면, 토큰을 localStorage에 두는 선택은 XSS에 그대로 노출된다(JWT 글이 보관 위치를 견준다). localStorage는 자바스크립트가 읽으라고 있는 저장소라 가릴 방법이 없다.

주의

HttpOnly는 XSS를 막지 않는다. 훔쳐가는 경로 하나를 좁힐 뿐이다. 코드가 이미 페이지 안에서 돌고 있으면 쿠키를 못 읽어도 그 쿠키가 실리는 요청을 직접 보낼 수 있다. 이체 API를 부르는 데는 쿠키 값을 아는 게 필요 없다. 브라우저가 알아서 붙여주니까.

방어는 한 겹으로 끝나지 않는다

앞의 경고가 이 절의 주제다. XSS 방어에서 “이것만 하면 안전”이라고 말할 수 있는 항목은 없다. 층으로 쌓는다.

하는 일못 하는 일
출력 이스케이프값이 코드가 되는 걸 막는다이스케이프를 끈 자리는 못 지킨다
정화 라이브러리HTML을 받아야 할 때 위험한 태그를 제거허용 목록이 넓으면 무의미
HttpOnly쿠키 탈취 경로를 좁힌다실행 자체는 못 막는다
CSP실행될 수 있는 스크립트의 출처를 제한설정이 헐거우면 효과가 얇다
입력 검증애초에 이상한 값을 줄인다단독 방어로는 못 쓴다
참고

CSP(Content Security Policy) 는 서버가 응답 헤더로 “이 페이지에서는 이런 출처의 스크립트만 실행해라”라고 브라우저에 선언하는 규칙이다. 브라우저가 집행하는 규칙이라는 점에서 이 시리즈의 다른 것들과 한 식구다. 잘 걸어두면 이스케이프가 뚫린 자리에서도 실행을 한 번 더 걸러준다. 다만 지시어가 많고, 기존 페이지에 뒤늦게 붙이면 멀쩡한 스크립트까지 막혀서 단계적으로 적용하게 된다. 여기서는 “실행 가능한 출처를 좁히는 안전망” 이라는 것까지만 알아두면 된다.

층으로 쌓는 이유는 각 층이 못 하는 일이 서로 다르기 때문이다. 오른쪽 칸을 보면 어느 하나도 혼자서는 빈칸이 안 채워진다.

XSS가 뚫리면 다른 방어도 같이 무너진다

마지막으로 짚을 게 있다. 이 시리즈에서 XSS를 가운데 둔 이유다.

XSS는 그 자체의 피해로 끝나지 않는다. 다른 방어들이 딛고 선 전제를 무너뜨린다.

  • 동일 출처 정책은 “이 출처의 코드는 이 출처가 쓴 코드”라는 전제 위에 있다. XSS는 그 전제를 깬다.
  • CORS 설정은 “허용한 출처만 응답을 읽는다”는 전제 위에 있다. 공격 코드가 허용된 출처 안에 있으면 소용이 없다.
  • HttpOnly는 “값을 못 읽으면 못 쓴다”는 전제 위에 있다. 값을 몰라도 요청은 보낼 수 있다.

그래서 XSS는 다른 것을 아무리 잘 해놔도 여기 하나가 뚫리면 그 위가 다 흔들리는 자리에 있다. 다음 글에서 볼 방어도 예외가 아니다.

정리

XSS란공격자의 코드가 피해자의 브라우저에서, 우리 페이지 안에서 실행되는 것
뿌리데이터로 넣은 글자를 브라우저가 코드로 파싱한다
왜 정책이 못 막나실행되는 출처가 우리 출처다. 격리할 대상이 아니다
세 유형저장형(서버에 남음) · 반사형(링크로 전달) · DOM 기반(프런트 코드)
막는 자리입력 필터링이 아니라 출력 이스케이프, 꽂는 자리에 맞게
프레임워크기본이 안전. 사고는 이스케이프를 끈 자리에서 난다
HTML을 받아야 하면검증된 정화 라이브러리 + 좁은 허용 목록
HttpOnly쿠키를 스크립트에서 못 읽게 한다. 실행은 못 막는다
CSP실행 가능한 스크립트 출처를 좁히는 안전망

값을 화면에 꽂을 때마다 “이 자리에서 이 글자는 무슨 뜻이 되나”를 묻는 것, 그게 XSS 방어의 전부에 가깝다.

이제 남은 가지 하나로 간다. 방금 경고에서 이상한 문장을 하나 봤다. “쿠키 값을 몰라도 그 쿠키가 실리는 요청은 보낼 수 있다.” 브라우저가 알아서 붙여주기 때문이다. 그렇다면 굳이 남의 페이지에 코드를 심지 않아도, 그냥 자기 사이트에서 요청을 하나 쏘게 만들면 되지 않나. 다음 글이 그 이야기다.

관련 글