App Attest
App Attest

OS 앱의 보안을 강화해야 하는 상황에서 가장 먼저 떠올린 질문은 단순했습니다.

“앱이 변조되면 실행 자체를 막을 수 있을까?”

처음에는 앱 내부에서 탈옥 감지나 무결성 검사를 수행하고, 문제가 발견되면 앱을 종료시키는 방식이면 충분할 것이라고 생각했습니다.

예를 들면 다음과 같은 코드입니다.

ifSecurityCore.jailbreak() {
showBlockScreen()
return
}

하지만 조금 더 생각해 보면 문제가 있습니다.

공격자가 앱 바이너리를 변조해 SecurityCore.jailbreak() 호출 자체를 제거하거나, 항상 정상 결과를 반환하도록 바꾸면 앱 내부 검사만으로는 신뢰할 수 없기 때문입니다.

그래서 알아보게 된 것이 Apple의 App Attest였습니다.

이번 글에서는 제가 App Attest를 검토하면서 헷갈렸던 부분과, 최종적으로 이해하게 된 역할을 정리해 보겠습니다.


먼저 결론부터 정리하면

App Attest는 다음과 같은 기능입니다.

변조된 앱의 실행 자체를 막는 기능이 아니라, 서버가 중요한 요청을 정상 앱 인스턴스에서 생성된 요청인지 확인하도록 돕는 기능입니다.

즉, 아래와 같이 이해하는 것이 가장 정확합니다.

구분App Attest만으로 가능한가
변조된 앱의 설치 자체 차단불가
변조된 앱의 실행 자체 차단불가
탈옥 기기에서 앱 실행을 100% 차단보장 불가
변조 앱이 중요한 서버 API를 정상적으로 사용하는 것 차단가능
요청 데이터가 assertion 생성 이후 변경되었는지 검증가능

제가 처음에 가장 헷갈렸던 부분은 바로 이것이었습니다.

“앱이 변조되면 attestKey()가 무조건 실패하는 것 아닌가?”

결론적으로는 그렇게 단정하면 안 됩니다.

변조된 앱이 실행되더라도 어떤 방식으로든 App Attest API 호출을 시도할 수 있습니다. 중요한 것은 그 앱이 만든 attestation 결과가 우리 서버가 기대하는 정상 앱 식별값과 일치하는지입니다.

따라서 App Attest의 중심은 앱 내부의 실행 차단이 아니라, 서버 검증입니다.


HTTPS와 App Attest는 역할이 다르다

App Attest를 처음 볼 때 또 하나 오해하기 쉬운 부분이 있습니다.

App Attest가 API 요청 데이터 자체를 숨겨주는 기능처럼 느껴질 수 있다는 점입니다.

예를 들어 앱에서 쿠폰 사용 요청을 보낸다고 가정해 보겠습니다.

{
  "payload": {
    "userId":"1001",
    "couponId":"WELCOME_3000",
    "amount":3000
  },
  "keyId":"GENERATED_KEY_ID",
  "assertion":"base64-encoded-assertion"
}

여기서 payload를 외부에서 읽지 못하게 보호하는 역할은 HTTPS입니다.

App Attest는 payload를 암호화해서 숨기는 기능이 아닙니다.

App Attest는 다음을 검증하는 데 사용됩니다.

  • 이 요청이 서버가 신뢰할 수 있는 앱 인스턴스에서 만들어졌는가
  • 앱이 assertion을 만든 이후 요청 데이터가 변경되지 않았는가
  • 예전에 성공했던 요청을 그대로 재사용하는 재전송 공격을 막을 수 있는가

따라서 민감한 API에서는 보통 다음 두 가지를 함께 사용해야 합니다.

HTTPS
→ 통신 중 데이터 노출 방지

App Attest
→ 정상 앱 인스턴스 요청 여부와 요청 무결성 검증

App Attest는 두 단계로 동작한다

App Attest는 크게 두 단계로 나누어 이해하면 편합니다.

단계목적
Attestation최초 한 번, 이 키가 정상 앱 인스턴스에서 생성된 키인지 서버에 등록
Assertion실제 API 호출마다, 해당 요청이 등록된 앱 인스턴스에서 만들어졌는지 검증

저는 처음에 이 두 단계를 섞어서 이해하고 있었습니다.

attestKey()가 모든 API 요청마다 호출되는 것이라고 생각했지만, 실제로는 최초 키 등록 단계에서 사용하고 이후 중요한 요청에서는 generateAssertion()을 사용합니다.


1. Attestation: 정상 앱 인스턴스의 키를 서버에 등록하는 단계

앱에서 키 생성하기

먼저 앱에서는 App Attest용 키를 생성합니다.

DCAppAttestService.shared.generateKey {keyId,errorin
guardletkeyId=keyIdelse {
return
    }

// keyId 저장 후 attestation 진행
}

