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)
}
}