4 분 소요

certification_image.jpg

개요

Android 17(API 37)에는 APK Signature Scheme v3.2 (양자 내성 하이브리드 서명) 이 새로 들어왔다.

Google Play는 앱에 양자 내성(ML-DSA) 키 서명을 함께 붙여 배포할 수 있고, Android 17은 이 키를 앱의 현재 서명 키로 취급한다.

이 때문에 앱이 자기 서명 인증서의 해시를 구해 정상 여부를 판단하는 앱 위·변조 검사는 Android 17에서 기존과 다른 해시를 얻게 되고, 정상 앱을 위·변조된 앱으로 오판할 수 있다.

이 글에서는 v3.2가 서명 인증서를 어떻게 바꾸는지, 왜 Android 17 + Play 설치본에서만 영향을 받는지, 어떻게 대응하면 되는지를 정리한다.

앱 서명 검증(위·변조 검사)이란

앱이 리패키징(디컴파일 후 수정해서 다른 키로 재서명)되지 않았는지 확인하기 위해, 앱이 자기 자신의 서명 인증서 해시를 구해서 미리 등록해 둔 정상 해시와 비교하는 방식을 많이 쓴다.

  • 정상 앱 → 등록된 키로 서명되어 있음 → 해시 일치 → 통과
  • 리패키징된 앱 → 공격자의 키로 재서명됨 → 해시 불일치 → 차단

흔히 쓰는 코드는 이렇다.

val packageInfo = packageManager.getPackageInfo(
    packageName,
    PackageManager.GET_SIGNING_CERTIFICATES
)
// 현재 서명 인증서 "하나"만 꺼내서 해시
val signer = packageInfo.signingInfo?.apkContentsSigners?.firstOrNull()
val hash = MessageDigest.getInstance("SHA-256")
    .digest(signer!!.toByteArray())
    .joinToString("") { "%02X".format(it) }

// hash 를 미리 등록해 둔 정상 해시와 비교

문제는 apkContentsSigners 가 “현재” 서명 인증서를 돌려준다는 점이다. 이 “현재”가 OS 버전에 따라 달라질 수 있다.

APK 서명 방식의 변화

스킴 도입 특징
v1 (JAR 서명) 초기 ZIP 안의 개별 파일 서명
v2 Android 7.0 APK 전체를 하나의 블록으로 서명. 설치 속도·무결성 향상
v3 Android 9 (API 28) 키 교체(Key Rotation) 지원. 서명 이력(lineage)을 APK에 담는다
v3.1 Android 13 (API 33) 교체된 키를 특정 API 레벨 이상에만 적용하도록 분리
v3.2 Android 17 (API 37) 고전 키(RSA/EC) + 양자 내성 키(ML-DSA) 하이브리드 서명

키 교체와 서명 이력(lineage)

v3부터는 앱 서명 키를 바꿀 수 있다. 키를 바꾸면 “이전 키가 새 키를 보증한다”는 체인이 APK에 함께 들어가는데, 이것을 서명 이력(lineage) 이라고 한다.

원래 키  →  새 키  →  더 새로운 키(현재)

플랫폼은 이 체인을 검증하기 때문에, 업데이트 APK가 새 키로 서명되어 있어도 같은 앱으로 인정하고 설치해준다.

이때 apkContentsSigners 는 체인의 마지막(현재) 키를 돌려주고, 전체 체인은 signingCertificateHistory 로 볼 수 있다.

v3.2 하이브리드 서명이 바꾸는 것

Android 17은 양자 컴퓨터 시대에 대비해 ML-DSA(양자 내성 서명 알고리즘) 를 앱 서명에 도입했다.

  • APK에 고전 키 서명 + ML-DSA 서명을 한 블록(v3.2 블록)에 함께 넣는다
  • Android 17은 이 하이브리드 블록을 암묵적인 키 교체로 취급한다
    • 새 고전 키는 lineage의 끝에서 두 번째로 추가되고
    • 새 ML-DSA 키가 앱의 “현재 서명 신원”이 된다
  • Android 16 이하는 v3.2 블록을 무시하고 기존 v3.0/v3.1 블록(고전 키)으로만 검증한다

그리고 Google Play 앱 서명(Play App Signing)을 쓰는 앱은 Play Console에서 양자 내성 키 업그레이드를 하면 Play가 이 하이브리드 서명을 자동으로 붙여서 배포한다.

즉 lineage는 이렇게 된다.