여기서 중요한 점은 keyId가 개인키 자체가 아니라는 것입니다.

개인키는 기기 내부의 보안 영역에서 관리되고, 앱은 해당 키를 직접 꺼내서 사용할 수 없습니다. 앱은 keyId를 통해 App Attest 서비스에 서명 작업을 요청합니다.


서버에서 일회성 challenge 발급하기

키를 생성했다고 바로 서버가 신뢰해서는 안 됩니다.

서버는 먼저 일회성 랜덤 값인 challenge를 발급합니다.

서버
  ↓
REGISTER_RANDOM_VALUE 발급
  ↓
앱

앱은 이 challenge를 해시한 값을 clientDataHash로 만들어 attestKey()에 전달합니다.

letclientDataHash=Data(SHA256.hash(data:challengeData))

DCAppAttestService.shared.attestKey(
keyId,
clientDataHash:clientDataHash
) {attestationObject,errorin
guardletattestationObject=attestationObjectelse {
return
    }

// attestationObject와 keyId를 서버로 전달
}

서버가 직접 발급한 challenge를 사용하는 이유는, 공격자가 이전에 성공했던 attestation 결과를 그대로 재사용하지 못하게 하기 위해서입니다.


attestationObject에는 무엇이 들어 있을까

앱이 attestKey()를 호출하면 Apple App Attest 서비스는 attestationObject를 반환합니다.

이 데이터는 JSON 문자열이 아니라 CBOR 기반의 바이너리 데이터이며, 내부적으로는 WebAuthn 계열의 attestation 구조를 사용합니다.

CBOR
CBOR

큰 구조는 다음과 같이 이해할 수 있습니다.

attestationObject
 ├─ fmt
 │   └─ apple-appattest
 │
 ├─ attStmt
 │   ├─ 인증서 체인
 │   └─ receipt
 │
 └─ authData
     ├─ 앱 식별 정보 해시
     ├─ Counter
     ├─ 환경 식별 정보
     ├─ Credential ID
     └─ Public Key 관련 데이터

이 단계에서 서버가 해야 하는 일은 단순히 데이터를 저장하는 것이 아닙니다.

서버는 전달받은 attestation 결과를 검증한 뒤에만 해당 공개키를 신뢰해야 합니다.


서버에서 검증하는 항목

서버 검증은 대략 다음 흐름으로 진행됩니다.

┌──────────────── 앱 ────────────────┐
│                                     │
│ keyId                               │
│ attestationObject                   │
│                                     │
└────────────────┬────────────────────┘
                 │
                 ▼
┌────────────── 서버 검증 ──────────────┐
│                                       │
│ 1. App Attest 형식인지 확인            │
│                                       │
│ 2. Apple이 증명한 인증서 체인인지 확인 │
│                                       │
│ 3. 서버가 발급한 challenge와 연결되는지│
│    nonce 검증                         │
│                                       │
│ 4. 우리 Team ID / Bundle ID 앱인지 확인│
│                                       │
│ 5. 개발/운영 환경이 맞는지 확인        │
│                                       │
│ 6. keyId와 공개키 관계 검증            │
│                                       │
│ 7. 검증 성공 시 공개키 저장            │
│                                       │
└───────────────────────────────────────┘

여기서 핵심은 서버가 공개키를 아무 키나 저장하면 안 된다는 것입니다.

Apple이 증명한 정상 앱 인스턴스의 키이며, 동시에 우리 서버가 기대하는 앱 식별 정보와 일치할 때만 저장해야 합니다.

서버에는 보통 다음과 같은 정보가 저장됩니다.

userId
keyId
publicKey
environment
counter
receipt
registeredAt

이렇게 최초 등록이 끝나면, 이후에는 실제 API 요청 시마다 이 공개키를 사용해 assertion을 검증할 수 있습니다.


2. Assertion: 실제 API 요청이 정상 앱에서 생성되었는지 확인하는 단계

Attestation은 최초 등록 과정입니다.

하지만 실제로 중요한 것은 이후 API 요청입니다.

예를 들어 사용자가 포인트를 사용하거나, 쿠폰을 발급받거나, 유료 콘텐츠를 내려받는 요청을 보낸다고 가정하겠습니다.

{
  "action":"point_use",
  "amount":1000,
  "userId":"user_123"
}

이 데이터가 단순히 HTTPS로 전달되기만 한다면 통신 중 노출은 막을 수 있지만, 변조 앱이 정상 사용자처럼 서버 API를 호출하는 문제까지 해결하지는 못합니다.

그래서 실제 중요 API 요청에는 Assertion을 함께 사용합니다.


Assertion 생성 흐름

서버는 API 요청 직전에 새로운 일회성 challenge를 발급합니다.

서버 challenge: d4f7...9a11

앱은 서버 challenge와 실제 payload를 함께 사용해 해시값을 만듭니다.

