[하루한줄] CVE-2026-28666 : Android LocalImageResolver의 mimetype 검사 우회로 인한 Persistent DoS & EOP취약점
URL
https://www.rapid7.com/db/vulnerabilities/cve-2026-28666/
https://www.strix.ai/cve/CVE-2026-28666
Target
- Android 14, 15, 16, 16-qpr2, 17 버전 (ASB 2026-09 미적용 빌드)
Explain
Background
Android의 알림은 앱이 이미지 URI만 넘기면 시스템이 그 파일을 열어 렌더링합니다. 즉 디코딩이 앱 프로세스가 아니라 SystemUI 프로세스에서 일어납니다. LocalImageResolver가 그 디코딩 경로의 진입점입니다.
이 구조 때문에 이미지 디코더의 버그는 그대로 시스템 프로세스의 문제가 됩니다.
Root Cause
본 취약점은 검사를 bypass하는 Logic Bug로 아래 Flow를 통해 발생합니다.
- 검사 위치 : mimetype 허용 목록과 4096px 하드 리밋이
onHeaderDecoded()콜백 안에 인라인으로 구현돼 있고, 이 함수를 디코딩 콜백으로 넘기는 것은resolveImage(Uri, Context)하나 뿐입니다. - 검사 우회 : 크기 인자를 받는 공개 오버로드는 모두 private
resolveImage()로 통하는데, 이쪽 람다는setTargetSize()로 다운스케일만 하고 검사를 수행하지 않습니다. - 도달 : 알림의 largeIcon이
resolveImage()를 호출하며, 이 호출은@RemotableViewMethod를 통해 SystemUI 프로세스에서 실행됩니다. - 결과 : 검증을 거치지 않은 데이터가 그대로 디코더로 전달되고, 디코딩 중 발생하는 결함은 앱이 아니라 SystemUI 프로세스에서 진행되어 Persistent DoS 및 EoP가 가능합니다.
먼저 검사가 걸리는 경로를 보겠습니다.
// LocalImageResolver.java:64 — 검사를 타는 경로
public static Drawable resolveImage(Uri uri, Context context) throws IOException {
try {
final ImageDecoder.Source source =
ImageDecoder.createSource(context.getContentResolver(), uri);
return ImageDecoder.decodeDrawable(source,
(decoder, info, s) -> LocalImageResolver.onHeaderDecoded(decoder, info, // :69
DEFAULT_MAX_SAFE_ICON_SIZE_PX, DEFAULT_MAX_SAFE_ICON_SIZE_PX));
} catch (Exception e) {
throw new IOException(e); // :74
}
}
// LocalImageResolver.java:259 — 두 검사가 전부 이 함수 안에 있습니다
private static void onHeaderDecoded(ImageDecoder decoder, ImageDecoder.ImageInfo info,
int maxWidth, int maxHeight) {
final Size size = info.getSize();
final String mimeType = info.getMimeType();
boolean isAllowedCodec = false;
if (mimeType != null) {
switch (mimeType.toLowerCase(Locale.US)) {
case "image/png": case "image/jpeg": /* … 허용 10종 … */
isAllowedCodec = true;
break;
}
}
if (!isAllowedCodec) {
throw new RuntimeException("Image mime type (" + mimeType + ") is not allowed."); // :282
}
if (size.getWidth() > DEFAULT_DECODE_HARD_LIMIT_PX
|| size.getHeight() > DEFAULT_DECODE_HARD_LIMIT_PX) { // :285
throw new RuntimeException("Image dimensions (…) exceed the maximum allowed size.");
}
…
}
mimetype 허용 목록과 4096px 하드 리밋은 둘 다 onHeaderDecoded() 내부에 인라인으로 존재하고, 이 함수를 ImageDecoder 의 헤더 디코딩 콜백으로 넘기는 것은 resolveImage하나 뿐입니다.
// LocalImageResolver.java:156 — 크기 인자를 받는 public 오버로드
public static Drawable resolveImage(Uri uri, Context context, int maxWidth, int maxHeight) {
final ImageDecoder.Source source =
ImageDecoder.createSource(context.getContentResolver(), uri);
[2] // private으로 반환
return resolveImage(source, maxWidth, maxHeight);
}
// LocalImageResolver.java:220 — 검사가 빠진 경로
[1]
private static Drawable resolveImage(ImageDecoder.Source source,
int maxWidth, int maxHeight) {
try {
[3] // 콜백이 onHeaderDecoded가 아니다
return ImageDecoder.decodeDrawable(source,
(decoder, info, unused) -> {
if (maxWidth <= 0 || maxHeight <= 0) {
return;
}
final Size size = info.getSize();
…
if (size.getWidth() > maxWidth) {
final int targetHeight = size.getHeight() * maxWidth / size.getWidth();
[4] // 다운스케일만 한다
decoder.setTargetSize(maxWidth, targetHeight);
}
…
});
} catch (IOException | Resources.NotFoundException e) {
Log.d(TAG, "Couldn't use ImageDecoder for drawable, falling back to non-resized load.");
return null;
}
}
[1] 핵심은 private resolveImage 입니다.
[2] 크기 인자를 받는 public 오버로드는 모두 ImageDecoder.createSource() 를 호출한 뒤 resolveImage 함수로 반환합니다.
[3] 같은 ImageDecoder.decodeDrawable()을 호출하지만 콜백이 다릅니다.
[4] 이 람다는 decoder.setTargetSize()로 다운스케일만 수행하고 mimetype도 크기 리밋도 확인하지 않습니다. onHeaderDecoded()를 거치지 않으므로 두 검사가 통째로 빠집니다.
android-17.0.0_r1 기준으로 LocalImageResolver를 호출하는 곳은 7군데인데, 검사를 진행하는 것은 1군데 뿐입니다.
| 호출부 | 호출하는 오버로드 | 최종 도달 |
|---|---|---|
MessagingImageMessage.java:103 |
resolveImage(Uri, Context) |
onHeaderDecoded() — 검사 O |
BigPictureNotificationImageView.java:134 |
resolveImage(Uri, Context, int, int) |
:220 — 검사 X |
BigPictureNotificationImageView.java:141 |
resolveImage(Icon, Context, int, int) |
:220 — 검사 X |
CachingIconView.java:117 |
resolveImage(Icon, Context, int, int) |
:220 — 검사 X |
CachingIconView.java:149 |
resolveImage(int, Context, int, int) |
:220 — 검사 X |
CachingIconView.java:180 |
resolveImage(Uri, Context, int, int) |
:220 — 검사 X |
NotificationInlineImageResolver.java:124 (SystemUI) |
resolveImage(Uri, Context, int, int) |
:220 — 검사 X |
// CachingIconView.java:101
@RemotableViewMethod(asyncImpl="setImageIconAsync")
// RemoteViews, 즉 SystemUI에서 실행
public void setImageIcon(@Nullable Icon icon) {
…
Drawable drawable = loadSizeRestrictedIcon(icon);
}
// CachingIconView.java:116
Drawable loadSizeRestrictedIcon(@Nullable Icon icon) {
return LocalImageResolver.resolveImage(icon, getContext(), mMaxDrawableWidth,
mMaxDrawableHeight); // 220, 검사가 없는 경로로 반환됨.
}
이 중 CachingIconView는 core/res/res/layout/notification_2025_right_icon.xml이 @+id/right_icon으로 사용하는 뷰, 즉 알림의 largeIcon입니다. 진입점이 @RemotableViewMethod이므로 이 디코딩은 앱이 아니라 SystemUI 프로세스에서 실행됩니다.
PoC
트리거는 알림 하나 입니다. 같은 DNG 파일을 두 경로로 보내 검사 유무를 확인했습니다.
(A) // 취약 경로 — largeIcon → CachingIconView → resolveImage(Icon, Context, int, int) → :220
builder.setLargeIcon(Icon.createWithContentUri(dngUri));
(B) // 대조군 — MessagingStyle 이미지 메시지 → MessagingImageMessage → resolveImage(Uri, Context)
Notification.MessagingStyle.Message m =
new Notification.MessagingStyle.Message("dng", System.currentTimeMillis(), person);
m.setData("image/x-adobe-dng", dngUri);
builder.setStyle(new Notification.MessagingStyle(person).addMessage(m));
(A)는 DNG를 Icon.createWithContentUri()로 넘기므로 CachingIconView를 거쳐 검사 없는 :220으로, (B)는 Message.setData()로 넘기므로 MessagingImageMessage를 거쳐 검사가 있는 resolveImage()로 들어갑니다. dngUri는 PoC 앱의 content provider가 노출하는 DNG 파일입니다.
검증 환경은 Android 17 에뮬레이터이며 security_patch가 2026-05-05, 즉 2026-09 ASB 미적용 상태입니다. 아래는 검증한 결과 입니다.
fingerprint: google/sdk_gphone64_x86_64/emu64xa:17/CE2A.260420.019/15611780:userdebug/dev-keys
security_patch: 2026-05-05 ← 2026-09 ASB 미적용
release: 17 sdk: 37
(A)는 예외도 실패 로그도 없이 디코딩에 성공하고,
W System.err: java.io.IOException: java.lang.RuntimeException: Image mime type (image/x-adobe-dng) is not allowed.
W System.err: at com.android.internal.widget.LocalImageResolver.onHeaderDecoded(LocalImageResolver.java:282)
W System.err: at com.android.internal.widget.LocalImageResolver.lambda$resolveImage$0(LocalImageResolver.java:69)
W System.err: at com.android.internal.widget.LocalImageResolver.resolveImage(LocalImageResolver.java:74)
(B)는 SystemUI에서 차단됩니다.
이 PoC가 증명하는 범위는 크래시가 아니라 차단 대상 코덱이 SystemUI에서 실행된다는 사실까지이고, 실제 DoS는 그 뒤 dng_sdk/piex에서 디코딩 하는 과정에 따라 발생할 수 있습니다.
Patch
수정은 세 군데입니다.
[1] 검사를 함수로 추출 — onHeaderDecoded() 안에 인라인 돼 있던 mimetype 허용 목록과 4096px 리밋을 checkHeader(ImageDecoder.ImageInfo)로 분리했습니다. 이제 검사는 어느 오버로드에서도 호출할 수 있는 하나의 함수입니다.
private static Drawable resolveImage(ImageDecoder.Source source, int maxWidth, int maxHeight) {
try {
[2] // 검사 없던 람다 맨 앞에 checkHeader 호출 추가
return ImageDecoder.decodeDrawable(source, (decoder, info, unused) -> {
+ checkHeader(info);
if (maxWidth <= 0 || maxHeight <= 0) {
return;
}
@@
// ImageDecoder documentation is misleading a bit - it'll throw NotFoundException
- // in some cases despite it not saying so.
- } catch (IOException | Resources.NotFoundException e) {
+ // in some cases despite it not saying so. We also throw a RuntimeException if the
+ // image size is unreasonable (see checkHeader).
[3] // 그 거부를 잡아 크래시 방지
+ } catch (IOException | RuntimeException e) {
Log.d(TAG, "Couldn't use ImageDecoder for drawable, falling back to non-resized load.");
return null;
}
[2] 우회 경로에 그 검사를 호출 — 검사가 없던 resolveImage()(:220) 람다의 맨 앞에 checkHeader(info) 한 줄을 추가했습니다. 이 한 줄이 largeIcon, BigPicture, inline image 경로 전부에 적용 되어서 허용 목록에 없는 mimetype과 4096px 초과 이미지를 디코딩 전에 막습니다.
[3] catch 범위를 확장 — 패치 전 catch는 Resources.NotFoundException만 잡았습니다. checkHeader는 거부를 RuntimeException으로 던지므로 [2]만 추가하면 차단이 곧 SystemUI 크래시가 됩니다. catch를 RuntimeException으로 넓혀 거부를 예외 전파 대신 null 반환으로 처리하게 만들었습니다.
Reference
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.