Bas Alberts of the GitHub Security Lab discovered a local command
execution vulnerability in GNU Emacs 30.2 onwards, and possibly earlier.

I am attaching a fix prepared by Michael Albinus, the TRAMP maintainer.

Here is Bas's description of the problem (edited down a bit).

--8<---------------cut here---------------start------------->8---
1. Tramp: local shell command injection through the user field
   (tramp-sh.el, CRITICAL)

tramp-maybe-open-connection builds the login command by expanding
tramp-login-args (%u, %h, ...) and joining the words with
string-join, unquoted, into a command line that is sent to a live
LOCAL shell (tramp-encoding-shell, i.e. /bin/sh) that Tramp has
already spawned for the connection:

- tramp-sh.el:5489-5514  unquoted string-join of login args
- tramp-sh.el:5406-5413  the local shell the string is sent to

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

Consequence: merely stat-ing a file name such as

    /ssh:$(cd;touch$IFS'pwned')@127.0.0.1:/x

e.g. via file-exists-p, executes the $(...) payload in the local
shell during connection setup, before and regardless of any actual
ssh connection or server. The excluded characters are easily worked
around within the allowed charset: $IFS substitutes for blanks,
$(printf$IFS'\057') composes the excluded /, and $(cd;...) sidesteps
absolute paths entirely, so arbitrary commands are expressible.

2. Tramp: file name dispatch regexp is line-anchored, not
   string-anchored (tramp.el, CRITICAL enabler)

tramp-build-prefix-regexp (tramp.el:1025-1027) anchors with rx "bol";
the docstring even says the result "Should always start with ^". The
derived tramp-file-name-regexp is what Tramp registers in
file-name-handler-alist once loaded (replacing the string-anchored
tramp-initial-file-name-regexp autoload entry, tramp.el:1284-1288).

Consequence: after Tramp is loaded, any file name merely CONTAINING
newline + "/ssh:..." is dispatched to Tramp handlers, and
tramp-dissect-file-name parses the embedded line, feeding defect 1.
File names cannot contain "/", but two on-disk carriers exist:

  a. symlink targets (arbitrary bytes except NUL, committable to
     git);
  b. a directory whose name ends in a newline, containing a file
     named "ssh:...": the path component boundary supplies the "/",
     so any code that composes the path (e.g.
     directory-files-recursively) produces a string containing
     "\n/ssh:...". Verified: recursively scanning such a tree
     executes the payload with Tramp loaded.

Additionally, an ABSOLUTE symlink target "/ssh:$(...)@host:/x"
matches even the string-anchored autoload regexp, so no Tramp preload
is required for carrier (a).
--8<---------------cut here---------------end--------------->8---

-- 
Sean Whitton
Author: Michael Albinus <[email protected]>

diff --git a/lisp/net/tramp-gvfs.el b/lisp/net/tramp-gvfs.el
index a5919e071c3..ecd298e4525 100644
--- a/lisp/net/tramp-gvfs.el
+++ b/lisp/net/tramp-gvfs.el
@@ -1246,7 +1246,7 @@ tramp-gvfs-handle-expand-file-name
 	  (when (string-match
 		 (rx bos "/" (+ (not "/")) (group "/.." (? "/"))) localname)
 	    (setq localname (replace-match "/" t t localname 1)))
-	(when (string-match (rx bol "/.." (? "/")) localname)
+	(when (string-match (rx bos "/.." (? "/")) localname)
 	  (setq localname (replace-match "/" t t localname))))
       ;; There might be a double slash.  Remove this.
       (while (string-match "//" localname)
@@ -1340,8 +1340,8 @@ tramp-gvfs-get-file-attributes
   (with-parsed-tramp-file-name filename nil
     (setq localname (file-name-unquote localname))
     (if (or (and (string-match-p
-		  (rx bol (| "afp" (: "dav" (? "s")) "smb") eol) method)
-		 (string-match-p (rx bol (? "/") (+ (not "/")) eol) localname))
+		  (rx bos (| "afp" (: "dav" (? "s")) "smb") eos) method)
+		 (string-match-p (rx bos (? "/") (+ (not "/")) eos) localname))
 	    (string-equal localname "/"))
 	(tramp-gvfs-get-root-attributes filename)
       (assoc
@@ -1375,7 +1375,11 @@ tramp-gvfs-handle-file-attributes
 		(lambda (x)
 		  (unibyte-string (string-to-number (match-string 1 x) 16)))
 		res-symlink-target)
-	       'utf-8)))
+	       'utf-8))
+	;; If the resulting localname looks remote, we must quote it
+	;; for security reasons.
+	(when (tramp-tramp-file-p res-symlink-target)
+	  (setq res-symlink-target (file-name-quote res-symlink-target 'top))))
       ;; ... number links
       (setq res-numlinks
 	    (string-to-number
@@ -1768,14 +1772,14 @@ tramp-gvfs-file-name
   "Retrieve file name from D-Bus OBJECT-PATH."
   (dbus-unescape-from-identifier
    (replace-regexp-in-string
-    (rx bol (* nonl) "/" (group (+ (not "/"))) eol) "\\1" object-path)))
+    (rx bos (* nonl) "/" (group (+ (not "/"))) eos) "\\1" object-path)))
 
 (defun tramp-gvfs-url-host (url)
   "Return the host name part of URL, a string.
 We cannot use `url-host', because `url-generic-parse-url' returns
 a downcased host name only."
   (and (stringp url)
-       (string-match (rx bol (+ alnum) "://" (group (+ (not (any "/:"))))) url)
+       (string-match (rx bos (+ alnum) "://" (group (+ (not (any "/:"))))) url)
        (match-string 1 url)))
 
 ;; This is used in GNU ELPA package tramp-locproc.el.
@@ -1929,7 +1933,7 @@ tramp-gvfs-handler-mounted-unmounted
 		   (cadr (assoc "ssl" (cadr mount-spec)))))
 	     (uri (tramp-gvfs-dbus-byte-array-to-string
 		   (cadr (assoc "uri" (cadr mount-spec))))))
-	(when (string-match (rx bol (group (| "afp" "smb"))) method)
+	(when (string-match (rx bos (group (| "afp" "smb"))) method)
 	  (setq method (match-string 1 method)))
 	(when (and (string-equal "dav" method) (string-equal "true" ssl))
 	  (setq method "davs"))
@@ -2029,7 +2033,7 @@ tramp-gvfs-connection-mounted-p
 		      (or
 		       (cadr (assoc "share" (cadr mount-spec)))
 		       (cadr (assoc "volume" (cadr mount-spec)))))))
-	 (when (string-match (rx bol (group (| "afp" "smb"))) method)
+	 (when (string-match (rx bos (group (| "afp" "smb"))) method)
 	   (setq method (match-string 1 method)))
 	 (when (and (string-equal "dav" method) (string-equal "true" ssl))
 	   (setq method "davs"))
@@ -2062,7 +2066,7 @@ tramp-gvfs-connection-mounted-p
 		(string-equal host (tramp-file-name-host vec))
 		(string-equal port (tramp-file-name-port vec))
 		(string-match-p
-		 (rx bol "/" (literal (or share "")))
+		 (rx bos "/" (literal (or share "")))
 		 (tramp-file-name-unquote-localname vec)))
 	   ;; Set mountpoint and location.
 	   (tramp-set-file-property vec "/" "fuse-mountpoint" fuse-mountpoint)
@@ -2088,7 +2092,7 @@ tramp-gvfs-unmount
 (defun tramp-gvfs-mount-spec-entry (key value)
   "Construct a mount-spec entry to be used in a mount_spec.
 It was \"a(say)\", but has changed to \"a{sv})\"."
-  (if (string-match-p (rx bol "(aya{sv})") tramp-gvfs-mountlocation-signature)
+  (if (string-match-p (rx bos "(aya{sv})") tramp-gvfs-mountlocation-signature)
       (list :dict-entry key
 	    (list :variant (tramp-gvfs-dbus-string-to-byte-array value)))
     (list :struct key (tramp-gvfs-dbus-string-to-byte-array value))))
@@ -2107,9 +2111,9 @@ tramp-gvfs-mount-spec
 		   (tramp-media-device-port media) (tramp-file-name-port vec)))
 	 (localname (tramp-file-name-unquote-localname vec))
 	 (share (when (string-match
-		       (rx bol (? "/") (group (+ (not "/")))) localname)
+		       (rx bos (? "/") (group (+ (not "/")))) localname)
 		  (match-string 1 localname)))
