APK 뜯어봤더니 로그인 Callback 꼬인 썰
Android Deep Link Hijacking + OAuth PKCE 누락으로 이어진 Account Takeover

APK 뜯어봤더니 로그인 Callback을 아무 앱이나 받을 수 있었다
— Android Deep Link Hijacking + OAuth PKCE 누락으로 이어진 Account Takeover
모바일 앱 모의해킹을 시작하면 보통 제일 먼저 APK부터 뜯어본다.
인증서 확인하고,
exported component 보고,
hardcoded secret 검색하고,
WebView 설정 보고,
API endpoint 찾는다.
그러다 보면 이런 intent-filter를 꽤 자주 만나게 된다.
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="acmeapp"
android:host="oauth" />
</intent-filter>
딱히 이상해 보이지 않는다.
로그인 끝나면 브라우저에서
acmeapp://oauth/callback
으로 돌아오고,
앱이 그 URI를 받아서 인증을 완료하는 구조다.
모바일 앱에서는 아주 흔하다.
그런데 이걸 보고 한 가지가 궁금해졌다.
acmeapp://을 이 앱만 받을 수 있다는 보장이 있나?
답은 아니었다.
그리고 그 질문 하나 때문에 평범했던 로그인 기능이 Account Takeover 가능성까지 이어졌다.
시작은 APK 정적 분석이었다
테스트 대상은 Android 기반 B2C 서비스라고 가정해보자.
로그인은 이메일/비밀번호도 지원하지만,
대부분 사용자는 Google이나 회사 SSO를 사용한다.
인증 흐름은 대략 이렇다.
Mobile App
|
v
System Browser
|
v
Authorization Server
|
v
OAuth Callback
|
v
Mobile App
모바일 OAuth에서 브라우저를 사용하는 것 자체는 이상하지 않다.
오히려 RFC 8252는 native application의 OAuth 인증 요청에 external user-agent, 즉 일반적으로 시스템 브라우저를 사용하도록 권고한다.
APK를 JADX로 열고 AndroidManifest.xml부터 확인했다.
jadx-gui target.apk
그리고 URI 관련 문자열을 찾는다.
android.intent.action.VIEW
BROWSABLE
oauth
callback
redirect_uri
그중 로그인 callback을 처리하는 Activity 하나가 보였다.
<activity
android:name=".auth.OAuthCallbackActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="acmeapp"
android:host="oauth"
android:path="/callback" />
</intent-filter>
</activity>
exported=true 자체가 취약점은 아니다.
외부 브라우저에서 callback을 받아야 하니 당연히 외부에서 Activity를 실행할 수 있어야 한다.
문제는 어떤 URI scheme을 신뢰하고 있느냐였다.
Custom URI Scheme은 소유권 증명이 없다
웹에서 생각해보면 조금 이상하다.
https://login.example.com/callback
이라는 주소를 아무나 가져갈 수는 없다.
example.com을 소유해야 한다.
그런데 custom URI scheme은 다르다.
acmeapp://oauth/callback
에서 acmeapp이라는 문자열에 DNS 같은 중앙 소유권 검증이 있는 것이 아니다.
다른 Android 앱도 동일한 scheme을 등록할 수 있다.
RFC 8252도 private-use URI scheme의 한계로 여러 앱이 같은 scheme을 등록할 수 있으며, 다른 앱이 authorization code를 가로챌 가능성을 명시하고 있다.
Android 문서 역시 여러 앱이 동일한 deep link URI를 처리하도록 등록할 수 있다는 점을 보안 위험으로 설명한다.
그래서 바로 테스트해봤다.
먼저 Deep Link 자체를 직접 호출해본다
ADB에서 callback Activity가 어떻게 반응하는지 확인한다.
adb shell am start \
-a android.intent.action.VIEW \
-d "acmeapp://oauth/callback?code=TEST_CODE"
앱이 열린다.
예상대로였다.
그다음에는 약간 다른 값을 넣는다.
adb shell am start \
-a android.intent.action.VIEW \
-d "acmeapp://oauth/callback?error=access_denied"
그리고 URI parsing 과정도 확인한다.
scheme
host
path
query parameters
여기서 중요한 건 아직 공격을 증명하는 것이 아니다.
앱이 어디까지 외부 입력을 신뢰하는지 파악하는 단계다.
모바일 pentest에서는 이런 component를 발견했다고 바로 finding으로 쓰면 안 된다.
Exported Activity
!=
Vulnerability
실제 security impact가 있어야 한다.
같은 Scheme을 다른 앱도 등록할 수 있을까?
테스트용 Android 앱 하나를 만든다.
목적은 하나뿐이다.
동일한 scheme을 처리하게 한다.
<activity
android:name=".CallbackReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="acmeapp"
android:host="oauth"
android:path="/callback" />
</intent-filter>
</activity>
악성 행위를 넣을 필요는 없다.
PoC에서는 들어온 URI만 확인하면 충분하다.
Received URI:
acmeapp://oauth/callback?code=...
이 테스트 앱을 동일한 단말에 설치한 상태에서 다시 OAuth 로그인을 수행한다.
이제 중요한 문제가 생긴다.
Authorization Server
|
v
acmeapp://oauth/callback
|
+--------------------+
| |
v v
Original App Test App
운영체제 입장에서 두 앱 모두 해당 URI를 처리할 수 있다고 선언한 것이다.
Android가 제공하는 verified App Links가 존재하는 이유 중 하나도 이 문제를 줄이기 위해서다. App Links는 웹 도메인과 앱 사이의 소유 관계를 assetlinks.json 등을 통해 검증한다.
그런데 여기서 중요한 질문이 하나 더 있다
Authorization Code가 다른 앱으로 넘어갔다고 치자.
그럼 바로 계정 탈취인가?
아니다.
여기서 Senior pentester라면 반드시 OAuth flow를 한 단계 더 확인해야 한다.
요즘 native application의 OAuth라면 보통 PKCE가 있어야 한다.
정상적인 Authorization Code + PKCE 흐름은 이런 구조다.
Client
|
| Generate code_verifier
|
| SHA256(code_verifier)
v
code_challenge
|
v
Authorization Server
<--- authorization_code ---
Client
|
| authorization_code
| code_verifier
v
Token Endpoint
로그인 시작 시 앱이 random한 code_verifier를 만든다.
그리고 그 값을 기반으로 code_challenge를 만들어 authorization request에 보낸다.
나중에 authorization code를 token으로 교환할 때는 원래의 code_verifier가 필요하다.
따라서 다른 앱이 code 하나만 가로채도
Authorization Code
|
v
No code_verifier
|
v
Token Exchange Failed
가 되어야 한다.
RFC 8252는 public native app에서 PKCE를 사용해야 한다고 명시하고 있으며, 바로 이런 authorization-code interception 공격을 방어하는 것이 핵심 목적 중 하나다.
그래서 실제 로그인 Request를 본다
프록시를 통해 authorization request를 확인했다.
대략 이런 형태였다.
GET /authorize?
client_id=mobile-app
&response_type=code
&redirect_uri=acmeapp%3A%2F%2Foauth%2Fcallback
&scope=openid%20profile
&state=8d91...
여기서 찾고 싶은 건 이것이다.
code_challenge
code_challenge_method
그런데 없었다.
client_id
response_type
redirect_uri
scope
state
No code_challenge
다시 로그인해본다.
똑같다.
APK 내부 OAuth client 구현도 확인한다.
역시 verifier generation 과정이 없다.
여기서 finding의 심각도가 달라지기 시작한다.
state가 있으니까 괜찮은 것 아닌가?
개발팀에서 정말 자주 나오는 질문이다.
"
state검증하고 있는데요?"
state는 중요하다.
하지만 목적이 다르다.
대략적으로 보면
state
|
v
Request / Response Correlation
CSRF Protection
이고,
PKCE
|
v
Authorization Code
Interception Protection
이다.
둘 중 하나가 다른 하나를 대체하는 게 아니다.
PortSwigger 역시 OAuth 보안에서 callback과 redirect_uri 검증, authorization code 탈취 가능성을 별도의 공격 표면으로 다룬다.
이제 최소 PoC를 만든다
중요한 건 실제 사용자 계정을 공격하는 게 아니다.
테스트 계정을 하나 만든다.
Account:
pentest-mobile@example.test
그리고 테스트 기기에
Original App
Test Receiver App
두 개를 설치한다.
Original App에서 테스트 계정으로 로그인을 시작한다.
인증이 끝난 뒤 callback이 테스트 Receiver로 전달되는 상황을 재현한다.
Receiver에는 URI만 기록하도록 한다.
acmeapp://oauth/callback?code=REDACTED
여기까지만 해도 code interception은 입증됐다.
그다음 authorization server가 PKCE 없이 public client의 code redemption을 허용하는지도 승인된 테스트 계정 범위에서 확인한다.
성공한다면 attack chain이 완성된다.
Victim Starts Login
|
v
Authorization Server
|
v
Authorization Code
|
v
Custom URI Scheme
|
v
Attacker-controlled App
|
v
Code Interception
|
v
Token Exchange
|
v
Victim Session
여기서 처음으로 Account Takeover 가능성을 이야기할 수 있다.
사실 Deep Link만 보면 Medium처럼 보였다
처음 정적 분석에서 보였던 건 이것뿐이었다.
Custom URI Scheme
이것만 보고 severity를 높게 줄 수는 없다.
다음 단계에서
Scheme Collision
이 확인된다.
그래도 아직 계정 탈취는 아니다.
그리고 마지막으로
PKCE Missing
이 결합된다.
그제야 전체 attack path가 나온다.
Custom URI Scheme
+
Scheme Hijacking
+
Missing PKCE
|
v
OAuth Code Interception
|
v
Account Takeover
화이트해킹 실무에서 이런 부분이 재미있다.
하나씩 보면 별것 아닌 finding들이
인증 프로토콜 안에서 연결되는 순간 영향도가 완전히 달라진다.
WebView도 같이 확인한다
OAuth를 보고 있으면 자연스럽게 WebView도 보게 된다.
APK에서 다음 클래스들을 검색한다.
WebView
WebViewClient
loadUrl
setJavaScriptEnabled
addJavascriptInterface
shouldOverrideUrlLoading
특히 deep link에서 받은 URL을 그대로 WebView에 넘기는 코드가 있는지 본다.
예를 들어 이런 구조라면 검토 대상이다.
External Deep Link
|
v
url Parameter
|
v
WebView.loadUrl()
URL allowlist나 origin validation이 없다면 또 다른 attack surface가 생길 수 있다.
Android 역시 deep link에서 전달된 값을 엄격히 검증하지 않는 경우 host validation bypass나 다른 보안 문제가 발생할 수 있다고 안내한다.
하지만 여기서도 중요한 원칙은 똑같다.
Interesting Code
!=
Finding
실제로 신뢰 경계를 넘을 수 있는지 검증해야 한다.
모바일 Pentest에서는 서버와 앱을 따로 보면 안 된다
이런 케이스에서 흔히 발생하는 실수가 하나 있다.
모바일 팀은 이렇게 말한다.
"OAuth는 서버팀 담당인데요."
서버팀은 이렇게 말한다.
"Callback은 앱에서 처리하는데요."
보안 관점에서는 둘 다 의미 없는 구분이다.
Attack path는 시스템 전체를 지나간다.
Android
|
v
Browser
|
v
Identity Provider
|
v
Authorization Server
|
v
Deep Link
|
v
Android
|
v
Token API
공격자는 조직도를 신경 쓰지 않는다.
그래서 finding도
Android Bug
하나로만 쓰면 부족하다.
정확하게는
OAuth Client Security
+
Android Platform Interaction
문제다.
수정은 Custom Scheme 이름을 어렵게 만드는 게 아니다
가끔 이런 수정이 나온다.
acmeapp://
대신
com.company.product.mobile.authentication://
처럼 scheme을 길게 만든다.
조금 나아 보이지만 본질적인 해결책은 아니다.
공격자는 APK를 내려받을 수 있다.
Manifest도 볼 수 있다.
Secret URI Scheme
이라는 개념 자체가 성립하기 어렵다.
RFC 8252에서도 custom scheme을 사용할 경우 reverse-domain 형태를 권고하지만, 동시에 private-use scheme에서 다른 앱에 의한 interception 가능성을 명확히 고려한다.
첫 번째 수정 — PKCE
native application이면 우선 PKCE를 제대로 적용한다.
Authorization Request
code_challenge=S256(...)
code_challenge_method=S256
그리고 token exchange에서는
authorization_code
code_verifier
둘 다 필요하게 만든다.
그러면 authorization code를 다른 앱이 가져가더라도 사용할 수 없다.
Intercepted Code
|
v
Missing Verifier
|
v
Rejected
PKCE는 모바일 OAuth에서는 옵션으로 취급할 성격의 기능이 아니다. RFC 8252는 public native clients에 PKCE 구현을 요구한다.
두 번째 수정 — Verified App Links
가능하면 custom scheme보다 verified HTTPS App Link를 사용한다.
예를 들면
https://auth.example.com/mobile/callback
형태다.
Android Manifest에서는 domain verification을 활성화한다.
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="auth.example.com"
android:pathPrefix="/mobile/callback" />
</intent-filter>
그리고 웹 서버에서 앱과 도메인의 관계를 증명한다.
https://auth.example.com/.well-known/assetlinks.json
Android App Links는 Digital Asset Links를 이용해 웹사이트와 앱의 관계를 검증하므로 일반적인 custom scheme보다 링크 hijacking에 강하다.
세 번째 수정 — Redirect URI를 정확히 검증한다
Authorization Server에서 이런 식의 느슨한 검증은 위험하다.
*.example.com
혹은
StartsWith()
Contains()
방식.
가능하면 사전에 등록된 callback과 정확히 일치시키는 것이 좋다.
RFC 8252 역시 native app client의 redirect URI를 path까지 포함해 등록하고, 일반적인 경우 정확히 일치하지 않는 redirect URI를 거절하도록 요구한다.
보안에서 자주 보는 패턴이다.
Flexible Validation
|
v
Unexpected Trust
운영 편의를 위해 만든 wildcard 하나가 authentication boundary를 무너뜨릴 수 있다.
Retest에서는 공격을 그대로 다시 한다
패치 버전 APK를 받았다.
개발팀 설명은 이랬다.
PKCE 적용했고 App Links로 변경했습니다.
말만 듣고 close하지 않는다.
다시 APK를 열어본다.
jadx-gui patched.apk
Manifest 확인.
https
android:autoVerify=true
Expected Host
Expected Path
실제 device에서도 확인한다.
adb shell pm get-app-links com.example.app
링크 routing도 테스트한다.
adb shell am start \
-a android.intent.action.VIEW \
-d "https://auth.example.com/mobile/callback?code=TEST"
그리고 OAuth request를 다시 캡처한다.
이번에는 보인다.
code_challenge
code_challenge_method=S256
마지막으로 이전에 사용했던 Receiver 앱에서도 같은 attack flow를 재현해본다.
실패한다.
Deep Link Interception: Failed
Code Redemption: Failed
Unauthorized Session: Not Obtained
그제야 retest를 PASS 처리한다.
모바일 화이트해킹에서 진짜 중요한 건 APK를 잘 뜯는 게 아니다
초반에는 모바일 보안을 이렇게 생각하기 쉽다.
APK
|
+-- Hardcoded Secrets
+-- Root Detection
+-- SSL Pinning
+-- WebView
+-- Exported Components
물론 다 본다.
하지만 시간이 갈수록 더 중요한 건 앱이 다른 시스템과 만나는 경계라는 생각이 든다.
App <-> Android OS
App <-> Browser
App <-> OAuth
App <-> Backend
App <-> Other Apps
버그는 종종 각각의 시스템 안이 아니라 시스템과 시스템 사이에서 생긴다.
이번 케이스도 그랬다.
Android는 specification대로 URI를 전달했다.
OAuth Server는 specification대로 authorization code를 발급했다.
앱은 specification대로 callback을 처리했다.
각각 따로 보면 모두 정상처럼 보였다.
그런데 연결하면 달라졌다.
Android trusts URI registration.
OAuth trusts redirect handling.
The app trusts the callback.
No component owns the entire trust chain.
그래서 모바일 모의해킹하면서 내가 제일 많이 하는 질문 중 하나는 결국 이거다.
“이 데이터가 여기 들어오기 직전까지 누구 손을 거쳐왔지?”
그걸 따라가다 보면,
APK에서 별것 없어 보였던 intent-filter 하나가
어느 순간 Account Takeover의 시작점이 되어 있기도 한다.