The Denylist Grew Twelve Characters and Still Lost: Emacs TRAMP’s Filename RCE Survives Its Own Fix

On 11 October 2026 at 21:16:43Z, NVD published CVE-2026-108976 with a description that is unusually blunt about its own lineage: “GNU Emacs before 31.2 (and TRAMP through 2.8.2) allows OS command injection via a filename because tramp-user-regexp has an incomplete list of disallowed inputs. NOTE: this issue exists because of an incomplete fix for CVE-2026-79992.” CWE-184, Incomplete List of Disallowed Inputs. MITRE scored it 7.8 high, CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H — the same vector Red Hat assigned to the original in August.

The record is correct and, by itself, almost uninformative. What makes this worth a briefing is what we found when we stopped reading advisories and went to look at what Emacs users can actually install today. We fetched three distribution channels and read tramp-user-regexp in each. They are in three different states, and none of them is patched.

What the original bug was: a filename is a shell command

Bas Alberts of the GitHub Security Lab reported CVE-2026-79992 to the Emacs maintainers; Sean Whitton posted it to oss-security on 21 August 2026. We read his message first-hand. The mechanism has two halves, and Alberts labelled both CRITICAL.

Half one is a shell-concatenation defect in tramp-sh.el. tramp-maybe-open-connection expands the %u, %h, %p specifiers of tramp-login-args and joins the result with string-join, unquoted, into a command line handed to a live local /bin/sh that TRAMP has already spawned. Alberts’ summary, quoted verbatim from the report:

tramp-user-regexp (tramp.el:1073) is
(+ (not (any "/:|[]" blank))), which admits $ ( ) ; ` ' " \ and more.
tramp-dissect-file-name performs no sanitization.

The consequence he demonstrates is that merely stat-ing a file name — file-exists-p is enough — executes the payload locally, “before and regardless of any actual ssh connection or server.” His worked example is /ssh:$(cd;touch$IFS'pwned')@127.0.0.1:/x, and he notes that the characters the regexp did exclude are trivially reconstructed from inside the permitted set: $IFS substitutes for a blank, $(printf$IFS'\057') composes a /, and $(cd;...) avoids needing an absolute path at all.

Half two is why this is zero-click rather than a self-inflicted wound. tramp-build-prefix-regexp anchored the file-name dispatch regexp with bol rather than string-start — line-anchored, not string-anchored. Once TRAMP is loaded it owns that entry in file-name-handler-alist, so any string merely containing a newline followed by /ssh:… gets dispatched into TRAMP and parsed. Alberts names two on-disk carriers for that newline, and both are the sort of thing a repository can carry: a symlink target (arbitrary bytes except NUL, committable to git) and a directory whose name ends in a newline containing a file named ssh:…, where the path separator is supplied by the component boundary itself. He reports verifying that recursively scanning such a tree executes the payload. An absolute symlink target matches even the string-anchored autoload regexp, so for that carrier TRAMP need not be preloaded at all.

Restate that in the terms this site usually works in: cloning a repository and walking it with directory-files-recursively is the exploit. No prompt, no confirmation, no network. That is the same category of trust boundary as the editor-extension loaders in GlassWorm’s VS Code theme packages — the developer workstation executing attacker-authored metadata it never meant to interpret.

The August fix, and what it actually changed

Michael Albinus, the TRAMP maintainer, committed the fix as f3e7104d, “Restrict Tramp user name”, on 21 August 2026 at 12:23:38Z — about two hours before the oss-security post. We read the commit metadata from the Emacs mirror: six files, and lisp/net/tramp.el gains twelve lines and loses four. It fixed the anchoring (bol → bos, with the docstring updated to match) and it widened the character denylist in tramp-user-regexp from five characters to twenty:

;; before  (vulnerable)
(rx (+ (not (any "/:|[]" blank))))

;; after f3e7104d  (CVE-2026-79992 fix)
(rx (| (+ (not (any "/\\^$?*:;|[]{}()<>`'\"" blank)))
       ;; Environment variable.
       (: "$" (+ (any "_" alnum)))))

Fifteen characters added to the exclusion set — and one new permission. The alternation’s second branch explicitly admits a bare environment-variable form, $ followed by alphanumerics and underscores, because TRAMP documents that as legitimate syntax; the same commit updates doc/misc/tramp.texi to say so. Note what that means structurally: the character most central to Alberts’ proof-of-concept was banned from one branch and formally blessed in the other.

The October finding

On 11 October 2026 at 16:04:52Z, Whitton posted again, subject line “Incomplete fix for Emacs CVE-2026-79992.” We read it first-hand. The credit: Wonyoung Jung of 78ResearchLab, “using their PatchHawk tool,” with Seongyeop Nam copied. The finding, in Whitton’s words: “The tramp-user-regexp failed to check for some additional problematic characters in user names, meaning that exploitation was still possible.”

Albinus’ second patch is attached to that message. Diffing the two regexps gives the exact delta:

-  (rx (| (+ (not (any "/\\^$?*:;|[]{}()<>`'\"" blank)))
+  (rx (| (+ (not (any "/\\^$!#&?*:;,|[]{}()<>`'\"" blank control)))

Four more characters — !, #, &, , — plus the control character class. That last addition is the one that deserves a pause. Until this patch, control characters, newline among them, were valid in a TRAMP user name. The August commit closed the newline dispatch path by re-anchoring the prefix regexp, but a newline remained legal inside the field that gets concatenated into a shell command line. Two independent defences against the same byte, one fixed in August and one left open until October.

The part of the patch that is not a denylist

The headline is the regexp, but it is not the interesting half of the patch. The October diff adds something TRAMP did not previously have: an explicit validation step inside tramp-dissect-file-name, which fails closed with tramp-user-error. Quoting the patch:

;; User name could be invalid after expansion of
;; environment variables, or contain a literal
;; environment variable.
(unless (tramp-string-empty-or-nil-p user)
  (when (or (not (string-match-p tramp-user-regexp user))
            (string-match-p
             (rx "$") (tramp-file-name-user-domain v))
            (and (not allow-double-percent)
                 (string-match-p
                  (rx "%" (* nonl) "%")
                  (tramp-file-name-user-domain v))))
    (tramp-user-error
     v "Not a valid user name: \"%s\""
     (tramp-file-name-user-domain v))))

Read what that does to the environment-variable branch the August commit introduced. The regexp still matches $HOME — the name is still recognised as a TRAMP file name and dispatched — but the new validator then rejects any user-or-domain string containing $ outright. Match liberally, validate strictly, error cleanly. The same treatment is applied to the host field, and a new connection-local variable tramp-allow-double-percent (defaulting to t everywhere except Windows, and forced t for file archives via a new connection-local profile in tramp-archive.el) governs whether %NAME% patterns are tolerated.

Our editorial reading is that this is the first time TRAMP distinguishes parsing from authorisation, and that the distinction is the actual fix. tramp-user-regexp was written to answer “what does a user name look like?” — a grammar question. It was being used to answer “is this string safe to interpolate into /bin/sh?” — a security question. Those have different answers, and every iteration that only edits the character class is re-litigating the grammar while the security question goes unasked. CWE-184 names the symptom. The cure in this patch is the four lines that raise an error, not the four characters added to the bracket expression. We have made the same argument about hyper-mcp’s cosign invocation, where the flags were present, the verification ran, and the thing being verified was the wrong object: a check that executes is not the same as a control that holds.

One thing neither patch touches: the unquoted string-join that Alberts identified as half one. We read tramp-sh.el at Emacs master and the login-argument expansion at line 5394 still feeds a plain (string-join … " ") with no shell quoting. Both fixes work the input-validation side of the boundary; the concatenation into a live shell is unchanged. That is a defensible engineering choice — the specifier expansion has to produce shell syntax for some methods — but it means the denylist is load-bearing, which is precisely why its completeness keeps being tested.

Three channels, three states, zero patched

This is the part we did not expect, and it is the reason this record matters more than its CVSS suggests. We fetched each artifact and read line 1073 of tramp.el.

Channel one — the GNU tarball. https://ftp.gnu.org/gnu/tramp/tramp-2.8.2.tar.gz is the newest TRAMP release in that directory; we confirmed the listing contains nothing above 2.8.2. Its files are dated 29 June 2026, and trampver.el.in maps 2.8.2 to Emacs 31.1. Its tramp-user-regexp reads:

(defconst tramp-user-regexp (rx (+ (not (any "/:|[]" blank))))
  "Regexp matching user names.")

That is the original expression — character-for-character what Alberts quoted as vulnerable. Its tramp-build-prefix-regexp still uses bol, and its docstring still says “Should always start with ^”. The authoritative GNU release tarball, seven weeks after a public oss-security posting that includes a working payload, carries the pre-August code with both halves of CVE-2026-79992 intact.

Channel two — GNU ELPA. The package Emacs users actually get from package-install is tramp-2.8.2.3.tar, dated 29 September 2026 on the ELPA index. We unpacked it: the August fix is present in full — the twenty-character denylist, the environment-variable branch, and bos in the prefix regexp. The October patch is not: tramp-allow-double-percent does not appear anywhere in the file. This is the artifact CVE-2026-108976 is actually about.

Channel three — Emacs master. At mirror HEAD b2363e0b92 (12 October 2026, 02:07:18Z — current when we checked), lisp/net/tramp.el was last modified by 7e47e1290c on 2 October. Line 1071 carries the August regexp. tramp-allow-double-percent is absent and the string Not a valid user name appears nowhere in the file. The October patch has not landed in the repository at all. It exists as a diff in a mailing-list message. Whitton’s own note says it “will be included in Emacs 31.2 though that won’t be released soon.”

And downstream, the first bug is still open. Debian’s security tracker lists emacs as vulnerable to CVE-2026-79992 in bookworm (1:28.2+1-15+deb12u4), trixie (1:30.1+1-6+deb13u1) and forky/sid (1:30.2+1-11), with the source package marked unfixed against bug 1145049. NVD still has CVE-2026-79992 in Awaiting Analysis with only Red Hat’s score attached, and CVE-2026-108976 in Received.

So the ledger reads: one vulnerability, two patches, and no shipping artifact that contains the second. The JeecgBoot shape — the fix exists, on a branch, and every installable copy is without it — and the openPDC shape, where the advisory names a version you cannot obtain. Here it is both at once, in the editor a very large number of security engineers have open right now.

Why this lands on an AI-security site

Because the trigger is a file name, and the population of software that enumerates file names it did not author has grown sharply. An agent that clones a repository and indexes it, an LSP server returning paths, a RAG ingestion pass over a checkout, a coding assistant running inside Emacs through comint or an MCP client — all of them reduce to the same primitive Alberts used: a string reaches a file-name predicate, and TRAMP’s handler is registered. Nothing about the attack requires a model to be fooled. It does not need prompt injection, because it does not need the model to cooperate; it needs the harness to call file-exists-p on something a repository supplied. The attacker-authored input that matters here is not a prompt, it is a symlink target.

That shape should be familiar. It is structurally the same finding as GitSpawn, where seven coding agents executed a repository’s own core.fsmonitor command simply because they ran git to gather context — opening the folder was the exploit. TRAMP is the Emacs-shaped instance of the same class: a file-system primitive that the ecosystem treats as inert, carrying an interpreter behind it. The difference is instructive. GitSpawn’s trigger was a config key the agent chose to honour; TRAMP’s is a path string that nothing in the stack ever decided to execute.

And the carrier makes this a supply-chain question rather than a local-attacker curiosity. CVSS calls it AV:L and the scoring is defensible, but a malicious symlink target committed to a public repository is delivered by exactly the same mechanism as every other dependency-confusion and install-script finding we have covered — the one where a developer workstation holding live cloud credentials and agent API tokens is the thing executing. That is the same workstation Warden Stealer was built to enumerate.

What is actionable today

There is no version to upgrade to for CVE-2026-108976, so the available controls are configuration and workflow:

  • Check which TRAMP you have, not which Emacs. M-x tramp-version and then read tramp-user-regexp directly — C-h v tramp-user-regexp shows the character class. If it is "/:|[]" you are on the pre-August code regardless of what your Emacs version number says, and both halves of the original bug apply. The GNU ELPA package (2.8.2.3) is strictly better than the ftp.gnu.org tarball right now.
  • Apply the October diff locally if you cannot wait for 31.2. It is attached to the oss-security message and is self-contained in tramp.el and tramp-archive.el. Neither file is compiled against private internals, which makes local application unusually low-risk; we did not run it, and you should read it before you do.
  • Treat recursive tree walks over untrusted checkouts as privileged. If an agent or indexer runs directory-files-recursively, projectile-find-file, rgrep or an LSP workspace scan over a freshly cloned repository, that is the exploit path. Run it where a shell escape costs nothing: a container without credentials, not the workstation with the tokens.
  • Screen for the carriers at clone time. Both are cheap to detect and neither has a legitimate use in nearly any repository: symlinks whose targets contain a newline or begin with /ssh:, and path components containing control characters. git ls-files -s plus a check on mode 120000 blob contents will find the first; a control-character scan over git ls-tree -r --name-only will find the second. This is a pre-indexing hygiene check, not an Emacs setting, and it generalises past this CVE.
  • If you do not use TRAMP, do not load it. The zero-click path for the newline carrier requires TRAMP to have replaced the autoload entry in file-name-handler-alist. The absolute-symlink carrier does not, so this reduces exposure rather than eliminating it.

The closing observation is about the discovery, not the bug. The incomplete fix was found by a tool built to read patches — PatchHawk, from 78ResearchLab, which the researchers describe publicly as working on changelog-versus-diff discrepancies. We did not evaluate the tool and make no claim about how it works internally. But the economics are worth naming plainly: a fix posted to a public mailing list with a worked payload is a free specification of where to look, and the window between “patch posted” and “patch in a release artifact” is, on this evidence, measured in months. Automated patch analysis compresses the attacker’s side of that window and does nothing to the defender’s. Fifty-two days separated the two oss-security posts. Zero shipping artifacts have closed the second gap.

Verification note. The CVE identifiers, descriptions, publication timestamps (CVE-2026-108976 at 2026-10-11T21:16:43Z; CVE-2026-79992 published 2026-08-25, last modified 2026-10-11T19:16Z), vulnerability statuses, CWE-184 assignment and both 7.8 CVSS 3.1 vectors were read first-hand from the NVD 2.0 API. Both oss-security messages — Sean Whitton’s 21 August 2026 post carrying Bas Alberts’ description, and his 11 October 2026 post carrying Michael Albinus’ second patch — were read in full from openwall.com, and all quoted text is verbatim. The three distribution states were established by downloading each artifact and reading lisp/tramp.el: the ftp.gnu.org tramp-2.8.2.tar.gz (files dated 2026-06-29), the GNU ELPA tramp-2.8.2.3.tar (indexed 2026-09-29), and lisp/net/tramp.el from the emacs-mirror repository at HEAD b2363e0b92 (2026-10-12T02:07:18Z). Commit f3e7104d’s date, message and per-file line counts came from the GitHub API. The Debian package statuses and bug 1145049 were read from Debian’s security tracker. The reading that the validation step rather than the character class is the substantive fix, the parsing-versus-authorisation argument, the agent and supply-chain framing, and the mitigation list are our editorial analysis. We did not execute any proof-of-concept, did not construct any malicious file name, symlink or repository, did not apply or run the patch, and sent no request to any third-party system. We have no evidence of exploitation of either CVE and imply none.

Sources: