← Blog

Claude skill Worker Thread security

Claude skills that offload CPU-intensive work — image processing, cryptography, data parsing, PDF rendering — to Node.js Worker threads pass context via workerData and postMessage. When that context includes user-supplied tool arguments, the four patterns in the SA-SKL-WRK family allow attackers to inject code into the Worker, corrupt shared memory, execute arbitrary JavaScript via eval in vm.runInContext, or crash the parent MCP host process by triggering an unhandled Worker exception.

By SkillAudit  |  2 Nov 2026

Worker threads in Node.js share the same process memory space as the main thread but run in a separate V8 isolate. They are commonly used in MCP skills to avoid blocking the event loop during parsing (XML, CSV, Markdown), image or PDF processing, or intensive cryptographic operations. Workers are initialised with workerData, a structured-clone of whatever the main thread passes at creation time; Workers can also receive messages from the main thread via parentPort.on('message', ...) after startup. Both channels carry user-supplied tool argument data into the Worker environment. The Worker environment is not sandboxed — it has full access to Node.js built-ins including fs, child_process, and require — so any code execution primitive within the Worker has the same impact as code execution in the main thread.

SA-SKL-WRK-001: workerData used in require() or dynamic import within the Worker script

Skills that parameterise the Worker behaviour by passing a module path or plugin name in workerData allow an attacker to load arbitrary code. The skill developer intends workerData.processorModule to be one of a few named built-in processors; the attacker supplies a filesystem path or a network-resolvable module specifier. require(workerData.processorModule) within the Worker then loads attacker-controlled code with full Node.js API access. Because the Worker runs in a separate isolate, the exploit is not immediately visible in the main thread, but its effects — reading files, calling out to a network endpoint, modifying shared data — persist across the process boundary.

// VULNERABLE: worker script uses workerData.processorModule in require()
// worker.js
const { workerData, parentPort } = require('worker_threads');

// processorModule intended to be one of ['csv', 'xml', 'markdown']
// Attacker supplies: "/tmp/evil.js" or "child_process" or "../../../etc/shadow"
const processor = require(workerData.processorModule);  // ← arbitrary module load

const result = processor.parse(workerData.input);
parentPort.postMessage({ result });

// main skill — spawns Worker with user-supplied tool argument
import { Worker } from 'worker_threads';
import path from 'path';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'parse_document') {
    const { format, content } = req.params.arguments;
    // format = "csv" intended; attacker supplies format = "/tmp/injected.js"

    return new Promise((resolve, reject) => {
      const worker = new Worker(path.join(__dirname, 'worker.js'), {
        workerData: { processorModule: format, input: content }
        // ^ processorModule passed directly from tool argument → require() in Worker
      });
      worker.on('message', resolve);
      worker.on('error', reject);
    });
  }
});

// SECURE: allowlist on main thread before passing to Worker
const ALLOWED_PROCESSORS = new Set(['csv', 'xml', 'markdown', 'json']);

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'parse_document') {
    const { format, content } = req.params.arguments;

    if (!ALLOWED_PROCESSORS.has(format)) {
      throw new Error(`format must be one of: ${[...ALLOWED_PROCESSORS].join(', ')}`);
    }
    // Worker receives only validated format name — require() inside Worker is safe
    const worker = new Worker(path.join(__dirname, 'worker.js'), {
      workerData: { processorModule: format, input: content }
    });
  }
});

// DETECTION (SkillAudit):
// In Worker source files: require(workerData.*) or import(workerData.*) without preceding allowlist check
const workerRequire = searchAST(
  'require(expr) | import(expr) where expr includes workerData.* or parentPort.message.*'
);
if (workerRequire.length > 0) → CRITICAL  // SA-SKL-WRK-001

The Worker isolate does not restrict Node.js built-in access: unlike browser Workers, Node.js Worker threads have full require access to fs, child_process, net, crypto, and every other Node.js built-in. A Worker that loads an attacker-controlled module can exfiltrate files, spawn child processes, make network calls, and read environment variables. The isolation only prevents sharing the same V8 context, not the same OS privileges.

CRITICAL
SA-SKL-WRK-001 — require(workerData.*) or import(workerData.*) within a Worker script: attacker supplies a module path or built-in name, Worker loads it with full Node.js API access. Detection: dynamic require/import of workerData fields without allowlist → CRITICAL.

SA-SKL-WRK-002: shared ArrayBuffer between main thread and Worker without synchronisation

Node.js Worker threads can share memory directly via SharedArrayBuffer. When a skill passes a SharedArrayBuffer in workerData and both the main thread and the Worker read and write to it concurrently, race conditions allow the Worker (or the main thread) to observe a partially written value. If the shared buffer holds security-relevant data — a session token, a user ID for an authorisation decision, an intermediate cryptographic value — a Worker that writes to overlapping bytes at a carefully timed moment can corrupt the main thread’s view of that data. The attack surface is particularly acute in skills that use SharedArrayBuffer for inter-thread caching of request context, since the LLM can invoke multiple tool calls concurrently, causing the Worker handling one call to overwrite buffer regions used by another.

// VULNERABLE: SharedArrayBuffer passed to Worker, both threads write without Atomics
import { Worker, isMainThread, workerData, parentPort } from 'worker_threads';

const sharedBuffer = new SharedArrayBuffer(1024);
const sharedView = new Int32Array(sharedBuffer);

if (isMainThread) {
  // main thread writes session context into shared buffer
  sharedView[0] = currentUserId;   // userId for auth check
  sharedView[1] = currentSessionToken;  // token fragment

  const worker = new Worker(__filename, {
    workerData: { sharedBuffer, taskData: userInput }
  });

  // Worker may also write to sharedView[0] or sharedView[1]
  // If LLM issues two concurrent tool calls, Worker A writes userId=1, Worker B writes userId=2
  // Main thread auth check reads sharedView[0] = userId from whichever wrote last

  worker.on('message', (result) => {
    // Auth check using sharedView[0] — value may be from a different user's call
    if (sharedView[0] === currentUserId) {
      grantAccess(result);  // race: could grant user A access to user B's data
    }
  });
} else {
  // Worker writes back processed userId
  const view = new Int32Array(workerData.sharedBuffer);
  view[0] = processedUserId;  // ← corrupts main thread's userId if interleaved
  parentPort.postMessage({ done: true });
}

// SECURE: use Atomics for coordination, never use SharedArrayBuffer for security-sensitive data
// If you must share memory, use Atomics.compareExchange for CAS operations:
Atomics.store(sharedView, 0, userId);      // atomic write
const stored = Atomics.load(sharedView, 0); // atomic read
// But: do NOT store auth tokens or session identifiers in SharedArrayBuffer at all
// Use postMessage for per-call context — it creates a structured clone, not a reference

// DETECTION (SkillAudit):
// Worker spawned with SharedArrayBuffer in workerData, and main thread reads the same
// buffer after worker.on('message') without Atomics.load()
const sharedBufRace = searchAST(
  'new Worker with workerData containing SharedArrayBuffer ' +
  'AND main-thread read of Int32Array/Float64Array of that buffer after postMessage ' +
  'without Atomics.load()'
);
if (sharedBufRace.length > 0) → HIGH  // SA-SKL-WRK-002

Never store auth context in SharedArrayBuffer: even with Atomics, a SharedArrayBuffer holding a user ID, session token, or authorisation bitmask is a data race waiting to happen under concurrent tool calls. Pass per-call context to Workers via workerData structured clone (a private copy per Worker) or via MessageChannel. Reserve SharedArrayBuffer for truly stateless shared data like a lookup table or a pre-computed constant array.

HIGH
SA-SKL-WRK-002 — shared ArrayBuffer between main thread and Worker with concurrent writes and no Atomics coordination: race condition corrupts security-sensitive data (user IDs, tokens) under concurrent LLM tool calls. Detection: SharedArrayBuffer in workerData with non-atomic reads in main thread → HIGH.

SA-SKL-WRK-003: eval or vm.runInContext inside Worker with message-received data

Some skills use Workers as a scripting sandbox, sending user-provided code strings to the Worker via postMessage and evaluating them inside the Worker’s vm.runInContext or eval. This pattern appears in skills that offer user-defined transformation functions, custom validation rules, or scriptable data pipelines. The assumption is that Worker isolation limits the blast radius; in reality, vm.runInContext in Node.js is not a security sandbox. A one-line escape ({}.constructor.constructor('return process')()) retrieves the Node.js process object from within the context, enabling access to process.env, process.mainModule.require, and the full Node.js API. Running this escape inside a Worker thread produces the same capabilities as running it in the main thread.

