Lightweight iOS & visionOS Security Detection Framework
Detect jailbreaks, debuggers, emulators, Frida, runtime hooks, SSL pinning bypasses, VPN/proxy usage and more.
| Category | Detection |
|---|---|
| 🔓 Jailbreak | Files, sandbox escape, fork capability, URL schemes, symlinks, environment variables |
| 🐞 Debugger | sysctl, ptrace, parent process, timing analysis, breakpoint instructions |
| 📱 Emulator | Hardware mismatch, simulator artifacts, DeviceCheck validation |
| 🧬 Reverse Engineering | Frida, Substrate, libhooker, runtime tampering |
| 🔒 App Integrity | Code signature validation, Team ID verification, CodeResources hash validation |
| 🪝 Hook Detection | Runtime function hook detection via ARM64 prologue inspection |
| 🔄 Swizzling Detection | Objective-C IMP redirection validation, including biometric (LAContext) method hooking |
| 👾 Frida Detection | Libraries, symbols, process checks, multi-port scanning |
| 📺 Screen Recording | Active recording and mirroring detection |
| 📸 Screenshot Detection | Real-time screenshot notifications |
| 🌐 Pinning Bypass Detection | Detects bypass tools (SSLKillSwitch, ssl-proxy, etc.) and proxy configurations — not a substitute for implementing certificate/public-key pinning in your networking stack |
| 🔌 VPN / Proxy Detection | VPN interfaces and proxy configuration detection |
| 🔐 App Attest | Apple App Attest validation |
| 📦 Anti-Repackaging | Signing certificate verification |
| 🛡️ DSK Integrity | Runtime validation of DSK internals |
| ⏱️ Monitoring | Continuous background security monitoring, BGTaskScheduler integration |
| 🔄 Signature Updates | Ed25519-verified remote updates to detection lists |
| 🏢 MDM Detection | Flags devices running under an enterprise Managed App Configuration |
| 📋 Clipboard Monitoring | Detects unexpected pasteboard changes after the app copies sensitive data |
| 🖥️ External Display Detection | Flags AirPlay screen mirroring or external/wired monitor connections |
| ⌨️ Keyboard Extension Detection | Flags third-party keyboards active while a sensitive field (password, OTP, payment) is being edited |
| 🎯 Concurrent Threat Tracking | currentThreats exposes every threat active right now, not just the most severe one |
| 🌍 Localization | All user-facing strings (threat/status/severity descriptions, reports) ship via a String Catalog and adapt to the device's locale |
| 🔏 Signed Reports | Tamper-evident SignedSecurityReport with Secure Enclave-backed ECDSA signing — backends can verify reports weren't forged or replayed client-side |
- File → Add Package Dependencies
- Enter:
https://github.com/galahador/DeviceSecurityKit.git
- Select:
from: "0.40.0"
- Add Package
dependencies: [
.package(
url: "https://github.com/galahador/DeviceSecurityKit.git",
from: "0.40.0"
)
]##
import DeviceSecurityKit
DSK.shared
.configure(.production)
.onThreatDetected { threat in
print("Threat: \(threat.description)")
}
.start()DSK.shared
.configure(.production)
.start()DSK.shared
.onThreatDetected { threat in
print(threat.description)
}
.start()DSK.shared
.onStatusChange { status in
print(status)
}
.start()let result = DSK.shared.performCheck()
if result.isSecure {
print("Secure")
} else {
print(result.threats)
}
performCheck()runs all enabled detectors synchronously and may take several seconds. Call it off the main thread, or use the async variant below.
let result = await DSK.shared.performCheckAsync()
let secure = await DSK.shared.isSecureAsync()let attestation = try await DSK.shared.attest(challengeHash: serverChallenge)
// Send `attestation` to your backend for verificationSubscribe to a live stream of threat events:
Task {
for await event in DSK.shared.threatEvents {
print("\(event.threat) at \(event.detectedAt)")
print("Evidence: \(event.evidence)")
}
}Multiple consumers can subscribe independently. The stream ends when stop() is called or the consuming Task is cancelled.
There's an equivalent stream for status transitions:
Task {
for await status in DSK.shared.statusUpdates {
print("Status changed to \(status)")
}
}DSKReportSigner produces cryptographically signed SignedSecurityReport values that your backend can verify were generated by a legitimate, unmodified app — not forged or replayed client-side.
On physical devices the signing key lives in the Secure Enclave — the raw private key bytes never leave the chip. On the iOS Simulator a software P256 key is used instead.
// Call once after first launch and send the key to your server.
let pubKey = try DSKReportSigner.shared.publicKeyData() // 65-byte x963 P256 point
uploadPublicKey(pubKey, forInstallID: installID)// Convenience — runs performCheck() and signs the result in one call.
let report = try DSK.shared.signedCheck()
// Or sign an existing result:
let result = DSK.shared.performCheck()
let report = try DSKReportSigner.shared.sign(result)
// report contains:
// .payload — canonical JSON (SecurityResult + nonce + timestamp)
// .signature — DER-encoded ECDSA-SHA256 signature over payload
// .publicKey — uncompressed P256 public key (65 bytes)
// .generatedAt — UTC timestamp
uploadToBackend(report) // send as JSON; all fields are CodableThe payload embeds a fresh UUID nonce on every call — even identical SecurityResult values produce a different payload each time. Store seen nonces on the backend and reject any report that reuses one.
if report.isSignatureValid() {
// payload hasn't been tampered with after signing
// note: this does NOT prove the key is trusted — that's the backend's job
}1. Assert report.publicKey == pinned key for this installID
2. Verify ECDSA-SHA256(report.signature, report.payload) using report.publicKey
3. Decode report.payload as JSON, extract `nonce`
4. Reject if nonce has been seen before (replay protection)
5. Read `result.threats`, `result.riskScore`, etc.
Call deleteKey() to force a new key pair on the next sign. Remember to re-register the new public key with your backend after rotation.
try DSKReportSigner.shared.deleteKey()Inject any DSKReportSigning conformer — useful for tests, App Attest-backed signing, or other custom schemes:
let report = try DSK.shared.signedCheck(using: myCustomSigner)DSK keeps a ring buffer of recent ThreatEvents so you can inspect detections after the fact:
let history = DSK.shared.threatHistory
for event in history {
print("\(event.threat) — \(event.detectedAt)")
}Configure the buffer size (default: 100) and clear it:
DSK.shared
.threatHistoryMaxSize(200)
.start()
// Later:
DSK.shared.clearThreatHistory()Opt in to persist threatHistory to the Keychain so a tampering event survives an app relaunch or kill — useful for forensics:
let config = DeviceSecurityConfiguration.default
.withThreatHistoryPersistence(true)
DSK.shared
.configure(config)
.start()History is rehydrated from the Keychain on init when enabled, and clearThreatHistory() also wipes the persisted copy. Disabled by default.
DSKObservable wraps DSK as an ObservableObject, publishing status and threatHistory for use directly in SwiftUI views:
struct ContentView: View {
@StateObject private var dsk = DSKObservable()
var body: some View {
VStack {
Text("Status: \(dsk.status.rawValue)")
List(dsk.threatHistory) { event in
Text("\(event.threat) — \(event.detectedAt)")
}
}
}
}DSK.shared
.onThreatDetected { threat in
switch threat.severity {
case .critical:
AuthManager.shared.clearTokens()
KeychainManager.shared.wipe()
Analytics.log(
"security_threat",
["type": threat.rawValue]
)
exit(0)
case .high:
showSecurityAlert()
default:
break
}
}
.start().configure(.default)
.configure(.production)
.configure(.jailbreakOnly)
.configure(.disabled)let config = DeviceSecurityConfiguration.default
.withJailbreakCheck(true)
.withDebuggerCheck(true)
.withEmulatorCheck(false)
.withReverseEngineeringCheck(true)
.withScreenRecordingCheck(true)
.withScreenshotDetection(true)
.withHookDetection(true)
.withPinningBypassDetection(true)
.withSwizzlingDetection(true)
.withFridaDetection(true)
.withAttestationCheck(true)
.withVPNProxyDetection(
true,
allowedBundleIDs: [
"com.example.corporate-vpn"
]
)
.withAppIntegrityCheck(
true,
expectedTeamID: "ABCDE12345"
)
.withAntiRepackagingCheck(
true,
expectedCertificateHash: "a1b2c3..."
)
.withMDMDetection(true)
.withClipboardMonitoring(true)
.withExternalDisplayDetection(true)
.withKeyboardExtensionDetection(true)
DSK.shared
.configure(config)
.start()iOS does not let an app detect when another process reads the pasteboard. As a
practical proxy, call ClipboardMonitor.markSensitiveCopy() right after copying
sensitive data (passwords, OTPs, tokens) to the clipboard. This baselines
UIPasteboard.general.changeCount; if the pasteboard changes again — by this app or
another process — without another markSensitiveCopy() call, DSK reports
SecurityThreat.clipboardExfiltration.
UIPasteboard.general.string = oneTimePasscode
ClipboardMonitor.markSensitiveCopy()When withExternalDisplayDetection(true) is enabled, DSK checks
UIScreen.screens.count > 1 to detect AirPlay screen mirroring or a connected
external/wired monitor. If a second screen is present, DSK reports
SecurityThreat.externalDisplayConnected — useful for hiding sensitive content
(e.g. with secureScreen(dsk:)) while the device's screen is being mirrored.
Not applicable on visionOS, which has no discrete external-display concept —
isExternalDisplayConnected() always returns false there.
When withKeyboardExtensionDetection(true) is enabled, mark sensitive fields as
they become active so DSK can flag a third-party keyboard typing into them:
textField.becomeFirstResponder()
KeyboardExtensionMonitor.markSensitiveFieldActive(textField)
// When editing ends:
KeyboardExtensionMonitor.markSensitiveFieldInactive()If the active input mode isn't one of Apple's own (com.apple.*) while a
sensitive field is marked active, DSK reports
SecurityThreat.thirdPartyKeyboardActive. The detection is time-boxed by
KeyboardExtensionMonitor.detectionWindowSeconds (default: 10s).
status collapses to the single worst-severity threat, but DSK actually tracks
every threat that's active at once:
let active = DSK.shared.currentThreats // Set<SecurityThreat>
if active.contains(.jailbreak) && active.contains(.screenRecording) {
// both conditions are true concurrently, not just the highest-severity one
}In SwiftUI, DSKObservable.activeThreats publishes the same set reactively.
#if DEBUG
print(
RepackagingDetector.currentCertificateHash()
)
#endif.withAntiRepackagingCheck(
true,
expectedCertificateHash:
"your-hash-here"
)SignatureUpdateManager extends DSK's built-in detection lists (jailbreak paths, debugger process names, reverse-engineering libraries, etc.) with additional entries from a remotely-distributed, Ed25519-signed manifest. Detectors append these entries to their static lists, so you can react to newly-discovered jailbreak tools or hooking frameworks without shipping an app update.
SignatureUpdateManager.shared
.configure(publicKey: yourEd25519PublicKey)
let manifest = try await SignatureUpdateManager.shared.update(
from: manifestURL
)
print(manifest.version)
print(SignatureUpdateManager.shared.entries(for: .jailbreakPaths))The manifest is a signed envelope (payload + signature); update(from:) verifies the signature against the configured public key before applying or caching it. An invalid signature throws SignatureUpdateError.invalidSignature, and calling update(from:) before configure(publicKey:) throws .notConfigured. The most recently verified manifest is cached on disk and reloaded automatically the next time configure(publicKey:) is called.
.withVPNProxyDetection(
true,
allowedBundleIDs: [
"com.cisco.anyconnect",
"com.microsoft.intune.tunnel"
]
)DSK.shared
.monitoringInterval(30)
.start()Default: 60 seconds
DSK uses exponential backoff to balance responsiveness with efficiency:
- Threat detected — interval snaps to
minMonitoringIntervalfor rapid re-checking. - Consecutive clean cycles — interval doubles each cycle:
base × 2^cleanCycles, clamped to[min, max].
DSK.shared
.monitoringInterval(60) // base interval
.minMonitoringInterval(10) // fastest re-check
.maxMonitoringInterval(600) // slowest backoff
.start()Query the current adaptive interval at any time:
let current = DSK.shared.currentMonitoringIntervalRapid or concurrent calls to performCheck() / isSecure (e.g. from several
call sites within milliseconds of each other) are coalesced into a single
detector sweep instead of each triggering a full ~20-detector pass:
DSK.shared
.checkCoalescingWindow(0.5) // default: 0.5s
.start()DSK can run a security check while the app is suspended, via BGAppRefreshTask:
let identifier = "com.example.app.dsk-refresh"
DSK.shared
.registerBackgroundTask(identifier: identifier)
let submitted = DSK.shared.scheduleBackgroundCheck(identifier: identifier)registerBackgroundTask(identifier:) should be called during app launch (before applicationDidFinishLaunching returns). Each run automatically reschedules the next check and calls performCheckAsync().
scheduleBackgroundCheck(identifier:earliestBeginDate:) returns @discardableResult Bool — false if BGTaskScheduler rejected the submission (e.g. too many pending requests), which is also logged via SecurityLogger.
Add the identifier to your Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.example.app.dsk-refresh</string>
</array>Countermeasures are automatic actions that fire when a threat is detected.
DSK.shared
.countermeasure(throttled: false) { threat in
Analytics.log("dsk_threat", ["type": threat.rawValue])
}DSK.shared
.countermeasure(for: .jailbreak, throttled: true) { _ in
AuthManager.shared.clearTokens()
}DSK.shared
.countermeasure(forMinimumSeverity: .critical, throttled: true) { threat in
KeychainManager.shared.wipe()
exit(0)
}let cm = Countermeasure(
trigger: .threat(.fridaDetected),
throttled: true
) { _ in
exit(0)
}
DSK.shared.addCountermeasure(cm)DSK.shared.removeCountermeasure(cm)
DSK.shared.removeAllCountermeasures()Throttled countermeasures execute once every 300 seconds per threat type. Adjust with
.threatCallbackThrottleInterval(_:).
For observers that outlive a single closure (e.g. an analytics or logging object), conform to SecurityEventSink instead of using the callback-based handlers:
final class SecurityAuditLogger: SecurityEventSink {
func threatDetected(_ event: ThreatEvent) {
AuditLog.record(event)
}
func statusChanged(to status: SecurityStatus) {
AuditLog.record(status)
}
func checkCompleted(_ result: SecurityResult) {
AuditLog.record(result.riskScore, result.riskLevel)
}
}
let sink = SecurityAuditLogger()
DSK.shared.addEventSink(sink)
// Later:
DSK.shared.removeEventSink(sink)
DSK.shared.removeAllEventSinks()statusChanged(to:) and checkCompleted(_:) have default no-op implementations, so a sink only needs to implement threatDetected(_:).
Inspect per-detector timing and timeout information from the most recent check:
for (name, diagnostic) in DSK.shared.lastDetectorDiagnostics {
print("\(name): \(diagnostic.duration)s, timedOut: \(diagnostic.timedOut)")
}isScreenBeingRecorded() is backed by UIScreen.main.isCaptured on iOS by default (always false on visionOS, which has no UIScreen). Inject your own ScreenRecordingProvider conformer for testing or custom logic:
DSK.shared
.screenRecordingProvider(myCustomProvider)
.start()| Severity | Meaning |
|---|---|
| 🟢 Normal | No threat detected |
| 🔵 Low | Informational |
| 🟡 Medium | Potential risk |
| 🟠 High | Dangerous environment |
| 🔴 Critical | Immediate action recommended |
| Method | Description |
|---|---|
| performCheck() | Run all configured checks |
| isSecure() | Quick security status |
| startMonitoring() | Begin monitoring |
| stopMonitoring() | Stop monitoring |
| configure() | Update configuration |
| onStatusChange() | Status callback |
| onThreatDetected() | Threat callback |
| Threat | Severity |
|---|---|
| Jailbreak | 🔴 Critical |
| Reverse Engineering | 🔴 Critical |
| App Integrity Failure | 🔴 Critical |
| Hook Detection | 🔴 Critical |
| Method Swizzling | 🔴 Critical |
| Pinning Bypass | 🔴 Critical |
| Frida | 🔴 Critical |
| Attestation Failure | 🔴 Critical |
| DSK Tampering | 🔴 Critical |
| Repackaging | 🔴 Critical |
| Debugger | 🟠 High |
| Screen Recording | 🟠 High |
| Emulator | 🟡 Medium |
| VPN / Proxy | 🟡 Medium |
| Screenshot | 🟡 Medium |
| MDM / Enterprise Management | 🟢 Low |
| Clipboard Exfiltration | 🟡 Medium |
| External Display Connected | 🟡 Medium |
| Third-Party Keyboard Active | 🟡 Medium |
All user-facing strings — SecurityThreat.description, ThreatSeverity.description, SecurityStatus.description, RiskLevel.description, and SecurityResult.generateReport(...) — are sourced from a String Catalog (Localizable.xcstrings) bundled with DSK. They automatically follow the host app's locale; no extra setup is required. Contribute additional translations by adding languages to the catalog.
<key>LSApplicationQueriesSchemes</key>
<array>
<string>cydia</string>
<string>sileo</string>
<string>zbra</string>
<string>filza</string>
<string>undecimus</string>
<string>checkra1n</string>
<string>taurine</string>
<string>odyssey</string>
<string>dopamine</string>
</array>| Requirement | Version |
|---|---|
| iOS | 15.0+ |
| visionOS | 1.0+ |
| Swift | 5.9+ |
| Xcode | 15.0+ |
visionOS support omits detectors tied to concepts that don't exist there: ExternalDisplayDetector.isExternalDisplayConnected() and the default ScreenRecordingProvider both return false (no UIScreen/discrete-display concept). Everything else — jailbreak, debugger, Frida, hook, integrity, attestation, clipboard, keyboard-extension, etc. — behaves the same as on iOS.
DeviceSecurityKit is a client-side detection library. All checks run within the app process on the user's device, which means:
- Bypassable by a determined attacker. Anyone with full control of the device (root access, custom kernel, instrumentation frameworks) can intercept, patch, or suppress any check. No client-side security library can prevent this.
- Best used as a signal, not a gate. Treat detection results as one input into a broader risk-assessment pipeline. Combine them with server-side validation (App Attest, device posture APIs, backend anomaly detection) for defence in depth.
- False positives are possible. Some legitimate developer tools, accessibility software, enterprise MDM profiles, or VPN configurations may trigger detections. Test thoroughly with your user base and use the configuration API to disable checks that don't apply.
- Simulator environment. Several detectors are automatically disabled in the iOS Simulator (
#if targetEnvironment(simulator)) because they would always trigger. Test security-critical flows on a real device.
Issues and pull requests are welcome.
For major changes, please open an issue first.
MIT License
Created by @galahador
🛡️ Security First • Zero Dependencies • Open Source Forever
