diff --git a/README.md b/README.md index 7565467..52ab33f 100644 --- a/README.md +++ b/README.md @@ -168,4 +168,8 @@ checkDeviceSecurity(); or please see **example-app** for a complete example. +## Security limitations + +Root and jailbreak detection is a best-effort risk signal. The iOS implementation combines file and directory checks, sandbox escape checks, process environment checks, known local Frida endpoint checks, and loaded Mach-O image inspection. Runtime instrumentation can still modify an application's code or its returned values, so no client-side plugin can guarantee detection against an attacker who fully controls the process. Use the result as one layer of a broader security strategy and enforce sensitive decisions on a trusted backend. + Use this plugin to enhance your application's security and respond appropriately to potential risks. diff --git a/example-app/ios/App/App.xcodeproj/project.pbxproj b/example-app/ios/App/App.xcodeproj/project.pbxproj index 90b786b..c8e1732 100644 --- a/example-app/ios/App/App.xcodeproj/project.pbxproj +++ b/example-app/ios/App/App.xcodeproj/project.pbxproj @@ -283,7 +283,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -334,7 +334,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; @@ -352,7 +352,7 @@ DEVELOPMENT_TEAM = 9XFYDGX5DN; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 7.0.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; @@ -374,7 +374,7 @@ DEVELOPMENT_TEAM = 9XFYDGX5DN; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MARKETING_VERSION = 7.0.0; PRODUCT_BUNDLE_IDENTIFIER = ryltsov.alex.device.security.detect.demo; diff --git a/example-app/ios/App/App/Info.plist b/example-app/ios/App/App/Info.plist index 39cd3cc..60c302e 100644 --- a/example-app/ios/App/App/Info.plist +++ b/example-app/ios/App/App/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - example-app + example-app CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -22,12 +22,12 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS - NSCameraUsageDescription - This app requires camera access to support taking photos. - NSPhotoLibraryAddUsageDescription - This app requires photo library access to support accessing photos. - NSPhotoLibraryUsageDescription - This app requires photo library access to support accessing photos. + NSCameraUsageDescription + This app requires camera access to support taking photos. + NSPhotoLibraryAddUsageDescription + This app requires photo library access to support accessing photos. + NSPhotoLibraryUsageDescription + This app requires photo library access to support accessing photos. UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/example-app/ios/App/Podfile b/example-app/ios/App/Podfile index 4c40054..38e967a 100644 --- a/example-app/ios/App/Podfile +++ b/example-app/ios/App/Podfile @@ -1,6 +1,6 @@ require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' -platform :ios, '14.0' +platform :ios, '15.0' use_frameworks! # workaround to avoid Xcode caching of Pods that requires diff --git a/ios/Sources/DeviceSecurityDetectPlugin/DeviceSecurityDetect.swift b/ios/Sources/DeviceSecurityDetectPlugin/DeviceSecurityDetect.swift index f3bf1e0..a0e0134 100644 --- a/ios/Sources/DeviceSecurityDetectPlugin/DeviceSecurityDetect.swift +++ b/ios/Sources/DeviceSecurityDetectPlugin/DeviceSecurityDetect.swift @@ -1,15 +1,17 @@ import Foundation -import UIKit import LocalAuthentication @objc public class DeviceSecurityDetect: NSObject { + private let jailbreakDetector = JailbreakDetector(environment: LiveJailbreakDetectionEnvironment()) + @objc public func isJailBreak() -> Bool { log("Checking if device is jailbroken") #if targetEnvironment(simulator) log("Skipping jailbreak checks on simulator") return false + #else + return jailbreakDetector.isJailbroken() #endif - return hasCydiaInstalled() || isContainsSuspiciousApps() || isSuspiciousSystemPathsExists() || canEditSystemFiles() } @objc public func pinCheck() -> Bool { @@ -24,71 +26,4 @@ import LocalAuthentication return false } } - - func hasCydiaInstalled() -> Bool { - return UIApplication.shared.canOpenURL(URL(string: "cydia://")!) - } - - func isContainsSuspiciousApps() -> Bool { - for path in suspiciousAppsPathToCheck { - if FileManager.default.fileExists(atPath: path) { - return true - } - } - return false - } - - func isSuspiciousSystemPathsExists() -> Bool { - for path in suspiciousSystemPathsToCheck { - if FileManager.default.fileExists(atPath: path) { - return true - } - } - return false - } - - func canEditSystemFiles() -> Bool { - let jailBreakText = "Developer Insider" - do { - try jailBreakText.write(toFile: jailBreakText, atomically: true, encoding: .utf8) - return true - } catch { - return false - } - } - - var suspiciousAppsPathToCheck: [String] { - return [ - "/Applications/Cydia.app", - "/Applications/blackra1n.app", - "/Applications/FakeCarrier.app", - "/Applications/Icy.app", - "/Applications/IntelliScreen.app", - "/Applications/MxTube.app", - "/Applications/RockApp.app", - "/Applications/SBSettings.app", - "/Applications/WinterBoard.app" - ] - } - - var suspiciousSystemPathsToCheck: [String] { - return [ - "/Library/MobileSubstrate/DynamicLibraries/LiveClock.plist", - "/Library/MobileSubstrate/DynamicLibraries/Veency.plist", - "/private/var/lib/apt", - "/private/var/lib/apt/", - "/private/var/lib/cydia", - "/private/var/mobile/Library/SBSettings/Themes", - "/private/var/stash", - "/private/var/tmp/cydia.log", - "/System/Library/LaunchDaemons/com.ikey.bbot.plist", - "/System/Library/LaunchDaemons/com.saurik.Cydia.Startup.plist", - "/usr/bin/sshd", - "/usr/libexec/sftp-server", - "/usr/sbin/sshd", - "/etc/apt", - "/bin/bash", - "/Library/MobileSubstrate/MobileSubstrate.dylib" - ] - } } diff --git a/ios/Sources/DeviceSecurityDetectPlugin/JailbreakDetector.swift b/ios/Sources/DeviceSecurityDetectPlugin/JailbreakDetector.swift new file mode 100644 index 0000000..604e3ec --- /dev/null +++ b/ios/Sources/DeviceSecurityDetectPlugin/JailbreakDetector.swift @@ -0,0 +1,358 @@ +import Foundation +import MachO +import UIKit + +protocol JailbreakDetectionEnvironment { + func canOpen(_ url: URL) -> Bool + func fileExists(atPath path: String) -> Bool + func contentsOfDirectory(atPath path: String) -> [String]? + func canWriteOutsideSandbox() -> Bool + func canConnectToLocalPort(_ port: UInt16) -> Bool + func loadedImageIdentifiers() -> [String] + var processEnvironment: [String: String] { get } +} + +struct JailbreakDetector { + private let environment: JailbreakDetectionEnvironment + + init(environment: JailbreakDetectionEnvironment) { + self.environment = environment + } + + func isJailbroken() -> Bool { + return hasSuspiciousProcessEnvironment() + || hasSuspiciousLocalEndpoint() + || hasSuspiciousLoadedImage() + || hasSuspiciousURLScheme() + || hasSuspiciousFile() + || hasSuspiciousDirectory() + || hasSuspiciousDirectoryEntry() + || hasSuspiciousApplication() + || environment.canWriteOutsideSandbox() + } + + private func hasSuspiciousLocalEndpoint() -> Bool { + return fridaLocalPorts.contains(where: environment.canConnectToLocalPort) + } + + private func hasSuspiciousProcessEnvironment() -> Bool { + for (key, value) in environment.processEnvironment { + let normalizedKey = key.lowercased() + let normalizedValue = value.lowercased() + + if suspiciousEnvironmentKeys.contains(normalizedKey) + || normalizedKey.contains("frida") + || suspiciousImageTokens.contains(where: normalizedValue.contains) { + return true + } + } + + return false + } + + private func hasSuspiciousLoadedImage() -> Bool { + return environment.loadedImageIdentifiers().contains { identifier in + let normalizedIdentifier = identifier.lowercased() + return suspiciousImageTokens.contains(where: normalizedIdentifier.contains) + } + } + + private func hasSuspiciousURLScheme() -> Bool { + return suspiciousURLSchemes.contains { scheme in + guard let url = URL(string: "\(scheme)://") else { + return false + } + return environment.canOpen(url) + } + } + + private func hasSuspiciousFile() -> Bool { + return suspiciousPaths.contains(where: environment.fileExists) + } + + private func hasSuspiciousDirectory() -> Bool { + return suspiciousDirectories.contains { path in + environment.contentsOfDirectory(atPath: path) != nil + } + } + + private func hasSuspiciousApplication() -> Bool { + return applicationDirectories.contains { path in + guard let entries = environment.contentsOfDirectory(atPath: path) else { + return false + } + let normalizedEntries = Set(entries.map { $0.lowercased() }) + return !normalizedEntries.isDisjoint(with: suspiciousApplicationNames) + } + } + + private func hasSuspiciousDirectoryEntry() -> Bool { + return suspiciousDirectoryEntries.contains { path, suspiciousEntries in + guard let entries = environment.contentsOfDirectory(atPath: path) else { + return false + } + let normalizedEntries = Set(entries.map { $0.lowercased() }) + return !normalizedEntries.isDisjoint(with: suspiciousEntries) + } + } + + private let suspiciousEnvironmentKeys: Set = [ + "frida", + "_mssafemode" + ] + + private let fridaLocalPorts: Set = [27_042, 27_043] + + private let suspiciousImageTokens = [ + "frida", + "cynject", + "cycript", + "substrate", + "substitute", + "libhooker", + "ellekit", + "tweakinject" + ] + + private let suspiciousURLSchemes = [ + "cydia", + "sileo", + "zbra", + "filza", + "activator", + "undecimus" + ] + + private let applicationDirectories = [ + "/Applications", + "/var/jb/Applications", + "/private/var/jb/Applications" + ] + + private let suspiciousApplicationNames: Set = [ + "cydia.app", + "sileo.app", + "zebra.app", + "filza.app", + "palera1n.app", + "blackra1n.app", + "fakecarrier.app", + "icy.app", + "intelliscreen.app", + "mxtube.app", + "rockapp.app", + "sbsettings.app", + "winterboard.app" + ] + + private let suspiciousDirectories = [ + "/var/jb", + "/private/var/jb", + "/Library/MobileSubstrate/DynamicLibraries", + "/var/jb/Library/MobileSubstrate/DynamicLibraries", + "/private/var/jb/Library/MobileSubstrate/DynamicLibraries", + "/usr/lib/TweakInject", + "/var/jb/usr/lib/TweakInject", + "/usr/lib/frida", + "/var/jb/usr/lib/frida" + ] + + private let suspiciousDirectoryEntries: [String: Set] = [ + "/usr/sbin": ["frida-server", "sshd"], + "/var/jb/usr/sbin": ["frida-server", "sshd"], + "/private/var/jb/usr/sbin": ["frida-server", "sshd"], + "/Library/LaunchDaemons": ["re.frida.server.plist"], + "/var/jb/Library/LaunchDaemons": ["re.frida.server.plist"], + "/usr/lib": ["libhooker.dylib", "libsubstitute.dylib"] + ] + + private let suspiciousPaths = [ + "/Applications/Cydia.app", + "/Applications/Sileo.app", + "/Applications/Zebra.app", + "/Applications/Filza.app", + "/Applications/palera1n.app", + "/Library/MobileSubstrate/MobileSubstrate.dylib", + "/Library/LaunchDaemons/re.frida.server.plist", + "/private/var/lib/apt", + "/private/var/lib/cydia", + "/private/var/mobile/Library/SBSettings/Themes", + "/private/var/stash", + "/private/var/tmp/cydia.log", + "/System/Library/LaunchDaemons/com.ikey.bbot.plist", + "/System/Library/LaunchDaemons/com.saurik.Cydia.Startup.plist", + "/usr/bin/sshd", + "/usr/lib/libhooker.dylib", + "/usr/lib/libsubstitute.dylib", + "/usr/libexec/sftp-server", + "/usr/sbin/frida-server", + "/usr/sbin/sshd", + "/etc/apt", + "/bin/bash", + "/.bootstrapped_electra", + "/.installed_unc0ver" + ] +} + +final class LiveJailbreakDetectionEnvironment: JailbreakDetectionEnvironment { + var processEnvironment: [String: String] { + return ProcessInfo.processInfo.environment + } + + func canOpen(_ url: URL) -> Bool { + return UIApplication.shared.canOpenURL(url) + } + + func fileExists(atPath path: String) -> Bool { + return FileManager.default.fileExists(atPath: path) + } + + func contentsOfDirectory(atPath path: String) -> [String]? { + return try? FileManager.default.contentsOfDirectory(atPath: path) + } + + func canWriteOutsideSandbox() -> Bool { + let path = "/private/device-security-detect-\(UUID().uuidString)" + let url = URL(fileURLWithPath: path) + + do { + try Data("DeviceSecurityDetect".utf8).write(to: url, options: .atomic) + try? FileManager.default.removeItem(at: url) + return true + } catch { + return false + } + } + + func canConnectToLocalPort(_ port: UInt16) -> Bool { + let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0) + guard socketDescriptor >= 0 else { + return false + } + defer { close(socketDescriptor) } + + var timeout = timeval(tv_sec: 0, tv_usec: 50_000) + setsockopt( + socketDescriptor, + SOL_SOCKET, + SO_SNDTIMEO, + &timeout, + socklen_t(MemoryLayout.size) + ) + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + return withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + connect(socketDescriptor, socketAddress, socklen_t(MemoryLayout.size)) == 0 + } + } + } + + func loadedImageIdentifiers() -> [String] { + return DyldImageInspector.loadedImageIdentifiers() + } +} + +enum DyldImageInspector { + private static let machHeader32Magic: UInt32 = 0xfeedface + private static let machHeader64Magic: UInt32 = 0xfeedfacf + private static let dylibLoadCommands: Set = [ + 0x0c, // LC_LOAD_DYLIB + 0x0d, // LC_ID_DYLIB + 0x18, // LC_LOAD_WEAK_DYLIB + 0x1f, // LC_REEXPORT_DYLIB + 0x20, // LC_LAZY_LOAD_DYLIB + 0x23 // LC_LOAD_UPWARD_DYLIB + ] + private static let requiredByDyldFlag: UInt32 = 0x80000000 + + static func loadedImageIdentifiers() -> [String] { + var identifiers: [String] = [] + + for imageIndex in 0..<_dyld_image_count() { + guard let header = _dyld_get_image_header(imageIndex) else { + continue + } + identifiers.append(contentsOf: dylibIdentifiers(in: UnsafeRawPointer(header))) + } + + return identifiers + } + + static func dylibIdentifiers(in header: UnsafeRawPointer) -> [String] { + let magic = header.load(as: UInt32.self) + let headerSize: Int + + switch magic { + case machHeader32Magic: + headerSize = 28 + case machHeader64Magic: + headerSize = 32 + default: + return [] + } + + let numberOfCommands = Int(header.load(fromByteOffset: 16, as: UInt32.self)) + let commandsSize = Int(header.load(fromByteOffset: 20, as: UInt32.self)) + guard numberOfCommands <= 4_096, commandsSize <= 64 * 1_024 * 1_024 else { + return [] + } + + var identifiers: [String] = [] + var commandOffset = headerSize + let commandsEnd = headerSize + commandsSize + + for _ in 0..= 8, commandOffset + commandSize <= commandsEnd else { + break + } + + if dylibLoadCommands.contains(commandType), commandSize >= 12 { + let nameOffset = Int(command.load(fromByteOffset: 8, as: UInt32.self)) + if let identifier = nullTerminatedString( + from: command, + offset: nameOffset, + upperBound: commandSize + ) { + identifiers.append(identifier) + } + } + + commandOffset += commandSize + } + + return identifiers + } + + private static func nullTerminatedString( + from baseAddress: UnsafeRawPointer, + offset: Int, + upperBound: Int + ) -> String? { + guard offset >= 0, offset < upperBound else { + return nil + } + + let buffer = UnsafeRawBufferPointer( + start: baseAddress.advanced(by: offset), + count: upperBound - offset + ) + guard let stringEnd = buffer.firstIndex(of: 0), stringEnd > 0 else { + return nil + } + + return String(decoding: buffer[.. = [] + var existingPaths: Set = [] + var directoryContents: [String: [String]] = [:] + var isOutsideSandboxWritable = false + var imageIdentifiers: [String] = [] + var openLocalPorts: Set = [] + var variables: [String: String] = [:] + + var processEnvironment: [String: String] { + return variables + } + + func canOpen(_ url: URL) -> Bool { + guard let scheme = url.scheme else { + return false + } + return openableSchemes.contains(scheme) + } + + func fileExists(atPath path: String) -> Bool { + return existingPaths.contains(path) + } + + func contentsOfDirectory(atPath path: String) -> [String]? { + return directoryContents[path] + } + + func canWriteOutsideSandbox() -> Bool { + return isOutsideSandboxWritable + } + + func loadedImageIdentifiers() -> [String] { + return imageIdentifiers + } - XCTAssertEqual(value, result) + func canConnectToLocalPort(_ port: UInt16) -> Bool { + return openLocalPorts.contains(port) } }