정보처리안전확보지원사(情報処理安全確保支援士) 2023년 가을(레이와 5년) 오후 문제1 해설 ── 16건의 리뷰가 2건만 표시되는 저장형 XSS

· · 정보처리안전확보지원사, SC 시험, XSS, 크로스사이트 스크립팅, 웹 애플리케이션, 정보보안, 취약점, IPA, 세션 관리

“16건의 리뷰가 있어야 하는데, 2건만 표시된다”

정보처리안전확보지원사(情報処理安全確保支援士) 시험 2023년 가을(레이와 5년) 오후 문제1은, 이 사용자의 문의에서 시작됩니다1. 화면 전환에는 이상한 점이 없고, 오류도 나타나지 않습니다. 다만 표시 건수가 맞지 않습니다.

원인은 저장형 크로스사이트 스크립팅(XSS)이었습니다. 그러나 이 문제가 흥미로운 지점은 ‘XSS’라는 답 그 자체가 아닙니다. Q사(문제문에 등장하는 EC 사업자)가 이미 갖추고 있던 “그럴듯한 대책”이 하나같이 뚫려버렸다는 점입니다. 리뷰 타이틀의 50자 제한은 깨졌고, 업로드에 필요한 토큰은 정규 절차로 입수되었으며, 훔친 세션ID는 외부 서버로 전송되지 않고도 반출되었습니다.

이 글에서는 이 대문항을 한 문제씩 따라가면서, 효과가 없었던 대책과 효과가 있었을 대책이 어디서 갈리는지를 정리합니다.

이 글에서 얻을 수 있는 것은, 시험 풀이(각 설문의 해답 예시와 그 근거)에 더해 실무에서 쓸 수 있는 XSS 대책의 전체상입니다. 시험 대비로 읽는 분은 설문별 섹션을, 실무의 코드 리뷰 관점만 필요한 분은 9장과 10장을 먼저 읽어도 뜻이 통하도록 썼습니다.

참고로, 웹사이트 전체의 보안을 무엇을 기준으로 점검하면 좋을지에 대한 조망은 IPA 「안전한 웹사이트 만드는 법」을 체크리스트로 활용하기에서 다루고 있습니다. 이 글은 거기서 다룬 11가지 취약점 중 XSS 하나를 실제 사례로 깊이 파고드는 글입니다.

1. 먼저 결론

  • 취약점의 종류는 저장형 XSS입니다. 공격자의 문자열이 서버에 저장되어 이후 페이지를 연 모든 사람의 HTML에 출력되고 있었습니다. 삽입된 스크립트가 DOM API를 사용하는 것과, DOM Based XSS인 것은 별개의 이야기입니다
  • 입력 문자 수 제한은 XSS 대책이 되지 못했습니다. 공격자는 게시를 15회로 분할하고, 게시 사이에 끼어드는 HTML을 JavaScript 주석으로 건너뛰게 하여 하나의 스크립트로 이어 붙였습니다
  • 업로드용 토큰(CSRF 대책)도 XSS 대책이 되지 못했습니다. 공격 스크립트는 정상 화면과 동일한 절차로 토큰을 취득합니다. 동일 오리진에서 동작하는 이상, 정규 이용자가 할 수 있는 일은 무엇이든 할 수 있습니다
  • 훔친 세션ID는 외부로 전송되지 않았습니다. cookie의 내용을 “a.png”라는 이미지 파일로 만들어 사이트 자신의 아이콘 업로드 기능에 올려두고, 공격자는 그것을 평범하게 열람하여 회수했습니다. 출구 대책으로는 탐지할 수 없습니다
  • 문제문이 Q사에 없었다고 명시하는 것은 3가지입니다. 출력 시 이스케이프(근본적 해결)와, cookie의 HttpOnly 속성·업로드 파일의 형식 확인(이 2가지는 보험적 대책)입니다. 이 중 하나라도 있었다면 이 공격의 연쇄는 끊어졌을 것입니다. 다만 형식 확인은 수법을 바꾸면 통과되어 버리므로 재인코딩까지 필요합니다
  • 문제문에는 나오지 않지만, CSP의 script-src도 같은 연쇄를 끊을 수 있습니다. 'unsafe-inline'을 허용하지 않는 구성이라면, 삽입된 인라인 스크립트는 애초에 실행되지 않습니다

2. 소재에 대하여 ── 출전과 이 글에서의 다루는 방식

다루는 것은 다음 문제입니다.

출전: 2023년 가을(레이와 5년) 정보처리안전확보지원사 시험 오후 문제1

IPA는 공표하고 있는 과거 시험 문제에 대해, 법령에 특별한 규정이 있는 경우를 제외하고는 허락이나 사용료가 필요 없다고 밝히고 있습니다. 다만 저작권을 포기한 것은 아니며, 출전을 “연도, 기, 시험 구분, 시간 구분, 문제 번호 등”의 형식으로 명기할 것, 문제의 일부를 변경한 경우에는 그 사실도 명기할 것을 요구하고 있습니다2.

이 글에서는 문제 책자에 실린 HTML이나 스크립트를 그대로 전재하지 않습니다. 구조를 설명하는 데 필요한 범위에서, 당사가 새로 작성한 등가의 예시 코드로 대체합니다. 설문 문구와 해답 예시도 요약해서 다룹니다. 문제 책자·해답 예시·채점 강평의 원문은 IPA 페이지에서 무료로 다운로드할 수 있으므로, 직접 펼쳐 보면서 읽으시길 권합니다1 3 4.

설문과 본 글의 대응

문제 책자를 펼쳐 놓고 읽는 분을 위해, 설문과 본 글 섹션의 대응을 표로 보여드립니다. 풀고 싶은 설문부터 읽기 시작해도 무방합니다.

설문 묻는 내용(글자 수) 본 글의 대응 섹션
설문1(1) 악용된 XSS 취약점의 종류(3지선다) 4장
설문1(2) 웹앱 Q에서의 대책(30자 이내) 4장 “설문1(2): 대책”
설문2 입력 문자 수 제한을 초과하는 길이의 스크립트를 실행시킨 방법(50자 이내) 5장
설문3(1) 공격 스크립트 6~20행의 처리 내용(60자 이내) 6장
설문3(2) 공격자가 업로드된 정보를 취득하는 방법(50자 이내) 7장
설문3(3) 취득한 정보로 할 수 있는 일(40자 이내) 7장 “설문3(3): 세션ID로 무엇을 할 수 있는가”
설문4 공격자의 도메인에서는 공격이 성공하지 않는 브라우저의 구조(40자 이내) 8장

설문을 떠나 실무 이야기만 필요한 분은 9장(효과가 있었던 대책과 없었던 대책 목록)과 10장(코드 리뷰 관점)부터 읽어 주십시오. 공격의 연쇄 자체를 한 장으로 보고 싶다면, 7장의 시퀀스 다이어그램이 게시부터 회수까지의 전체상을 보여줍니다.

문제 책자의 기술과 본 글 예시의 대응

원문과 대조할 수 있도록, 어디를 어떻게 대체했는지 정리합니다.

문제 책자의 기술 본 글에서의 다루는 방식 게재 위치
페이지 V의 HTML(분할 게시된 리뷰 타이틀 포함) 그대로 전재하지 않고, 같은 구조를 3건의 게시로 줄인 등가 예시를 당사에서 작성 5장
추출된 공격 스크립트(20행 정도) 그대로 전재하지 않고, 같은 처리를 수행하는 등가의 JavaScript를 당사에서 작성. 코드 내 주석은 본 글의 설명용 6장
각 설문의 설문 문구 요지를 유지한 요약(글자 수 제한 등의 조건은 원문 값) 4~8장 각 도입부
해답 예시 IPA가 공표한 해답 예시3 4~8장 각 부분
채점 강평 IPA가 공표한 채점 강평 중 해당 부분4 4장·5장·7장
문제문의 사양(Q사, 페이지 V, 회원 A/B, 기능과 글자 수 제한 등) 원문의 기술에 따라 요약 2장 “문제의 배경”

문제의 배경

등장하는 것은 의류 EC 사업을 운영하는 종업원 100명 규모의 Q사입니다. 자체 개발한 “웹앱 Q”로 EC 사이트를 운영하고 있으며, 이용자는 HTTPS로 접속합니다. 이번에 회원용 상품 리뷰 기능을 추가했다는 설정입니다.

짚어두어야 할 사양은 다음 5가지입니다.

기능 사양
로그인 회원ID와 비밀번호로 인증하며, 세션ID를 cookie로 발급
상품 리뷰 로그인한 회원만 게시할 수 있음. 리뷰 타이틀은 50자, 리뷰 상세는 300자의 입력 문자 수 제한이 있으며, 둘 다 자유 기술
회원 프로필 아이콘 이미지를 업로드하는 페이지와 신용카드 정보를 등록하는 페이지를 제공. 둘 다 로그인한 회원만 이용 가능
아이콘 이미지 업로드 /user/upload에 이미지 파일과 토큰을 파라미터로 전송함. 토큰이 /user/profile에서 발급된 것과 일치할 때만 성공함
아이콘 이미지 표시 업로드된 아이콘 이미지는 회원 프로필 설정 페이지와 리뷰 페이지에 표시됨

