AIR GO에 안드로이드 9 APK 서명 scheme v3 적용하기

배경

안녕하세요. LINE에서 AIR GO를 개발하고 있는 김승훈입니다. AIR GO에 대해선 이전에 ‘AIR GO를 소개합니다‘에서 먼저 말씀드렸는데요. 이번 블로그에서는 그에 이어 APK 서명에 관한 내용과 AIR GO 탐지 정보를 소개하고자 합니다. 최근 Google이 Android 9(Pie)에서 APK Signature Scheme v3을 소개했는데요. 이에 관련된 내용을 AIR GO에 적용하면서 개발자에게 도움이 될 만한 부분을 정리한 글입니다.

APK 서명이란

APK 서명의 역할

APK 서명은 아래와 같은 역할을 수행합니다.

  • APK 파일의 무결성을 검증합니다.
  • 서명자(개발자)를 식별하는 지문 역할을 합니다.
  • 추가적으로 Google Play 스토어에 업데이트할 때 사용합니다(같은 인증서로 서명한 APK만 업데이트 가능).

APK 서명 종류(서명 scheme)

안드로이드에서는 아래 3종류의 서명 scheme이 존재합니다.

  • v1 scheme: JAR 서명
  • v2 scheme: APK Signature Scheme v2, Android 7.0(Nougat)에서 소개됨
  • v3 scheme: APK Signature Scheme v3, Android 9(Pie)에서 소개됨

현재 안드로이드 스튜디오는 아래 그림과 같이 콤보 박스로 v1과 v2를 제공하고 있습니다(SDK v28.0.3 이후, 2018.12.27. 기준).

서명 scheme별 제약사항

  • v1: APK 파일의 무결성을 부분적으로만 보증할 수 있습니다.
  • v2: APK 파일의 무결성을 전체적으로 보증할 수 있습니다. 다만 Google Play 스토어에 앱을 업데이트할 때 항상 같은 Sign Key를 사용해야 합니다.
  • v3: APK 파일의 무결성을 전체적으로 보증할 수 있습니다. Sign Key 갱신이 가능하여 갱신된 Sign Key를 사용해서 Google Play 스토어에 올려진 앱을 업데이트할 수 있습니다(v3 scheme 기능은 Git에서 소스를 빌드하여 사용할 수 있습니다).

APK 서명 블록의 버전별 scheme 설명

v1 scheme(JAR 서명) 

가장 기본적인 서명으로 manifest 파일을 확장하여 다시 사용합니다. Manifest 파일은 내부 구성 파일의 파일명과 서명값을 가지고 있으며, 추가로 META-INF/<signer>.SF(서명 파일) 파일과 META-INF/<signer>.(RSA|DSA|EC) 파일을 이용하여 서명을 검증합니다. 해당 서명이 유효하면 클래스를 메모리에 로드(load)하고, 서명이 일치하지 않아 유효하지 않은 클래스는 로드를 거부합니다.

v1 scheme은 APK 내부 파일의 무결성은 체크하지만 APK 전체 포맷에 대한 무결성은 체크하지 못해 ‘끼워넣기’와 같은 공격에 취약합니다(2017년에 Janus vulnerability(CVE-2017-13156) 관련된 내용이 발표되면서 v2 이상의 서명이 권고된 이력이 있습니다). 또한 파일별 서명값을 확인할 때 파일을 메모리에 적재한 뒤 서명값을 비교하기 때문에 메모리를 많이 사용하고 시간이 다소 걸리는 문제가 있습니다.

v2 scheme

v2는 v1을 보완하기 위해 Android 7.0(Nougat)에서 소개되었습니다. v2 서명은 v1과는 다르게 APK 파일 내에 별도로 서명 블록을 생성하여 APK를 검증합니다. v1에선 검증을 위해 ‘파일(manifest)’이 존재했다면, v2에선 ZIP entry를 구성하고 있는 데이터를 기반으로 서명 정보를 생성하여 서명 블록에 저장하고 이를 설치할 때 검증합니다.

APK 서명 블록은 3개 영역(Contents of Zip Entries, Central Directory, End Of Central Directory)을 기반으로 APK 파일 내에 별도로 생성됩니다(Digest 생성 루틴 참고). 또한 아래 그림처럼 Central Directory 앞에 자리하게 됩니다.

APK 서명 블록은 Central Directory에서 명세하지 않습니다. 따라서 Central Directory를 파싱하여 압축을 해제하는 도구에서는 해당 영역이 보이지 않습니다(APK 포맷을 따르므로 기존 010 Editor의 ZIP Template으로 해당 영역을 확인할 수 없습니다). APK 서명 블록을 시각화하여 분석하기 위해 010 Editor의 ZIP Template를 수정하였고, 아래와 같이 APK 서명 블록을 확인하였습니다.

APK 서명 블록은 기존 ZIP entry(ZIP 파일 포맷 내에 존재하는 파일 header를 포함한 entry)와는 다른 구조를 가지고 있습니다. 전체 구조는 아래와 같습니다.

구조체 맴버설명
uint     uiApkSignBlockSizeAPK 서명 블록 전체 크기
uint     uiApkSignBlockExtendSize확장 크기(위 4바이트로 표시할 수 없는 크기를 표시하기 위해 예약된 공간)
Struct  scheme[]v2, v3 scheme이 담기는 scheme 리스트
Struct  paddingscheme(Option)Padding scheme (정렬 목적의 scheme)
uint     uiEndApkSignBlockSizeAPK 서명 블록 전체 크기(uiApkSignBlockSize와 동일해야 함)
uint     uiEndApkSignBlockExtendSize확장 크기(4바이트로 표시할 수 없는 크기를 표시하기 위해 예약된 공간,uiApkSignBlockExtendSize와 동일해야 함)
char     signSignature[16]End Signature(‘APK Sig Block 42‘)로 APK 서명 블록 마지막을 나타내는 시그니쳐

Scheme v2 블록의 구조는 아래와 같습니다. v2의 blockId값은 v2임을 식별하기 위해 항상 ‘0x7109871a‘로 고정되어 있습니다.

구조체 맴버설명
uint     uiSchemeBlockSizeScheme Block 전체 크기
uint     uiSchemeblockExtendSize확장 크기
uint     blockIdScheme ID(Scheme 를 식별하기 위한 고유값. v2 scheme의 경우 항상 고정된 ‘0x7109871a'값으로 표시)
uint     uiSignBlockSizeSignBlock 크기(Signed Data, Signature, Public key 영역 전체 크기)
uint     uiSignSizeSigned Data 크기
char     SignedData[uiSignSize]Signed Data
uint     uiSignatureSizeSignature Data 크기
char     signatureData[uiSignatureSize]Signature Data
uint     uiPublickeySizePublic Key Data 크기
char     publickeyData[uiPublickeySize]Public Key Data

v3 scheme

Scheme v3 블록의 구조는 아래와 같습니다. v3 scheme이 등장하게 된 배경과 v2 scheme과의 자세한 차이점은 APK Signature Scheme v3 섹션에서 말씀드리겠습니다.

v3의 blockId값은 v3임을 식별하기 위해 항상 ‘0xf05368c0‘로 고정되어 있습니다.

구조체 맴버설명
uint     uiSchemeBlockSizeScheme 블록 전체 크기 표시
uint     uiSchemeblockExtendSize확장 크기 표시
uint     blockIdScheme ID (Scheme 를 식별하기 위한 고유값. v3 scheme의 경우 항상 고정된 ‘0xf05368c0‘값으로 표시)
uint     uiSignBlockSizeSignBlock 크기(Signed Data와 MinSDK, MaxSDK, Signature, Public key 영역의 전체 크기 표시)
uint     uiSignSizeSigned Data 크기 표시
char     SignedData[uiSignSize]Signed Data
uint     uiMinSDKMinSDK(v3에 새로 추가된 항목으로 APK 동작을 위한 Min SDK 정보 표시)
uint     uiMaxSDKMaxSDK(v3에 새로 추가된 항목으로 APK 동작을 위한 Max SDK 정보 표시)
uint     uiSignatureSizeSignature Data 크기 표시
char     signatureData[uiSignatureSize]Signature Data
uint     uiPublickeySizePublic Key Data 크기 표시
char     publickeyData[uiPublickeySize]Public Key Data

APK Signature Scheme v2

Digest

v2 검증에 대해 설명하기 전에, v2 검증을 위해 SignedData에 들어가는 생소한 개념인 ‘digest’를 간단하게 살펴보겠습니다.

  • Chunks : APK 서명 블록을 제외한 모든 블록(1MB 단위로 chunk를 구성하며 마지막 chunk는 1MB보다 작을 수 있음)
  • Digests of chunks : Digest 알고리즘에 따라 32비트(bit)나 64비트로 크기가 정해져 있으며, 별도 연산을 통해 만들어진 chunk hash의 집합(Digests 앞에 빈 digest를 생성하고, 가장 앞의 digest는 0x5a값과 chunk 개수 정보를 기록)
  • Digest : Digests of chunks 데이터를 기반으로 만든 데이터(Digests of chunks를 계산하여 별도 Hash로 나타냄. APK 변조 여부 검증에 사용함)

Digest 알고리즘이 SHA256이고 chunk가 100개일 때, digests of chunks는 아래와 같이 구성됩니다. 최상위 40비트 블록에서 제일 처음 바이트(byte)는 0x5a이며 이후 4바이트는 chunk 개수를 나타냅니다.

각 digest of chunk는 1MB 혹은 더 작은 데이터를 계산한 hash값을 나타내고, digest는 digests of chunks를 기반으로 다시 hash값을 만들어 낸 값입니다(APK 변조 여부를 검증할 때 동일한 방법으로 digest를 추출하여 APK 서명 블록에 기록된 digest와 같은지 확인합니다).

v2 서명 검증 시 APK 설치 순서

v2 서명을 사용할 때 APK 설치 순서는 아래와 같습니다(v1 서명만 지원하는 단말기는 v2만으로 서명되어 있으면 설치가 되지 않습니다).

  1. APK 서명 블록이 있는지 확인하고 없다면 v1 서명 검증을 수행합니다.
  2. Signature algorithm ID를 확인하고 Signed Data를 추출합니다.
  3. Digest값을 계산합니다.
  4. Signed Data에서 digest를 추출하고 계산된 digest값과 일치하는지 확인합니다.
  5. Certificate 내에 존재하는 SubjectPublicKeyInfo(등록되어 있는 대상이 실제로 인증서를 제공했는지 여부를 식별하는데 사용)가 공개 키와 일치하는지 확인합니다.
  6. APK 설치를 시작합니다.

APK Signature Scheme v3

v3는 기본적으로 v2와 같은 구조를 가지고 있으며 v2와 마찬가지로 APK 서명 블록 내에 존재합니다. v3 서명 정보를 자세히 보기 위해 서명 도구(이 글에선 apksigner를 사용)를 사용하여 v3로 서명한 APK를 보면서 확인해 보겠습니다.

v3로 서명한 후 서명 도구로 검증하면 아래와 같이 v3 검증 항목이 ‘true‘임을 확인할 수 있습니다.

java -jar apksigner.jar verify --verbose certTest.apk 
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
Number of signers: 1

010 Editor를 이용하여 v3로 서명된 APK 서명 블록을 살펴 보면 아래와 같습니다. 아래 그림에서 하이라이트된 부분이 v3 blockId(0xf05368c0)를 나타내는 부분입니다. v3로 서명된 파일의 blockId값은 항상 동일합니다.

v2 vs v3 scheme

APK 서명 블록 내에 존재하는 scheme 블록 구조는 동일합니다. 따라서 scheme 블록 구조만으로는 v2와 v3를 구별할 수 없고 ID값을 확인해야 구별할 수 있습니다. 추가로 어떤 부분이 다른지 자세히 살펴보도록 하겠습니다.

v2 scheme은 아래와 같이 구성됩니다(출처).

private static final class V2SignatureSchemeBlock{
  private static final class Signer{
    public byte[] signedData;
    public List<Pair<Integer, byte[]>> signatures;
    public byte[] publicKey;
  }

  private static final class SignedData{
    public List<Pair<Integer, byte[]>> digests;
    public List<byte[]> certificates;
    public byte[] additionalAttributes;
  }
}

v3 scheme은 아래와 같이 구성됩니다(출처).

private static final class V3SignatureSchemeBlock{
  private static final class Signer{
    public byte[] signedData;
    public int minSdkVersion;
    public int maxSdkVersion;
    public List<Pair<Integer, byte[]>> signatures;
    public byte[] publicKey;
  }
 
  private static final class SignedData{
    public List<Pair<Integer, byte[]>> digests;
    public List<byte[]> certificates;
    public int minSdkVersion;
    public int maxSdkVersion;
    public byte[] additionalAttributes;
  }
}

두 구조체의 가장 큰 차이는 minSdkVersion과 maxSdkVersion의 존재 여부입니다. v3 서명은 Signer 클래스 내에 minSdkVersion과 maxSdkVersion을 가지고 있고, 추가로 SignedData 클래스에서도 minSdkVersion과 maxSdkVersion을 가지고 있습니다.

Proof-of-rotation

Google Play 스토어에 등록된 안드로이드 앱은 반드시 서명이 필요하며, 업데이트할 때 새 버전과 이전 버전의 서명을 비교하여 일치할 때만 업데이트를 수행합니다.

이런 방식은 아예 서명을 하지 않는 것과 비교하면 안전하지만 아래와 같은 두 가지 문제를 안고 있습니다.

  • 개발팀에서 단일 키를 공유해야 하는 문제
  • 키를 분실하면 키를 새로 생성한 뒤 Google Play 스토어 리스트에 앱 항목을 다시 추가해야 하는 문제

이러한 문제를 해결하기 위해서 Android 9(Pie)에선 v3 서명에 proof-of-rotate 개념이 적용되었습니다. 개발자는 앱의 과거 서명 인증서를 새로운 인증서에 연결하여 생성한 ‘SigningCertificateLineage 파일’을 이용해 앱에 서명할 수 있으며, 새 키와 이전 키 사이의 신뢰 수준이 확인되면 새로운 키를 이용해 업데이트할 수 있습니다. 해당 proof-of-rotate 정보는 APK 서명 블록 내에 존재하게 됩니다.

Proof-of-rotate 기능을 사용하기 위해서는 아래 명령을 통해 인증서 간의 상관 관계를 표시하는 SigningCertificateLineage 파일을 생성해야 하며, 결과물로 나오는 파일로 서명을 할 수 있습니다.

명령어 예 1) apksigner의 rotate 옵션으로 각 앱 서명에 이용한 keystore 2개를 신뢰하는 서명 정보로 연결하여 새로운 파일을 생성합니다.