-	 (ssl (if (string-match-p (rx bol (| "davs" "nextcloud")) method)
+	 (ssl (if (string-match-p (rx bos (| "davs" "nextcloud")) method)
 		  "true" "false"))
 	 (mount-spec
           `(:array
@@ -2118,7 +2122,7 @@ tramp-gvfs-mount-spec
                 (list (tramp-gvfs-mount-spec-entry "type" "smb-share")
                       (tramp-gvfs-mount-spec-entry "server" host)
                       (tramp-gvfs-mount-spec-entry "share" share)))
-               ((string-match-p (rx bol (| "davs" "nextcloud")) method)
+               ((string-match-p (rx bos (| "davs" "nextcloud")) method)
                 (list (tramp-gvfs-mount-spec-entry "type" "dav")
                       (tramp-gvfs-mount-spec-entry "host" host)
                       (tramp-gvfs-mount-spec-entry "ssl" ssl)))
@@ -2132,7 +2136,7 @@ tramp-gvfs-mount-spec
                ((string-equal "nextcloud" method)
                 (list (tramp-gvfs-mount-spec-entry "type" "owncloud")
                       (tramp-gvfs-mount-spec-entry "host" host)))
-               ((string-match-p (rx bol "http") method)
+               ((string-match-p (rx bos "http") method)
                 (list (tramp-gvfs-mount-spec-entry "type" "http")
                       (tramp-gvfs-mount-spec-entry
 		       "uri"
@@ -2149,8 +2153,8 @@ tramp-gvfs-mount-spec
             ,@(when port
                 (list (tramp-gvfs-mount-spec-entry "port" port)))))
 	 (mount-pref
-          (if (and (string-match-p (rx bol "dav") method)
-                   (string-match (rx bol (? "/") (+ (not "/"))) localname))
+          (if (and (string-match-p (rx bos "dav") method)
+                   (string-match (rx bos (? "/") (+ (not "/"))) localname))
               (match-string 0 localname)
 	    (tramp-gvfs-get-remote-prefix vec))))
 
diff --git a/lisp/net/tramp-rclone.el b/lisp/net/tramp-rclone.el
index cd5c3f46f54..125640560ff 100644
--- a/lisp/net/tramp-rclone.el
+++ b/lisp/net/tramp-rclone.el
@@ -192,7 +192,7 @@ tramp-rclone-parse-device-names
   (with-tramp-connection-property nil "rclone-device-names"
     (tramp-compat-seq-keep
      (lambda (line)
-       (when (string-match (rx bol (group (+ (not blank))) ":" eol) line)
+       (when (string-match (rx bos (group (+ (not blank))) ":" eos) line)
 	 `(nil ,(match-string 1 line))))
      (tramp-process-lines nil tramp-rclone-program "listremotes"))))
 