마지막 2줄이 나중에 중요하게 작용합니다.

3. 증상 ── 왜 16건이 2건이 되었는가

회원으로부터 “무지 티셔츠 리뷰 페이지(페이지 V)에 16건 표시되어야 할 리뷰가 2건밖에 표시되지 않는다”는 문의가 들어옵니다. 개발부의 N씨가 페이지 V를 열자, 화면상에는 다음과 같이 보였습니다.

  • 헤더에는 “16건의 리뷰”
  • 회원 A의 리뷰가 1건(타이틀 “Good”, 본문 “Nice shirt!”)
  • 회원 B의 리뷰가 1건
  • 말미에 “이상, 전체 16건의 리뷰”

건수 표시는 16건, 실제로 나열되어 있는 것은 2건. 여기서 HTML을 확인해 보면, 회원 A에 의한 게시가 15건 존재하며, 그 안에 긴 스크립트가 삽입되어 있음을 알 수 있습니다.

즉, 리뷰 자체는 16건분이 HTML에 출력되어 있는 것입니다. 그럼에도 2건밖에 보이지 않는 이유는, 그 대부분이 <script> 요소의 내용이 되어 버려서 브라우저가 “표시해야 할 콘텐츠”로 취급하지 않게 되었기 때문입니다.

삼켜지는 범위는, 정확히 말하면 “15건 전부”는 아닙니다. 시작 태그 <script>는 1번째 리뷰 타이틀의 도중(“Good” 직후)에 나타나고, 종료 태그 </script>는 15번째 리뷰 타이틀의 에 있습니다. 따라서,

  • 1번째 카드(아이콘·표시명·날짜·★·타이틀 “Good”까지)는 <script>보다 앞에 있으므로 표시됩니다
  • 거기서부터 15번째 타이틀 끝까지가 script 요소의 내용으로 삼켜집니다
  • 15번째의 나머지(본문 “Nice shirt!”)는 </script>보다 뒤에 있으므로 표시됩니다

결과적으로 1번째의 헤더 부분과 15번째의 본문이 이어져, 화면상에는 “타이틀이 Good이고 본문이 Nice shirt!인, 회원 A의 리뷰 1건”으로 보입니다. 여기에 회원 B의 1건을 더해 2건입니다. 공격자가 1번째의 첫머리에 “Good”, 15번째의 본문에 “Nice shirt!”라는 자연스러운 문자열을 배치한 것은, 표시 붕괴를 부자연스럽게 보이지 않게 하기 위해서일 것입니다.

이 “건수는 맞는데 표시가 맞지 않는다”는 증상을 읽어내는 방식은 실무에서도 도움이 됩니다. 표시 건수 로직에 버그가 있는 것이 아니라, 출력된 HTML의 구조가 파괴되어 있을 가능성을 의심하는 입구가 되기 때문입니다.

4. 왜 “저장형” XSS인가 ── 설문1

설문1에서는 이 공격에 사용된 XSS 취약점의 종류를 DOM Based XSS·저장형 XSS·반사형 XSS 3지선다 중에서 고르게 합니다. 정답은 저장형 XSS입니다.

그리고 이 설문에 대해 IPA의 채점 강평은 다음과 같이 적고 있습니다.

정답률은 평균적이었으나, 스크립트에서 DOM을 사용했기 때문인지 “DOM Based XSS”라고 잘못 답한 수험자가 종종 눈에 띄었다.

문제문에 등장하는 공격 스크립트는 XMLHttpRequest를 사용하고, 응답을 DOM으로 받아 getElementById로 요소를 가져옵니다. 확실히 DOM을 다루고 있습니다. 그러나 그것은 취약점의 종류와는 관계가 없습니다.

갈림길은 “공격 문자열이 어디서 스크립트가 되는가”

3가지 유형을 가르는 것은, 공격 문자열이 어디서 실행 가능한 코드로 바뀌는가, 즉 취약한 출력 지점(싱크)이 서버 측에 있는가 브라우저 측에 있는가입니다.

종류 싱크(공격 문자열이 실행 가능해지는 위치) 공격 문자열은 어디서 오는가
반사형 XSS 서버가 HTML을 조립하는 처리 공격자가 준비한 URL의 파라미터 등, 그 요청 자체
저장형 XSS 서버가 HTML을 조립하는 처리 서버 측에 저장된 데이터
DOM Based XSS 브라우저 상의 JavaScript(innerHTML에의 대입, eval, document.write 등) URL의 프래그먼트, postMessage, 서버로부터 받은 값 등

이 문제에서는 공격자가 게시한 리뷰 타이틀을 서버가 그대로 HTML에 삽입하여 반환하고 있었습니다. 싱크는 서버 측의 출력 처리에 있고, 그 문자열은 데이터베이스에 저장되어 이후 페이지 V를 여는 모든 사람에게 배포됩니다. 따라서 저장형입니다.

브라우저 상의 JS가innerHTML 등에전달했을 때서버가 조립한HTML에 들어 있었다저장되어 있다(이후 전원에게 출력)저장되어 있지 않다(그 요청에만)공격자의 문자열이스크립트로 실행되었다실행 가능해진 것은어디인가DOM Based XSS그 문자열은서버에 저장되어있는가저장형 XSS반사형 XSS

다만 “서버 응답에 공격 문자열이 들어 있으면 저장형/반사형”이라고 단순화하는 것은 위험합니다. 서버가 값을 무해한 텍스트나 JSON으로 반환하더라도, 그것을 브라우저 측 JavaScript가 innerHTML에 대입하면 DOM Based XSS가 됩니다(저장된 값이 원인이 되는, 이른바 stored DOM XSS입니다). 이 경우 고쳐야 할 것은 서버의 출력 처리가 아니라 클라이언트 측의 싱크이므로, 응답에 문자열이 보이는지 여부가 아니라 어디서 실행 가능해졌는지로 판단하십시오.

삽입된 스크립트가 무엇을 하는가(DOM을 다룬다, 통신한다, cookie를 읽는다)와, 그 스크립트가 어떻게 페이지에 들어왔는가는 별개로 생각할 필요가 있습니다. 혼동하기 쉬운 이유는 대책을 고를 때 곤란해서가 아니라, 대책을 잘못 고르게 되기 때문입니다. DOM Based XSS라고 판단하면 “클라이언트 측 JavaScript를 고치면 된다”는 결론이 나오지만, 실제로 고쳐야 했던 것은 서버 측의 출력 처리였습니다.

설문1(2): 대책

설문1(2)는 웹앱 Q에서의 대책을 30자 이내로 답하게 합니다. 해답 예시는 “리뷰 타이틀을 출력하기 전에 이스케이프 처리를 실시한다”입니다.

여기서 “출력하기 전에”라는 순서가 명시되어 있다는 점이 중요합니다. 입력을 받았을 때 이스케이프하는 것이 아니라, HTML로 출력하기 직전에 출력 대상 문맥에 맞춰 이스케이프합니다. IPA 「안전한 웹사이트 만드는 법」에서도 XSS의 근본적 해결 가운데 첫 번째로 “웹페이지에 출력하는 모든 요소에 대해 이스케이프 처리를 실시한다”가 제시되어 있습니다5.

“SQL 인젝션에서는 입력 이스케이프가 중요하지 않나요?”

여기서 반드시 나오는 의문입니다. 결론부터 말하면, SQL 인젝션에서도 “입력 시 이스케이프”는 대책이 아닙니다.

IPA가 SQL 인젝션의 근본적 해결로 제시하는 것은 “SQL문의 조립은 모두 플레이스홀더로 구현한다”입니다. 문자열 연결로 SQL문을 조립할 수밖에 없는 경우의 대안으로 이스케이프 처리가 제시되어 있지만, 그것도 “SQL문의 조립을 문자열 연결로 수행하는 경우에는, 이스케이프 처리 등을 수행하는 데이터베이스 엔진의 API를 사용하여 SQL문의 리터럴을 올바르게 구성한다”라고 적혀 있어, 이스케이프하는 시점은 SQL문을 구성하는 순간입니다6. 입력을 받은 시점이 아닙니다.

즉 HTML의 경우와 완전히 같은 구조입니다. 공통되는 원칙은 이렇게 정리됩니다.

이스케이프는 데이터가 어떤 문법의 세계로 나갈지가 정해지는 지점에서 수행한다.

  • HTML로 나간다면 HTML 이스케이프
  • SQL문의 일부가 된다면 플레이스홀더(부득이한 경우 SQL 리터럴로서의 이스케이프)
  • 셸 명령의 일부가 된다면 셸 방식의 처리

왜 입력 시점이면 안 되는가. 입력을 받은 시점에는 그 데이터가 앞으로 어디로 나갈지가 정해져 있지 않기 때문입니다. 같은 리뷰 본문이 HTML 페이지에도, CSV 내보내기에도, JSON API 응답에도, 알림 메일에도, 로그에도 나갑니다. 입력 시점에 HTML 이스케이프를 걸어 버리면 CSV에는 &amp;라는 문자열이 그대로 나오고, 데이터베이스의 내용은 원래 입력과 다른 것이 되어 검색도 집계도 어긋납니다. 이중 이스케이프의 온상이 되기도 합니다.

그렇다면 입력 측은 아무것도 하지 않아도 되는가

아닙니다. 다만 입력 측에서 하는 일은 이스케이프가 아니라 검증(밸리데이션)입니다. 역할이 다릅니다.

  • 입력에서 “걸러낸다” ── 사양상 있을 수 없는 값을 받아들이지 않습니다. 우편번호 입력란이라면 숫자 7자리 이외를 거부하고, 수량 입력란이라면 음수를 거부합니다. 이는 데이터의 정확성을 지키기 위한, 독립적으로 필요한 처리입니다
  • 출력에서 “이스케이프한다” ── 받아들인 데이터를, 나가는 곳의 문법으로 안전하게 표현합니다. 이것이 취약점의 근본적 해결입니다

그리고 입력 측 검증에 XSS 대책을 지나치게 기대하지 않는 것도 중요합니다. IPA는 XSS에 대해, 입력값이 애플리케이션의 사양에 부합하는지 확인하는 방법을 언급한 뒤, 이 대책의 유효성은 제한적이며, 애플리케이션이 요구하는 사양이 폭넓은 문자 종류의 입력을 허용하는 경우에는 대책이 되지 않으므로 이 방법에 의존하는 것은 권장되지 않는다고 명시하고 있습니다5.

이번 리뷰 타이틀과 리뷰 상세는 그야말로 자유 기술로 폭넓은 문자 종류를 허용하는 사양이었습니다. 50자, 300자라는 상한은 있지만, 스크립트를 동작시키는 데 필요한 문자 수는 그보다 훨씬 적으므로 상한이 있다는 사실 자체는 방어가 되지 않습니다. 실제로 Q사에서 무슨 일이 일어났는지는 다음 설문2에서 살펴봅니다.

5. 50자 제한은 어떻게 깨졌는가 ── 설문2

설문2는 이 문제의 백미입니다.

그림3에 대해, 입력 문자 수 제한을 초과하는 길이의 스크립트가 실행되도록 만든 방법을 50자 이내로 답하라.

해답 예시는 “HTML이 주석 처리되어 하나의 스크립트가 되도록 게시를 여러 차례에 나누어 실행했다“입니다.

공격자는 1건의 게시에 들어갈 길이로 분할하여 15회 게시했습니다. 핵심은 게시와 게시 사이에 반드시 끼어드는 HTML(</div><div class="..."> 등)을 어떻게 처리하느냐입니다. 이들은 JavaScript로서는 구문 오류가 됩니다.

여기서 사용된 것이 JavaScript의 블록 주석입니다. 다음은 구조를 설명하기 위해 당사에서 작성한 간략판입니다(문제 책자의 그림을 그대로 인용한 것이 아닙니다).

3회로 나누어 리뷰 타이틀란에 다음과 같이 게시했다고 합시다.

1번째 게시: 훌륭해요<script>a=1;/*
2번째 게시: */b=2;/*
3번째 게시: */c=3;</script>

서버는 이것을 각각의 리뷰 타이틀로 삼아 다음과 같이 HTML로 출력합니다.

<div class="review-title">훌륭해요<script>a=1;/*</div>
<div class="description">…</div>
<div class="review-title">*/b=2;/*</div>
<div class="description">…</div>
<div class="review-title">*/c=3;</script></div>

브라우저 입장에서 보면, 첫 번째 <script>부터 마지막 </script>까지가 하나의 script 요소입니다. 그 내용은 다음과 같습니다.

a=1;/*</div>
<div class="description">…</div>
<div class="review-title">*/b=2;/*</div>
<div class="description">…</div>
<div class="review-title">*/c=3;

/**/ 사이에 낀 HTML은 JavaScript 주석으로 건너뛰어져, 실제로 실행되는 것은 a=1; b=2; c=3;뿐입니다. 그리고 주석으로 건너뛰어진 부분도 script 요소의 내용인 이상 화면에는 표시되지 않습니다. 이것이 “16건이 2건으로 보이는” 증상의 정체입니다.

여기서 가져가야 할 것

여기서 인과관계를 착각하지 마십시오. 입력 문자 수 제한이 XSS 대책이 되지 못하는 이유는 게시 횟수가 무제한이었기 때문이 아닙니다. 애당초 50자만 있어도 스크립트는 실행할 수 있기 때문입니다. 이벤트 핸들러 속성을 하나 덧붙이는 것만으로, 혹은 외부 스크립트를 불러오는 태그를 하나 놓는 것만으로도 모두 수십 자면 충분합니다. 게시를 1건으로 제한했다 하더라도 문자 수 제한이 방어가 되는 일은 없습니다.

그렇다면 이번 분할 게시는 무엇이었는가. 그것은 20행 정도 되는 긴 스크립트를 통째로 들여오기 위한 수단입니다. 공격자가 하고 싶었던 일이 길었기 때문에 횟수로 채운 것일 뿐, 문자 수 제한이 깨진 이유 그 자체는 아닙니다. 수법에 대한 설명과 대책으로서의 평가는 나누어 생각할 필요가 있습니다.

같은 이야기는 그 밖의 “입력 측 제한” 전반에 대해서도 할 수 있습니다.

  • 문자 수 제한, 입력 가능 문자 종류 제한, 프런트엔드 밸리데이션 ── 이들은 사양상 필요한 것일 뿐, XSS의 근본적 해결을 대신하지 못합니다
  • 공격자는 분할·인코딩·다른 경로를 통한 투입 등의 수단으로 입력 측 제한을 우회할 여지를 언제나 갖고 있습니다
  • 지켜야 할 지점은 데이터가 HTML로 나가는 순간입니다

참고로 IPA의 채점 강평은 이 설문에 대해 “‘개발자 도구로 입력 제한을 삭제한 뒤 게시했다’처럼, 확인이 부족하다고 여겨지는 답안이 일부 보였다”라고도 적고 있습니다. 확실히 프런트엔드 제한은 개발자 도구로 해제할 수 있습니다. 그러나 이 설문에서 요구했던 것은 HTML에 남은 흔적으로부터 실제로 무엇이 이루어졌는지를 읽어내는 일입니다. 그림3의 HTML에는 제한 범위 안에 들어가는 길이의 게시가 15건으로 나뉘어 늘어서 있었고, 각각의 앞뒤에 주석 기호가 놓여 있었습니다. 이는 제한을 “해제한” 흔적이 아니라 제한을 “우회한” 흔적입니다. 공격자가 남긴 것을 하나씩 확인하는 자세가 요구되었다고 읽어야 할 것입니다.

입력값을 어디까지 어떻게 검증할 것인가 하는 설계 사고방식은 QR 코드 판독값을 앱에서 어떻게 검증할 것인가에서도, 외부에서 들어오는 데이터를 신뢰하지 않는 설계로서 정리하고 있습니다.

6. 스크립트는 무엇을 했는가 ── 설문3(1)

N씨가 추출한 스크립트는 20행 정도입니다. 설문3(1)은 그중 6행부터 20행까지(1차 통신이 성공한 뒤 실행되는 부분)의 처리 내용을 60자 이내로 답하게 합니다.

해답 예시는 “XHR 응답에서 취득한 토큰과 함께, 아이콘 이미지로 세션ID를 업로드한다“입니다.

다음은 이 동작을 설명하기 위해 당사에서 작성한 등가 코드입니다(문제 책자의 그림을 그대로 인용한 것이 아닙니다).

// ① 먼저 프로필 설정 페이지를 가져온다
const xhr = new XMLHttpRequest();
xhr.open("get", "https://example.jp/user/profile");
xhr.responseType = "document";   // 응답을 텍스트가 아니라 DOM으로 받는다
xhr.send();

xhr.onload = function () {
  // ② 정상 화면과 동일한 절차로 업로드용 토큰을 읽는다
  const token = xhr.response.getElementById("token").value;

  // ③ cookie(세션ID 포함)를 그대로 PNG 파일의 내용으로 만든다
  const file = new File([document.cookie], "a.png", { type: "image/png" });

  // ④ 자사 사이트의 아이콘 이미지 업로드 기능으로 전송한다
  const form = new FormData();
  form.append("uploadfile", file);
  form.append("token", token);

  const xhr2 = new XMLHttpRequest();
  xhr2.open("post", "https://example.jp/user/upload");
  xhr2.send(form);
};

앞부분 5행은 Q사의 CSRF 대책을 통과하기 위해서만 존재한다

이 스크립트에서 주목할 점은, 앞부분 전체가 토큰 취득을 위해 소비되고 있다는 것입니다.

Q사는 아이콘 이미지 업로드 시에 “/user/profile에서 발급된 토큰과 일치할 것”을 요구하고 있었습니다. 이는 다른 사이트에 마련된 가짜 폼으로부터 마음대로 업로드되는 것을 막기 위한 장치, 즉 CSRF 대책입니다.

그러나 공격 스크립트는 피해자의 브라우저 안에서 피해자와 같은 오리진으로 동작합니다. 그렇다면 정상 화면이 하는 일을 그대로 하면 됩니다. 프로필 설정 페이지를 GET하고, 그 안의 token이라는 요소의 값을 읽는다. 그것만으로 토큰을 손에 넣을 수 있습니다.

CSRF 대책 토큰은 XSS를 막지 못합니다. XSS가 성립한 시점에서 공격자의 코드는 “로그인한 정규 이용자”로 행세할 수 있기 때문입니다. 토큰이 지키고 있는 것은 “다른 오리진에서 마음대로 보내오는 요청”이지, “같은 오리진에서 동작하는 악의적인 스크립트”가 아닙니다.

이 구별은 실무에서 보안 요구사항을 읽을 때도 유효합니다. “CSRF 토큰을 넣었으니 괜찮습니다”라는 설명은 CSRF에 대해서는 옳더라도, XSS에 대해서는 아무 말도 하지 않은 것입니다.

File 객체가 갖는 의미

③번 줄도 볼거리입니다. document.cookie의 문자열을 내용으로 삼고, 파일 이름을 a.png, MIME 타입을 image/png로 한 파일 객체를 만들고 있습니다.

내용은 그저 텍스트입니다. PNG 파일이 아닙니다. 그럼에도 업로드가 성공한 것은 문제문에 있는 대로 웹앱 Q가 업로드된 이미지 파일의 형식을 확인하지 않았기 때문입니다.

그리고 document.cookie를 읽을 수 있었던 것은 cookie에 HttpOnly 속성이 부여되어 있지 않았기 때문입니다. RFC 6265는 HttpOnly 속성에 대해 “cookie의 범위를 HTTP 요청으로 한정한다. 특히 cookie를 스크립트에 공개하는 것과 같은 웹 브라우저 API 등 ‘비(非) HTTP’ API를 통해 cookie에 접근하도록 제공할 때, 그 cookie를 제외하도록 사용자 에이전트에 지시한다”라고 규정하고 있습니다7. 이 속성이 붙어 있었다면 ③번 줄이 붙잡는 문자열에서 세션ID만 빠지므로, 반출해도 가치가 없는 것이 되었을 것입니다.

여기서 주의할 점은, HttpOnly가 감추는 것은 그 속성이 붙은 cookie뿐이라는 사실입니다. 같은 오리진에 HttpOnly가 아닌 cookie(표시 설정이나 계측용 ID 등)가 있으면 document.cookie는 그것들을 계속 반환합니다. “HttpOnly를 붙이면 document.cookie가 비게 된다”는 뜻이 아닙니다. 지키고 싶은 것은 세션ID이므로, 세션ID의 cookie에 확실히 붙어 있는지를 개별적으로 확인하십시오.

7. 밖으로 나가지 않는 정보 반출 ── 설문3(2)(3)

설문3(2)는 “공격자는 업로드된 정보를 어떻게 취득할 수 있는가”를 50자 이내로 묻습니다. 해답 예시는 “회원의 아이콘 이미지를 다운로드하여, 거기서 세션ID 문자열을 추출한다“입니다.

떠올려 보십시오. 문제문의 사양에는 이렇게 적혀 있었습니다.

업로드한 아이콘 이미지는 회원 프로필 설정 페이지와 리뷰 페이지에 표시된다.

즉, 피해자의 세션ID가 기록된 “아이콘 이미지”는 사이트 위에서 열람할 수 있는 장소에 놓이는 셈입니다. 공격자는 특별한 일을 할 필요가 없습니다. 피해자의 아이콘 이미지가 표시되는 페이지를 열어, 그 이미지의 URL을 얻기만 하면 됩니다. 내용은 텍스트이므로 열면 세션ID를 읽을 수 있습니다.

보충하자면, 이 회수가 순조롭게 이루어지는 경우는 피해자의 아이콘 이미지가 공격자에게 보이는 페이지(자신이 리뷰를 게시한 상품의 리뷰 페이지 등)에 노출되어 있을 때입니다. 아이콘 URL은 회원마다 정해져 있으므로, 한 번 어딘가에서 보이면 이후로는 그 URL을 직접 가져올 수 있습니다. 어느 쪽이든 공격자 측의 조작은 정규 열람자로서 페이지나 이미지를 GET하는 것뿐이므로, 사이트 입장에서 보면 이상 통신이 되지 않습니다.

피해자의 브라우저웹앱 Q(example.jp)공격자피해자의 브라우저웹앱 Q(example.jp)공격자준비 단계게시된 문자열을그대로 저장(저장 자체는 정상)피해자가 페이지 V를 열람★취약점은 여기★저장된 타이틀을이스케이프 없이 HTML에 삽입script 요소로 실행됨document.cookie를 읽어a.png의 내용으로 만듦형식을 확인하지 않고 저장아이콘 이미지로 공개회수리뷰를 15회로 나누어 게시(주석 기호로 HTML을 건너뛰게 함)1GET 페이지 V2공격 스크립트를 포함한 HTML3GET /user/profile4토큰을 포함한 HTML5POST /user/upload(uploadfile=a.png, token=…)6GET 피해자의 아이콘 이미지7세션ID가 적힌 파일8

출구 대책으로는 막을 수 없다

이 경로의 성가신 점은 그림으로 그려 보면 명확해집니다. 피해자의 브라우저는 처음부터 끝까지 정규 사이트(example.jp)하고만 통신합니다.

그 결과, 통신을 감시하는 종류의 대책은 하나같이 헛스윙이 됩니다. 효과가 있는 것은 마지막 하나뿐입니다.

대책 이 공격에 대해 이유
프록시의 URL 필터링 효과 없음 접속 대상이 업무상 정당한 EC 사이트뿐임
방화벽의 외부 발신 통신 감시 효과 없음 낯선 도메인으로의 통신이 발생하지 않음
Content Security Policy의 connect-src 제한('self' 등 동일 오리진을 허용하는 지정) 효과 없음 2회의 통신 모두 동일 오리진 앞으로 전송되어 정책의 허용 범위 안에 있음
Content Security Policy의 script-src 제한('unsafe-inline'을 허용하지 않는 구성) 효과 있음 삽입된 인라인 스크립트가 애초에 실행되지 않음

XSS에 의한 정보 반출이라고 하면 “공격자의 서버로 cookie가 전송된다”는 그림을 떠올리기 쉽지만, 반출 대상지가 피해자 사이트 자신이 될 수 있다는 사실을 이 문제는 명확히 보여줍니다. 쓰기가 가능하고 동시에 읽기도 가능한 장소가 사이트 안에 있으면, 그곳이 전달 장소가 됩니다. 파일 업로드 기능, 공개 프로필란, 공개 메모란 등이 전형적입니다.

CSP에 대해 보충하면, 실무에서 자주 쓰이는 지정 가운데 이 공격을 확실히 막을 수 있는 것은 connect-src(통신 대상 제한)가 아니라 script-src입니다. script-src 'self'를 지정하고 'unsafe-inline'을 허용하지 않는 구성이라면, 리뷰에 삽입된 인라인 <script>는 애초에 실행되지 않습니다. CSP를 “외부로의 통신을 제한하는 것”으로만 이해하고 있으면 이 차이를 놓치게 됩니다.

connect-src 쪽도 원리적으로 무력한 것은 아닙니다. connect-src 'none'처럼 동일 오리진으로의 통신까지 금지하는 지정이나, 통신 대상을 특정 엔드포인트만으로 좁힌 허용 목록이라면 이 2회의 XHR도 막을 수 있습니다. 다만 거기까지 좁힐 수 있는 사이트는 한정적이므로, 현실적인 대비책으로는 script-src가 본명이 됩니다. “동일 오리진이라서 CSP의 대상 밖”인 것이 아니라, “동일 오리진을 허용하는 일반적인 지정으로는 통과되어 버린다”는 이해가 정확합니다.

설문3(3): 세션ID로 무엇을 할 수 있는가

설문3(3)은 취득한 정보로 무엇을 할 수 있는지를 40자 이내로 묻습니다. 해답 예시는 “페이지 V에 접속한 회원으로 위장하여 웹앱 Q의 기능을 사용한다“입니다.

채점 강평에 따르면 이 설문의 정답률은 높았다고 합니다. EC 사이트에서 cookie를 공격자에게 탈취당했을 때의 영향은 잘 이해되고 있었다고 적혀 있습니다.

그리고 문제문의 사양을 다시 읽어 보면, 회원 프로필 기능에는 신용카드 정보를 등록하는 페이지가 있고, 로그인한 회원만 이용할 수 있다고 명기되어 있었습니다. 위장 뒤에 무엇이 있는지를, 문제문은 처음부터 준비해 두었던 셈입니다.

8. 왜 공격자의 사이트에서는 성립하지 않는가 ── 설문4

설문4는 이 대문항 중에서 가장 본질적인 질문입니다.

가령 공격자가 준비한 도메인의 사이트에 그림4와 같은 스크립트를 포함한 HTML을 준비하고, 그 사이트에 웹앱 Q에 로그인한 회원이 접속하더라도, 웹 브라우저의 구조에 의해 공격은 성공하지 않는다. 이 구조를 40자 이내로 답하라.

해답 예시는 “스크립트에서 다른 도메인의 URL로는 cookie가 전송되지 않는 구조“입니다.

같은 스크립트를 evil.example에 두고 Q사의 회원이 밟게 했다고 합시다. 무슨 일이 일어날까요.

먼저 1차 통신이 헛스윙이 됩니다. evil.example 위의 스크립트에서 https://example.jp/user/profile로 XMLHttpRequest를 보내더라도, 이는 크로스 오리진 요청입니다.

결정적인 것은 cookie가 붙지 않는다는 점입니다. 그림4의 스크립트는 withCredentials를 지정하지 않았으므로, 크로스 오리진 요청에는 cookie가 첨부되지 않습니다. Q사의 서버 입장에서는 로그인하지 않은 접속이 되어, 토큰을 포함한 프로필 설정 페이지는 반환되지 않습니다. 스크립트는 토큰을 취득하지 못하고 거기서 멈춥니다.

또 하나, 응답을 읽을 수 없다는 벽도 있습니다. Q사가 Access-Control-Allow-Origin으로 evil.example을 허용하지 않았다면, 브라우저는 CORS 확인에 실패하여 이 통신을 네트워크 오류로 취급하고, onload는 발화하지 않아 xhr.response에 도달할 수 없습니다. 보통 사이트는 낯선 오리진에 이 헤더를 반환하지 않으므로, 실제로는 여기서도 멈출 것입니다.

다만 Q사가 CORS를 어떻게 설정했는지는 문제문에 적혀 있지 않습니다. 가령 evil.example로부터의 접속을 허용했다 하더라도, 읽을 수 있는 것은 로그인하지 않은 상태의 응답이며 토큰은 들어 있지 않으므로 역시 공격은 성립하지 않습니다. cookie가 붙지 않는다는 첫 번째 이유만으로도 공격은 멈춰 있습니다.

둘 중 어느 한쪽만으로도 스크립트는 멈추지만, 실제로는 양쪽 모두 작용하고 있습니다.

더욱이 cookie 자체도 읽을 수 없습니다. document.cookie가 반환하는 것은 그 스크립트가 동작하는 오리진(evil.example)의 cookie입니다. example.jp에 대해 발급된 세션ID는 거기에 들어 있지 않습니다.

IPA의 해답 예시는 전자, 즉 “다른 도메인의 URL로는 cookie가 전송되지 않는다”는 것을 답으로 삼고 있습니다.

여기서 이 두 가지를 같은 구조로 기억하지 마십시오. 서로 다른 구조입니다.

  • document.cookieexample.jp의 cookie를 반환하지 않는 것은 cookie가 도메인별로 분리되어 관리되기 때문입니다. evil.exampleexample.jp처럼 서로 다른 사이트(서로 다른 등록 가능 도메인)끼리는 이 분리를 설정으로 완화할 수 없습니다. 다만 같은 도메인 아래의 서브도메인 사이는 별개로, cookie의 Domain 속성을 사용하면 sub.example.jpexample.jp 사이에서 공유할 수 있습니다7
  • 크로스 오리진 XHR에 cookie가 붙지 않는 것은 withCredentials가 기본값으로 false이기 때문입니다. 이쪽은 조건이 갖춰지면 달라집니다. 스크립트가 withCredentials = true를 지정하고, 또한 cookie의 SameSite 속성이 크로스 사이트 전송을 허용하며, 또한 서버가 송신원 오리진을 명시한 Access-Control-Allow-OriginAccess-Control-Allow-Credentials: true를 반환하고 있다면, cookie는 전송되고 응답도 읽을 수 있게 됩니다

여기서 실제로 실패한 이유만약 고쳐 썼다면 무엇이 필요한지를 나누어 파악하십시오.

문제문과 그림4로부터 확실히 말할 수 있는 것은 하나입니다. withCredentials를 지정하지 않았으므로 cookie가 첨부되지 않고, 로그인하지 않은 것으로 취급되어 토큰을 얻지 못합니다. 이것만으로 공격은 멈춥니다.

CORS 허용이 없다는 점도 응답을 읽지 못하게 하는 벽이 되지만, Q사의 설정은 문제문에 적혀 있지 않으므로 이쪽은 “보통 사이트라면 그렇게 되어 있다”는 조건부 보강으로 여겨 주십시오.

그렇다면 공격자가 withCredentials = true로 고쳐 쓰면 어떻게 될까요. 여기서 비로소 또 하나의 조건이 문제가 됩니다. cookie의 SameSite가 크로스 사이트 전송을 허용하지 않는다면, withCredentials를 세워도 그 cookie는 역시 첨부되지 않습니다. 지금의 브라우저는 SameSite가 지정되지 않은 cookie를 Lax로 취급하므로, 기본값으로는 첨부되지 않는 쪽으로 기웁니다.

참고로 Q사의 세션ID cookie에 SameSite가 어떻게 설정되어 있었는지는 문제문에 적혀 있지 않습니다. 그러므로 “SameSite 덕분에 지켜지고 있었다”라고는 말할 수 없습니다. 어디까지나 credentials를 동반하여 공격해 오는 상대에 대해서는 SameSite가 효과를 발휘할 수 있다는 이야기입니다.

정리하면, 다른 도메인에서 인증된 응답을 읽으려면 withCredentials = true·cookie의 SameSite가 크로스 사이트 전송을 허용할 것·서버가 credentials를 동반한 CORS로 송신원 오리진을 허용할 것, 이 3가지가 갖춰져야 합니다. 도메인이 다르면 반드시 안전하다는 이야기는 아니지만, 조건이 하나 갖춰졌다고 바로 뚫리는 이야기도 아닙니다. 자사 사이트를 점검할 때는 cookie의 도메인 분리에 안심할 것이 아니라, CORS 설정(특히 credentials를 동반한 요청을 어느 오리진에 허용하고 있는지)과 cookie의 SameSite 속성을 실제로 확인할 필요가 있습니다.

그래서 저장형 XSS의 가치가 높다

이 설문이 알려주는 것은 공격자가 “피해자 사이트 안에서 자신의 코드를 동작시키는” 것에 집착하는 이유입니다.

겉모습이 똑같은 가짜 사이트를 자신의 도메인에 준비하더라도, 브라우저는 그것을 다른 사이트로 취급합니다. document.cookie로 상대 사이트의 cookie는 읽을 수 없고, 이 설문의 스크립트처럼 자격 증명 없이 보내는 XHR로는 상대 사이트 입장에서 로그인 상태조차 되지 못합니다. 공격자에게 있어 가치는 코드가 피해자 사이트의 오리진에서 동작하는 것 그 자체에 있습니다. 저장형 XSS는 바로 그것을 실현하는 수단입니다.

다만 “다른 도메인에서는 인증을 동반한 요청을 일절 보낼 수 없다”라고 읽지는 마십시오. cookie의 SameSite가 크로스 사이트 전송을 허용하는 설정(SameSite=None 등)이라면, 악의적인 페이지에서 cookie를 동반한 요청을 상대 서버로 보내는 것 자체는 가능합니다. 폼의 POST 등이 그 전형이며, CORS가 제어하는 것은 주로 “스크립트가 응답을 읽을 수 있는가”이지, 요청이 서버에 도달하는가가 아닙니다(다만 커스텀 헤더를 붙이는 경우나 application/json으로 보내는 경우 등은 프리플라이트가 선행하므로, 허용되지 않으면 본 요청 자체가 전송되지 않습니다). 이것이 CSRF라는 공격이 성립하는 이유이며, 그렇기 때문에 CSRF 대책 토큰이 필요해집니다.

즉, 다른 오리진의 스크립트가 기본값으로 할 수 없는 것은 “상대의 cookie를 읽는” 일과 “응답을 읽는” 일이지, “요청을 보내는” 일이 아닙니다.

게다가 이 2가지의 “읽을 수 없음”은 강도가 다릅니다. document.cookie의 도메인 분리는 서버 측 설정으로 완화할 수 없지만, 응답을 읽을 수 있는지 여부는 상대 서버의 CORS 설정에 달려 있습니다. 서버가 송신원 오리진을 허용하고 있다면(자격 증명을 동반하는 경우 Access-Control-Allow-Origin에 구체적인 오리진을 반환하고, 아울러 Access-Control-Allow-Credentials: true를 반환하고 있다면), 다른 오리진의 스크립트라도 응답을 읽을 수 있습니다. 자사 사이트의 CORS 설정을 확인해야 하는 이유가 여기에 있습니다.

XSS가 특별한 이유는 이러한 조건들을 모두 뛰어넘어, 처음부터 피해자 사이트의 오리진으로서 행세할 수 있다는 점에 있습니다.

바꾸어 말하면, XSS는 “이상한 문자가 화면에 나타나 버리는 결함”이 아니라, 공격자를 자사 사이트의 정규 이용자와 같은 입장에 세워 버리는 결함입니다. 이 인식의 차이가 우선순위 판단을 가릅니다.

9. 효과가 있었던 대책과 효과가 없었던 대책

여기까지의 내용을 표 하나로 정리합니다. 이 글에서 가장 가져가시길 바라는 부분입니다.

Q사에 있었던/없었던 구조 이 공격에 대해 이유
HTTPS 통신 효과 없었음 통신 경로 보호이며, 페이지에 삽입된 스크립트와는 무관
리뷰 타이틀 50자·상세 300자의 입력 문자 수 제한 효과 없었음 15회로 분할하여 게시되었고, 사이의 HTML은 JavaScript 주석으로 건너뛰어짐
업로드용 토큰(CSRF 대책) 효과 없었음 동일 오리진의 스크립트는 정규 절차로 토큰을 취득할 수 있음
로그인 필수의 업로드 기능 효과 없었음 실행하는 주체는 로그인한 피해자 본인의 브라우저
출력 시 이스케이프(없었음) 효과 있음(근본적 해결) 게시된 문자열이 HTML로 해석되지 않게 되어, 애초에 스크립트가 동작하지 않음
cookie의 HttpOnly 속성(없었음) 효과 있음(보험적 대책) document.cookie로 세션ID를 읽을 수 없게 됨
업로드 파일의 형식 확인·재인코딩(없었음) 효과 있음(보험적 대책) 원시 텍스트는 PNG로 저장할 수 없고, 메타데이터에 숨겨진 문자열도 재인코딩으로 사라짐. 다만 이미지의 픽셀로 부호화되면 남음

위 4가지는 이 공격에 대해 아무런 작용도 하지 못했습니다. 아래 3가지를 IPA 「안전한 웹사이트 만드는 법」의 구분에 대입하면, 첫 번째가 근본적 해결, 나머지 2가지가 보험적 대책입니다5.

한 가지 보충하면, 위 4가지가 “IPA가 대책으로 인정하지 않는 것”이라는 의미는 아닙니다. IPA의 구분은 근본적 해결과 보험적 대책 2가지이며, 입력값의 내용 확인은 오히려 보험적 대책으로 제시되어 있습니다. 다만 거기에는 “이 대책이 유효한 것은 제한적입니다”라는 단서가 붙어 있으며, 이번이 바로 그 제한에 해당했다는 뜻입니다.

그리고 중요한 것은, 아래 3가지 중 어느 하나라도 있었다면, 이 문제에서 실행된 공격의 연쇄는 끊어졌을 것이라는 점입니다.

  • 이스케이프했다면 애초에 스크립트가 실행되지 않습니다
  • HttpOnly가 붙어 있었다면 스크립트는 실행되지만 cookie를 읽을 수 없습니다
  • 형식을 확인했다면 cookie는 읽을 수 있지만, 원시 텍스트를 PNG로 업로드할 수 없습니다

다층 방어가 의미를 갖는 것은 바로 이런 상황입니다. 다만 보험적 대책은 근본적 해결을 대신하지 못합니다. HttpOnly를 붙였더라도 XSS가 남아 있으면 공격자는 피해자의 브라우저에서 임의의 조작(상품 구매, 등록 정보 변경 등)을 실행할 수 있습니다. 세션ID를 훔칠 필요조차 없는 공격은 얼마든지 설계할 수 있습니다.

왜 “형식 확인”뿐 아니라 “재인코딩”까지 적었는가

표의 마지막 행을 “형식 확인·재인코딩”이라고 2단계로 적은 데는 이유가 있습니다.

문제문의 Q사는 이미지 파일의 형식을 전혀 확인하지 않았으므로, 형식 확인을 넣는 것만으로도 이 공격은 멈춥니다. 그러나 그것은 공격자가 원시 텍스트를 보내온 경우에 한정된 이야기입니다. 공격자는 정상적인 PNG 파일을 조립하고, 그 메타데이터 영역 등에 문자열을 숨겨 넣을 수도 있습니다. 이 경우 “형식이 올바른가”의 확인은 통과되어 버립니다.

따라서 이 경로를 좁히려면, 업로드된 이미지를 서버 측에서 재인코딩하여 배포하는(원래의 바이트열을 그대로 저장하지 않는) 단계까지 해야 합니다.

다만 재인코딩으로도 완전히 막을 수 있는 것은 아닙니다. 공격자가 세션ID를 이미지의 픽셀 자체로 그려 넣어 정상적인 PNG를 만든 경우, 일반적인 재인코딩은 그 겉보기 정보를 유지하므로 공격자는 다운로드하여 읽어낼 수 있습니다. 재인코딩이 막는 것은 원시 바이트열을 보내는 변종과 메타데이터 영역에 숨기는 변종이지, 이미지의 내용으로 부호화된 정보가 아닙니다.

즉 “쓸 수 있고 동시에 읽어낼 수 있는 장소”가 사이트 안에 있는 한, 그곳을 반출 경로로 완전히 막을 수는 없습니다. 이것은 보험적 대책의 성질을 잘 보여줍니다. 보험적 대책은 “지금 관측된 공격”을 막는 것만으로는 부족하며, 공격자가 수법을 조금 바꿨을 때도 성립하는지까지 살펴서 설계할 필요가 있습니다. 그리고 아무리 겹쳐도 근본적 해결인 출력 시 이스케이프를 대신하지는 못합니다.

참고로, 업로드된 파일을 본체와 다른 도메인에서 배포한다는 설계도 흔히 권장되지만, 이는 이 반출 경로에 대한 대책이 아닙니다. 공격자는 다른 도메인에서도 마찬가지로 이미지를 다운로드할 수 있고, 동일 오리진 정책은 공개되어 있는 바이트열을 비밀로 만드는 장치가 아니기 때문입니다. 다른 도메인 배포가 효과를 발휘하는 대상은, 업로드된 파일 자체가 능동적인 콘텐츠로서 피해자 사이트의 오리진에서 실행되어 버리는 위협(HTML이나 SVG가 업로드되는 유형)입니다. 다른 위협에 대한 대책으로서 구분하여 이해하십시오.

파일을 송신 측의 신고(확장자나 Content-Type)가 아니라 내용으로 판단한다는 사고방식 자체는 웹에 국한되지 않고 필요한 기본 동작입니다. 다만 “내용으로 판단한다”의 내용은 형식에 따라 다릅니다. PNG처럼 선두 바이트열에 정해진 배열을 갖는 형식이라면 그것을 확인할 수 있습니다. 한편 CSV에는 표준화된 서명이 없고, 선두의 BOM은 문자 인코딩을 나타낼 뿐 내용이 CSV라는 증거는 되지 못합니다. CSV 파일 다루기에서 다룬 것처럼, CSV의 경우에는 상정하는 방언으로 파싱이 되는지, 기대하는 스키마와 건수 상한에 들어맞는지를 확인하게 됩니다.

10. 자신의 코드에서 확인할 관점

이 대문항을 코드 리뷰 체크리스트로 정리하면 다음과 같습니다. 회원 기능이나 게시 기능을 가진 웹 애플리케이션이라면 그대로 사용할 수 있습니다.

  1. 출력 시 이스케이프가 모든 출력 지점에서 작동하고 있는가. 템플릿 엔진의 자동 이스케이프를 명시적으로 해제한 곳(raw, | safe, dangerouslySetInnerHTML 등)을 전부 찾아내어, 각각에 대해 해제해도 되는 이유를 설명할 수 있는지 확인한다
  2. 입력 측 제한을 XSS 대책으로 치고 있지는 않은가. 문자 수 제한이나 입력 가능 문자 종류 제한은 사양상의 요건일 뿐, XSS의 근본적 해결이 아니다
  3. 입력 시점에 이스케이프하고 있지는 않은가. 입력의 역할은 사양 외 값을 걸러내는 검증이지 이스케이프가 아니다. 입력 시점에는 출력 대상(HTML·CSV·JSON·메일·로그)이 정해져 있지 않으므로, 입력 시 이스케이프는 이중 이스케이프와 데이터 파괴를 초래한다. SQL문 조립도 마찬가지로, 근본적 해결은 플레이스홀더다
  4. cookie에 HttpOnly, Secure, SameSite가 붙어 있는가. 세션ID의 cookie는 특히 확인한다
  5. 업로드된 파일의 형식을 확장자나 Content-Type이 아니라 내용으로 확인하고 있는가. 송신 측이 신고한 정보는 검증의 근거가 되지 못한다. 다만 형식 확인만으로는 정상적인 이미지의 메타데이터 영역에 데이터를 숨기는 수법은 통과되어 버린다
  6. 업로드된 이미지를 재인코딩하여 배포하고 있는가. 원래의 바이트열을 그대로 반환하고 있지는 않은가. 다만 재인코딩으로도 픽셀로 부호화된 정보는 남으므로, “쓸 수 있고 읽어낼 수 있는 장소”는 완전히는 막을 수 없다는 점을 이해해 둔다. 참고로 다른 도메인으로부터의 배포는 업로드된 파일이 자사 사이트의 오리진에서 실행되는 위협에 대한 대책이지, 이 반출 경로에 대한 대책이 아니다
  7. CSP로 인라인 스크립트를 막을 수 있는 구성으로 되어 있는가. 판단 기준은 “'unsafe-inline'이 쓰여 있는가”가 아니라 “인라인 스크립트가 실제로 허용되어 있는가“이다. script-src에 nonce나 hash 지정이 있으면 CSP Level 2 이후의 브라우저는 'unsafe-inline'을 무시하므로, 'unsafe-inline'과 nonce를 병기하는 하위 호환 정책에서는 nonce가 없는 주입 스크립트는 차단된다
  8. 토큰이 “무엇을” 지키고 있는지 설명할 수 있는가. CSRF 대책 토큰은 XSS를 막지 못한다. 각각 별도의 대책이 필요하다

특히 첫 번째는 실제 조사에서 발견되는 전형적인 패턴입니다. 프레임워크가 자동으로 이스케이프해 주니 안전하다고 생각했지만, “HTML을 그대로 넣고 싶다”는 개별 요건 때문에 한 곳만 자동 이스케이프를 해제해 두었던 경우는 드물지 않습니다.

마치며 ── 이 문제가 묻고 있는 진짜 능력

여기까지 살펴본 대책 목록은 첫머리의 “먼저 결론”에 정리해 두었습니다. 마지막으로 이 문제 자체에 대해 한 가지만 적어 둡니다.

시험 문제로서 잘 만들어진 점은, “XSS를 아는가”가 아니라 “손에 쥐고 있는 대책들 가운데 어느 것이 실제로 효과가 있는지 가려낼 수 있는가“를 묻고 있다는 점입니다.

Q사는 아무것도 하지 않은 것이 아닙니다. HTTPS로 통신하고, 입력에 문자 수 제한을 두고, 업로드에는 토큰을 요구하고, 기능은 로그인한 회원으로 한정하고 있었습니다. 나열해 보면 나름대로 대책이 갖춰진 구조로 보입니다. 그럼에도 전부 뚫렸습니다. 반대로 빠져 있던 3가지는 하나같이 수수해서, 릴리스 노트에 적을 만한 기능이 아닙니다.

보안 논의가 어려운 이유는 대책의 수와 방어의 강도가 비례하지 않기 때문입니다. “무엇을 하고 있는가”가 아니라 “그 대책은 어떤 공격의 어느 단계를 막는가”를 하나씩 말할 수 있는가. 이것은 자격시험만을 위한 능력이 아니라, 보안 대책에 대한 설명을 듣는 쪽에게도 구현하는 쪽에게도 가장 필요한 판단력입니다.

관련 상담 영역

합동회사 코무라소프트는 회원 기능이나 게시 기능을 가진 웹사이트 제작과, 기존 웹 애플리케이션에 같은 구멍이 나 있지 않은지에 대한 설계 리뷰를 다루고 있습니다.

참고 링크

  1. IPA 독립행정법인 정보처리추진기구, 문제 책자·배점 비율·해답 예시·채점 강평(2023년도, 레이와 5년도) 수록 「2023년 가을(레이와 5년) 정보처리안전확보지원사 시험 오후 문제」. Q사의 웹앱 Q 기능(회원 등록, 로그인과 cookie를 통한 세션ID 발급, 상품 리뷰 기능의 리뷰 타이틀 50자·리뷰 상세 300자의 입력 문자 수 제한, 회원 프로필 기능의 아이콘 이미지 업로드와 신용카드 정보 등록, 아이콘 이미지 업로드가 이미지 파일과 토큰을 파라미터로 하는 것, 업로드한 아이콘 이미지가 회원 프로필 설정 페이지와 리뷰 페이지에 표시되는 것), 페이지 V에서 16건의 리뷰가 2건밖에 표시되지 않는 현상, 페이지 V의 HTML에 회원 A에 의한 15건의 게시와 긴 스크립트가 포함되어 있었던 것, 추출된 스크립트의 내용, 그리고 웹앱 Q에 회원이 입력한 스크립트가 실행되어 버리는 취약점이 있었던 것, cookie에 HttpOnly 속성을 부여하지 않았던 것, 업로드된 이미지 파일의 형식을 확인하지 않았던 것에 대하여. 설문1부터 설문4까지의 설문 문구도 이 책자에 의한다.  2

  2. IPA 독립행정법인 정보처리추진기구, 시험에 관한 자주 묻는 질문. 당 기구가 공표하고 있는 과거 시험 문제의 사용에 관해, 법령에 특별한 규정이 있는 경우를 제외하고는 허락이나 사용료가 필요 없다는 것, 다만 저작권을 포기한 것은 아니라는 것, 출전을 “연도, 기, 시험 구분, 시간 구분, 문제 번호 등”의 형식으로 명기할 필요가 있다는 것(예로 “출전: 2019년 봄(헤이세이 31년) 기본정보기술자 시험 오전 문제1”이 제시되어 있다), 그리고 문제의 일부를 변경한 경우에는 그 사실도 명기할 필요가 있다는 것에 대하여. 

  3. IPA 독립행정법인 정보처리추진기구, 2023년 가을(레이와 5년) 정보처리안전확보지원사 시험 해답 예시. 문제1의 출제 취지(웹 애플리케이션 프로그램의 취약점이 악용된 데 따른 인시던트 대응을 소재로, HTML과 ECMAScript로부터 악용된 취약점과 문제점을 읽어내고 대책을 입안하는 능력을 묻는다), 그리고 각 설문의 해답 예시(설문1(1)은 “이. 저장형 XSS”, 설문1(2)는 “리뷰 타이틀을 출력하기 전에 이스케이프 처리를 실시한다.”, 설문2는 “HTML이 주석 처리되어 하나의 스크립트가 되도록 게시를 여러 차례에 나누어 실행했다.”, 설문3(1)은 “XHR 응답에서 취득한 토큰과 함께, 아이콘 이미지로 세션ID를 업로드한다.”, 설문3(2)는 “회원의 아이콘 이미지를 다운로드하여, 거기서 세션ID 문자열을 추출한다.”, 설문3(3)은 “페이지 V에 접속한 회원으로 위장하여 웹앱 Q의 기능을 사용한다.”, 설문4는 “스크립트에서 다른 도메인의 URL로는 cookie가 전송되지 않는 구조”)에 대하여.  2

  4. IPA 독립행정법인 정보처리추진기구, 2023년 가을(레이와 5년) 정보처리안전확보지원사 시험 채점 강평. 문제1 전체의 정답률이 평균적이었다는 것, 설문1(1)에 대해 “스크립트에서 DOM을 사용했기 때문인지 ‘DOM Based XSS’라고 잘못 답한 수험자가 종종 눈에 띄었다”는 것, 취약점은 특징과 대책 방법까지 포함하여 정확히 이해해 주었으면 한다는 지적, 설문2에 대해 “‘개발자 도구로 입력 제한을 삭제한 뒤 게시했다’처럼 확인이 부족하다고 여겨지는 답안이 일부 보였다”는 것, 공격자가 남긴 흔적을 주의 깊게 확인하여 공격 방법을 정확히 파악하는 능력을 길러 주었으면 한다는 지적, 그리고 설문3(3)의 정답률이 높아 EC 사이트에서 cookie를 공격자에게 탈취당했을 때의 영향이 잘 이해되고 있었다는 것에 대하여.  2

  5. IPA 독립행정법인 정보처리추진기구, 안전한 웹사이트 만드는 법. 웹사이트의 취약점 11종류에 대해 위협과 대책을 “근본적 해결”(취약점의 원인 자체를 제거하는 구현)과 “보험적 대책”(취약점이 남아 있는 경우 공격 성공률이나 피해를 낮추는 대책)으로 나누어 제시하고 있다는 것, 크로스사이트 스크립팅의 근본적 해결로서 웹페이지에 출력하는 모든 요소에 대해 이스케이프 처리를 실시하는 것이 제시되어 있다는 것, 그리고 부속된 보안 구현 체크리스트와 별책 「안전한 SQL 호출 방법」, 「웹 건강진단 사양」에 대하여.  2 3

  6. IPA 독립행정법인 정보처리추진기구, 안전한 웹사이트 만드는 법 - 1.1 SQL 인젝션. SQL 인젝션의 근본적 해결로서 “SQL문의 조립은 모두 플레이스홀더로 구현한다”가 제시되어 있고, 정적 플레이스홀더(프리페어드 스테이트먼트) 쪽이 원리적으로 취약점이 발생할 수 없다고 여겨진다는 것, 문자열 연결로 SQL문을 조립하는 경우의 구현으로서 “SQL문의 조립을 문자열 연결로 수행하는 경우에는, 이스케이프 처리 등을 수행하는 데이터베이스 엔진의 API를 사용하여 SQL문의 리터럴을 올바르게 구성한다”라고 적혀 있어, 이스케이프가 SQL문을 구성하는 리터럴의 생성에 대해 이루어지는 것(입력을 받은 시점이 아니라는 것), 아울러 근본적 해결로서 “웹 애플리케이션에 전달되는 파라미터에 SQL문을 직접 지정하지 않는다”가, 보험적 대책으로서 “오류 메시지를 그대로 브라우저에 표시하지 않는다”, “데이터베이스 계정에 적절한 권한을 부여한다”가 제시되어 있다는 것에 대하여. 

  7. IETF, RFC 6265: HTTP State Management Mechanism, Section 4.1.2.6 “The HttpOnly Attribute”. HttpOnly 속성이 cookie의 범위를 HTTP 요청으로 한정한다는 것, 특히 cookie를 스크립트에 공개하는 것과 같은 웹 브라우저 API 등 ‘비(非) HTTP’ API를 통해 cookie에 접근하도록 제공할 때 그 cookie를 제외하도록 사용자 에이전트에 지시한다는 것에 대하여. 아울러 Secure 속성(Section 4.1.2.5)이 보안 채널을 통해서만 cookie를 전송하도록 제한하는 것이라는 점에 대하여.  2

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

