A real intrusion against a banking app last quarter looked like this: a tester-grade test device, a tester-grade .dylib, no Frida. The attacker had read the published RuntimeGuard checklist and avoided every named tool. Their .dylib resolved a single high-value selector, swapped its IMP for a thin wrapper that buffered the original arguments to a side channel, and called through. The selector was -[KeychainAccess fetchTokenFor:]. The wrapper added 0.4ms of latency. The IMP was technically still inside a code-signed binary, just not the right one.
Every one of our individual detectors gave the same answer: probably fine.
- AnomalyKit’s TelemetryDetector noted a 9% increase in keychain fetch frequency in the last 30 minutes. Within its normal noise band; soft alert at confidence 0.31.
- RuntimeGuard’s SecurityEngine ran all 5 layers. Image-table walk clean (the dylib was signed by an enterprise team-ID the attacker had bought). Hardened-runtime intact. No symbol interposing. The IMP-integrity check on
fetchTokenFor:passed because the attacker’s IMP was in another binary that also had a valid code signature. Risk level:.elevated. - BehaviorGuard’s BaselineEngine observed a session whose touch rhythm sat 1.4 sigma off the per-user baseline. Risk level
.suspicious. Not alarming on its own; a tired user types differently.
Three detectors. Three “probably fine” outcomes. Each one alone would have shipped the user a clean experience. Together, they crossed a fusion threshold and gated the session into step-up auth before the wrapper had collected enough tokens to be useful.
This post is the architecture for that fusion, the Swift wiring, the false-positive analysis, and the cost.
Why single-signal detectors lose
Every detector in the industry has an FPR/TPR curve. Tune it aggressive and you false-positive your legitimate users into step-up loops; tune it conservative and you miss the attacks that sit just below the threshold. The attacker’s job is to find that gap. The defender’s job is to make the gap not exist.
You cannot eliminate the gap on a single detector. The reason is structural: an attack that looks anomalous on every axis is easy; an attack that’s been tuned to sit 0.3 sigma below the threshold on every axis is the actual threat. The interesting attacks are the ones in the noise band.
The way you eliminate that gap is to require correlated anomaly across uncorrelated signals. A telemetry shift is correlated with the user’s app usage. A runtime indicator is correlated with the device’s integrity state. A behavioral drift is correlated with the user’s identity. These three sources have orthogonal noise. An attack has to be tuned against all three simultaneously, which is materially harder than tuning against one.
That’s the fusion thesis. Three signals from three independent sources, combined under a sliding-window consensus policy.
What each SDK actually emits
AnomalyKit’s four detectors (Telemetry, Behavioral, Acoustic, Sensor) each emit an array of Anomaly values:
public struct Anomaly: Identifiable, Sendable, Hashable, Codable {
public let id: UUID
public let modality: DataModality // .telemetry, .behavioral, .acoustic, .sensor
public let classification: String // detector-specific label
public let score: Double // 0.0...1.0
public let severity: Severity // .informational ... .critical
public let detectedAt: Date
public let modelIdentifier: String
public let evidenceHash: String // for downstream dedupe
}
RuntimeGuard returns a SecurityReport from assess() after walking its layers:
public struct SecurityReport: Sendable, Equatable {
public let riskLevel: SecurityRiskLevel // .secure, .elevated, .compromised
public let detectedThreats: [DetectedThreat] // each with scannerIdentifier + severity
public let timestamp: Date
}
BehaviorGuard emits a .riskScoreUpdated(RiskScore) event from its hysteretic 4-band engine, carrying a RiskScore:
public struct RiskScore: Sendable, Equatable {
public let confidence: Double // how far from this user's baseline
public let level: RiskLevel // .nominal, .elevated, .suspicious, .critical
public let evaluatedAt: Date
public let contributingSensors: Set<SensorType> // touch rhythm, gyro, dwell, etc.
}
Three streams. Three observation models. The fusion happens at the consumer.
The fusion policy
Hysteretic, weighted, 2-of-3:
import AnomalyKit
import RuntimeGuard
import BehaviorGuard
import Combine
final class TamperFusionEngine {
private let anomaly: TelemetryAnomalyDetector
private let runtime: any RuntimeGuard
private let behavior: BehaviorGuard
@Published private(set) var fusedRisk: FusedRisk = .clear
private var lastAnomaly: Anomaly?
private var lastRuntime: SecurityReport?
private var lastBehavior: RiskScore?
private var cancellables = Set<AnyCancellable>()
init(anomaly: TelemetryAnomalyDetector, runtime: any RuntimeGuard, behavior: BehaviorGuard) {
self.anomaly = anomaly
self.runtime = runtime
self.behavior = behavior
anomaly.hits
.sink { [weak self] hit in self?.ingest(anomaly: hit) }
.store(in: &cancellables)
runtime.reports
.sink { [weak self] report in self?.ingest(runtime: report) }
.store(in: &cancellables)
behavior.updates
.sink { [weak self] update in self?.ingest(behavior: update) }
.store(in: &cancellables)
}
private func ingest(anomaly hit: Anomaly) {
lastAnomaly = hit
recompute()
}
private func ingest(runtime report: SecurityReport) {
lastRuntime = report
recompute()
}
private func ingest(behavior score: RiskScore) {
lastBehavior = score
recompute()
}
private func recompute() {
let anomalyTripped = (lastAnomaly?.score ?? 0) >= 0.30
let runtimeTripped = (lastRuntime?.riskLevel.rawValue ?? 0) >= SecurityRiskLevel.elevated.rawValue
let behaviorTripped = (lastBehavior?.level ?? .nominal) >= .suspicious
let trippedCount = [anomalyTripped, runtimeTripped, behaviorTripped]
.filter { $0 }
.count
let next: FusedRisk
switch trippedCount {
case 0, 1: next = .clear
case 2: next = .elevated
case 3: next = .critical
default: next = .clear
}
// Hysteresis: never downgrade for 90 seconds after an elevation.
if next.rawValue < fusedRisk.rawValue,
Date().timeIntervalSince(lastElevation) < 90 {
return
}
if next.rawValue > fusedRisk.rawValue {
lastElevation = Date()
}
fusedRisk = next
}
private var lastElevation: Date = .distantPast
}
The thresholds (score 0.30 for AnomalyKit, .elevated for RuntimeGuard, .suspicious for BehaviorGuard) are each individually below what any single detector would gate on. The point is exactly that: each individual signal is in the “probably fine” range. The fusion catches the correlation.
In the keychain-wrapper attack: AnomalyKit was at score 0.31, RuntimeGuard reported .elevated, BehaviorGuard reached .suspicious. All three tripped. trippedCount = 3, fusedRisk = .critical. The session got bumped to step-up auth before the wrapper accumulated value.
Tuning each detector against your own traffic
The thresholds above are starting points. The right thresholds are the ones that match your traffic. There are two ways to land them:
Offline tuning. For two weeks after integration, run all three SDKs in observation-only mode. Log every Anomaly, every SecurityReport, every RiskScore to a flat table. At the end, you have a per-detector distribution. Set each threshold at the 95th percentile of the observed clean traffic. That gives you a per-detector FPR of about 5%. If the three detectors were independent, a 2-of-3 fusion would drop the combined FPR by roughly a factor derived from multiplying the per-detector rates. The independence is an assumption, not a measurement: it’s plausible because the three noise sources are structurally orthogonal (app usage, device integrity, user identity), but validate it against your own traffic rather than taking the multiplication on faith.
Online tuning. A small portion of your sessions (1-3%) gets a stricter threshold (the 90th percentile). Compare the step-up frequency in that bucket against the main bucket; if they’re similar, the threshold is too loose. If the strict bucket has materially more step-ups, your main threshold is in the sweet spot. Roll the strict threshold to production gradually.
The fusion is robust to detector mis-calibration: as long as each detector has some signal, the 2-of-3 vote produces a clean output. A detector that’s badly tuned just contributes less signal; it doesn’t poison the fusion.
Cost
Three SDKs initialize at app launch. We don’t publish benchmark figures for the cost (there’s no harness behind them, and any number would depend on your device floor, build, and model set), but the shape of the cost is worth understanding:
- AnomalyKit init loads four INT4-quantized detector models, verifies their Ed25519 signatures, and warms the ComputeRouter. The model loads dominate.
- RuntimeGuard init runs the initial 5-layer scan, enrolls App Attest if it’s the first run, and computes the baseline image-table.
- BehaviorGuard init loads the baseline from the on-device store and initializes the touch-rhythm observer. The lightest of the three.
The three SDKs initialize on separate dispatch queues and finish concurrently, so the marginal app-launch wall-time is closer to the slowest of the three than to their sum. Older hardware pays more. Measure it on your own device floor before you commit.
Memory footprint at steady state is dominated by AnomalyKit’s detector models, which live in the ANE-pinned buffer pool; RuntimeGuard and BehaviorGuard hold comparatively little resident state. Profile the resident footprint on-device rather than trusting a headline figure.
Battery: AnomalyKit’s ComputeRouter is thermal-aware; it drops detector frequency when the device gets warm. RuntimeGuard runs its layers on-demand (when the app calls assess()) plus a passive background check every 60 seconds. BehaviorGuard’s observation is touch-event-driven; it consumes negligible idle power. Idle-power cost is low, but measure it against your own usage profile.
Limits
Be honest about what this catches and what it doesn’t.
It does not catch zero-day exploits that none of the three detectors are trained on. An attack that exploits a kernel vulnerability to read memory directly, bypassing every selector and every observable behavior, is invisible to all three SDKs. The defense against that class of attack is App Attest’s hardware-rooted attestation plus iOS itself; we don’t replace it.
It requires baseline-learning time before the fusion is useful. BehaviorGuard needs about 30 minutes of typing/tapping/dwelling activity to build a per-user baseline. Before that, its signal is noisy. AnomalyKit’s TelemetryDetector needs about 14 days of per-cohort data to settle; the AnomalyKit threat model details the attacker capabilities its detectors assume once that baseline has converged. RuntimeGuard works from the first call. A brand-new user has weaker fusion coverage than a returning user; we mitigate this by gating high-value operations on App Attest until BehaviorGuard’s baseline reaches confidence 0.7.
It has a launch-cost. Three SDKs initialising, plus resident memory for their detectors and baselines, plus battery for continuous sensing. Not free, and worth measuring on your own device floor before you commit. For a consumer app whose cold start is already dominated by your own initialisation, the added time tends to disappear into the noise. For latency-critical apps (a payments terminal where the merchant taps and waits for an authorization screen), the cost may matter. Lazy-init the SDKs after the first frame paints if you need to.
It’s not the response policy. Detecting that something is wrong is half the problem. The other half (degrade-in-session, force-reauth, backend-revoke) is the response pipeline, which is a separate piece of architecture. We cover it in a companion post on the response stack.
The fusion catches the correlated-anomaly case. It does not catch the surgical attack against a primitive none of the three SDKs observe. The point is to raise the bar from “use a different injection toolchain than Frida” to “tune your attack against three independent signal streams simultaneously, before you have any visibility into our thresholds”. That bar is materially higher.
See /sdk/bundles/ai-anomaly for the bundle landing page, /sdk/anomalykit, /sdk/runtimeguard, and /sdk/behaviorguard for the individual integrations.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.