programming/Swift(iOS)

[Swift/iOS] Jailbreak Detection(탈옥 감지)의 필요성

마들브라더 2026. 4. 25. 22:35

Jailbreak Detection, 완벽한 방어는 없지만 그래도 해야 하는 이유


1. 들어가며

iOS 개발을 하다 보면 보안 관련 작업은 왠지 "굳이 하지 않아도 되는 것"처럼 느껴질 때가 있습니다. 애플의 보안성을 믿기도 하고, 요즘도 탈옥하는 사람이 있나? 싶은 마음도 있습니다. 기능 개발도 바쁜데, 탈옥 감지나 앱 무결성 검증까지 신경 써야 하나 싶기도 합니다. 그럼에도 불구하고 왜 해야 하는지, 어떻게 접근해야 하는지를 이야기해보려 합니다.

이 글에서는 iOS의 기본 보안 구조를 살펴보고, 탈옥이 그 구조를 어떻게 무너뜨리는지, 공격자가 실제로 무엇을 할 수 있는지, 그리고 탈옥 감지와 앱 무결성 검증을 어떻게 구현하는지까지 차근차근 살펴보겠습니다.


2. iOS 보안 개념

iOS 샌드박스란?

iOS의 모든 앱은 샌드박스(Sandbox) 안에서 실행됩니다. 쉽게 말하면 각 앱이 자기만의 독립된 방 안에 갇혀 있는 것입니다. A 앱은 B 앱의 데이터에 접근할 수 없고, 시스템 파일에도 함부로 접근할 수 없습니다. 이 구조 덕분에 악성 앱이 설치되더라도 다른 앱이나 시스템 전체에 피해를 주기 어렵습니다.

코드 서명(Code Signing)이란?

앱스토어에 올라가는 모든 앱은 Apple의 인증을 받은 개발자 인증서로 서명되어야 합니다. iOS는 앱을 실행할 때 이 서명을 검증해서, 서명이 유효하지 않으면 실행 자체를 거부합니다. 이 덕분에 출처가 불분명한 앱이 기기에서 실행되기 어렵고, 앱이 배포 이후에 변조됐는지도 확인할 수 있습니다.

탈옥이 이 구조를 어떻게 무너뜨리는가?

탈옥(Jailbreak)은 iOS의 커널 취약점을 이용해 커널 수준의 제약을 해제하고, 루트 접근과 코드 서명 우회를 가능하게 하는 행위입니다. 과거에는 단순히 루트 권한을 획득하는 방식이 주였지만, palera1n이나 Dopamine 같은 현대의 탈옥 도구들은 더 정교한 방식을 사용합니다.

커널 패치(kernel patch)란 기기의 운영체제 핵심부인 커널을 직접 수정해서, 코드 서명 검증 로직 자체를 비활성화하는 것을 말합니다. 여기에 PPL(Page Protection Layer) 우회라는 개념도 등장하는데, PPL은 Apple이 ARM 하드웨어 수준에서 커널의 핵심 메모리 영역을 보호하는 보안 계층입니다. 쉽게 말하면, 커널조차 함부로 수정할 수 없도록 하드웨어가 한 번 더 지켜주는 자물쇠입니다.

현대 탈옥 도구들은 이 PPL마저 우회하거나, PPL을 건드리지 않으면서도 코드 서명 제약을 무너뜨리는 방식을 택하고 있습니다. 이른바 "rootless 탈옥"이라 불리는 방식으로, 루트 권한 없이도 코드 서명 제약을 무력화할 수 있게 됩니다. 탈옥이 되면 샌드박스 제한이 해제되고, 공식 앱스토어를 거치지 않은 앱을 설치할 수 있게 되며, 시스템 파일에도 자유롭게 접근할 수 있게 됩니다.


3. 탈옥 기기에서 공격자가 할 수 있는 것들

탈옥을 통해서 어떤 일이 일어날까요? 단순히 "앱 하나더 설치할 수 있는 것" 수준은 아닙니다. 탈옥은 수많은 공격의 시작점일 뿐입니다.

런타임 조작은 Frida나 Cydia Substrate 같은 툴을 이용해 앱이 실행 중인 상태에서 함수를 후킹(hooking)하는 것입니다. 예를 들어, 로그인 성공 여부를 판단하는 함수를 후킹해서 어떤 아이디/비밀번호를 입력해도 항상 "인증 성공"을 반환하게 만들 수 있습니다. 게임이라면 체력이나 재화 값을 실시간으로 조작하거나, 프리미엄 기능의 잠금을 해제하는 조건 함수를 통째로 무력화할 수도 있습니다.

네트워크 트래픽 가로채기는 앱과 서버 사이의 통신을 평문으로 들여다보는 공격입니다. SSL Pinning 우회 자체는 탈옥 없이도 가능하지만, 탈옥 환경에서 특히 위험한 이유가 있습니다. 일반 환경에서는 프록시 설정을 통해 트래픽을 가로채는 수준에 그치지만, 탈옥 기기에서는 Frida를 이용해 앱 내부의 TLS 스택 자체를 후킹할 수 있습니다.

TLS 스택이란 앱이 HTTPS 통신을 처리할 때 내부적으로 암호화·복호화를 담당하는 함수들의 집합입니다. 이 함수들을 직접 후킹하면 SSL Pinning 로직이 아무리 정교해도 의미가 없어집니다. 암호화되기 전, 혹은 복호화된 직후의 데이터에 바로 접근할 수 있기 때문입니다. 이게 가능해지면 인증 토큰의 구조, API 엔드포인트, 요청 파라미터가 모두 노출되고, 공격자는 앱 없이 서버를 직접 공격할 수 있게 됩니다.

메모리 덤프는 앱이 실행 중일 때 메모리를 통째로 읽어오는 공격입니다. 암호화해서 저장한 데이터도 앱이 사용하려면 반드시 복호화해서 메모리에 올려야 합니다. 그 순간을 노리면 JWT 토큰, 세션 키, 개인정보 등을 탈취할 수 있습니다. "나는 저장할 때 암호화하니까 괜찮다"는 생각이 왜 충분하지 않은지 보여주는 사례입니다.

앱 바이너리 위변조 후 재배포는 앱을 역공학으로 분석한 뒤, 광고를 제거하거나 유료 기능을 무료로 만들거나 악성코드를 삽입해서 앱스토어 외부(Cydia 등)에 다시 올리는 공격입니다. 사용자 입장에서는 겉으로 보기에 똑같은 앱처럼 보이지만, 실제로는 개인정보가 공격자 서버로 조용히 빠져나가고 있는 상황이 됩니다.

결제/인앱구매 우회는 결제 성공 여부를 검증하는 함수를 후킹해서, 실제 결제 없이도 항상 "결제 완료" 상태로 만드는 것입니다. 아이템이나 구독이 활성화되지만 실제 결제는 발생하지 않습니다. 앱 내부에서만 결제를 검증하고 서버 검증이 없다면 특히 취약합니다.


4. 탈옥 감지 (Jailbreak Detection)

탈옥 감지가 필요한 이유

위에서 살펴본 공격들은 대부분 탈옥 기기를 전제로 합니다. 탈옥 기기를 감지해서 앱 실행을 제한하거나 민감한 기능을 비활성화하면, 공격자의 진입 장벽을 높일 수 있습니다. 완벽히 막을 수는 없지만, "이 앱은 공격하기 귀찮다"는 인식을 심어주는 것만으로도 의미가 있습니다.

주요 감지 기법들

탈옥 감지에는 여러 가지 기법이 사용됩니다.

첫 번째는 특정 파일 존재 여부 확인입니다. 탈옥이 되면 탈옥 관련 파일이나 디렉토리가 기기에 생성됩니다. 다만 여기서 중요한 점이 있습니다. 과거의 rooted 탈옥 방식에서는 /Applications/Cydia.app, /bin/bash, /etc/apt 같은 경로에 파일이 생성됐지만, palera1n 기반의 현대적인 rootless 탈옥에서는 이 경로들이 존재하지 않을 수 있습니다. rootless 환경에서는 탈옥 관련 파일들이 /var/jb/ 하위에 위치하기 때문에, 두 경우를 모두 커버하려면 아래처럼 양쪽 경로를 함께 확인해야 합니다.

private func checkForJailbreakFiles() -> Bool {
    let paths = [
        // rooted 탈옥 환경 경로
        "/Applications/Cydia.app",
        "/bin/bash",
        "/etc/apt",
        "/private/var/lib/apt",
        "/usr/sbin/sshd",
        // rootless 탈옥 환경 경로 (palera1n 등)
        "/var/jb/Applications/Cydia.app",
        "/var/jb/usr/bin/ssh",
        "/var/jb/etc/apt"
    ]
    return paths.contains { FileManager.default.fileExists(atPath: $0) }
}

두 번째는 URL Scheme 확인입니다. Cydia는 cydia:// 라는 커스텀 URL Scheme을 등록합니다. 이 Scheme으로 앱을 열 수 있는지 확인하면 Cydia 설치 여부를 간접적으로 알 수 있습니다.

private func checkForCydiaURLScheme() -> Bool {
    guard let url = URL(string: "cydia://package/com.example.package") else { return false }
    return UIApplication.shared.canOpenURL(url)
}

iOS 9 이후부터 canOpenURLInfo.plistLSApplicationQueriesSchemes에 등록된 scheme만 조회할 수 있습니다. cydia를 아래처럼 추가하지 않으면 이 함수는 항상 false를 반환하고, 탈옥 감지가 전혀 이루어지지 않습니다.

<key>LSApplicationQueriesSchemes</key>
<array>
    <string>cydia</string>
</array> 

세 번째는 syscall을 이용한 감지입니다. Swift에서는 C 표준 함수인 syscall을 직접 호출할 수 없기 때문에, dlopen(nil, RTLD_NOW)으로 현재 실행 중인 프로세스의 심볼 테이블을 열고 dlsym으로 syscall 함수 포인터를 꺼내오는 방식을 사용합니다. 여기서 nil을 넘기는 것이 "현재 프로세스 자신의 심볼 테이블을 참조한다"는 의미입니다. 이렇게 꺼낸 syscall로 호출하는 것이 번호 163번의 SYS_csops인데, 이 시스템 콜은 현재 프로세스의 코드 서명 플래그를 조회합니다.

코드에서 flags & 0x1로 확인하는 것이 CS_VALID 플래그입니다. 여기서 한 가지 주의할 점이 있습니다. CS_VALID켜져 있으면 true를 반환하는 이 함수는 사실 "코드 서명이 유효하다 = 정상 기기"라는 의미입니다. 이 함수 단독으로는 탈옥 감지 역할을 하지 않습니다. 실제 사용 의도는 호출부에서 if !checkToFlags()처럼 반전시켜서, "CS_VALID가 꺼져 있으면 코드 서명이 훼손된 탈옥 환경일 수 있다"고 판단하는 것입니다.

private func checkToFlags() -> Bool {
    // Swift에서 syscall을 직접 호출할 수 없으므로,
    // dlopen(nil, ...)으로 현재 프로세스의 심볼 테이블을 열어
    // syscall 함수 포인터를 동적으로 꺼내옵니다
    // 참고: nil을 넘긴 경우 현재 프로세스 자신의 핸들이라 실질적 누수는 없지만,
    // 일반적인 dlopen 사용에서는 반드시 dlclose(handle)로 닫는 것이 원칙입니다
    typealias SyscallPtr = @convention(c) (Int32, Int32, UInt32, UnsafeMutablePointer<UInt32>, Int) -> Int32
    var flags: UInt32 = 0
    let handle = dlopen(nil, RTLD_NOW)
    if let sym = dlsym(handle, "sys" + "call") { // 정적 분석 툴의 문자열 탐지를 피하기 위한 난독화
        let syscall = unsafeBitCast(sym, to: SyscallPtr.self)
        // 163 = SYS_csops: 현재 프로세스의 코드 서명 플래그를 조회하는 시스템 콜
        let result = syscall(50*3+10+3*3, getpid(), 0, &flags, MemoryLayout<UInt32>.size)
        if result == 0 {
            // CS_VALID(0x1)가 켜져 있으면 true(서명 유효 = 정상)를 반환
            // 호출부에서 !checkToFlags()로 반전해서 사용:
            // "CS_VALID가 꺼져 있으면 탈옥 환경으로 의심"
            return (flags & 0x1) != 0
        }
    }
    return false
}

다만 이 방식도 한계가 있습니다. 일부 구형 탈옥 환경에서는 코드 서명 우회 과정에서 CS_VALID가 꺼지는 경우가 있었지만, 현대의 탈옥 도구들은 CS_VALID를 그대로 유지하면서 코드 서명 검증만 선택적으로 우회하는 방향으로 진화했습니다. 이 방법은 현대 탈옥 환경에서 신뢰도가 제한적이므로, 다른 감지 기법과 함께 보조적으로 사용하는 것이 적절합니다.

참고로 "sys" + "call" 문자열 분리 난독화는 strings 명령어 같은 정적 분석 수준에서는 효과가 있지만, Frida처럼 런타임에서 동작하는 분석 툴 앞에서는 의미가 없습니다. 난독화는 정적 분석의 장벽을 높이는 것이지, 동적 분석까지 막아주지는 않습니다.

한계 — 왜 우회가 가능한가?

위의 방법들은 모두 우회가 가능합니다. 파일 존재 여부 확인의 경우, FileManager의 Swift 브리지 레이어를 후킹해서 해당 파일이 없는 것처럼 속일 수 있습니다. URL Scheme 확인도 마찬가지 입니다. 그래서 고급 구현에서는 FileManager대신 POSIXstat() 같은 저수준 API를 직접 사용하기도 하는데, Swift/ObjC 레이어보다 후킹이 어렵기 때문입니다. syscall 기반 감지도 결국 런타임에서 실행되는 코드이기 때문에, 후킹 툴이 개입할 여지가 있습니다. 탈옥 감지 자체를 우회하는 Cydia 트윅도 이미 많이 존재합니다.


5. 앱 무결성 검증 (App Integrity)

앱 위변조란 무엇인가?

앱 위변조는 공격자가 앱 바이너리를 수정한 뒤 다시 서명해서 배포하는 행위입니다. 이렇게 변조된 앱은 원본 앱과 겉으로는 구별이 어렵지만, 내부 동작이 바뀌어 있습니다. 무결성 검증은 "지금 실행 중인 앱이 정말 우리가 서명한 원본인가"를 확인하는 과정입니다.

Team ID 기반 검증 구현

앱의 Bundle IDTeam IDSHA256으로 해싱한 뒤, 미리 하드코딩해둔 값과 비교하는 방법입니다. Team IDKeychainAccess Group 속성에서 가져올 수 있습니다.

private func checkToAreIDsNotMatched() -> Bool? {
    guard let bundleID = Bundle.main.bundleIdentifier else { return nil }
    guard let teamID = getTeamID() else { return nil }

    // getSha256의 반환값이 옵셔널이므로 반드시 언래핑 후 비교해야 합니다
    guard let bundleHash = getSha256(bundleID),
          let teamHash = getSha256(teamID) else { return nil }

    // Bundle ID 해시와 Team ID 해시 중 하나라도 원본과 다르면 위변조로 판단
    if bundleHash != "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz"
        || teamHash != "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz" {
        return true
    }
    return false
}

private func getSha256(_ text: String) -> String? {
    guard let data = text.data(using: .utf8) else { return nil }
    let digest = SHA256.hash(data: data)
    return digest.compactMap { String(format: "%02x", $0) }.joined()
}

private func getTeamID() -> String? {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: "bundleSeedID",
        kSecAttrService as String: "",
        kSecReturnAttributes as String: true
    ]

    var item: CFTypeRef?
    let status = SecItemAdd(query as CFDictionary, &item)

    if status == errSecDuplicateItem {
        SecItemCopyMatching(query as CFDictionary, &item)
    }

    guard let existingItem = item as? [String: Any],
          let accessGroup = existingItem[kSecAttrAccessGroup as String] as? String else {
        return nil
    }

    let components = accessGroup.components(separatedBy: ".")
    return components.first
}

직접 겪은 한계 — 클라이언트 검증의 근본적인 문제

이 방식을 실제로 구현하고 탈옥 환경에서 테스트해봤을 때, 위변조된 앱을 제대로 잡지 못하는 경우가 있었습니다. 처음에는 getTeamID()Keychain API 후킹 문제를 의심했습니다. 탈옥 환경에서는 Frida로 SecItemAddSecItemCopyMatching 같은 Keychain API 자체를 후킹해서, 항상 정상적인 Team ID를 반환하도록 조작할 수 있기 때문입니다.

하지만 더 근본적인 문제가 있습니다. 하드코딩된 해시값과 비교하는 로직 자체가 바이너리 안에 들어 있다는 것입니다. 공격자는 앱 바이너리를 역공학으로 분석해서 해시 비교 조건을 찾아낸 뒤, 그 분기 자체를 패치해버릴 수 있습니다. getTeamID()가 뭘 반환하든 상관없이, 비교 로직이 항상 "일치함"을 반환하도록 바이너리를 수정해버리는 것입니다. 즉, 무엇을 어떻게 가져오느냐의 문제보다, 검증 로직 자체가 공격 대상의 바이너리 안에 존재한다는 것이 클라이언트 무결성 검증의 근본적인 한계입니다.

