목차
앞 글 끝에서 던진 질문이 이 글의 시작이다. 쿠키 값을 몰라도 그 쿠키가 실리는 요청은 보낼 수 있다면, 코드를 심을 필요조차 없다.
링크를 눌렀을 뿐인데 내 계정에서 요청이 나갔다
아침에 은행 사이트에 로그인해뒀다. 그 탭은 그대로 두고, 메신저로 온 링크를 하나 눌러 낯선 페이지를 열었다. 그 페이지에는 이런 게 들어 있다.
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="공격자계좌">
<input type="hidden" name="amount" value="1000000">
</form>
<script>document.forms[0].submit();</script>페이지가 열리자마자 폼이 스스로 제출된다. 화면에는 아무것도 안 보인다. 그리고 은행 서버에는 정상적으로 로그인된 사용자의 이체 요청이 도착한다.
서버 입장에서 이 요청은 흠잡을 데가 없다. 세션 쿠키가 붙어 있고, 형식도 맞고, 그 사용자에게 이체 권한도 있다. 누가 시켰는지만 다르다.
브라우저는 쿠키를 알아서 붙인다
이게 왜 되나. 두 문장이면 설명된다.
첫째, 브라우저는 요청을 보낼 때 그 목적지에 저장해둔 쿠키를 자동으로 붙인다. 로그인 상태가 유지되는 원리가 그것이다. 요청을 만든 게 우리 페이지인지 남의 페이지인지는 안 따진다. 목적지만 본다.
둘째, 앞에서 본 대로 다른 출처로 폼을 제출하는 건 원래 허용된 동작이다. 웹에 폼이 있던 시절부터 그랬다.
이 둘을 합치면 그림이 나온다.
마지막 두 줄이 핵심이다. 공격자는 응답을 못 읽는다. 동일 출처 정책이 거기까지는 지킨다. 그런데 이 공격은 응답이 필요 없다. 서버가 처리해버리는 것 자체가 목적이기 때문이다.
이게 CSRF(Cross-Site Request Forgery), 교차 사이트 요청 위조다. 이름 그대로 남의 사이트에서 우리 사이트로 가는 요청을 위조한다.
동일 출처 정책은 왜 안 막나
여기서 첫 글의 문장이 그대로 돌아온다. 동일 출처 정책이 막는 건 읽기지 보내기가 아니다.
두 공격을 나란히 놓으면 성격 차이가 선명해진다.
| XSS | CSRF | |
|---|---|---|
| 코드가 도는 곳 | 우리 페이지 안 | 공격자 페이지 안 |
| 노리는 것 | 데이터를 읽어간다 | 요청을 실행시킨다 |
| 응답이 필요한가 | 필요하다 | 필요 없다 |
| 쿠키 값을 아는가 | 알 수도 있다 | 몰라도 된다 |
| 정책과의 관계 | 정책 안쪽에 들어가 무력화 | 정책이 원래 안 막던 틈 |
CSRF가 답답한 이유가 오른쪽 열에 다 있다. 공격자는 우리 데이터를 하나도 못 본다. 세션 쿠키 값도 모른다. 그런데도 사용자 이름으로 행동할 수 있다. 브라우저가 대신 서명해주기 때문이다.
그래서 이름의 “Cross-Site”가 CORS의 “Cross-Origin”과 다르다는 점도 같이 봐두면 좋다. 첫 글에서 출처와 사이트를 구분한 이유가 여기서 쓰인다. 뒤에 나올 SameSite 쿠키는 출처가 아니라 사이트를 기준으로 판단한다. 그래서 같은 사이트의 다른 서브도메인은 “남”이 아니다.
위조가 성립하는 조건 셋
막는 법을 보기 전에, 무엇이 갖춰져야 이 공격이 되는지를 먼저 세우면 방어가 저절로 정해진다. 셋이 다 있어야 한다.
하나, 인증이 브라우저가 자동으로 붙여주는 것에 걸려 있다. 쿠키가 대표적이다. 자동으로 안 붙는 것에 걸려 있으면 애초에 성립하지 않는다.
둘, 요청의 모양을 공격자가 미리 알 수 있다. 경로·메서드·파라미터 이름이 예측 가능해야 폼을 만들어둘 수 있다.
셋, 요청이 도착한 것만으로 뭔가 바뀐다. 응답을 못 읽어도 목적이 달성되는 요청, 즉 상태를 바꾸는 요청이어야 한다.
방어는 전부 이 셋 중 하나를 깨는 일이다. 토큰은 두 번째를, SameSite는 첫 번째를 깬다.
세 번째 조건에서 나오는 규칙이 하나 있다. GET으로 상태를 바꾸면 안 된다. GET /posts/3/delete 같은 설계는 <img src="..."> 한 줄로도 호출된다. 폼도 스크립트도 필요 없다. 상태를 바꾸는 요청은 POST·PUT·DELETE로 두는 게 규약 문제이기 전에 방어의 전제다. 뒤에 볼 방어들도 대개 안전한 메서드는 검사하지 않으니, 여기가 어긋나 있으면 방어를 켜도 그 구멍은 남는다.
막는 법 하나: 토큰
두 번째 조건을 깨는 방법이다. 공격자가 미리 알 수 없는 값을 요청에 하나 요구한다.
서버가 페이지를 내려줄 때 예측 불가능한 값을 하나 심어두고, 상태를 바꾸는 요청에 그 값이 같이 오는지 확인한다.
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="9f2c...">
...
</form>공격자 페이지에서도 폼은 똑같이 만들 수 있다. 하지만 이 값은 못 채운다. 값을 알려면 우리 페이지의 응답을 읽어야 하는데, 그건 동일 출처 정책이 막고 있는 바로 그 일이기 때문이다.
여기서 CSRF 방어의 정체가 드러난다. “쿠키가 붙어 있다”를 “사용자가 의도했다”의 증거로 쓰지 않는 것이다. 쿠키는 브라우저가 붙이지만 토큰은 우리 페이지를 실제로 받아본 쪽만 가질 수 있다.
토큰을 어디에 보관하느냐로 방식이 갈린다. 서버 세션에 저장해두고 대조하는 방식이 있고, 토큰을 쿠키로도 내려주고 요청 헤더로도 같이 보내게 해서 두 값이 일치하는지만 보는 방식이 있다. 뒤쪽은 서버가 상태를 안 들고 있어도 돼서 편하지만, 같은 사이트의 다른 서브도메인이 쿠키를 심을 수 있는 환경이면 전제가 약해진다. 도메인 관리가 느슨한 조직에서 특히 그렇다.
토큰이 화면에 뿌려지는 값이라는 점 때문에, XSS가 있으면 이 방어는 그대로 무너진다. 뒤에서 다시 짚는다.
막는 법 둘: SameSite 쿠키
첫 번째 조건을 깨는 방법이다. 토큰이 “증거를 하나 더 요구”하는 쪽이라면, 이쪽은 아예 쿠키를 안 붙이게 한다.
쿠키를 내려줄 때 속성을 하나 준다.
Set-Cookie: session=abc123; HttpOnly; SameSite=Lax; Path=/SameSite는 “다른 사이트에서 시작된 요청에도 이 쿠키를 실을 것인가”를 정한다. 값이 셋이다.
| 값 | 다른 사이트에서 온 요청에 | 결과 |
|---|---|---|
Strict | 아무 경우에도 안 붙인다 | 가장 강하다. 외부 링크로 들어오면 로그아웃처럼 보인다 |
Lax | 최상위 화면 이동인 GET에만 붙인다 | 앞의 자동 제출 POST는 쿠키가 안 붙는다 |
None | 언제나 붙인다 | 예전 기본 동작. 별도 조건이 붙는다 |
Lax가 균형점으로 널리 쓰이는 이유가 표에 보인다. 주소창이 실제로 바뀌는 이동(링크 클릭)은 살려두고, 화면 뒤에서 조용히 나가는 교차 사이트 요청은 끊는다. 검색 결과에서 우리 사이트 링크를 눌러 들어온 사용자가 로그아웃 상태로 보이는 일은 없으면서, 앞의 이체 폼은 막힌다.
여기서 GET으로 상태를 바꾸지 말라던 경고가 다시 걸린다. Lax는 최상위 이동 GET에는 쿠키를 붙여준다. 상태 변경을 GET에 걸어뒀다면 이 방어가 그 요청을 안 막아준다.
SameSite의 기본값을 일반 사실로 외우지 마라. 속성을 지정하지 않은 쿠키를 어떻게 취급할지는 브라우저와 버전에 따라 달라져 왔다. Chromium 계열이 지정 없는 쿠키를 Lax처럼 다루는 방향으로 바꾼 이력이 있고, 다른 브라우저는 도입 시기와 세부 동작이 달랐다. 그러니 “요즘은 기본이 Lax니까 괜찮다”는 판단은 위험하다. 서버에서 명시적으로 지정하고, 대상 브라우저의 현재 동작을 확인하는 것이 맞다.
None으로 열어야 하는 경우도 있다. 결제 대행사가 우리 사이트로 돌려보내는 흐름처럼, 교차 사이트로 쿠키가 실려야 하는 정상 시나리오다. 이때 None은 Secure 속성을 함께 요구한다. 그리고 None을 쓰는 순간 이 방어는 없어지므로, 그 자리는 토큰으로 받쳐야 한다.
헤더로 출처를 확인하는 방법
보조 수단이 하나 더 있다. 브라우저가 붙이는 Origin이나 Referer 헤더를 서버가 확인하는 것이다.
POST /transfer HTTP/1.1
Origin: https://evil.example우리 사이트의 페이지에서 시작된 요청이 아니라는 게 헤더에 그대로 적혀 있다. 스크립트가 이 값을 위조할 수 없으니 근거로 쓸 만하다.
다만 단독으로 쓰기엔 약한 구석이 있다.
Referer는 개인정보 문제 때문에 생략되거나 축약되는 경우가 흔하다. 없을 때 통과시키면 방어가 새고, 막으면 정상 사용자가 막힌다.Origin은 요즘 상태 변경 요청에는 대체로 붙지만, 붙는 조건이 요청 종류와 브라우저에 걸려 있다.
그래서 이건 토큰이나 SameSite를 대체하는 게 아니라 한 겹 더 얹는 검사로 쓴다. 앞 글에서 본 것과 같은 이야기다. 한 겹으로 끝나는 방어는 없다.
XSS가 뚫리면 토큰은 소용없다
이 시리즈에서 가장 놓치기 쉬운 연결이 여기다. CSRF 방어를 아무리 잘 해도, 같은 사이트에 XSS가 하나 있으면 대부분 무너진다.
이유는 토큰의 전제에 있다. 토큰이 안전한 근거는 “공격자가 우리 페이지의 응답을 못 읽는다”였다. 그런데 XSS로 주입된 코드는 우리 페이지 안에서 돈다.
같은 논리가 SameSite에도 걸린다. SameSite는 다른 사이트에서 시작된 요청을 걸러내는데, 주입된 코드가 보내는 요청은 우리 사이트에서 시작된다. 걸릴 이유가 없다.
그래서 순서가 이렇게 된다. XSS 방어가 CSRF 방어보다 아래층이다. 아래가 무너지면 위는 같이 내려앉는다. 두 항목을 체크리스트의 나란한 두 줄로 두면 이 관계가 안 보인다.
실무에서: 여기도 필터 자리다
CSRF 토큰 검사도 모든 상태 변경 요청이 똑같이 겪는 일이라 컨트롤러가 할 일이 아니다. 미들웨어 글에서 본 보안 필터 체인의 맨 앞 칸이 그 자리다. 요청이 핸들러에 닿기 전에 검사하고, 통과 못 하면 넘기지 않는다.
Spring Security를 쓰면 이 필터가 기본으로 켜져 있다. 그래서 폼을 만들었는데 POST가 403으로 떨어지는 경험을 하게 되는데, 대개 토큰을 안 실어 보낸 것이다. 그럴 때 첫 반응이 “CSRF를 끄자”가 되면 안 된다. 끄기 전에 물어야 할 질문이 하나 있다.
이 API의 인증이 브라우저가 자동으로 붙여주는 것에 걸려 있나?
- 쿠키·세션 기반이면 자동 첨부가 일어나니 CSRF 방어가 필요하다.
Authorization헤더에 토큰을 실어 보내는 방식이면 상황이 다르다. 이 헤더는 브라우저가 알아서 붙여주지 않는다. 코드가 명시적으로 넣어야 하고, 남의 사이트 코드는 그 값을 모른다. 성립 조건의 첫 번째가 애초에 빠진다.
그래서 “SPA니까 CSRF는 신경 안 써도 된다”는 말은 반만 맞다. 갈림길은 SPA인지 아닌지가 아니라 자격 증명을 어디에 실어 보내는지다. SPA라도 쿠키 세션을 쓰면 그대로 해당된다.
| 인증 방식 | 자동 첨부 | CSRF 방어 |
|---|---|---|
| 쿠키 세션 | 된다 | 토큰 + SameSite |
| 쿠키에 담은 토큰 | 된다 | 마찬가지로 필요 |
Authorization 헤더 | 안 된다 | 성립 조건이 빠진다 |
헤더 방식을 고르면 CSRF는 가벼워지지만 대신 그 토큰을 어디에 보관할지가 남는다. 자바스크립트가 읽을 수 있는 곳에 두면 앞 글에서 본 대로 XSS에 그대로 노출된다. CSRF와 XSS 중 어느 쪽 표면을 넓힐지의 교환이지, 한쪽이 공짜로 이기는 선택이 아니다.
정리
| CSRF란 | 남의 사이트가 사용자의 브라우저로 우리 서버에 요청을 위조해 보내는 것 |
| 왜 되나 | 브라우저가 쿠키를 자동으로 붙이고, 교차 사이트 폼 제출은 원래 허용된다 |
| 정책이 왜 안 막나 | 동일 출처 정책은 읽기를 막지, 요청이 나가는 것을 막지 않는다 |
| 응답은 | 공격자가 못 읽는다. 그런데 이 공격엔 응답이 필요 없다 |
| 성립 조건 | 자동 첨부되는 인증 + 예측 가능한 요청 + 도착만으로 바뀌는 상태 |
| 토큰 | 예측 불가능한 값을 요구해 두 번째 조건을 깬다 |
SameSite | 교차 사이트 요청에 쿠키를 안 붙여 첫 번째 조건을 깬다 |
| 기본값 | 브라우저·버전에 따라 달라져 왔다. 명시적으로 지정한다 |
GET으로 상태 변경 | 하지 않는다. 방어의 전제가 무너진다 |
| XSS와의 관계 | XSS가 뚫리면 토큰도 SameSite도 우회된다 |
이 시리즈를 여는 글에서 브라우저가 출처를 갈라 서로를 못 읽게 만든다고 했다. 그 규칙 하나에서 셋이 갈라져 나왔다. CORS는 그 규칙을 서버가 승인해서 여는 통로였고, XSS는 그 규칙 안쪽으로 들어가 규칙을 무의미하게 만드는 공격이었고, CSRF는 그 규칙이 애초에 막지 않던 틈을 쓰는 공격이었다.
셋을 따로 외우면 매번 새로 배워야 하지만, 이 지도 위에 놓으면 새 항목을 만나도 질문이 같다. 이건 무엇을 완화하는가, 무엇을 안쪽에서 무너뜨리는가, 원래 무엇을 안 막고 있었는가. 브라우저 보안에서 새로 나오는 헤더와 정책도 대개 이 셋 중 하나를 손보는 물건이다.
그리고 마지막으로 남겨둘 문장 하나. 어느 것도 혼자서는 충분하지 않다. 이스케이프도, HttpOnly도, 토큰도, SameSite도 각자 못 하는 일이 있어서 겹쳐 쌓는 것이지, 하나를 켰다고 그 칸이 닫히는 게 아니다.