개념적으로는 다음과 같습니다.

clientDataHash = SHA256(challenge + payload)

그리고 해당 해시값을 이용해 assertion을 생성합니다.

letclientDataHash=Data(SHA256.hash(data:requestData))

DCAppAttestService.shared.generateAssertion(
keyId,
clientDataHash:clientDataHash
) {assertion,errorin
guardletassertion=assertionelse {
return
    }

// payload와 assertion을 서버로 전송
}

실제 구현에서는 challenge + payload를 단순 문자열 결합으로 처리하기보다, 항상 동일한 바이트 배열이 만들어지도록 규칙을 명확히 정해야 합니다.

예를 들면 다음과 같은 항목을 정해야 합니다.

  • JSON 키 순서를 어떻게 고정할지
  • 공백과 줄바꿈을 제거할지
  • UTF-8 인코딩을 사용할지
  • challenge와 payload를 어떤 형식으로 결합할지

앱과 서버가 서로 다른 바이트 배열을 해시하면 정상 요청도 검증에 실패할 수 있기 때문입니다.


서버는 무엇을 검증할까

앱은 서버에 다음과 같은 데이터를 전달할 수 있습니다.

{
  "payload": {
    "action":"point_use",
    "amount":1000,
    "userId":"user_123"
  },
  "keyId":"GENERATED_KEY_ID",
  "assertion":"base64-encoded-assertion"
}

서버는 다음 순서로 검증합니다.

1. keyId로 등록된 publicKey 조회

2. 해당 요청에 발급했던 challenge 조회

3. 서버에서도 동일한 규칙으로
   SHA256(challenge + payload) 생성

4. 저장된 publicKey로 assertion 검증

5. counter와 challenge 사용 여부 확인

6. 모두 정상인 경우에만 실제 API 처리

여기서 challenge는 반드시 일회성이어야 합니다.

한 번 사용된 challenge를 다시 허용하면 공격자가 과거에 성공했던 요청과 assertion을 그대로 재전송할 수 있기 때문입니다.

예를 들어 포인트 사용 요청이 성공한 뒤, 공격자가 같은 요청을 다시 보내는 것을 막으려면 서버는 다음과 같이 처리해야 합니다.

challenge 발급
  ↓
요청 검증 성공
  ↓
challenge 사용 완료 처리
  ↓
동일 challenge 재사용 요청 거부

제가 가장 궁금했던 질문: 앱을 변조하면 attestKey()가 실패할까

처음에는 이렇게 생각했습니다.

정상 앱 추출
  ↓
코드 변조
  ↓
공격자 인증서로 재서명
  ↓
앱 실행
  ↓
attestKey() 호출
  ↓
Apple이 변조 앱이므로 즉시 실패 처리

하지만 App Attest를 서버 검증 관점에서 다시 살펴보니, 이 흐름을 그대로 확정해서 생각하면 안 된다는 결론에 도달했습니다.

변조 앱이 실행된 뒤 attestKey()를 호출하려는 것 자체까지 앱 내부에서 무조건 차단된다고 기대해서는 안 됩니다.

중요한 것은 다음입니다.

변조 앱이 어떤 attestation 결과를 만들어 보내더라도
서버가 기대하는 정상 앱 식별 정보와 일치하지 않으면
서버가 해당 키를 등록하거나 중요 API 요청을 허용하지 않아야 한다.

예를 들어 공격자가 IPA를 추출한 뒤 일부 보안 코드를 제거하고 자신의 방식으로 다시 서명해 실행한다고 가정해 보겠습니다.

정상 앱 추출
  ↓
탈옥 감지 코드 제거
  ↓
변조 앱 재서명
  ↓
앱 실행
  ↓
서버 API 호출 시도

이 경우 앱 실행 자체는 가능할 수 있습니다.

하지만 서버가 App Attest 검증을 제대로 구현했다면, 공격자가 보낸 요청은 정상 앱 인스턴스가 만든 요청으로 인정되지 않아야 합니다.

따라서 App Attest를 적용할 때 목표는 아래처럼 잡는 것이 현실적입니다.

앱 실행 자체를 100% 막겠다
→ 현실적으로 어려움

변조 앱으로 중요한 서버 기능을 사용하지 못하게 하겠다
→ App Attest와 서버 검증으로 대응 가능

그렇다면 탈옥 감지 코드는 필요 없을까

App Attest가 있다고 해서 앱 내부의 탈옥 감지나 변조 감지가 의미 없는 것은 아닙니다.

역할이 다릅니다.

앱 내부 보안 검사는 사용자 경험과 초기 방어선 역할을 할 수 있습니다.

예를 들면 다음과 같습니다.

  • 탈옥 흔적이 발견되면 경고 화면 표시
  • 디버거 연결이나 후킹 흔적 감지
  • 비정상 실행 환경에서는 민감 화면 접근 제한
  • 보안 위협 로그를 서버로 전달

하지만 앱 내부 검사는 공격자가 바이너리를 수정하면 우회될 가능성이 있습니다.

반면 App Attest는 서버가 중요한 API 호출을 승인할지 판단하는 데 사용됩니다.

따라서 실제 서비스에서는 두 가지를 함께 사용하는 편이 좋습니다.

앱 내부 검사
→ 빠른 탐지와 사용자 차단 화면

App Attest + 서버 검증
→ 중요한 서버 기능 보호

App Attest를 적용하면서 특히 주의할 점

직접 구조를 정리하면서, 구현에서 가장 중요하다고 느낀 부분은 다음과 같습니다.

1. 중요 API에만 적용 범위를 정하기

모든 API 요청마다 assertion을 생성하면 구현과 운영이 복잡해질 수 있습니다.

다음과 같이 조작되었을 때 피해가 큰 API부터 적용하는 것이 현실적입니다.

  • 포인트 사용
  • 쿠폰 발급
  • 결제 승인
  • 유료 콘텐츠 다운로드
  • 계정 중요 정보 변경
  • 이벤트 보상 지급

2. challenge는 서버가 발급하고 일회성으로 관리하기

challenge를 앱이 임의로 생성하게 두면 재전송 공격 방어가 어렵습니다.

반드시 서버가 발급하고, 다음 정보를 함께 관리해야 합니다.

challenge 값
발급 대상 사용자 또는 세션
발급 시간
사용 완료 여부
만료 시간

3. payload 해시 규칙을 앱과 서버에서 동일하게 만들기

다음 두 데이터가 의미상 같더라도 바이트 배열은 다를 수 있습니다.

{"amount":1000,"userId":"user_123"}
{
  "userId":"user_123",
  "amount":1000
}

따라서 JSON을 그대로 문자열 결합해서 해시하면 검증 오류가 발생할 수 있습니다.

실제 구현에서는 canonical JSON 규칙을 정하거나, 검증 대상 필드를 고정된 순서로 직렬화하는 방식이 필요합니다.


4. 클라이언트의 성공 여부만 믿지 않기

앱에서 다음과 같이 성공 결과를 보내더라도 서버가 그대로 믿어서는 안 됩니다.

{
  "isSafeDevice":true,
  "isIntegrityValid":true
}

변조 앱은 이 값을 얼마든지 조작할 수 있습니다.

서버는 반드시 자신이 저장한 공개키와 challenge, assertion을 이용해 직접 검증해야 합니다.


5. 실패 시 정책도 함께 설계하기

App Attest는 네트워크 상황이나 지원 여부, 키 재생성, 앱 재설치 등의 예외 상황을 고려해야 합니다.

따라서 무조건 실패 즉시 영구 차단하기보다는 서비스 특성에 따라 정책을 나누는 것이 좋습니다.

예를 들면 다음과 같습니다.

상황처리 예시
일반 조회 API제한적으로 허용
포인트 사용 APIassertion 검증 성공 시에만 허용
새 기기 또는 앱 재설치키 재등록 절차 진행
검증 반복 실패계정 또는 기기 위험도 증가 처리

정리: App Attest는 앱을 잠그는 기능이 아니라 서버의 판단 근거다

App Attest를 처음 접했을 때는 “변조 앱을 실행하지 못하게 하는 기능”으로 생각했습니다.

하지만 실제로 구조를 따라가 보니 핵심은 달랐습니다.

App Attest는 앱 내부에서 모든 공격을 막아주는 방패가 아니라, 서버가 다음 질문에 답할 수 있도록 도와주는 장치입니다.

“지금 들어온 이 중요한 요청을 정말 우리 앱의 정상 인스턴스가 만든 것으로 믿어도 되는가?”

정리하면 다음과 같습니다.

앱 코드 변조 후 실행 자체 차단
→ App Attest만으로 보장할 수 없음

탈옥 감지 함수 제거 방지
→ 앱 내부 코드만으로 완전 방어 어려움

변조 앱이 중요 서버 API 사용
→ 서버가 App Attest를 검증하면 차단 가능

payload 통신 중 노출 방지
→ HTTPS의 역할

payload 변조 및 재전송 방지
→ Assertion + 서버 challenge 검증의 역할

iOS 앱에서 보안을 강화하려면 탈옥 감지 코드 한두 개를 추가하는 것만으로는 부족합니다.

앱은 변조될 수 있다는 전제 아래, 정말 보호해야 하는 기능은 서버에서 검증하도록 설계해야 합니다.

그때 App Attest는 “앱 실행을 막는 기술”이 아니라, 정상 앱의 요청만 서버가 신뢰하도록 만드는 기술로 활용할 수 있습니다.


관련 자료

앱프로그램 무결성 관련 글