클릭재킹
고친 사람 github-actions[bot]
클릭재킹은 사용자가 누르려던 것과 다른 버튼을 누르게 만드는 공격입니다. 공격자는 자기 페이지 위에 진짜 사이트를 투명하게 겹쳐 두고 그럴듯한 미끼 버튼을 보여 줍니다. 사용자가 미끼를 누르면 그 클릭은 보이지 않는 진짜 사이트의 버튼에 떨어집니다. 서버가 남의 페이지 안에 제 페이지가 뜨지 못하게 응답에 적어 두면 막힙니다.
쉽고 빠른 이해
보이지 않는 버튼을 대신 누르게 하는 공격입니다. 「경품 받기」를 눌렀는데, 그 위에 투명하게 깔린 소셜 서비스 설정 화면의 「전체 공개로 전환」이 눌립니다.
이 공격이 까다로운 까닭은 서버가 보기에 멀쩡한 요청이기 때문입니다. 요청은 로그인된 주인의 브라우저에서, 주인의 클릭으로 나갑니다. 위조 요청을 막으려고 심어 둔 토큰도 정상으로 실려 갑니다.
어떻게 도나:
- 공격자 페이지가 진짜 사이트를 제 안에 불러와 투명하게 만듭니다
- 진짜 버튼과 같은 위치에 미끼 버튼을 놓습니다
- 사용자가 미끼를 누르면 진짜 사이트가 그 클릭을 받습니다
로그인해 둔 사이트가 남의 페이지 안에 뜰 수 있어야 터집니다. 그 화면에 클릭 한두 번으로 끝나는 버튼이 있으면 피해가 납니다.
막는 데도 대가가 있습니다. 남의 페이지 안에 못 뜨게 막으면 내 페이지를 일부러 심어 쓰던 협력 사이트도 같이 막힙니다. 허용할 곳을 하나하나 적어 두고 관리해야 합니다.
상세
이 절은 먼저 클릭 한 번이 어떻게 엉뚱한 곳에 떨어지는지 봅니다. 그다음 그 클릭이 왜 진짜 요청이 되는지, 어떤 조건이 겹쳐야 터지는지 봅니다. 마지막으로 서버가 응답 헤더 한 줄로 막는 법과 헷갈리는 이웃 공격을 봅니다.
응모권 위의 투명한 계약서
경품 응모권에 이름을 적어 달라는 사람이 있다고 해 봅시다. 응모권 위에는 투명한 필름 한 장이 덮여 있습니다. 그 필름이 사실은 계약서입니다. 서명란은 응모권의 이름 칸과 딱 겹치게 놓여 있습니다.
펜 끝은 응모권이 아니라 맨 위의 필름에 닿습니다. 그래서 이름을 쓰는 순간 계약서에 서명이 남습니다. 글씨를 쓴 손은 분명 본인의 손입니다. 속인 것은 손이 아니라 눈입니다.
응모권이 미끼 버튼입니다. 그 위에 덮인 투명한 필름이 진짜 사이트입니다.
남의 페이지 안에 뜨는 페이지
웹 페이지는 다른 웹 페이지를 제 안에 통째로 띄울 수 있습니다. 이 일을 하는 HTML 요소가 iframe입니다. HTML 은 HyperText Markup Language 의 줄임말로, 웹 페이지의 뼈대를 적는 언어입니다. iframe 이라는 이름은 inline frame, 곧 본문 안에 끼운 틀에서 왔습니다.
iframe 은 쓸모가 있어서 생긴 기능입니다. 블로그 글 가운데 지도나 동영상 플레이어를 끼워 넣을 때 씁니다. 결제 대행사의 결제 창을 쇼핑몰 화면 안에 띄울 때도 씁니다.
iframe 안에 뜬 페이지는 따로 연 창과 똑같이 동작합니다. 버튼을 누르면 그 페이지의 서버로 요청이 나갑니다. 클릭재킹은 공격자의 페이지가 이 기능을 쓰는 것입니다.
한 번의 클릭재킹이 지나가는 단계
아래 그림은 사용자가 공격자의 페이지를 연 뒤 진짜 사이트에 요청이 나가기까지를 순서대로 보입니다. 사용자는 소셜 서비스에 미리 로그인해 둔 상태입니다. 공격자 페이지와 그 안의 iframe 은 둘 다 사용자의 브라우저 안에 뜹니다. 진짜 사이트와 주고받는 쪽은 브라우저 안의 iframe 입니다.
sequenceDiagram
participant U as 사용자
participant B as 사용자의 브라우저
participant R as 진짜 사이트
U->>B: 링크를 눌러 공격자 페이지를 연다
Note over B: 공격자 페이지가 iframe 을 만든다
B->>R: iframe 이 설정 화면을 요청한다
R-->>B: 로그인된 설정 화면이 iframe 에 뜬다
Note over B: iframe 을 투명하게 만들고<br/>그 아래에 미끼 버튼을 둔다
U->>B: 「경품 받기」를 누른다
B->>R: 클릭은 투명한 「전체 공개로 전환」에 떨어진다
R-->>B: 전환 완료가 iframe 안에 뜬다
사용자가 한 일은 버튼 하나를 누른 것뿐입니다. 화면에는 미끼 버튼만 보입니다. 설정 화면은 투명해서 안 보입니다. 그런데 클릭을 받는 쪽은 맨 위에 놓인 투명한 설정 화면입니다.
투명하게 겹치는 법
겹치는 일은 CSS 몇 줄로 끝납니다. CSS 는 Cascading Style Sheets 의 줄임말로, 페이지 요소의 모양과 위치를 정하는 언어입니다. 아래는 공격자 페이지가 iframe 에 거는 설정입니다.
iframe {
opacity: 0; /* 안 보인다 */
position: absolute;
top: 0; left: 0; /* 버튼 위치를 맞춘다 */
z-index: 2; /* 미끼보다 위에 선다 */
}
opacity 는 불투명도입니다. 0 이면 완전히 투명해서 화면에 아무것도 안 그려집니다. z-index 는 겹친 요소 가운데 무엇이 위에 오는지를 정하는 값으로, 클수록 위입니다.
이 공격의 알맹이는 투명한 요소도 클릭을 받는다는 것입니다. 브라우저는 눈에 보이는지와 상관없이 맨 위에 놓인 요소에 클릭을 넘깁니다. 그래서 사용자 눈에는 미끼가 맨 앞에 있습니다. 브라우저에게는 설정 화면의 버튼이 맨 앞에 있습니다.
그 클릭이 진짜 요청이 되는 까닭
iframe 으로 설정 화면을 불러오는 요청은 공격자의 서버가 아니라 사용자의 브라우저가 보냅니다. 브라우저는 소셜 서비스로 가는 요청에 소셜 서비스의 쿠키를 붙입니다. 쿠키는 로그인 상태를 기억하려고 브라우저에 맡겨 두는 값입니다. 그래서 iframe 안에는 사용자가 로그인된 설정 화면이 뜹니다.
그 안에서 누른 버튼도 평범한 요청이 됩니다. 요청은 주인의 브라우저에서 주인의 쿠키를 달고 주인의 클릭으로 나갑니다. 서버는 이 요청을 주인이 직접 누른 요청과 가를 방법이 없습니다.
주소 앞부분의 프로토콜과 호스트 이름과 포트를 묶은 것을 출처라고 부릅니다. 공격자 페이지와 소셜 서비스는 호스트 이름이 달라서 출처가 다릅니다.
브라우저는 출처가 다른 페이지끼리 서로의 내용을 못 읽게 막습니다. 이 규칙을 동일 출처 정책이라고 부릅니다. 이 정책 때문에 공격자 페이지는 iframe 안의 설정 화면을 읽지 못합니다.
그런데 클릭재킹에는 읽기가 필요 없습니다. 화면을 띄우고 사용자가 누르게만 하면 됩니다.
터지는 조건
클릭재킹은 세 조건이 겹칠 때 터집니다. 하나라도 빠지면 클릭이 엉뚱한 곳에 떨어져도 피해가 나지 않습니다. 아래 그림은 조건을 하나씩 거르며 내려갑니다.
flowchart TD
A["다른 출처의 페이지가<br/>이 페이지를 iframe 에 띄울 수 있나"] -->|아니오| X["안 터진다"]
A -->|예| B["iframe 안에서도<br/>로그인 쿠키가 붙나"]
B -->|아니오| X
B -->|예| C["클릭 한두 번으로 끝나는<br/>동작이 있나"]
C -->|아니오| X
C -->|예| D["클릭재킹이 된다"]
첫째 조건은 서버가 가장 확실하게 끊을 수 있는 조건입니다. 뒤의 「응답 헤더로 막는 법」이 이 조건을 끊습니다.
둘째 조건은 쿠키 설정에 달렸습니다. 쿠키에 SameSite 속성을 Strict 나 Lax 로 걸면, 다른 사이트 안에 심긴 iframe 의 요청에는 그 쿠키가 안 붙습니다. 그러면 iframe 안에는 로그인 안 된 화면이 뜹니다. 눌러도 할 수 있는 일이 없습니다.
셋째 조건은 동작의 모양입니다. 전체 공개로 전환, 팔로우, 삭제 확인, 권한 허용처럼 클릭 한두 번으로 끝나는 동작이 표적입니다. 금액과 받는 사람을 타이핑하거나 비밀번호를 다시 넣어야 하는 동작은 클릭만으로 끝나지 않아 표적이 되기 어렵습니다.
노리는 버튼
어느 버튼을 누르게 하느냐에 따라 피해가 갈립니다. 흔한 표적과 눌렸을 때 생기는 일을 아래 표에 짝지었습니다.
| 표적 | 눌리면 생기는 일 |
|---|---|
| 소셜 서비스의 좋아요·팔로우 | 사용자 이름으로 공격자의 글이 퍼집니다 |
| 계정 설정의 공개 전환 | 비공개로 둔 정보가 공개됩니다 |
| 로그인 연동의 「허용」 | 공격자의 앱이 사용자 계정에 접근할 권한을 받습니다 |
| 저장된 결제 수단으로 바로 사는 「구매」 | 사용자가 모르는 결제가 이뤄집니다 |
로그인 연동은 백엔드 개발자가 눈여겨볼 표적입니다. 로그인 연동의 「허용」 화면은 대개 OAuth2 의 동의 화면입니다. OAuth2 는 다른 서비스가 사용자 계정의 일부를 쓰도록 사용자가 허락해 주는 규약입니다.
그 허락을 받아 주는 서버를 인가 서버라고 부릅니다. 인가 서버의 동의 화면은 「허용」 버튼 하나로 끝납니다. 이 화면이 다른 사이트의 iframe 에 뜰 수 있으면, 사용자는 모르는 앱에 제 계정을 열어 주게 됩니다. 그래서 인가 서버는 동의 화면을 다른 사이트 안에 띄우지 못하게 막아 둡니다.
휴대폰 앱도 로그인 화면을 앱 안에 심지 않고 따로 된 브라우저로 여는 것이 좋습니다. 이때 화면을 덮을 수 있는 쪽은 공격자 페이지가 아니라 그 앱 자신입니다. 앱 안에 심긴 화면은 그 앱이 위에 무엇을 덮어도 사용자가 알아채기 어렵습니다.
응답 헤더로 막는 법
막는 쪽은 서버입니다. 서버가 응답에 「이 페이지를 누가 제 안에 띄워도 되는가」를 적어 두면, 브라우저가 그 말을 따라 iframe 표시를 거절합니다.
이 말을 싣는 곳이 HTTP 헤더입니다. HTTP 는 HyperText Transfer Protocol 의 줄임말로, 브라우저와 서버가 요청과 응답을 주고받는 규약입니다. 헤더는 응답 본문 앞에 붙는 이름과 값의 목록입니다.
이 일을 하는 헤더는 둘입니다. 먼저 나온 것이 X-Frame-Options입니다.
나중에 나온 것이 콘텐츠 보안 정책입니다. 영어 이름 Content Security Policy 를 줄여 CSP 라고도 부릅니다. 페이지가 무엇을 불러오고 어디에 심길 수 있는지를 서버가 정해 두는 헤더입니다.
CSP 안의 여러 항목 가운데 frame-ancestors 가 이 페이지를 제 안에 띄워도 되는 곳을 정합니다. X-Frame-Options 보다 더 세밀하게 고를 수 있습니다. 아래는 두 헤더를 모두 가장 엄하게 건 응답입니다.
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
두 줄은 같은 말을 합니다. 어느 페이지 안에도 이 페이지를 띄우지 말라는 뜻입니다. 두 헤더가 받는 값과 그 뜻은 아래 표와 같습니다.
| 헤더 | 값 | 이 페이지를 띄울 수 있는 곳 |
|---|---|---|
| X-Frame-Options | DENY |
없음 |
| X-Frame-Options | SAMEORIGIN |
출처가 같은 페이지 |
| CSP frame-ancestors | 'none' |
없음 |
| CSP frame-ancestors | 'self' |
출처가 같은 페이지 |
| CSP frame-ancestors | https://partner.example |
적어 둔 출처의 페이지 |
마지막 줄이 둘의 차이입니다. X-Frame-Options 로는 「아무도 안 된다」와 「나만 된다」 사이에서만 고를 수 있습니다. frame-ancestors 에는 믿는 협력 사이트의 출처를 여럿 적어 둘 수 있습니다. 두 헤더가 함께 오면 요즘 브라우저는 frame-ancestors 를 앞세웁니다.
헤더를 빠짐없이 붙이는 법
이 헤더는 페이지를 돌려주는 응답마다 붙어야 합니다. 한 페이지라도 빠지면 그 페이지가 표적이 됩니다. 그래서 페이지마다 손으로 붙이지 않고 한곳에서 한 번에 붙입니다.
그 한곳이 대개 미들웨어입니다. 미들웨어는 요청과 응답이 지나가는 길목에 끼워 두고 공통 처리를 하는 코드입니다. Django 에는 X-Frame-Options 를 응답마다 붙여 주는 미들웨어가 들어 있습니다.
애플리케이션 코드 앞단에서 붙이는 방법도 있습니다. 웹 서버 설정이 그 하나입니다. 요청을 앞에서 받아 뒤의 서버로 넘기는 리버스 프록시 설정에서도 붙일 수 있습니다.
이 방어에도 대가가 있습니다. 지도나 결제 창처럼 남의 페이지에 심기려고 만든 페이지는 모두 막아 버릴 수 없습니다. 그런 페이지는 frame-ancestors 에 허용할 출처를 적어 둡니다. 협력 사이트가 늘거나 줄 때마다 그 목록을 고칩니다.
스크립트로 막던 옛 방법
헤더가 나오기 전에는 페이지가 스스로 남의 iframe 안에 있는지 검사했습니다. 이 기법을 프레임 버스팅이라고 부릅니다. 아래 코드는 자기가 맨 바깥 창이 아니면 창 전체를 자기 페이지로 옮깁니다.
if (top !== self) { // 남의 틀 안이면
top.location = location; // 창째 옮긴다
}
top 은 맨 바깥 창이고 self 는 이 페이지가 뜬 창입니다. 둘이 다르면 누군가 이 페이지를 감싸고 있다는 뜻입니다.
이 방법은 공격자가 끄기 쉽습니다. iframe 에 sandbox 속성을 달면 안에 뜬 페이지가 바깥 창을 옮기지 못합니다. 검사 코드가 공격자가 고른 틀 안에서 돌기 때문에 생기는 약점입니다. 그래서 막는 일을 페이지 안의 코드가 아니라 브라우저가 지키는 헤더에 맡깁니다.
이름과 갈래
클릭재킹이라는 이름은 클릭(click)과 하이재킹(hijacking)을 합친 말입니다. 하이재킹은 남의 것을 가로챈다는 뜻입니다.
보이는 화면을 덧씌워 속인다는 데서 UI 리드레싱이라고도 부릅니다. UI 는 User Interface 의 줄임말로, 사용자가 보고 누르는 화면을 말합니다.
노리는 버튼에 따라 따로 부르는 이름도 있습니다. 좋아요 버튼을 노리면 라이크재킹이라고 합니다. 마우스 커서가 그려지는 위치를 속여 엉뚱한 곳을 누르게 하면 커서재킹입니다.
이 이름으로 부르지 않는 것
크로스 사이트 요청 위조는 사용자가 누르지 않은 요청을 브라우저가 대신 보내게 만드는 공격입니다. CSRF(Cross-Site Request Forgery)라고도 부릅니다. 이 공격은 흔히 서버가 양식마다 심어 둔 비밀 값, 곧 CSRF 토큰을 확인해서 막습니다.
클릭재킹은 이 토큰으로 못 막습니다. iframe 안에는 진짜 페이지가 뜨므로 진짜 토큰도 그 안에 함께 실립니다. 사용자가 누른 버튼은 토큰이 든 진짜 양식을 제출합니다.
교차 사이트 스크립팅은 공격자의 코드를 진짜 사이트 안에 넣어 돌립니다. 클릭재킹은 진짜 사이트에 아무것도 넣지 않습니다. 진짜 사이트의 코드와 화면은 멀쩡합니다. 그 위를 덮은 공격자 페이지만 있을 뿐입니다.
피싱은 진짜처럼 꾸민 가짜 사이트로 사용자를 데려갑니다. 클릭재킹에서 사용자가 누르는 것은 진짜 사이트의 진짜 버튼입니다. 가짜는 그 위에 덮인 미끼 그림뿐입니다.
관련 항목
클릭재킹이 속하는 공격 분류
웹 보안 · 브라우저 보안 · 프론트엔드 보안 · 사회공학 · OWASP
클릭재킹의 하위 종류
라이크재킹 · 커서재킹 · 더블 클릭재킹
클릭재킹이 올라타는 브라우저 기능
iframe · HTML · CSS · z-index · DOM · 브라우저
클릭재킹을 막는 응답 헤더
X-Frame-Options · 콘텐츠 보안 정책 · frame-ancestors · HTTP 헤더 · 보안 헤더
클릭재킹을 헤더 밖에서 막는 수단
SameSite 쿠키 · 프레임 버스팅 · sandbox 속성 · 재인증
클릭재킹이 기대는 로그인 상태
쿠키 · 세션 쿠키 · 세션 · 서드파티 쿠키 · 동일 출처 정책 · 오리진
클릭재킹이 노리는 동의 화면
OAuth 2.0 · 인가 서버 · 소셜 로그인 · OpenID Connect · 웹뷰
클릭재킹 방어 헤더를 붙이는 서버 쪽 도구
미들웨어 · Django · 웹 서버 · 리버스 프록시 · nginx
클릭재킹과 헷갈리는 이웃 공격
크로스 사이트 요청 위조 · 교차 사이트 스크립팅 · 피싱 · 세션 하이재킹 · 중간자 공격
다른 이름: clickjacking · 클릭 재킹 · UI redressing · UI 리드레싱