원래 앱 서명 키  →  새 고전 키  →  양자 내성(ML-DSA) 키 ← Android 17의 현재 서명
   ↑
 Android 16 이하의 현재 서명

왜 Android 17 + Play 설치본에서만 영향을 받는가

기기 설치 경로 apkContentsSigners 결과 기존 해시와 비교
Android 16 Google Play 원래 앱 서명 키 (v3.2 블록 무시) 일치
Android 17 에뮬레이터 로컬 빌드 업로드 키 (Play를 거치지 않아 하이브리드 서명 없음) 일치 (업로드 키 기준)
Android 17 실기기 Google Play 양자 내성 키 불일치

두 조건이 동시에 맞아야 해시가 달라진다.

  • Android 17 이상이어야 v3.2 블록을 읽는다
  • Play에서 설치해야 하이브리드 서명이 붙어 있다. 로컬 빌드는 업로드 키로 서명되므로 로컬 테스트로는 확인할 수 없다

대응 방법

1. 허용 해시 목록에 양자 내성 키 해시 추가

Play Console의 앱 서명 화면에서 양자 내성 암호화 키의 SHA-256을 확인해 정상 해시 목록에 추가하는 방법이다. 가장 빠르게 적용할 수 있다.

다만 이 방법은 다음에 키가 또 바뀌면 똑같이 깨진다. Google은 앞으로 최소 2년마다 서명 키 업그레이드를 권장할 예정이라고 밝혔다.

2. 서명 이력(lineage) 전체로 판정 (근본 해결)

“현재 키 하나”가 아니라 lineage에 포함된 키 중 하나라도 등록돼 있으면 통과시키는 방식이다.

lineage는 플랫폼이 검증한 체인이라 공격자가 임의로 끼워 넣을 수 없다. 그래서 검사가 약해지지 않으면서 이후 키 교체에도 깨지지 않는다.

fun getSigningCertHashes(context: Context): List<String> {
    val pm = context.packageManager
    val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        pm.getPackageInfo(
            context.packageName,
            PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong())
        )
    } else {
        pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
    }
    val signingInfo = packageInfo.signingInfo ?: return emptyList()

    val certs = if (signingInfo.hasMultipleSigners()) {
        // 다중 서명자 APK 는 lineage 가 없다 → 현재 서명자 전부
        signingInfo.apkContentsSigners
    } else {
        // 서명 이력 전체 (가장 오래된 키 → 현재 키)
        signingInfo.signingCertificateHistory
    }

    return certs.map { cert ->
        MessageDigest.getInstance("SHA-256")
            .digest(cert.toByteArray())
            .joinToString("") { "%02X".format(it) }
    }
}

구한 해시 목록 중 하나라도 정상 해시 목록에 있으면 통과시키면 된다.

더 간단하게는 PackageManager.hasSigningCertificate() 를 쓸 수 있다. 이 API는 과거 서명 인증서(lineage)까지 포함해서 해당 인증서로 서명된 적이 있는지 확인해준다.

val registered = pm.hasSigningCertificate(
    context.packageName,
    expectedSha256Bytes,
    PackageManager.CERT_INPUT_SHA256
)

3. 검사 실패 시 사유와 해시를 로그로 남기기

위·변조 검사가 실패했을 때 실패 사유와 실제 서명 해시를 로그로 남겨두면, 키 변경으로 인한 오판인지 실제 리패키징인지 빠르게 구분할 수 있다.

루팅 탐지 등 다른 보안 검사와 안내 문구를 공유한다면 사용자에게는 하나로 보여주더라도 로그에서는 구분해두는 것이 좋다. 인증서 지문은 공개된 값이라 민감 정보가 아니다.

정리

항목 내용
핵심 Android 17의 APK Signature Scheme v3.2가 양자 내성 키를 앱의 현재 서명 신원으로 삼음
영향 조건 Android 17 이상 + Play 앱 서명에서 양자 내성 키 업그레이드된 앱 + 앱이 apkContentsSigners 하나로 자기 서명 검사
로컬에서 확인이 어려운 이유 로컬 빌드는 업로드 키로 서명되고, Android 16 이하는 v3.2 블록을 무시함
빠른 대응 허용 해시 목록에 양자 내성 키 SHA-256 추가
근본 대응 signingCertificateHistory(lineage) 전체로 판정

참조

APK Signature Scheme v3.2

Android 17 Features

Post-quantum cryptography in Android

SigningInfo

APK Signature Scheme v3

카테고리:

업데이트: