애플리케이션의 민감한 정보를 보호하는 방법

“민감한 정보를 기기에 안전하게 저장하는 방법” 은 iOS 개발의 난제 중 하나라고 생각합니다.

실제로 GitHub 같은 소스 컨트롤을 통해서, 앱스토어에 배포된 .ipa 파일을 분석하는 도구를 통해서 등의 방법으로 앱의 중요한 정보들이 유출되는 경우도 존재합니다.

비즈니스에서 보안은 우선순위가 밀릴 때도 있는데요, 우선순위를 올리는 것을 진지하게 고민해보셔야 하는 이유가 여기 있습니다.

노스캐롤라이나 주 대학의 연구원들이 발견한 내용에 따르면, API Key, 이중 인증 정보를 포함하는 민감한 정보들이 하루에도 수천 개씩 GitHub에서 유출되고 있다고 합니다. (Meli, McNiece, & Reaves, 2019) 2018년에 발표된 또 다른 내용에 따르면, 인기 있는 100개의 샘플 앱 중 68개가 SDK 크레덴셜이 제대로 설정돼있지 않았다고 합니다. (Wen, Li, Zhang, & Gu, 2018)

이번 글에선 조금 오래된 밈을 이용해서 민감한 정보를 보호하는 작업을 점진적으로 진행해보겠습니다.

여러분의 앱에 트위터 액세스 토큰, Stripe API Key, AWS Key ID 같은 민감한 정보가 많다면 꼭 읽으시는 것을 추천합니다!


🧠 하수 민감한 정보를 하드 코딩함

인증을 위해서 민감한 정보인 API Key로 웹 애플리케이션과 통신하는 앱이 있다고 해보겠습니다. 그렇다면 인증 코드는 써드 파티 프레임워크로 빼거나 AppDelegateURLSession을 구현해서 작업했을 것입니다. 어떤 작업이든 API Key를 어디에 어떻게 저장하는지가 문제일 것입니다.

코드에 스트링으로 추가하는 것은 어떨까요?

enum Secrets {
    static let apiKey = "6a0f0731d84afa4082031e3a72354991"
}

그리고 앱 배포를 위해 아카이브하면 당연히 코드에 있던 ‘사람이 읽을 수 있는’ 텍스트가 ‘기계만 읽을 수 있는’ 바이너리로 바뀌니 문제가 없을 것입니다. 물론 아무런 보안 문제 없이요… 그렇죠?

그럴 리가요!

Radare2같은 리버스 엔지니어링 도구가 있다면 컴파일된 파일도 읽을 수 있습니다. 물론 실제 프로젝트에서 “Generic iOS Device”를 선택한 후 아카이브한(Product > Archive) 결과(.xcarchive)로도 증명이 가능합니다.

$ r2 ~/Developer/Xcode/Archives/.xcarchive/Products/Applications/Swordfish.app/Swordfish
[0x1000051fc]> iz
[Strings]
Num Paddr      Vaddr      Len Size Section  Type  String
000 0x00005fa0 0x100005fa0  30  31 (3.__TEXT.__cstring) ascii _TtC9Swordfish14ViewController
001 0x00005fc7 0x100005fc7  13  14 (3.__TEXT.__cstring) ascii @32@0:8@16@24
002 0x00005fe0 0x100005fe0  36  37 (3.__TEXT.__cstring) ascii 6a0f0731d84afa4082031e3a72354991

앱 스토어를 사용하는 보통의 사용자라면 당연히 .ipa 파일을 열어볼 생각조차 하지 않겠지만, 보안 및 통계 목적으로 앱의 페이로드를 분석하는 개인이나 단체가 분명 존재할 것입니다. 게다가 그런 회사에는 하드 코딩된 크레덴셜을 걸러내는 봇도 있을 것입니다.

일부는 추측이지만 정확한 부분도 있다고 생각합니다.

소스 코드에 민감한 정보를 하드 코딩하면 그것들은 소스 컨트롤에 영원히 존재하게 될 것입니다. 만약 저장소 설정을 잘못해서 바뀌거나 데이터가 유출됐을 경우 모든 민감한 정보가 공개될 가능성이 있습니다. 사람이 하는 일인데 이런 일이 일어나지 않을 거란 확신은 할 수 없습니다.

조심해서 나쁠 것은 없겠죠?

적에게서 나의 비밀을 지키고 싶다면 친구에게도 그 비밀을 말하지 말라.

벤자민 프랭클린이 했던 말입니다.

🧠 중수 Xcode 설정과 Info.plist에 저장함

이전에 썼던 글에선 12-Factor App에서 말하는 좋은 예제를 보며 .xcconfig 파일을 사용하는 방법에 대해 얘기했었습니다. 게다가 Info.plist를 통한 빌드 설정은 그럭저럭 괜찮은 .env 파일 역할을 할 것입니다.

// Development.xcconfig
API_KEY = 6a0f0731d84afa4082031e3a72354991

// Release.xcconfig
API_KEY = d9b3c5d63229688e4ddbeff6e1a04a49