그렇다면 어떻게 해야 하는가? — App Attest

이 한계를 극복하기 위해 Apple은 iOS 14부터 App Attest API를 공식 제공하고 있습니다. App Attest는 클라이언트가 Apple 서버에 앱의 무결성 증명(attestation)을 요청하고, 서버가 그 증명을 Apple 서버를 통해 검증하는 방식입니다. 핵심은 검증 로직이 클라이언트 바이너리가 아닌 서버와 Apple 인프라에 있다는 것입니다. 공격자가 클라이언트 코드를 아무리 뜯어봐도 검증을 우회할 수 없습니다.

코드를 보기 전에 두 가지 개념을 먼저 짚어야 합니다.

첫째, challenge의 역할입니다. 서버에서 일회성 난수(challenge)를 받아서 attestation에 포함시키는 이유는 리플레이 공격(replay attack) 을 막기 위해서 입니다. 만약 challenge 없이 attestation을 재사용할 수 있다면, 공격자가 정상 기기에서 한 번 통과한 attestation 결과를 녹화해뒀다가 나중에 변조된 앱에서 그대로 제출할 수 있습니다. 매번 새로운 난수를 포함시켜 서명하도록 강제함으로써, 이전에 발급된 attestation은 다시 쓸 수 없게 됩니다.

둘째, generateKey()attestKey()앱 최초 실행 시 딱 한 번씩만 호출해야 합니다. Apple은 이 두 API의 역할을 명확히 구분합니다. attestKey()는 "이 키가 정품 Apple 기기의 정품 앱에서 생성됐다"는 것을 Apple 서버에 최초 등록하는 절차입니다. 이후 매 실행마다 무결성을 증명할 때는 attestKey()가 아니라 generateAssertion()을 사용해야 합니다. generateAssertion()은 등록된 키를 이용해 "지금 이 요청도 같은 정품 앱/기기에서 보낸 것이다"를 매번 증명하는 API입니다. attestKey()를 반복 호출하면 rate limit을 소진하고 Apple이 의도한 설계에서도 벗어납니다.

올바른 흐름을 정리하면 이렇습니다. 최초 실행 시 generateKey() → attestKey()를 순서대로 호출해서 keyId를 저장하고, 이후 실행에서는 저장된 keyIdgenerateAssertion()을 호출하는 것입니다.

import DeviceCheck

// ── 최초 실행 시 한 번만 호출 ──────────────────────────────────────
// generateKey()로 키를 만들고, attestKey()로 Apple 서버에 등록한 뒤 keyId를 저장
func setupAppAttest() async {
    let service = DCAppAttestService.shared
    guard service.isSupported else { return }

    // 이미 저장된 keyId가 있으면 재등록하지 않음
    if UserDefaults.standard.string(forKey: "appAttestKeyId") != nil { return }
    // keyId 자체는 민감한 값이 아니어서 UserDefaults에 저장해도 무방
    // 다만 보안이 중요한 앱이라면 Keychain에 저장하는 것이 더 일관된 선택

    do {
        // 1. 키 생성 — 최초 1회만 호출, 매번 호출하면 rate limit 소진
        let keyId = try await service.generateKey()

        // 2. Apple 서버에 키 등록 (최초 1회)
        //    "이 키는 정품 기기의 정품 앱에서 만들어졌다"를 Apple이 서명
        let challenge = try await fetchChallengeFromServer()
        let clientDataHash = Data(SHA256.hash(data: challenge))
        let attestation = try await service.attestKey(keyId, clientDataHash: clientDataHash)

        // 3. 서버에 attestation 전달 — 서버가 Apple 서버를 통해 최종 검증
        try await sendAttestationToServer(keyId: keyId, attestation: attestation)

        // 4. 등록 완료된 keyId 저장
        UserDefaults.standard.set(keyId, forKey: "appAttestKeyId")
    } catch {
        print("App Attest setup failed: \(error)")
    }
}