웹사이트 제작

회원 기능이나 게시 기능을 가진 웹사이트에서는 출력 시 이스케이프나 cookie 속성 설정과 같은 이 글의 논점이 그대로 제작 품질로 직결되기 때문입니다.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

이 문제의 XSS는 왜 '저장형'인가요? 스크립트가 DOM을 조작하고 있는데 DOM Based XSS가 아닌가요?
취약점의 종류는 공격자의 문자열이 '어디서' 실행 가능한 코드로 바뀌는가, 즉 취약한 출력 지점(싱크)이 서버 측에 있는가 브라우저 측에 있는가로 결정됩니다. 이 문제에서는 공격자가 게시한 리뷰 타이틀을 서버가 그대로 HTML에 삽입하여 반환하고 있었습니다. 싱크는 서버의 출력 처리에 있고, 그 문자열은 저장되어 이후 페이지를 여는 모든 사람에게 배포되므로 저장형 XSS입니다. DOM Based XSS는 브라우저 상의 JavaScript가 innerHTML에의 대입 등을 통해 값을 실행 가능하게 만들어 버리는 유형을 가리킵니다. 삽입된 스크립트가 XMLHttpRequest나 getElementById 같은 DOM API를 사용하는지 여부는 취약점의 종류와는 관계가 없습니다. 참고로, 서버가 저장된 값을 무해한 텍스트나 JSON으로 반환하더라도, 그것을 브라우저 측 JavaScript가 innerHTML에 전달하면 DOM Based XSS가 됩니다. 응답에 문자열이 보이는지 여부가 아니라 어디서 실행 가능해졌는지로 판단하십시오. IPA의 채점 강평에서도 스크립트에서 DOM을 사용했기 때문인지 DOM Based XSS라고 잘못 답한 수험자가 종종 눈에 띄었다고 지적하고 있습니다.
리뷰 타이틀에 50자의 입력 문자 수 제한이 있었는데, 왜 긴 스크립트를 실행할 수 있었나요?
공격자는 1회 게시에 들어가는 길이로 나누어 여러 차례에 걸쳐 게시했습니다. 1번째 게시에서는 타이틀 도중에 script 태그를 열고, 타이틀 끝에서 JavaScript의 블록 주석(슬래시와 별표)을 열어 둡니다. 2번째 게시의 첫머리에서 그 주석을 닫은 뒤 다음 명령을 쓰고, 끝에서 다시 주석을 여는 방식을 반복합니다. 이렇게 하면 게시와 게시 사이에 있는 HTML(div 태그 등)은 모두 JavaScript 주석 안에 들어가므로 무시되고, 분할된 조각들만 이어져 하나의 스크립트로 실행됩니다. 입력 문자 수 제한은 게시 1건당 길이만 제한할 뿐, 게시 횟수는 제한하지 않았다는 뜻입니다.
훔친 세션ID는 어디로 전송되었나요?
외부 서버로는 전송되지 않았습니다. 스크립트는 cookie의 내용을 그대로 파일의 내용으로 만들어, 이름을 'a.png', MIME 타입을 'image/png'로 하여 피해자가 이용하는 EC 사이트 자신의 회원 아이콘 이미지 업로드 기능으로 전송했습니다. 업로드된 아이콘 이미지는 리뷰 페이지 등에 표시되므로, 공격자는 그 이미지를 평범하게 다운로드하여 내용의 문자열에서 세션ID를 추출할 수 있습니다. 피해자의 브라우저는 정규 사이트하고만 통신하며 외부로의 수상한 통신이 발생하지 않으므로, 프록시의 URL 필터링이나 방화벽의 외부 발신 통신 감시로는 알아챌 수 없는 경로가 되어 있습니다.
업로드에는 토큰이 필요했을 텐데, 왜 공격이 성립했나요?
그 토큰은 다른 사이트로부터의 무단 요청을 걸러내기 위한 장치(CSRF 대책)이지, 같은 사이트에서 동작하는 스크립트를 상정한 대책이 아니기 때문입니다. 공격 스크립트는 먼저 프로필 설정 페이지에 XMLHttpRequest로 접속하여, 정상 화면과 마찬가지로 토큰을 받은 뒤, 그 토큰을 첨부하여 업로드를 실행합니다. XSS에 의해 공격자의 코드는 피해자와 같은 오리진에서 동작하고 있으므로, 정규 이용자가 할 수 있는 일은 무엇이든 할 수 있습니다. CSRF 대책 토큰은 XSS를 막지 못합니다.
SQL 인젝션에서는 입력 이스케이프가 중요하다고 들었습니다. XSS만 출력 시인가요?
아닙니다. SQL 인젝션에서도 '입력 시 이스케이프'는 대책이 아닙니다. IPA가 근본적 해결로 제시하는 것은 'SQL문의 조립은 모두 플레이스홀더로 구현한다'는 것입니다. 문자열 연결로 SQL문을 조립하는 경우의 대안으로 이스케이프 처리가 제시되어 있지만, 그것도 'SQL문의 리터럴을 올바르게 구성하기' 위한 것, 즉 SQL문을 조립하는 순간에 하는 것이지 입력을 받은 시점이 아닙니다. 공통되는 원칙은 '이스케이프는 데이터가 어떤 문법의 세계로 나갈지가 정해지는 지점에서 수행한다'는 것입니다. HTML로 나간다면 HTML 이스케이프, SQL문의 일부가 된다면 플레이스홀더, 셸 명령의 일부가 된다면 셸 방식, 이런 식입니다. 입력 시점에 이스케이프해서는 안 되는 이유는 그 시점에는 출력 대상이 정해져 있지 않기 때문입니다. 같은 데이터가 HTML 페이지에도, CSV에도, JSON에도, 알림 메일에도, 로그에도 나가기 때문에, 입력 시점에 HTML 이스케이프를 걸면 CSV에 실체 참조가 그대로 나오거나, 데이터베이스의 내용이 원래 입력과 다른 것이 되어 검색이나 집계가 어긋나기도 합니다. 참고로 입력 측에서 아무것도 하지 않는다는 뜻은 아닙니다. 입력 측의 역할은 이스케이프가 아니라 검증(밸리데이션)이며, 사양상 있을 수 없는 값을 걸러내는 것입니다.
이 문제로부터 실무에 가져가야 할 대책은 무엇인가요?
근본적 해결은 리뷰 타이틀을 포함한 모든 이용자 입력에 대해, HTML로 출력하기 직전에 이스케이프 처리를 실시하는 것입니다. 그 위에 보험적 대책으로서, 세션ID의 cookie에 HttpOnly 속성을 붙여 스크립트에서 읽을 수 없게 하는 것, 업로드된 이미지를 서버 측에서 재인코딩하여 원래의 바이트열을 남기지 않는 것, Content Security Policy로 인라인 스크립트의 실행을 막을 수 있는 구성으로 해 두는 것을 들 수 있습니다. 이 문제의 Q사는 이 중 어느 하나라도 구현했다면 공격의 연쇄가 어딘가에서 끊어졌을 것입니다. 다만 보험적 대책은 공격자가 수법을 바꾸면 우회될 수 있는 것이므로, 근본적 해결인 이스케이프를 대신하지는 못합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기