[하루한줄] 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를 통해 발생합니다.

  1. 검사 위치 : mimetype 허용 목록과 4096px 하드 리밋이 onHeaderDecoded() 콜백 안에 인라인으로 구현돼 있고, 이 함수를 디코딩 콜백으로 넘기는 것은 resolveImage(Uri, Context) 하나 뿐입니다.
  2. 검사 우회 : 크기 인자를 받는 공개 오버로드는 모두 private resolveImage() 로 통하는데, 이쪽 람다는 setTargetSize()로 다운스케일만 하고 검사를 수행하지 않습니다.
  3. 도달 : 알림의 largeIcon이 resolveImage()를 호출하며, 이 호출은 @RemotableViewMethod를 통해 SystemUI 프로세스에서 실행됩니다.
  4. 결과 : 검증을 거치지 않은 데이터가 그대로 디코더로 전달되고, 디코딩 중 발생하는 결함은 앱이 아니라 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, 검사가 없는 경로로 반환됨.
}

이 중 CachingIconViewcore/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_patch2026-05-05, 즉 2026-09 ASB 미적용 상태입니다. 아래는 검증한 결과 입니다.

fingerprint:    google/sdk_gphone64_x86_64/emu64xa:17/CE2A.260420.019/15611780:userdebug/dev-keys
security_patch: 2026-05-052026-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 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.