// ── 서버에 민감한 요청을 보낼 때마다 호출 ────────────────────────────
// 앱 실행 시점이 아니라, 로그인/결제/개인정보 조회처럼
// 서버가 "이 요청이 정품 앱에서 온 것인지" 확인해야 하는 시점에 호출
// assertion을 요청 본문과 함께 서버로 전송하면, 서버가 무결성을 검증할 수 있음
// attestKey()가 아닌 generateAssertion()을 써야 한다는 점에 주의
func assertAppIntegrity() async {
    let service = DCAppAttestService.shared
    guard service.isSupported else { return }

    guard let keyId = UserDefaults.standard.string(forKey: "appAttestKeyId") else {
        // keyId가 없으면 setupAppAttest()가 먼저 호출
        return
    }

    do {
        // challenge를 포함해 서명하면 리플레이 공격을 막을 수 있음
        let challenge = try await fetchChallengeFromServer()
        let clientDataHash = Data(SHA256.hash(data: challenge))

        // generateAssertion(): 등록된 키로 "지금 이 요청도 정품 앱에서 보낸 것이다"를 증명
        let assertion = try await service.generateAssertion(keyId, clientData: clientDataHash)

        try await sendAssertionToServer(keyId: keyId, assertion: assertion)
    } catch {
        print("App integrity assertion failed: \(error)")
    }
}

클라이언트에서의 Team ID 해시 비교는 공격자가 바이너리를 패치하면 무력화되지만, App Attest는 Apple 서버가 직접 서명한 증명서를 사용하기 때문에 클라이언트 코드 조작으로는 우회할 수 없습니다. 물론 App Attest도 완벽하지는 않습니다. 시뮬레이터와 일부 기기에서는 지원하지 않고, 네트워크 요청이 필요하기 때문에 오프라인 상황에서의 처리 정책도 고려해야 합니다. 그럼에도 불구하고, 클라이언트 단독 검증에 비해 훨씬 강건한 방어선입니다.


6. 그래도 해야 하는 이유

여기까지 읽으면 "어차피 다 우회되는데 왜 해?"라는 생각이 들 수도 있습니다. 충분히 합리적인 생각입니다.

하지만 여기서 보안을 바라보는 올바른 시각은 "완벽한 방어"가 아니라 "공격 비용을 높이는 것" 입니다. 탈옥 감지와 무결성 검증이 있으면, 공격자는 이것들을 먼저 우회해야 합니다. 우회하는 데 시간과 노력이 들고, 그 과정에서 포기하는 공격자도 생깁니다. 자물쇠가 없는 집보다 자물쇠가 있는 집이 더 안전한 것처럼, 완벽하지 않아도 장벽이 존재하는 것 자체로 의미가 있습니다.

보안은 단일 레이어로 완성되지 않습니다. 앞서 syscall 코드에서 봤던 "sys" + "call" 문자열 분리처럼, 각각의 기법은 하나씩 떼어놓으면 우회 가능하지만 여러 겹으로 쌓이면 공격자의 부담이 기하급수적으로 늘어납니다. 탈옥 감지, 앱 무결성 검증, SSL Pinning, 난독화, 그리고 App Attest와 서버 사이드 검증까지 하나가 뚫려도 다음 레이어가 버텨주는 구조, 이것이 보안을 설계하는 기본 구조인 것입니다.

앞서 결제/인앱구매 우회 사례에서 "서버 검증이 없으면 취약하다"고 했는데, 이게 바로 다중 레이어의 전형적인 예시입니다. 클라이언트에서 아무리 정교하게 검증해도 서버가 최종 확인을 하지 않으면 그 레이어는 허수가 됩니다. 반대로 서버 검증이 있으면 클라이언트 레이어가 뚫려도 실질적인 피해로 이어지지 않습니다.


7. 마치며

이 글에서 다룬 내용을 정리하면 이렇습니다. iOS는 샌드박스와 코드 서명이라는 견고한 보안 구조를 갖고 있지만, 탈옥이 되면 그 구조가 무너지게 됩니다. 탈옥 기기에서 공격자는 런타임 조작, 네트워크 가로채기, 메모리 덤프, 앱 위변조 등 다양한 공격을 시도할 수 있습니다. 탈옥 감지는 어느 정도 진입 장벽을 높여주지만, 탈옥 방식의 진화(rootless 탈옥 등)에 맞춰 감지 로직도 계속 업데이트해야 합니다. 클라이언트 단독 무결성 검증은 바이너리 패치로 무력화될 수 있으며, 이를 보완하기 위해 App Attest 같은 서버 연동 검증을 함께 사용하는 것이 바람직합니다.

완벽한 보안은 없습니다. 하지만 공격자의 비용을 높이고, 방어 레이어를 두껍게 쌓아가는 것. 그것이 우리가 할 수 있는 최선이고, 그래서 해야 하는 이유입니다.