iOS 탈옥 감지가 필요한 이유
iOS 탈옥 감지는 금융, 결제, 인증 기능이 있는 앱을 개발할 때 반드시 고려해야 하는 보안 요소입니다. 일반적인 iOS 환경에서는 앱이 샌드박스 안에서 제한적으로 동작하지만, 탈옥 환경에서는 시스템 파일 접근, 런타임 후킹, 동적 라이브러리 주입, 디버깅 도구 실행 등 정상 환경에서는 허용되지 않는 동작이 가능해질 수 있습니다.
저는 실제 iOS 앱에 보안 진단 로직을 적용하면서, 단순히 앱 실행 시점에 한 번만 탈옥 감지를 하는 것으로는 부족하다고 느꼈습니다. 사용자가 앱을 실행한 뒤 백그라운드로 이동하고, 그 사이에 탈옥 환경을 활성화한 다음 다시 앱으로 돌아올 수 있기 때문입니다.
그래서 이번 글에서는 제가 iOS 탈옥 감지를 적용하면서 고민했던 부분과, SceneDelegate에서 앱 실행 시점과 포그라운드 복귀 시점에 보안 검사를 구성한 경험을 정리해보겠습니다.
iOS 탈옥 감지를 적용한 목표
이번 구현의 목표는 명확했습니다.
탈옥 또는 무결성 이상이 감지되면 앱 사용을 차단한다.
단순히 로그만 남기는 것이 아니라, 보안 위험이 감지되면 현재 화면 흐름을 중단하고 보안 경고 화면을 띄운 뒤 앱을 더 이상 사용할 수 없도록 구성하는 것이 목적이었습니다.
특히 결제 앱이나 인증 앱에서는 다음과 같은 위험을 고려해야 합니다.
1. 앱 내부 로직 후킹
2. 결제 요청 데이터 변조
3. 인증 토큰 탈취
4. 디버거 또는 Frida 기반 런타임 분석
5. 탈옥 우회 트윅을 통한 탐지 회피
물론 iOS 탈옥 감지만으로 모든 공격을 완벽하게 막을 수는 없습니다. 하지만 앱 내부에서 최소한의 방어 계층을 구성하고, 비정상 환경에서 주요 기능 사용을 제한하는 것은 충분히 의미 있는 보안 대응입니다.
iOS 탈옥 감지 테스트를 하며 느낀 점
iOS 탈옥 감지를 테스트하려면 실제 탈옥 환경이 필요합니다. 시뮬레이터에서는 탈옥 환경을 재현하기 어렵고, 대부분의 탐지 로직도 정상적으로 의미 있는 결과를 주지 않습니다.
테스트 과정에서는 다음과 같은 유형을 확인했습니다.
1. 탈옥 앱 또는 패키지 관리자 흔적
2. 탈옥 관련 시스템 경로 존재 여부
3. 후킹 프레임워크 또는 동적 라이브러리 로딩 여부
4. Frida, Cycript 같은 런타임 분석 도구 흔적
5. 샌드박스 우회 가능성
6. symbolic link를 이용한 시스템 경로 변경 흔적
이 과정에서 느낀 점은 “하나의 검사만으로는 부족하다”는 것이었습니다. 예를 들어 Cydia 앱만 찾는 방식은 최신 탈옥 환경에서는 효과가 낮을 수 있습니다. 반대로 파일 경로 검사만 하면 우회 트윅에 막힐 수 있습니다.
그래서 여러 검사를 조합하는 방식으로 구성했습니다.
iOS 탈옥 감지에서 확인한 주요 항목
제가 구성한 iOS 탈옥 감지 방식은 크게 다음과 같습니다.
1. 의심스러운 URL Scheme 체크
2. 의심스러운 앱 및 파일 경로 체크
3. 시스템 파일 접근 가능 여부 체크
4. fork() 실행 가능 여부 체크
5. symbolic link 체크
6. 주입된 동적 라이브러리 체크
7. Frida 등 의심 환경 변수 체크
8. 탈옥 우회 트윅 클래스 체크
각 항목은 단독으로 완벽하지 않습니다. 하지만 여러 항목을 함께 검사하면 탐지 가능성을 높일 수 있습니다.
iOS 탈옥 감지 코드 구조
먼저 전체 탐지 클래스는 JailbreakDetector처럼 분리했습니다.
import MachO
import UIKit
final class JailbreakDetector {
static func hasJailBreak() -> Bool {
return checkSuspiciousURIs()
|| checkSuspiciousFiles()
|| checkProcessForking()
|| checkSymbolicLinks()
|| checkInjectedDynamicLibraries()
|| checkShadowRulesetClasses()
|| isSuspiciousEnvironmentPresent()
}
}
이렇게 구성하면 호출부에서는 단순하게 사용할 수 있습니다.
if JailbreakDetector.hasJailBreak() {
// 보안 차단 처리
}
iOS 탈옥 감지 1: 의심스러운 URL Scheme 체크
탈옥 앱이나 패키지 관리자 앱은 특정 URL Scheme을 가지고 있을 수 있습니다. 예를 들어 Sileo, Zebra, Filza 같은 앱이 설치되어 있는지 확인할 수 있습니다.
static private func checkSuspiciousURIs() -> Bool {
let urlSchemes: [String] = [
"undecimus://",
"sileo://",
"zbra://",
"filza://"
]
for scheme in urlSchemes {
guard let url = URL(string: scheme) else {
continue
}
if UIApplication.shared.canOpenURL(url) {
return true
}
}
return false
}
다만 canOpenURL을 사용할 때는 Info.plist의 LSApplicationQueriesSchemes 설정이 필요할 수 있습니다. 또한 URL Scheme 체크는 우회 가능성이 있기 때문에 단독으로 사용하기보다는 보조 검사로 보는 것이 좋습니다.
iOS 탈옥 감지 2: 의심스러운 파일과 경로 체크
탈옥 환경에서는 일반 iOS 앱에서 접근하기 어려운 경로나 탈옥 관련 파일이 존재할 수 있습니다.
static private func checkSuspiciousFiles() -> Bool {
let jailBreakList: [String] = [
"/.bootstrapped_electra",
"/.cydia_no_stash",
"/.installed_unc0ver",
"/Applications/Cydia.app",
"/Applications/Sileo.app",
"/Applications/Zebra.app",
"/Applications/FlyJB.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/Library/MobileSubstrate/DynamicLibraries/",
"/Library/PreferenceBundles/ABypassPrefs.bundle",
"/Library/PreferenceBundles/ShadowPreferences.bundle",
"/bin/bash",
"/bin/sh",
"/etc/apt/",
"/private/var/lib/apt/",
"/private/var/lib/cydia",
"/private/var/stash",
"/usr/bin/ssh",
"/usr/bin/sshd",
"/usr/lib/libhooker.dylib",
"/usr/lib/libsubstitute.dylib",
"/usr/sbin/frida-server",
"/usr/sbin/sshd",
"/var/lib/cydia/",
"/var/log/apt/",
"/var/mobile/Library/Preferences/me.jjolano.shadow.plist"
]
for path in jailBreakList {
if FileManager.default.fileExists(atPath: path) {
return true
}
if canOpenSuspiciousFile(path: path) {
return true
}
if checkStatSuspiciousFile(path: path) {
return true
}
}
return false
}
파일 존재 여부만 확인하는 것이 아니라, fopen, stat까지 함께 사용했습니다.
static private func canOpenSuspiciousFile(path: String) -> Bool {
let file = fopen(path, "r")
guard file != nil else { return false }
fclose(file)
return true
}
static private func checkStatSuspiciousFile(path: String) -> Bool {
var statbuf: stat = stat()
if stat(path, &statbuf) == 0 {
return true
}
return false
}
이유는 단순한 FileManager.default.fileExists가 우회될 수 있기 때문입니다. 여러 방식으로 같은 경로를 확인하면 탐지 가능성을 조금 더 높일 수 있습니다.
iOS 탈옥 감지 3: 주입된 동적 라이브러리 체크
탈옥 환경에서는 앱 실행 중 동적 라이브러리가 주입될 수 있습니다. 대표적으로 Substrate, Substitute, libhooker, FridaGadget 같은 흔적을 확인할 수 있습니다.
static private func checkInjectedDynamicLibraries() -> Bool {
let suspiciousLibraries = [
"ElleKit",
"roothideinit.dylib",
"libhooker",
"MobileSubstrate.dylib",
"CydiaSubstrate",
"Substitute",
"SubstrateLoader.dylib",
"SubstrateInserter",
"SubstrateBootstrap",
"cynject",
"TweakInject.dylib",
"ABypass",
"FlyJB",
"Shadow",
"HideJB",
"systemhook.dylib",
"SSLKillSwitch.dylib",
"SSLKillSwitch2.dylib",
"frida",
"FridaGadget",
"libcycript",
"cycript",
"AppSyncUnified-FrontBoard.dylib",
"Cephei",
"Electra",
"PreferenceLoader",
"RocketBootstrap",
"WeeLoader"
]
let count = _dyld_image_count()
for i in 0..<count {
guard let imageNamePtr = _dyld_get_image_name(i) else {
continue
}
let imageName = String(cString: imageNamePtr)
for lib in suspiciousLibraries {
if imageName.lowercased().contains(lib.lowercased()) {
return true
}
}
}
return false
}
_dyld_image_count()와 _dyld_get_image_name()을 이용하면 현재 프로세스에 로드된 이미지 목록을 확인할 수 있습니다. 여기서 의심스러운 라이브러리 이름이 발견되면 후킹 또는 탈옥 우회 환경 가능성을 의심할 수 있습니다.
iOS 탈옥 감지 4: Frida 환경 변수 체크
Frida 같은 런타임 분석 도구는 환경 변수나 프로세스 흔적을 남길 수 있습니다. 그래서 ProcessInfo.processInfo.environment를 확인했습니다.
private static func isSuspiciousEnvironmentPresent() -> Bool {
let suspiciousKeywords = [
"frida",
"cycript",
"substrate",
"substitute",
"libhooker",
"DYLD_INSERT_LIBRARIES"
]
let environment = ProcessInfo.processInfo.environment
for (key, value) in environment {
let lowerKey = key.lowercased()
let lowerValue = value.lowercased()
for keyword in suspiciousKeywords {
if lowerKey.contains(keyword.lowercased())
|| lowerValue.contains(keyword.lowercased()) {
return true
}
}
}
return false
}
이 검사도 단독으로는 완벽하지 않지만, 동적 라이브러리 검사와 함께 사용하면 런타임 변조 흔적을 찾는 데 도움이 됩니다.
iOS 탈옥 감지 5: fork 실행 가능 여부 체크
일반적인 iOS 앱 샌드박스 환경에서는 fork() 호출이 제한됩니다. 그런데 탈옥 환경에서는 이 제한이 깨질 수 있습니다.
static private func checkProcessForking() -> Bool {
typealias ForkType = @convention(c) () -> Int32
let rtldDefault = UnsafeMutableRawPointer(bitPattern: -2)
guard let forkPointer = dlsym(rtldDefault, "fork") else {
return false
}
let dynamicFork = unsafeBitCast(forkPointer, to: ForkType.self)
let pid = dynamicFork()
if pid < 0 {
return false
} else if pid == 0 {
exit(0)
} else {
var status: Int32 = 0
waitpid(pid, &status, 0)
return true
}
}
이 코드는 fork()가 성공하는지를 확인합니다. 성공한다면 정상적인 샌드박스 환경이 아닐 가능성이 있습니다.
다만 이런 검사는 앱 심사나 운영 정책에 따라 민감할 수 있으므로, 실제 서비스 적용 전에는 충분한 테스트가 필요합니다.
iOS 탈옥 감지 6: symbolic link 체크
과거 탈옥 환경에서는 원래 시스템 경로처럼 보이는 위치가 실제로는 다른 writable 영역을 가리키는 경우가 있었습니다.
예를 들면 다음과 같은 형태입니다.
/Applications -> /var/stash/Applications
/Library/Ringtones -> /var/stash/Ringtones
/usr/include -> /var/stash/usr/include
이런 경우 lstat을 이용해 symbolic link 여부를 확인할 수 있습니다.
static private func checkSymbolicLinks() -> Bool {
let paths = [
"/Applications",
"/Library/Ringtones",
"/Library/Wallpaper",
"/usr/include",
"/usr/libexec",
"/usr/share",
"/usr/arm-apple-darwin9",
"/var/lib/undecimus/apt"
]
for path in paths {
var statBuffer: stat = stat()
if lstat(path, &statBuffer) == 0 {
if statBuffer.st_mode & S_IFMT == S_IFLNK {
return true
}
}
}
return false
}
symbolic link 검사는 최신 탈옥 환경에서는 탐지율이 낮을 수 있지만, 여러 검사 중 하나로 포함하기에는 의미가 있습니다.
iOS 탈옥 감지 7: 탈옥 우회 트윅 클래스 체크
일부 탈옥 우회 트윅은 앱 내부에 특정 클래스나 메서드 흔적을 남길 수 있습니다. 예를 들어 Shadow 같은 우회 도구의 클래스 흔적을 확인할 수 있습니다.
private static func checkShadowRulesetClasses() -> Bool {
if let shadowRulesetClass = objc_getClass("ShadowRuleset") as? NSObject.Type {
let selector = Selector(("internalDictionary"))
if class_getInstanceMethod(shadowRulesetClass, selector) != nil {
return true
}
}
return false
}
이 방식은 특정 우회 도구에 종속적이기 때문에 범용 탐지라고 보기는 어렵습니다. 하지만 실제 테스트 중 특정 우회 도구를 대상으로 확인이 필요할 때는 도움이 될 수 있습니다.
iOS 탈옥 감지 전체 코드 예시
위 검사들을 하나로 합치면 다음과 같은 구조가 됩니다.
import MachO
import UIKit
final class JailbreakDetector {
static func hasJailBreak() -> Bool {
return checkSuspiciousURIs()
|| checkSuspiciousFiles()
|| checkProcessForking()
|| checkSymbolicLinks()
|| checkInjectedDynamicLibraries()
|| checkShadowRulesetClasses()
|| isSuspiciousEnvironmentPresent()
}
static private func checkSuspiciousURIs() -> Bool {
let urlSchemes: [String] = [
"undecimus://",
"sileo://",
"zbra://",
"filza://"
]
for scheme in urlSchemes {
guard let url = URL(string: scheme) else {
continue
}
if UIApplication.shared.canOpenURL(url) {
return true
}
}
return false
}
static private func checkSuspiciousFiles() -> Bool {
let jailBreakList: [String] = [
"/.bootstrapped_electra",
"/.cydia_no_stash",
"/.installed_unc0ver",
"/Applications/Cydia.app",
"/Applications/Sileo.app",
"/Applications/Zebra.app",
"/Applications/FlyJB.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/Library/MobileSubstrate/DynamicLibraries/",
"/Library/PreferenceBundles/ABypassPrefs.bundle",
"/Library/PreferenceBundles/ShadowPreferences.bundle",
"/bin/bash",
"/bin/sh",
"/etc/apt/",
"/private/var/lib/apt/",
"/private/var/lib/cydia",
"/private/var/stash",
"/usr/bin/ssh",
"/usr/bin/sshd",
"/usr/lib/libhooker.dylib",
"/usr/lib/libsubstitute.dylib",
"/usr/sbin/frida-server",
"/usr/sbin/sshd",
"/var/lib/cydia/",
"/var/log/apt/",
"/var/mobile/Library/Preferences/me.jjolano.shadow.plist"
]
for path in jailBreakList {
if FileManager.default.fileExists(atPath: path) {
return true
}
if canOpenSuspiciousFile(path: path) {
return true
}
if checkStatSuspiciousFile(path: path) {
return true
}
}
return false
}
static private func canOpenSuspiciousFile(path: String) -> Bool {
let file = fopen(path, "r")
guard file != nil else { return false }
fclose(file)
return true
}
static private func checkStatSuspiciousFile(path: String) -> Bool {
var statbuf: stat = stat()
if stat(path, &statbuf) == 0 {
return true
}
return false
}
static private func checkProcessForking() -> Bool {
typealias ForkType = @convention(c) () -> Int32
let rtldDefault = UnsafeMutableRawPointer(bitPattern: -2)
guard let forkPointer = dlsym(rtldDefault, "fork") else {
return false
}
let dynamicFork = unsafeBitCast(forkPointer, to: ForkType.self)
let pid = dynamicFork()
if pid < 0 {
return false
} else if pid == 0 {
exit(0)
} else {
var status: Int32 = 0
waitpid(pid, &status, 0)
return true
}
}
static private func checkSymbolicLinks() -> Bool {
let paths = [
"/Applications",
"/Library/Ringtones",
"/Library/Wallpaper",
"/usr/include",
"/usr/libexec",
"/usr/share",
"/usr/arm-apple-darwin9",
"/var/lib/undecimus/apt"
]
for path in paths {
var statBuffer: stat = stat()
if lstat(path, &statBuffer) == 0 {
if statBuffer.st_mode & S_IFMT == S_IFLNK {
return true
}
}
}
return false
}
static private func checkInjectedDynamicLibraries() -> Bool {
let suspiciousLibraries = [
"ElleKit",
"roothideinit.dylib",
"libhooker",
"MobileSubstrate.dylib",
"CydiaSubstrate",
"Substitute",
"SubstrateLoader.dylib",
"SubstrateInserter",
"SubstrateBootstrap",
"cynject",
"TweakInject.dylib",
"ABypass",
"FlyJB",
"Shadow",
"HideJB",
"systemhook.dylib",
"SSLKillSwitch.dylib",
"SSLKillSwitch2.dylib",
"frida",
"FridaGadget",
"libcycript",
"cycript",
"AppSyncUnified-FrontBoard.dylib",
"Cephei",
"Electra",
"PreferenceLoader",
"RocketBootstrap",
"WeeLoader"
]
let count = _dyld_image_count()
for i in 0..<count {
guard let imageNamePtr = _dyld_get_image_name(i) else {
continue
}
let imageName = String(cString: imageNamePtr)
for lib in suspiciousLibraries {
if imageName.lowercased().contains(lib.lowercased()) {
return true
}
}
}
return false
}
private static func checkShadowRulesetClasses() -> Bool {
if let shadowRulesetClass = objc_getClass("ShadowRuleset") as? NSObject.Type {
let selector = Selector(("internalDictionary"))
if class_getInstanceMethod(shadowRulesetClass, selector) != nil {
return true
}
}
return false
}
private static func isSuspiciousEnvironmentPresent() -> Bool {
let suspiciousKeywords = [
"frida",
"cycript",
"substrate",
"substitute",
"libhooker",
"DYLD_INSERT_LIBRARIES"
]
let environment = ProcessInfo.processInfo.environment
for (key, value) in environment {
let lowerKey = key.lowercased()
let lowerValue = value.lowercased()
for keyword in suspiciousKeywords {
if lowerKey.contains(keyword.lowercased())
|| lowerValue.contains(keyword.lowercased()) {
return true
}
}
}
return false
}
}
iOS 탈옥 감지를 SceneDelegate에 적용한 이유
탈옥 감지는 앱 실행 시점에만 하면 부족합니다.
제가 실제로 고민했던 케이스는 다음과 같습니다.
1. 사용자가 앱을 정상 실행한다.
2. 앱을 백그라운드로 보낸다.
3. 탈옥 관련 앱이나 우회 도구를 실행한다.
4. 다시 우리 앱으로 돌아온다.
5. 이때 다시 탈옥 감지를 해야 한다.
그래서 SceneDelegate의 두 시점을 활용했습니다.
willConnectTo
→ 앱 최초 실행 시점 검사
sceneDidBecomeActive
→ 앱이 다시 활성화될 때마다 검사
다만 앱 최초 실행 시 willConnectTo에서 한 번 검사하고, 바로 이어서 sceneDidBecomeActive가 호출되면서 중복 검사되는 문제가 있었습니다.
그래서 didInitialSecurityCheck 플래그를 두고, 최초 실행 직후의 sceneDidBecomeActive만 한 번 건너뛰도록 구성했습니다.
iOS 탈옥 감지 호출부 구현
아래는 실제 적용한 구조를 단순화한 예시입니다.
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var coordinator: AppCoordinator?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else {
return
}
let navigationController = UINavigationController()
window = UIWindow(windowScene: windowScene)
coordinator = AppCoordinator(
window: window,
navigation: navigationController
)
SceneDelegate.didInitialSecurityCheck = true
guard checkAppSecurity() else {
return
}
coordinator?.start()
}
func sceneDidBecomeActive(_ scene: UIScene) {
if SceneDelegate.didInitialSecurityCheck {
SceneDelegate.didInitialSecurityCheck = false
return
}
guard checkAppSecurity() else {
return
}
prepareForEnteringIntoActiveMode()
#if DEBUG
showTestToast()
#endif
}
func sceneDidEnterBackground(_ scene: UIScene) {
prepareForEnteringIntoBackgroundMode()
}
}
여기서 핵심은 이 부분입니다.
if SceneDelegate.didInitialSecurityCheck {
SceneDelegate.didInitialSecurityCheck = false
return
}
앱 최초 실행 시 willConnectTo에서 이미 보안 검사를 했기 때문에, 바로 이어지는 sceneDidBecomeActive에서는 한 번만 건너뜁니다.
하지만 이후 백그라운드에서 다시 포그라운드로 돌아올 때는 didInitialSecurityCheck가 이미 false이므로 매번 다시 검사합니다.
iOS 탈옥 감지 후 앱 차단 처리
탈옥 또는 무결성 이상이 감지되면 기존 화면 흐름을 끊고 보안 차단 화면으로 교체했습니다.
extension SceneDelegate {
private static var isSecurityBlocked = false
private static var didInitialSecurityCheck = false
@discardableResult
public func checkAppSecurity() -> Bool {
guard !SceneDelegate.isSecurityBlocked else {
return false
}
let securityChecker = SecurityChecker.performIntegrityCheck()
if securityChecker.result {
SceneDelegate.isSecurityBlocked = true
let msg = securityChecker.hitChecks.first?.alertDescription
?? "보안 위협이 감지되었습니다."
showSecurityBlockingScreen(msg: msg)
return false
}
return true
}
private func showSecurityBlockingScreen(msg: String) {
DispatchQueue.main.async {
self.window?.rootViewController?.view.endEditing(true)
let blockVC = SecurityBlockViewController(message: msg)
self.window?.rootViewController = blockVC
self.window?.makeKeyAndVisible()
}
}
}
주의할 점은 isSecurityBlocked를 정상 상태에서 true로 바꾸면 안 된다는 것입니다. 이 값은 “이미 보안 차단 화면이 올라갔는지”를 의미해야 합니다.
따라서 반드시 감지된 경우에만 true로 바꿔야 합니다.
if securityChecker.result {
SceneDelegate.isSecurityBlocked = true
}
정상 상태에서는 계속 false로 유지되어야 앱이 포그라운드로 돌아올 때마다 탈옥 검사를 다시 수행할 수 있습니다.
iOS 탈옥 감지 차단 화면 구현
보안 위협이 감지되면 일반 Alert보다 전용 차단 화면을 사용하는 방식이 더 명확했습니다.
import UIKit
import Then
class SecurityBlockViewController: UIViewController {
// MARK: Properties
private let message: String
private let containerView = UIView().then {
$0.backgroundColor = .white
$0.layer.cornerRadius = 14
$0.clipsToBounds = true
}
private let titleLabel = UILabel().then {
$0.text = "보안 경고"
$0.font = .boldSystemFont(ofSize: 20)
$0.textColor = .black
$0.textAlignment = .center
}
private let messageLabel = UILabel().then {
$0.font = .systemFont(ofSize: 15)
$0.textColor = .black
$0.textAlignment = .center
$0.numberOfLines = 0
}
private let confirmButton = UIButton(type: .system).then {
$0.setTitle("확인", for: .normal)
$0.titleLabel?.font = .boldSystemFont(ofSize: 17)
$0.setTitleColor(.black, for: .normal)
}
// MARK: LifeCycle
init(message: String) {
self.message = message
super.init(nibName: nil, bundle: nil)
self.modalPresentationStyle = .fullScreen
self.isModalInPresentation = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setLayout()
bind()
}
// MARK: Helper
func setLayout() {
view.backgroundColor = UIColor.black.withAlphaComponent(0.75)
messageLabel.text = message
view.addSubview(containerView)
containerView.snp.makeConstraints { make in
make.left.right.equalToSuperview().inset(32)
make.centerY.equalToSuperview()
}
containerView.addSubview(titleLabel)
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(24)
make.left.right.equalToSuperview().inset(20)
}
containerView.addSubview(messageLabel)
messageLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(16)
make.left.right.equalToSuperview().inset(20)
}
containerView.addSubview(confirmButton)
confirmButton.snp.makeConstraints { make in
make.top.equalTo(messageLabel.snp.bottom).offset(24)
make.left.right.bottom.equalToSuperview()
make.height.equalTo(52)
}
}
func bind() {
confirmButton.addTarget(
self,
action: #selector(confirmButtonTapped),
for: .touchUpInside
)
}
@objc private func confirmButtonTapped() {
exit(0)
}
}
이 화면은 기존 화면 위에 단순히 Alert를 띄우는 방식이 아니라, rootViewController를 보안 차단 화면으로 교체하는 방식입니다.
즉, 감지 후에는 기존 Navigation Stack이나 Tab 상태를 유지하지 않고, 보안 경고 화면만 남도록 처리했습니다.
iOS 탈옥 감지 구현 시 주의할 점
iOS 탈옥 감지를 구현하면서 주의해야 할 점이 몇 가지 있었습니다.
첫째, 하나의 검사에 의존하면 안 됩니다. 파일 경로 검사, URL Scheme 검사, 동적 라이브러리 검사, 환경 변수 검사, fork 검사 등을 조합해야 합니다.
둘째, 앱 실행 시점만 검사하면 부족합니다. 사용자가 앱을 백그라운드로 보낸 뒤 환경을 변경하고 다시 돌아올 수 있으므로 sceneDidBecomeActive에서도 다시 검사하는 것이 좋습니다.
셋째, 보안 차단 플래그의 의미를 명확히 해야 합니다. isSecurityBlocked는 “검사를 했는가”가 아니라 “이미 보안 차단 화면이 올라갔는가”를 의미해야 합니다.
넷째, 시뮬레이터에서는 탈옥 감지 결과가 의미 없을 수 있습니다. 테스트는 실제 기기와 승인된 보안 테스트 환경에서 진행하는 것이 좋습니다.
다섯째, 탈옥 감지는 완벽한 방어가 아닙니다. 앱 내부 검사는 우회될 수 있으므로 서버 검증, 요청 무결성 검증, 인증 토큰 보호, SSL Pinning 등 다른 보안 대책과 함께 적용하는 것이 좋습니다.
iOS 탈옥 감지와 앱 보안은 함께 봐야 한다
iOS 탈옥 감지는 앱 보안의 한 부분입니다. 탈옥 여부만 확인한다고 해서 앱이 완전히 안전해지는 것은 아닙니다.
실제 서비스에서는 다음 항목도 함께 고려해야 합니다.
1. 서버 API 요청 검증
2. 인증 토큰 저장 방식 개선
3. Keychain 사용
4. SSL Pinning 적용
5. 앱 무결성 검증
6. 디버거 탐지
7. 런타임 후킹 탐지
8. 중요 로직 서버 처리
특히 금융, 결제, 인증 앱이라면 클라이언트 보안만 믿기보다는 서버 검증을 반드시 함께 구성해야 합니다.
참고하면 좋은 외부 자료
iOS 보안 구조와 모바일 앱 보안 테스트 기준을 더 자세히 보고 싶다면 아래 자료를 참고할 수 있습니다.
함께 보면 좋은 글
아래 글도 함께 연결하면 내부 링크 구성에 도움이 됩니다.
마무리
이번 글에서는 iOS 탈옥 감지를 실제 앱에 적용하면서 고민했던 부분을 정리했습니다.
처음에는 단순히 탈옥 관련 파일 경로만 체크하면 된다고 생각했지만, 실제로 테스트해보니 그 방식만으로는 부족했습니다. 의심스러운 앱, 시스템 경로, 동적 라이브러리, Frida 환경 변수, fork 실행 가능 여부, symbolic link 등 여러 신호를 함께 확인해야 했습니다.
또한 앱 실행 시점뿐 아니라 백그라운드에서 포그라운드로 돌아오는 시점에도 iOS 탈옥 감지를 다시 수행해야 한다는 점이 중요했습니다. 사용자가 앱을 사용하는 도중 환경이 바뀔 수 있기 때문입니다.
결론적으로 제가 적용한 구조는 다음과 같습니다.
앱 최초 실행
→ willConnectTo에서 iOS 탈옥 감지
앱 최초 활성화
→ 직전 검사와 중복되지 않게 1회 스킵
백그라운드 후 재진입
→ sceneDidBecomeActive에서 매번 iOS 탈옥 감지
보안 이상 감지
→ SecurityBlockViewController로 rootViewController 교체
→ 앱 사용 차단
iOS 탈옥 감지는 완벽한 보안 대책은 아니지만, 결제나 인증처럼 민감한 기능을 다루는 앱에서는 반드시 고려해야 할 방어 계층이라고 생각합니다.