$apksigner rotate-out/path/to/new/file-old-signer-ks release.jks-new-signer-ks release2.jks

명령어 예 2) apksigner의 sign 옵션에서 위에서 생성한 파일(SigningCertificateLineage)을 이용하여 앱에 서명을 합니다.

$apksigner sign-lineage/path/to/new/file-ks release.jks-nextsigner-ks release2.jks newapp.apk

SigningCertificateLineage의 파일 구조는 아래와 같습니다.

Node는 old-signer(이전 버전)와 new-signer(신규 버전)의 관계를 나타내며, child node를 증명(certificate)하는 signature를 가짐으로써 신뢰한다는 것을 표시합니다.

첫 번째 node는 old-signer의 parent 정보를 표시할 수 없으므로 Fake Node를 생성하여 표시합니다. 아래 예시 이미지에서 Fake Node를 확인할 수 있습니다.

SigningCertificateLineage
  • 0: Fake Node(이전 인증서의 데이터 기반으로 생성됨)
  • 1: Node Data

두 번째 node부터 parent(old-signer)와 child(new-signer) 정보가 표시되어 사용됩니다.

v3 서명 검증 시 APK 설치 순서

v3 서명을 사용할 때 APK 설치 순서는 아래와 같습니다. Android 9(Pie) 이상에서는 v3 scheme을 이용하여 APK 검증이 가능하며, Android 9(Pie) 이전 플랫폼은 v3 scheme를 무시하고 v2 서명 검증을 수행합니다.

  1. APK 서명 블록에서 v3 scheme 블록이 존재하는지 확인합니다(없을 경우 v2 검증 과정을 따릅니다).
  2. Signature algorithm ID를 확인하고 Signed Data를 추출합니다.
  3. SignedData 내에 MinSDK, MaxSDK값을 현재 플랫폼 정보와 비교합니다.
  4. Digest값을 계산합니다.
  5. SignedData에서 digest를 추출하고 계산된 digest값을 비교하여 일치 여부를 확인합니다.
  6. 인증서의 SubjectPublicKeyInfo가 공개 키와 일치하는지 확인합니다.
  7. proof-of-rotation attribute가 존재하는지 확인하고, 존재한다면 certificate정보를 보고 유효한지 확인합니다.
  8. APK 설치를 시작합니다.

마지막으로

AIR GO에서는 서명 정보를 확인하여 만약 v1 서명만 존재하는 APK가 있다면 아래와 같이 보안과 속도 측면에서 향상된 v2 이상의 서명을 사용하도록 안내하고 있습니다.

안전한 앱을 위해서는 v2이상의 서명을 권고 드립니다. 긴 글 읽어주셔서 감사합니다.


이 페이지의 일부분은 Android Open Source Project에서 생성 및 공유된 작업에서 복사되었고, Creative Commons 3.0 Attribution License에 설명된 조항에 따라 사용되었습니다.

Related Post