Info.plist 파일이 소스 컨트롤에 올라가지만 않는다면 이러한 접근 방식은 민감한 정보가 유출되는 문제를 방지할 수 있을 것입니다.

그리고 실제로 l33t hax0r 도구를 통해 검색해보면 아무 결과도 나오지 않습니다. (izz는 스트링 바이너리의 목록을 보여주는 명령어이고, ~6a0f07는 민감한 정보의 앞 몇 글자만 따서 가져온 것입니다.)

$ r2 ~/Developer/Xcode/Archives/.xcarchive/Products/Applications/Swordfish.app/Swordfish
[0x100005040]> izz~6a0f
[0x100005040]>

드디어 안전한 방법을 찾은걸까요? 앱의 페이로드를 확인해보겠습니다.

$ tree /Swordfish.app
├── Base.lproj
│   ├── LaunchScreen.storyboardc
│   │   ├── 01J-lp-oVM-view-Ze5-6b-2t3.nib
│   │   ├── Info.plist
│   │   └── UIViewController-01J-lp-oVM.nib
│   └── Main.storyboardc
│       ├── BYZ-38-t0r-view-8bC-Xf-vdC.nib
│       ├── Info.plist
│       └── UIViewController-BYZ-38-t0r.nib
├── Info.plist
├── PkgInfo
├── Swordfish
├── _CodeSignature
│   └── CodeResources
└── embedded.mobileprovision

Info.plist 파일은 우리가 민감한 정보를 담았던 파일이 아닌가요? 맞습니다. 실행 파일 옆에 잘 패키징돼있네요.

여러 측면에서 봤을 때, 이 방법은 오히려 코드에 민감한 정보를 하드 코딩하는 것보다 안전한 것으로 추측됩니다. 왜냐하면 아무런 작업 없이 페이로드에서 비밀에 바로 접근이 가능하기 때문입니다.

$ plutil -p /Swordfish.app/Info.plist
{
  "API_KEY" => "6a0f0731d84afa4082031e3a72354991"


사용자가 기기에서 앱을 실행했을 때 환경을 바꿀 수 있는 방법은 존재하지 않습니다. 그래서 앞에 얘기한 것처럼 Info.plist를 정보 교환소로 만들어서 빌드 세팅을 바꾸는 것은 의미없을 수 있습니다.

🧠 고수 코드 생성을 통해 민감한 정보를 난독화함

예전에 Swift 표준 라이브러리에서 GYB(한글)라는 코드 생성 도구를 사용했던 글을 기억하시나요? 그 게시글에선 보일러 플레이트 코드를 없애는 것에 중점을 뒀었지만, GYB는 그 이상의 잠재력을 가지고 있습니다.

다음은 생성된 코드에 환경 변수를 가져오기 위해 GYB를 사용하는 방법입니다.

$ API_KEY=6a0f0731d84afa4082031e3a72354991 \
gyb --line-directive '' <<"EOF"
%{ import os }%
let apiKey = "${os.environ.get('API_KEY')}"
EOF

let apiKey = "6a0f0731d84afa4082031e3a72354991"

환경 변수에서 민감한 정보를 가져와서 GYB로 Swift 파일을 생성한 후 커밋을 하지 않으면 소스 코드의 유출 문제는 해결됩니다. 하지만 정적 분석 도구에 의해 뚫릴 것입니다. 그러나 Swift와 (GYB를 통해) Python 코드의 조합을 이용하면 리버스 엔지니어링보다 더 안전한 방법으로 정보를 보호할 수 있습니다.

다음은 매 시간 무작위로 생성되는 솔트를 XOR 암호로 만드는 예제입니다.

// Secrets.swift.gyb
%{
import os

def chunks(seq, size):
    return (seq[i:(i + size)] for i in range(0, len(seq), size))

def encode(string, cipher):
    bytes = string.encode("UTF-8")
    return [ord(bytes[i]) ^ cipher[i % len(cipher)] for i in range(0, len(bytes))]
}%
enum Secrets {
    private static let salt: [UInt8] = [
    %{ salt = [ord(byte) for byte in os.urandom(64)] }%
    % for chunk in chunks(salt, 8):
        ${"".join(["0x%02x, " % byte for byte in chunk])}
    % end
    ]

    static var apiKey: String {
        let encoded: [UInt8] = [
        % for chunk in chunks(encode(os.environ.get('API_KEY'), salt), 8):
            ${"".join(["0x%02x, " % byte for byte in chunk])}
        % end
        ]

        return decode(encoded, salt: cipher)
    }

    
}

환경에서 가져온 정보는 Python 함수에 의해 [UInt8] 배열로 인코딩돼서 소스 코드에 포함됩니다. 인코딩된 값들은 Swift 함수에 의해서 필요할 때 원래 값으로 해석해서 사용합니다. 이렇게하면 소스에는 민감한 정보를 노출하지 않아도 됩니다.

생성되는 코드는 다음과 같습니다.

// Secrets.swift
enum Secrets {
    private static let salt: [UInt8] = [
        0xa2, 0x00, 0xcf, , 0x06, 0x84, 0x1c,
    ]

    static var apiKey: String {
        let encoded: [UInt8] = [
            0x94, 0x61, 0xff,  0x15, 0x05, 0x59,
        ]

        return decode(encoded, cipher: salt)
    }

    static func decode(_ encoded: [UInt8], cipher: [UInt8]) -> String {
        String(decoding: encoded.enumerated().map { (offset, element) in
            element ^ cipher[offset % cipher.count]
        }, as: UTF8.self)
    }
}

Secrets.apiKey // "6a0f0731d84afa4082031e3a72354991"

은닉을 통한 보안 (security through obscurity)이 이론적으로는 오류가 있지만 실전에서는 효과적인 해결책이 될 수 있습니다. (Wang, Wu, Chen, & Wei, 2018)

생각나는 격언이 있습니다.

안전하기 위해 곰을 앞설 필요는 없다. 옆에 있는 사람만 앞지르면 된다.

🧠 초고수 기기에 민감한 정보를 저장하지 않음

기기에 저장된 민감한 정보를 아무리 난독화하더라도 그것이 밝혀지는 것은 시간 문제입니다. 충분한 시간과 동기부여만 주어진다면, 해커는 여러분의 앱에 있는 어떤 정보든 빼낼 수 있습니다.

왕도가 있다면 그것은 바로 기기 대신 서버에 민감한 정보를 저장하는 것입니다.

이 방법을 따르는 순간 우리의 각본은 오션스 11 스타일의 도둑 영화에서 Behind Enemy Lines 처럼 변합니다. (설명하자면, 서버에서 오는 페이로드를 받은 후에 해커에게서 안전한 공간인 Secure Enclave에 저장한다는 의미입니다.)

믿을 수 있는 서버가 없다면, 애플에서 제공하는 서비스를 사용하면 됩니다.

민감한 정보가 Secure Enclave에 도착하면 바로 다음 아웃바운드 리퀘스트에 포함돼서 나갑니다. 하지만 리퀘스트 한 번 제대로 쏘기 위해서 이런 일을 하는 것은 아닙니다.

Secure Enclave 안에 있는 비밀은 안전합니다. 하지만 안전하게 존재하는 것이 비밀의 존재 이유는 아니죠.

🧠 신 클라이언트 보안이 불가능하다고 함

신은 클라이언트에 안전하게 비밀을 저장하는 것이 불가능하다고 말합니다. 누군가 자신의 기기에서 여러분의 소프트웨어를 실행시킬 수 있는 순간 이미 안전하지 않다고 생각합니다.

그리고 클라이언트와 서버 사이에 안전하면서 폐쇄적인 통신 경로를 유지보수하는 것은 엄청난 운영상의 복잡성을 야기합니다. 이것도 애초에 가능한 상황이 있다고 가정했을 경우를 말하는 것입니다.

Julian Assange의 말이 생각나네요.

비밀을 지키는 단 하나의 방법은 비밀을 만들지 않는 것이다.


우리는 앱의 민감한 정보를 관리하는 것을 해결해야 할 문제가 아닌 피해야 할 안티 패턴으로 봐야 한다고 생각합니다.

안전하지 않은 익명 인증 매커니즘의 API_KEY는 왜 존재할까요? 이것은 마치 아무나 사용할 수 있는 백지수표라고 생각합니다. 비즈니스 운영에 있어서

앱의 민감한 정보를 통해서 설정해야 하는 써드파티 SDK는 설계 자체가 안전하지 않습니다. 만약 그런 SDK를 사용하고 있다면 서버로 빼는 것이 가능한지 고려하셔야 합니다. 불가능하다면 여러분은 미래에 생길 유출이 가져올 여파에 대해 알고 계셔야 하고, 어떻게 그 리스크를 어떻게 떠안을지 생각하셔야 합니다. 그러한 방법 중 하나가 이렇게 코드에 있는 민감한 정보들을 난독화 하는 방법에 대해 찾는 것이겠죠?


처음으로 돌아가서 다시 질문해보겠습니다. “민감한 정보는 어떻게 앱에 안전하게 저장할 수 있나요?”

저희의 대답은 다음과 같습니다. “불가능합니다. (하지만 해야하는 상황이라면, 난독화 정도는 해주세요)”


References
  1. Meli, M., McNiece, M. R., & Reaves, B. (2019). How Bad Can It Git? Characterizing Secret Leakage in Public GitHub Repositories. In Proceedings of the NDSS Symposium 2019. Retrieved from https://www.ndss-symposium.org/ndss-paper/how-bad-can-it-git-characterizing-secret-leakage-in-public-github-repositories/
  2. Wen, H., Li, J., Zhang, Y., & Gu, D. (2018). An Empirical Study of SDK Credential Misuse in iOS Apps. In 2018 25th Asia-Pacific Software Engineering Conference (APSEC) (pp. 258–267). https://doi.org/10.1109/APSEC.2018.00040
  3. Wang, P., Wu, D., Chen, Z., & Wei, T. (2018). Protecting Million-User iOS Apps with Obfuscation: Motivations, Pitfalls, and Experience. In 2018 IEEE/ACM 40th International Conference on Software Engineering: Software Engineering in Practice Track (ICSE-SEIP) (pp. 235–244).