@@ -366,7 +366,7 @@ tramp-rclone-remote-file-name
 	  (tramp-rclone-maybe-open-connection v)
 	  ;; TODO: This shall be handled by `expand-file-name'.
 	  (setq localname
-		(replace-regexp-in-string (rx bol ".") "" (or localname "")))
+		(replace-regexp-in-string (rx bos ".") "" (or localname "")))
 	  (format "%s%s" (tramp-fuse-mounted-p v) localname)))
     ;; It is a local file name.
     filename))
diff --git a/lisp/net/tramp-smb.el b/lisp/net/tramp-smb.el
index 6f88c7dedba..2ed5f3369a6 100644
--- a/lisp/net/tramp-smb.el
+++ b/lisp/net/tramp-smb.el
@@ -1657,7 +1657,7 @@ tramp-smb-get-share
   "Return the share name of LOCALNAME."
   (save-match-data
     (let ((localname (tramp-file-name-unquote-localname vec)))
-      (when (string-match (rx bol (? "/") (group (+ (not "/"))) "/") localname)
+      (when (string-match (rx bos (? "/") (group (+ (not "/"))) "/") localname)
 	(match-string 1 localname)))))
 
 (defun tramp-smb-get-localname (vec &optional share)
@@ -1670,7 +1670,7 @@ tramp-smb-get-localname
 	(setq
 	 localname
 	 (if (string-match
-	      (rx bol (? "/") (+ (not "/")) (group "/" (* nonl))) localname)
+	      (rx bos (? "/") (+ (not "/")) (group "/" (* nonl))) localname)
 	     ;; There is a share, separated by "/".
 	     (if (not (tramp-smb-get-cifs-capabilities vec))
 		 (mapconcat
@@ -1679,7 +1679,7 @@ tramp-smb-get-localname
 	       (match-string 1 localname))
 	   ;; There is just a share.
 	   (if (string-match
-		(rx bol (? "/") (group (+ (not "/"))) eol) localname)
+		(rx bos (? "/") (group (+ (not "/"))) eos) localname)
 	       (match-string 1 localname)
 	     ""))))
 
diff --git a/lisp/net/tramp.el b/lisp/net/tramp.el
index eb0bc649d69..792887da772 100644
--- a/lisp/net/tramp.el
+++ b/lisp/net/tramp.el
@@ -1024,11 +1024,11 @@ tramp-prefix-format
 
 (defun tramp-build-prefix-regexp ()
   "Return `tramp-prefix-regexp'."
-  (rx bol (literal (tramp-build-prefix-format))))
+  (rx bos (literal (tramp-build-prefix-format))))
 
 (defvar tramp-prefix-regexp nil ; Initialized when defining `tramp-syntax'!
   "Regexp matching the very beginning of Tramp file names.
-Should always start with \"^\".  Derived from `tramp-prefix-format'.")
+Should always start with \"\\\\=`\".  Derived from `tramp-prefix-format'.")
 
 (defconst tramp-method-regexp-alist
   `((default . ,(rx (| (literal tramp-default-method-marker) (>= 2 alnum))))
@@ -1070,7 +1070,10 @@ tramp-postfix-method-regexp
   "Regexp matching delimiter between method and user or host names.
 Derived from `tramp-postfix-method-format'.")
 
-(defconst tramp-user-regexp (rx (+ (not (any "/:|[]" blank))))
+(defconst tramp-user-regexp
+  (rx (| (+ (not (any "/\\^$?*:;|[]{}()<>`'\"" blank)))
+	 ;; Environment variable.
+	 (: "$" (+ (any "_" alnum)))))
   "Regexp matching user names.")
 
 (defconst tramp-prefix-domain-format "%"
@@ -1845,6 +1848,8 @@ tramp-dissect-file-name
 	    (hop       (match-string (nth 5 tramp-file-name-structure) name))
 	    domain port v)
 	(when user
+	  (while (string-match (rx bos "$" (group (+ (any "_" alnum))) eos) user)
+	    (setq user (getenv (match-string 1 user))))
 	  (when (string-match tramp-user-with-domain-regexp user)
 	    (setq domain (match-string 2 user)
 		  user (match-string 1 user))))
@@ -2705,9 +2710,9 @@ tramp-file-name-handler
 	;; `file-remote-p' is called for everything, even for symbolic
 	;; links which look remote.  We don't want to get an error.
 	(non-essential (or non-essential (eq operation 'file-remote-p))))
+    (setq filename (tramp-replace-environment-variables filename))
     (if (tramp-tramp-file-p filename)
 	(save-match-data
-          (setq filename (tramp-replace-environment-variables filename))
           (with-parsed-tramp-file-name filename nil
             (let ((current-connection tramp-current-connection)
 		  (foreign
@@ -6950,6 +6955,9 @@ tramp-convert-file-attributes
 			       (caar attr))
 			      (decode-coding-string
 			       (match-string 1 (caar attr)) 'utf-8))))
+	       ;; Quote remote-like symlink.
+	       (when (and (stringp (car attr)) (tramp-tramp-file-p (car attr)))
+		 (setcar attr (file-name-quote (car attr) 'top)))
 	       ;; Set file's gid change bit.
 	       (setcar
 		(nthcdr 9 attr)

Reply via email to