// VULNERABLE: Worker evaluates user-supplied code via vm.runInContext
// worker.js
const { parentPort, workerData } = require('worker_threads');
const vm = require('vm');

parentPort.on('message', (msg) => {
  if (msg.type === 'run_transform') {
    const ctx = vm.createContext({ input: msg.data, output: null });

    // msg.code is user-supplied transformation code like "output = input.toUpperCase()"
    // Attacker supplies: "({}.constructor.constructor('return process')()).env"
    vm.runInContext(msg.code, ctx);
    // ^ vm.createContext() is NOT a security boundary
    // ^ attacker retrieves process.env, loads child_process, executes shell commands

    parentPort.postMessage({ result: ctx.output });
  }
});

// main skill — sends user tool argument as code to Worker
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'transform_data') {
    const { data, transformCode } = req.params.arguments;

    const worker = new Worker(path.join(__dirname, 'worker.js'), { workerData: {} });
    worker.postMessage({ type: 'run_transform', code: transformCode, data });
    // ^ transformCode from tool argument → eval inside Worker → process escape
  }
});

// SECURE: never eval user-supplied code; use a safe DSL or allowlisted operations
const SAFE_TRANSFORMS = {
  uppercase: (s) => s.toUpperCase(),
  lowercase: (s) => s.toLowerCase(),
  trim:      (s) => s.trim(),
  reverse:   (s) => s.split('').reverse().join(''),
};

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'transform_data') {
    const { data, transformName } = req.params.arguments;

    if (!Object.hasOwn(SAFE_TRANSFORMS, transformName)) {
      throw new Error(`transformName must be one of: ${Object.keys(SAFE_TRANSFORMS).join(', ')}`);
    }
    const result = SAFE_TRANSFORMS[transformName](String(data));
    return { content: [{ type: 'text', text: result }] };
    // No Worker needed — the operation is safe and synchronous
  }
});

// DETECTION (SkillAudit):
// Worker files containing eval() or vm.runInContext() where the expression
// includes data from parentPort.on('message', ...) payloads
const workerEval = searchAST(
  'eval(expr) | vm.runInContext(expr, ...) in Worker where expr derives from ' +
  'parentPort.on("message") payload or workerData.*'
);
if (workerEval.length > 0) → CRITICAL  // SA-SKL-WRK-003

vm.runInContext is not a sandbox: the Node.js documentation explicitly states that the vm module is not a security mechanism. The context object shares a V8 heap with the host process, and the constructor chain escape ({}.constructor.constructor('return process')() is documented, reliable, and one line. Any skill that runs user-supplied code in vm.runInContext inside a Worker is unconditionally vulnerable to full process compromise regardless of how restrictive the context object appears to be.

CRITICAL
SA-SKL-WRK-003 — eval or vm.runInContext in a Worker thread with user-supplied code via postMessage: constructor chain escape retrieves process from within vm context; full Node.js API access from what appears to be an isolated Worker. Detection: eval/vm.runInContext in Worker with postMessage-derived data → CRITICAL.

SA-SKL-WRK-004: Worker uncaught exceptions crash the parent MCP host process

By default, an unhandled exception inside a Node.js Worker thread that is not caught by the Worker's own process.on('uncaughtException') or try/catch terminates the Worker and emits an 'error' event on the Worker object in the main thread. If the main thread does not attach an 'error' listener to the Worker, the uncaught 'error' event propagates to the Node.js event emitter default, which calls process.exit(). A skill that creates Worker objects without attaching worker.on('error', ...) listeners is vulnerable to a denial-of-service attack: the attacker supplies tool arguments that cause the Worker to throw an uncaught exception (by triggering a known code path that lacks error handling, or by passing values that crash a Worker library), and the entire MCP host process exits. All co-hosted skills, all in-flight tool calls, and all open sessions terminate immediately.

// VULNERABLE: Worker spawned without 'error' event listener
import { Worker } from 'worker_threads';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'process_pdf') {
    const { pdfPath } = req.params.arguments;

    return new Promise((resolve) => {
      const worker = new Worker(path.join(__dirname, 'pdf-worker.js'), {
        workerData: { pdfPath }
      });
      worker.on('message', resolve);
      // MISSING: worker.on('error', ...) listener
      // If pdf-worker.js throws for a malformed PDF (which attacker can guarantee),
      // the 'error' event is unhandled → process.exit() → entire MCP host dies
    });
  }
});

// ALSO VULNERABLE: Worker with error handler that re-throws into main thread
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'process_pdf') {
    const { pdfPath } = req.params.arguments;

    return new Promise((resolve, reject) => {
      const worker = new Worker(path.join(__dirname, 'pdf-worker.js'), {
        workerData: { pdfPath }
      });
      worker.on('message', resolve);
      worker.on('error', (err) => { throw err; });  // ← re-throwing in main thread callback
      // 'throw' inside event callback → uncaughtException in main thread → process exit
    });
  }
});

// SECURE: always attach error handler; reject promise instead of throwing
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'process_pdf') {
    const { pdfPath } = req.params.arguments;

    return new Promise((resolve, reject) => {
      const worker = new Worker(path.join(__dirname, 'pdf-worker.js'), {
        workerData: { pdfPath }
      });

      // Always handle both channels
      worker.on('message', resolve);
      worker.on('error', (err) => {
        // reject the Promise — error is scoped to this tool call, not the process
        reject(new Error(`PDF processing failed: ${err.message}`));
        // Do NOT re-throw; do NOT call process.exit(); let the skill return an error result
      });
      worker.on('exit', (code) => {
        if (code !== 0) reject(new Error(`PDF worker exited with code ${code}`));
      });

      // Add a timeout — Workers that hang indefinitely also block the event loop queue
      const timer = setTimeout(() => {
        worker.terminate();
        reject(new Error('PDF processing timed out'));
      }, 30_000);
      worker.on('message', () => clearTimeout(timer));
    });
  }
});

// DETECTION (SkillAudit):
// new Worker(...) calls without a chained worker.on('error', ...) handler
const workerNoError = searchAST(
  'new Worker(script, ...) where the variable has no .on("error", ...) call ' +
  'within the same scope before the next await or return'
);
if (workerNoError.length > 0) → HIGH  // SA-SKL-WRK-004

Worker exit codes also need handling: a Worker that calls process.exit(N) internally (for instance, because a library calls process.exit on fatal errors) emits an 'exit' event with a non-zero code rather than an 'error' event. Without an 'exit' listener, the Promise returned by the tool call never resolves or rejects, hanging the LLM indefinitely. Always attach both 'error' and 'exit' listeners, with a termination timeout as a backstop.

HIGH
SA-SKL-WRK-004 — Worker spawned without 'error' event listener: attacker-triggered Worker exception propagates to main thread as an unhandled 'error' event, calling process.exit() and terminating the entire MCP host. Detection: new Worker() without .on('error') handler → HIGH.

SA-SKL-WRK summary

IDPatternSeverityImpact
SA-SKL-WRK-001require(workerData.*) in Worker script without allowlistCRITICALArbitrary module load; attacker loads any Node.js built-in or file from filesystem
SA-SKL-WRK-002Shared ArrayBuffer with concurrent writes and no AtomicsHIGHRace condition corrupts auth context under concurrent LLM tool calls
SA-SKL-WRK-003eval/vm.runInContext with postMessage-received codeCRITICALvm escape retrieves process object; full Node.js API access from Worker
SA-SKL-WRK-004Worker spawned without 'error' and 'exit' listenersHIGHAttacker-triggered Worker exception exits entire MCP host process; DoS all co-hosted skills

How SkillAudit detects SA-SKL-WRK patterns

SkillAudit’s analysis traces data flow from workerData initialisation and postMessage calls in the main thread to their corresponding reads inside Worker scripts. For SA-SKL-WRK-001, it identifies require() and import() calls where the module specifier includes a workerData field without a preceding allowlist validation. For SA-SKL-WRK-002, it identifies SharedArrayBuffer instances that are written by both main and Worker thread code paths without Atomics operations gating the writes. SA-SKL-WRK-003 is detected by finding eval() and vm.runInContext() in Worker scripts where the first argument derives from a parentPort.on('message') payload. SA-SKL-WRK-004 is a structural check — every new Worker() call site is verified to have both 'error' and 'exit' event handlers attached.

Run a free SkillAudit scan to check your Claude skill for all four SA-SKL-WRK patterns alongside SA-SKL-SBX sandbox escape, SA-SKL-SRL deserialization vulnerabilities, and SA-SKL-CLI subprocess injection.