Skip to content

Security: static form-walker has two known bypass paths #10

@rhblind

Description

@rhblind

Overview

The static Elisp form checker in mcp-server-security--check-form-safety has two structural limitations that allow dangerous operations to bypass the blocklist. These require architectural changes and are tracked here separately from the immediate fixes in #9.

Bypass 1: let-binding positions are not recursed into

The walker iterates the args of a form, but treats the car of a list argument (the "function" position) as opaque when it is not a symbol. In a let binding list ((x (shell-command "id"))), the structure seen by the walker is:

outer list: func=(x (shell-command "id")),  args=nil

Because func is not a symbol, the dangerous-function check is skipped. Because args is nil, no recursion happens. The inner call (shell-command "id") is never visited.

Verified bypass:

(let ((x (shell-command "id"))) x)   ; passes the checker, executes shell-command

The same pattern applies to let*, cl-loop, dotimes, and any macro form that wraps dangerous calls in a binding position rather than a tail position.

Bypass 2: Dynamic function-name construction via funcall/apply + intern

The checker operates on static symbols. A function name constructed at runtime is invisible to it:

(funcall (intern "delete-file") "/tmp/test")   ; passes, executes delete-file
(apply (car (list 'shell-command)) '("id"))    ; passes, executes shell-command

Any variant that avoids placing the dangerous symbol in a statically-visible position evades the check.

Impact

Both bypasses require the attacker to have already gained eval-elisp tool access (which is itself gated by destructiveHint and the function-level blocklist). They are therefore a second layer of concern, but relevant when users place trust in the Emacs-side enforcement.

Proposed approaches

For bypass 1

Run macroexpand-all on the form before walking it. This converts let, let*, when, unless, dolist, etc. into their primitive equivalents, making all subforms visible to the walker. Caveats: adds latency for complex forms; user-defined macros may not expand predictably.

For bypass 2

The static checker cannot close this gap by design — it cannot evaluate arbitrary code to determine what (intern ...) will return. Options:

  • Add funcall, apply, and intern to mcp-server-security-dangerous-functions by default, so they are blocked unless explicitly allowed. This is a breaking change for legitimate use.
  • Document that the function-level blocklist is the primary enforcement layer and the form walker is a best-effort defence-in-depth measure, not a complete sandbox.

Related

  • Fixed in the same PR: issue Confusion about access control settings #9 (sensitive file pattern matching), missing dangerous functions (with-temp-file, write-file, append-to-file, make-network-process, open-network-stream, directory-files, insert-file-contents-literally), glob pattern support in mcp-server-security-sensitive-file-patterns.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions