XCloak / blog
← All posts
2026-07-06 · Engineering · 5 min read

Building Enterprise Android MDM Without Google EMM

Most enterprise MDM solutions for Android require enrolling devices in Google Workspace/Enterprise (EMM). This works well for corporate-owned devices but creates friction for BYOD scenarios — employees don't want to give their employer Google-level control over their personal phone.

XCloak takes a different approach: a Flutter foreground service that collects security telemetry and accepts management commands, without requiring EMM enrollment.

What We Can Do Without Device Owner

Android restricts many sensitive management APIs to "Device Owner" (DO) or "Profile Owner" (PO) mode. Without them:

Feature Requires DO/PO XCloak approach
Screen lock detection ✅ (DevicePolicyManager) Not available — has_passcode is null
Remote lock ✅ (DevicePolicyManager.lockNow) Requires DO; BYOD: returns explanation
Remote wipe Requires DO; BYOD: intentionally rejected
VPN enforcement Not enforced; VPN detected via interface list
App whitelist/blacklist Not enforced; sideloads detected via inventory

The key insight: for BYOD, detection is more useful than enforcement. We can detect root, USB debugging, unknown sources, VPN absence, battery risk, and storage risk — and report these as a posture score to the SOC. The operator sees the risk; the user isn't locked out of their phone.

How We Detect Root

Root detection is a multi-layer check with no single point of bypass:

static Future<bool> _checkRooted(AndroidDeviceInfo info) async {
  // Layer 1: Build tags — test-keys means the ROM is not production-signed
  if (info.tags.contains('test-keys')) return true;

  // Layer 2: Well-known su binary locations
  const suPaths = [
    '/system/app/Superuser.apk', '/sbin/su', '/system/bin/su',
    '/system/xbin/su', '/data/local/xbin/su', '/data/local/bin/su',
    '/system/xbin/busybox', '/data/local/tmp/su',
  ];
  for (final path in suPaths) {
    if (await File(path).exists()) return true;
  }

  // Layer 3: Magisk socket (modern rootkit hiding method)
  try {
    final result = await Process.run('test', ['-S', '/dev/.magisk/mirror']);
    if (result.exitCode == 0) return true;
  } catch (_) {}

  return false;
}

Determined adversaries can bypass this — no BYOD root detection is perfect. The goal is detecting common rooted devices and unintentionally rooted phones (factory-rooted development devices, user-rooted phones with Magisk), not adversaries actively trying to hide.

VPN Detection via NetworkInterface

Android doesn't provide a direct "is VPN active?" API without DO mode. Instead, we inspect the network interface list:

final interfaces = await NetworkInterface.list();
vpn = interfaces.any((i) =>
    i.name.startsWith('tun') ||
    i.name.startsWith('ppp') ||
    i.name.startsWith('vpn'));

tun interfaces are created by WireGuard, OpenVPN, and most VPN clients. This covers the vast majority of VPN scenarios on Android without any special permissions.

Battery and Storage via Process.run

The Flutter SDK doesn't expose battery level or storage stats in a way that works from a background service isolate. We use Process.run to shell out to Android's command-line tools:

// Battery level and charging state
final result = await Process.run('dumpsys', ['battery']);
final out = result.stdout as String;
final levelMatch = RegExp(r'level: (\d+)').firstMatch(out);
// status 2 = CHARGING, 5 = FULL
final statusMatch = RegExp(r'status: (\d+)').firstMatch(out);

// Storage
final dfResult = await Process.run('df', ['-h', '/data']);
// Parse: Filesystem Size Used Avail Use% Mounted

These shell commands are available to non-root apps on Android — they don't require elevated permissions.

The Foreground Service Architecture

Android aggressively kills background processes. To keep the agent running while the app is closed, we use a foreground service — a service that shows a persistent notification to the user.

@pragma('vm:entry-point')
void onServiceStart(ServiceInstance service) async {
  if (service is AndroidServiceInstance) {
    service.setAsForegroundService();
  }

  // Immediate first run to confirm liveness
  await _checkIn(service);
  await _pollCommands();

  // 5 staggered periodic timers with jitter
  _schedulePeriodic(_checkinInterval,   () => _checkIn(service));
  _schedulePeriodic(_cmdPollInterval,   _pollCommands);
  _schedulePeriodic(_logInterval,       LogForwarder.forwardBatch);
  _schedulePeriodic(_inventoryInterval, _runInventory);
  _schedulePeriodic(_threatInterval,    _runThreatScan);
}

Each timer uses _schedulePeriodic() which adds random jitter (0–30 s) before the first tick. On a large fleet, this prevents all devices rebooting after an OS update from hitting the backend simultaneously.

The foreground notification degrades from "Monitoring device security…" to "Agent degraded — server unreachable" after 5 consecutive check-in failures, giving users a visible signal that something is wrong.

← How XCloak Maps Linux /proc/net to Process Names XCloak v0.2.0: Enterprise Security Hardening →