앱 밖에서 온 API 요청을 그냥 믿지 않기
App Attest로 앱 출처 신뢰를 확인하고, 상태를 바꾸는 요청은 한 번 더 조심해서 다뤘다.
한강자리는 로그인 없는 앱이다. 사용자는 계정을 만들지 않고 한강공원의 주차장, 행사, 날씨, 알림을 본다. 이 구조는 사용하기 쉽고 privacy 설명도 단순하다.
하지만 서버 입장에서는 한 가지 질문이 남는다. 이 HTTP 요청이 정말 App Store에서 받은 iOS 앱에서 온 것인지, 아니면 앱 밖에서 비슷하게 만든 요청인지 어떻게 구분할 것인가.
모바일 앱의 API는 앱 안에서만 보이는 문이 아니다. 앱이 호출하는 주소와 payload 모양을 알면, 앱 밖에서도 같은 모양의 요청을 만들 수 있다.
API key를 앱에 넣어도 그 key는 앱을 받은 사람의 기기 안에 있다. 숨겨진 서버 비밀처럼 볼 수 없다.
그래서 한강자리에서는 App Attest를 붙였다. 다만 모든 요청마다 App Attest assertion을 만들지는 않았다.
먼저 앱 인스턴스에서 온 요청인지 확인한다. 서버 상태나 운영 지표를 바꿀 수 있는 일부 요청은 본문 무결성까지 한 번 더 확인한다.
공격을 완전히 없애는 기능은 아니다
App Attest를 붙인다고 외부 공격이 사라지지는 않는다. 네트워크 위에서 오가는 것은 여전히 HTTP 요청이고, 공격자는 공개 API를 관찰하거나, 오래된 앱을 분석하거나, 자동화된 호출을 만들 수 있다.
그래서 이 기능을 “해킹 방지 스위치”로 보지 않았다. 더 정확한 목표는 서버가 요청을 받을 때 다음 질문에 답할 수 있게 만드는 것이었다.
- 이 요청은 우리가 배포한 앱 인스턴스에서 시작됐는가.
- 이 앱 인스턴스는 서버가 한 번 등록한 key와 검증 자료를 계속 쓰고 있는가.
- 상태를 바꾸는 요청의 핵심 내용이 중간에 바뀌지 않았는가.
- 더 약한 fallback 신호를 같은 수준의 권한으로 오해하고 있지는 않은가.
예를 들어 누군가 앱 밖에서 첫 화면 조회 API를 반복 호출할 수 있다고 하자. 이 요청을 완전히 불가능하게 만들 수는 없다.
하지만 서버가 앱 토큰을 요구하면, 앱 밖에서 단순히 주소와 JSON 모양만 흉내 낸 요청은 정상 앱 요청과 같은 대우를 받기 어렵다.
또 다른 예는 알림 설정 변경이다. 이런 요청은 서버에 상태를 만든다. 여기서 본문이 바뀌면 사용자가 의도하지 않은 설정이 만들어질 수 있다.
그래서 이런 요청은 앱 인스턴스 신호만 보지 않고, 요청의 핵심 내용까지 함께 확인한다.
먼저 앱 인스턴스를 등록한다
iOS 앱은 처음부터 서버 API를 바로 믿게 만들지 않는다. 앱 안의 보안 모듈이 App Attest 지원 여부를 확인하고, 지원되면 단말 안에서 key를 만든다.
앱은 private key를 직접 받지 않는다. key identifier만 받고, 등록이 성공한 뒤에만 단말의 안전한 저장소에 남긴다.
등록 흐름은 서버 challenge에서 시작한다.
sequenceDiagram autonumber participant App as iOS 앱 participant Apple as Apple App Attest participant API as 한강자리 API participant DB as 앱 인스턴스 저장소 App->>API: register용 challenge 요청 API-->>App: one-time challenge App->>Apple: key attestation 생성 Apple-->>App: attestation object App->>API: key id와 attestation object 등록 API->>API: attestation 검증 API->>DB: 앱 인스턴스 검증 자료 저장 API-->>App: 짧은 수명 app access token
서버는 attestation object를 그대로 믿지 않는다. Apple App Attestation 인증서 체인, 서버가 발급한 challenge, Team ID와 Bundle ID 같은 앱 식별 맥락, key identifier가 서로 맞는지 확인한다.
이 검증은 서버에서 해야 한다. 앱 안에서 “정상입니다”라고 판단해 봐야 변조된 앱은 그 판단 결과도 바꿀 수 있다.
App Attest에서 중요한 일은 iOS API 호출보다 서버가 public key를 저장하고, 이후 요청에서 같은 key를 계속 확인하는 것이다.
일반 요청은 짧은 수명 토큰으로 묶었다
등록이 끝난 뒤 iOS 앱은 매 요청마다 attestation을 다시 하지 않는다. 이미 등록한 key로 app access token을 갱신하고, 서버는 짧은 수명 token을 발급한다.
한강자리의 app access token은 짧은 수명으로 둔다. iOS 앱은 만료가 가까워지면 새 token을 받고, 일반 앱 API 요청에는 app access token header를 붙인다.
flowchart LR Request["일반 앱 API 요청"] --> Header["app access token header"] Header --> Verify["서명과 만료 검증"] Verify --> Instance["앱 인스턴스 조회"] Instance --> Context["등록 맥락과 상태 확인"] Context --> API["API 처리"]
이 token은 사용자 인증 토큰이 아니다. 사용자가 누구인지 말하지 않는다. 대신 서버가 이전에 등록한 앱 인스턴스에서 온 요청인지 확인하는 신호다.
이 구분이 중요했다. 한강자리는 로그인 없는 앱이고, 많은 조회 API는 공공 데이터를 내려준다. 사용자를 식별할 필요는 없다. 하지만 “앱 밖에서 만든 자동 호출”과 “우리가 배포한 앱의 정상 요청”을 같은 신뢰도로 볼 필요도 없다.
서버는 token을 검증한 뒤 DB의 앱 인스턴스를 다시 본다. token이 말하는 앱 인스턴스와 저장된 등록 맥락이 어긋나거나 비활성 상태라면 거부한다.
이렇게 하면 단순 복사 요청은 약해진다. 주소와 body 모양을 따라 하는 것만으로는 충분하지 않다. 서버가 발급한 짧은 수명 token이 필요하고, 그 token은 등록된 App Attest key에서 이어져야 한다.
상태를 바꾸는 요청은 body까지 묶었다
모든 요청에 App Attest assertion을 붙일 수도 있다. 하지만 그렇게 하지 않았다.
assertion은 Apple 서버를 왕복하지 않지만, 매번 암호 연산을 한다. 더 중요한 이유는 운영 해석이다. 모든 polling, 조회, telemetry에 같은 무게의 proof를 붙이면 어디가 정말 위험한 요청인지 흐려진다.
한강자리에서는 상태를 만들거나 운영 metric을 오염시킬 수 있는 쓰기 요청에만 request proof를 붙였다. 공개 글에서는 정확한 endpoint보다 어떤 성격의 요청을 더 강하게 봤는지가 더 중요하다.
| 요청 범주 | 왜 더 강하게 봤는가 |
|---|---|
| 알림 설정 변경 | 서버에 알림 설정을 만들거나 정리한다. |
| telemetry 수집 | 이벤트나 성능 신호를 운영 metric에 반영한다. |
request proof는 요청 단위 검증이다. token은 “등록된 앱 인스턴스인가”를 본다. request proof는 “이 key가 이 요청의 핵심 내용까지 보고 서명했는가”를 본다.
서버와 앱은 서버가 낸 challenge와 요청 본문 요약값을 함께 묶어 proof challenge를 만든다.
그 결과를 App Attest assertion으로 서명한다. 서버는 challenge와 assertion을 확인하고, 저장된 검증 자료로 다시 검증한다.
이 구조는 body 변조를 설명하기 쉽게 만든다. 예를 들어 정상 앱이 어떤 알림 설정 변경 요청을 만들었는데, 중간에서 본문이 다른 설정으로 바뀌었다고 하자.
서버가 요청 본문 요약값을 proof 재료에 넣으면, 바뀐 body로는 기존 assertion이 맞지 않는다.
replay에도 같은 원리가 들어간다. 서버 challenge는 한 번만 쓰고, assertion의 sign count 같은 counter 신호도 이전 요청과 이어져야 한다. 예전에 캡처한 요청을 다시 보내는 방식은 이 구간에서 걸린다.
fallback은 같은 권한이 아니다
모든 iOS 실행 환경에서 App Attest를 쓸 수 있는 것은 아니다. 개발 환경, 일부 실행 환경, extension 성격에 따라 지원 여부가 다를 수 있다.
한강자리 iOS 앱은 개발용 정책과 배포 빌드 정책을 분리했다.
개발 중에는 로컬 확인을 막지 않는 장치가 필요하지만, 배포 빌드는 App Attest 실패를 조용히 통과시키지 않는다.
DeviceCheck fallback도 있다. App Attest가 불가능하고 DeviceCheck는 쓸 수 있다면, 앱은 제한된 fallback 신호로 app access token을 받을 수 있다.
서버는 fallback으로 발급한 token을 App Attest token과 구분한다.
하지만 이 token을 App Attest와 같은 수준으로 보지 않았다. fallback은 일반 앱 요청을 살리는 장치이지, 더 강한 검증이 필요한 요청까지 열어 주는 우회로가 아니다.
flowchart TD
Signal["앱 출처 신호"] --> Strong{"App Attest 가능?"}
Strong -->|"예"| AppAttest["일반 요청 + 강화 요청 검증"]
Strong -->|"아니오"| Fallback["제한된 fallback"]
AppAttest --> Allow["요청 성격에 따라 허용"]
Fallback --> Limited["일반 앱 요청만 제한적으로 허용"]
이 구분은 외부 공격을 생각할 때 중요하다. fallback은 정상 사용자를 살리는 장치이지, 더 약한 검증으로 같은 권한을 주는 우회로가 되면 안 된다.
막은 것과 남긴 것
App Attest를 붙였다고 모든 공격을 막았다고 말하면 안 된다. 그래서 한강자리에서는 막는 것과 남는 것을 나눠 봤다.
| 상황 | App Attest 적용 뒤 달라지는 점 | 여전히 필요한 것 |
|---|---|---|
| 앱 밖에서 API 모양만 흉내 내는 호출 | 앱 출처 신호 없이는 일반 앱 요청처럼 보기 어렵다. | IP rate limit, abuse metric, cache 보호 |
| 상태 변경 body를 바꾸는 시도 | 요청의 핵심 내용을 함께 검증한다. | 알림 설정 보호, 구독 정리, audit |
| 이전 요청을 다시 보내는 replay | 일회성 challenge와 sign count 같은 counter 신호가 걸린다. | clock, 저장소 장애, retry 정책 |
| App Attest가 안 되는 환경 | fallback을 제한된 권한으로 다룬다. | 배포 빌드의 fail-closed 정책, 개발용 token 관리 |
| 손상된 단말이나 broker형 호출 | 위험 신호를 더 많이 얻는다. | 완전 차단으로 과신하지 않는 운영 판단 |
특히 마지막 줄이 중요하다. Apple도 App Attest를 손상된 OS를 완벽하게 판별하는 도구로 설명하지 않는다.
App Attest는 risk signal이다. 공격 비용을 올리고, 서버가 이상한 요청을 더 잘 분류하게 만든다. 하지만 rate limit, 로그 마스킹, token 만료 정책, key recovery, 운영 metric을 대체하지 않는다.
추가 risk signal은 다음 단계로 남겼다
Apple은 App Attest receipt를 서버에서 Apple로 보내 fraud metric을 받을 수 있게 한다. 이 값은 최근 30일 동안 특정 device/app 쌍에서 만들어진 attested key 수를 risk signal로 볼 수 있게 해준다.
한강자리에서는 이 신호를 바로 차단 규칙으로 쓰기보다 baseline을 쌓고 spike를 보는 쪽이 더 맞다고 봤다.
재설치, 복원, 기기 변경도 key 수를 늘릴 수 있다. 그래서 metric 하나로 사용자를 막으면 정상 사용자를 잘못 막을 수 있다.
지금 단계에서는 서버가 검증할 수 있는 핵심 경로를 먼저 닫고, 더 넓은 risk 판단은 다음 단계로 남겼다.
보안 기능보다 신뢰 질문이 먼저였다
처음에는 App Attest를 “iOS 보안 기능”으로 보기 쉽다. 하지만 실제로 붙여 보니 더 중요한 질문은 따로 있었다.
어떤 요청은 앱 인스턴스만 확인해도 충분한가. 어떤 요청은 body까지 서명해야 하는가. fallback은 어디까지 허용해야 하는가. 실패했을 때 사용자를 살릴 것인가, 서버 상태를 지킬 것인가.
한강자리에서는 일반 앱 API 요청을 짧은 수명 token으로 묶었다. 알림 설정이나 품질 신호처럼 상태를 만들거나 metric을 오염시킬 수 있는 요청에는 request proof를 붙였다.
DeviceCheck fallback은 살려 두되, 더 강한 검증이 필요한 요청까지 열어 주지는 않았다.
이 선택은 공격을 없애는 것이 아니라, 서버가 요청을 더 조심스럽게 믿게 만드는 일에 가까웠다. 로그인 없는 앱에서도 API를 전부 무방비로 둘 필요는 없다.
다만 무엇을 믿을지보다, 무엇을 그대로 믿지 않을지를 먼저 정해야 했다.
참고한 Apple 문서는 DeviceCheck, DCAppAttestService, Validating apps that connect to your server, Assessing fraud risk다.