34 min read

CVE-2026-5917: OS Command Injection in libgit2’s SSH Backend

libgit2’s libssh2 backend quotes the repository path but escapes nothing. One quote in the path can run commands on an SSH server that gives a shell.

Contents

Disclosure. CVE-2026-5917 was assigned by VulnCheck, which published its advisory on 11 August 2026. It is fixed in libgit2 v1.9.7 and v1.8.7, both released on 13 August 2026. If you build libgit2 with the libssh2 SSH backend, move to one of those. Everything I ran for this post ran against a local SSH server inside a WSL2 virtual machine on my own laptop. No proof-of-concept source code is included, and nothing here was tested against any hosted Git service.

Introduction

To fetch a repository over SSH, a Git client has to ask the server for it. It opens an SSH session and sends one line of text, which looks like this:

git-upload-pack '/libgit2/libgit2'

The server runs that line and streams the repository back. The single quotes are there so that a path containing spaces still arrives as one argument.

libgit2 builds that line by joining pieces of text together. One of the pieces is the repository path, taken from the URL the caller supplied. libgit2 puts a single quote before the path, the path itself, and a single quote after it. It does nothing else to the path.

If the path already contains a single quote, the quoted section ends at that quote rather than at the one libgit2 appends afterwards. Whatever follows is read by the server as further commands.

That is CVE-2026-5917, an OS command injection. It has real preconditions on both ends of the connection, and this post is about what it takes to reach the defect, which configurations stop it, and what an attacker actually gains where it works.

The part that took me longest to state correctly is not the bug itself. It is who ends up connected to whom. An attacker exploiting this does not host a server and wait for victims to connect to it. The victim connects to a machine the victim already has an SSH account on, and the injected commands run there, under the victim’s own account. The attacker supplies text, not infrastructure.

What I demonstrated, and what I did not. I built libgit2 v1.9.0 with the libssh2 backend, pointed it at an OpenSSH server on the same machine, and confirmed command execution three separate ways: a payload that created a file, a payload that wrote the output of id and hostname to disk, and a payload that opened an interactive shell back to a listener. I confirmed that two different libgit2 entry points reach the defect. I did not test this against any hosted Git service, and this post makes no claim about any of them. The demonstration is a controlled local one. Part 5 states which server configurations it applies to. Several common ones stop this defect entirely, and it also has a precondition on the client side that Part 3 sets out.

The fix arrived while I was writing this. When I finished the analysis, the four lines that build the request were byte-identical on libgit2’s main branch and in v1.9.6, the newest release at that point, to the ones in v1.9.0. I contacted the project directly on 13 August 2026. The change was committed the same day and released that evening in v1.9.7 and v1.8.7. Part 5 records the exact change and the sequence that led to it.


Part 1: Background

What libgit2 is

libgit2 is an implementation of Git written as a C library. Git itself is normally used as a command-line program: you type git clone, and a process starts, does the work, and exits. That is fine for a person at a terminal. It is awkward for a program that needs to do Git operations as part of something larger — a desktop application, a build system, a code-hosting service.

libgit2 exists for those callers. It exposes Git operations as function calls, so a program can clone a repository by calling git_clone() rather than by launching a separate process and parsing its output. It is used through bindings in other languages as well, so a Python, Rust, C#, or Ruby program can reach the same code.

The practical consequence for this post is that libgit2 is a second, independent implementation of Git’s behaviour. When Git the command-line program fixes something, libgit2 does not automatically get that fix. The two codebases are maintained separately.

How Git talks to an SSH server

SSH (Secure Shell) is a protocol for running commands on another machine over an encrypted connection. Most people use it for an interactive login: you type ssh user@host, you get a shell prompt on that host, and you type commands.

SSH can also run a single command without giving you a prompt. ssh user@host id connects, runs id, prints the result, and disconnects. This is called an exec request — the client asks the server to execute one specific command string.

Git over SSH uses exactly this. When you run git clone ssh://git@example.com/project.git, the client opens an SSH session and sends an exec request carrying the string git-upload-pack '/project.git'. On the server, a program named git-upload-pack reads the repository and streams its contents back over the same connection. For pushing, the command is git-receive-pack instead.

The detail that matters here is what the SSH server does with that string. It does not split it into a program name and arguments itself. It passes the whole string to the login shell of the account being used, for that shell to interpret as one line. That is the standard behaviour of OpenSSH, the SSH server used on most Linux systems. The consequence is that anything the shell treats as special — semicolons, quotes, backticks, dollar signs — is special in that string.

You can check this yourself on any machine you have an SSH account on. Running ssh host 'echo $0' prints the name of a shell, because a shell is what received the string. Running ssh host 'echo one; echo two' prints both words, because the semicolon was read as a separator rather than as text. On the test machine used later in this post, the first command printed bash.

What quoting does in a shell

A shell is the program that reads a command line and decides what to run. On Linux and macOS this is usually bash or a similar program.

The shell treats several characters as instructions rather than as text. A semicolon separates one command from the next. A space separates one argument from the next. A single quote suppresses all of that: everything between a single quote and the next single quote is taken as plain text.

So this line runs one command with one argument, and the space inside the path causes no trouble:

git-upload-pack '/my projects/repo.git'

The quoting only works if the text being quoted contains no single quote of its own. A single quote inside the text closes the quoted section early. Everything after it is back to being interpreted, not read as text. Any program that builds a shell command line by inserting untrusted text between two quotes has to do something about that possibility. libgit2’s libssh2 backend does not.

What command injection is

Command injection is the class of bug where a program builds a command line out of a fixed part and a variable part, and the variable part can contain characters that change the structure of the line instead of being treated as data within it. MITRE catalogues it as CWE-78. It is the same kind of mistake as SQL injection: the program meant to pass data, and the program receiving it read part of that data as instructions.

What a submodule is

A submodule is a Git repository placed inside another Git repository. A project that depends on a library can include that library as a submodule rather than copying its files in. The outer repository stores two things: a record of which commit of the inner repository it wants, and a file named .gitmodules that says where to fetch the inner repository from.

.gitmodules is a plain text file, committed like any other file, and it looks like this:

[submodule "helper"]
	path = helper
	url = https://example.com/helper.git

When someone clones the outer repository and asks for submodules as well, the client reads that url value and fetches from it. The person cloning does not usually see this file. It is not shown in a normal clone’s output, and there is no reason to open it.

That last point is what makes .gitmodules a useful delivery route for a crafted URL. It is a place where an attacker can put text that a client will use as a network address, without the person running the client ever reading it.


Part 2: The Defect

The function that builds the request

The function is gen_proto(), in src/libgit2/transports/ssh_libssh2.c. In libgit2 v1.9.0 it starts at line 65 and reads in full:

static int gen_proto(git_str *request, const char *cmd,
                     git_net_url *url)
{
    const char *repo;

    repo = url->path;

    if (repo && repo[0] == '/' && repo[1] == '~')
        repo++;

    if (!repo || !repo[0]) {
        git_error_set(GIT_ERROR_NET,
                      "malformed git protocol URL");
        return -1;
    }

    git_str_puts(request, cmd);
    git_str_puts(request, " '");
    git_str_puts(request, repo);
    git_str_puts(request, "'");

    if (git_str_oom(request))
        return -1;

    return 0;
}

Here is what each part does.

repo = url->path takes the path portion of the URL that the caller asked for. For ssh://git@example.com/project.git, the path is /project.git. This is the piece that comes from outside.

The repo[1] == '~' test handles one specific spelling. A URL path of /~alice/repo is rewritten to ~alice/repo by advancing the pointer one character, which is the form the receiving Git program expects for a path relative to a user’s home directory. This is the only transformation the function performs on the path.

The emptiness test rejects a null or zero-length path with an error. This is the only rejection the function performs.

The four git_str_puts() calls build the request string. git_str_puts appends text to a growable buffer. They append, in order: the command name (git-upload-pack or git-receive-pack), a space and an opening single quote, the path, and a closing single quote. Line 81 is the third of these, the one that appends the path.

git_str_oom() checks whether any of those appends failed because memory ran out. It is not a validation of the content.

There is no escaping step. The path goes into the buffer exactly as it arrived.

The caller is send_command(), immediately below it in the same file:

error = gen_proto(&request, s->cmd, &s->url);
if (error < 0)
    goto cleanup;

error = libssh2_channel_exec(s->channel, request.ptr);

libssh2_channel_exec() is the libssh2 library’s function for sending an exec request. The string built by gen_proto() is sent to the server as the command to run.

Why the quoting does not hold

The quoting in gen_proto() is correct for every path that contains no single quote. It handles spaces, and that is presumably why it is there.

A path containing a single quote produces a different structure. The figure below traces one example through.

One quote turns one command into three the path from the submodule URL /repo';id;echo ' gen_proto() adds a quote each side cmd + " '" + path + "'" what the server's shell receives git-upload-pack '/repo';id;echo '' The semicolons separate three commands.

Figure 1 — The quoting is correct only for paths with no quote in them. The path supplies its own closing quote, so the section libgit2 meant to quote ends early and the rest of the path is read as shell syntax.

Reading the result the way the shell reads it:

git-upload-pack '/repo'
id
echo ''

The first command is the one libgit2 meant to send. It fails, because /repo is not a repository on that server, and libgit2 reports that failure to its caller. The second command is the attacker’s. The third command exists only to give the closing quote that gen_proto() appends a valid place to go. Without it the line would end with an opening quote that is never closed, and the shell would reject the whole line rather than running any part of it. That rejection would stop every command, including the attacker’s, so the trailing fragment is not optional.

The visible outcome is worth stating plainly, because it is the part that makes this easy to miss. libgit2 returns an error. In my test runs the message was fatal: '/x' does not appear to be a git repository. That is the same message a mistyped URL produces. The injected command has already run by then.

That message is also a small piece of evidence in its own right. does not appear to be a git repository is the wording git-upload-pack uses, and git-upload-pack ran on the server. The client is relaying an error produced by the first of the three commands, on the remote machine, after the shell had already split the line.

One point of precision, because it changes what a working payload looks like. The quote has to be a literal quote in the URL.

URLs have a way of writing awkward characters as a percent sign followed by two hexadecimal digits, so a single quote can be written %27. Converting those back into the characters they stand for is called percent-decoding. libgit2’s URL parser percent-decodes the user, password, host, query and fragment, but it copies the path through as raw bytes, in git_net_url_parse() in src/util/net.c. A path written as %27 therefore arrives at gen_proto() still spelled %27, and the remote shell sees no quote at all. The path is also cut at the first ? or #, since those start the query and fragment, and with no decoding step there is no way to reintroduce them. Quotes, semicolons, backticks, $(, spaces and newlines all pass through unchanged.

The one check that does run

libgit2 does check the path before this point. In _git_ssh_setup_conn() there is a test with a comment above it explaining why it exists. It is quoted here with that comment reflowed to fit this page:

/* 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_error_set(GIT_ERROR_NET,
        "cannot ssh: path '%s' is ambiguous with "
        "command-line option", s->url.path);
    error = -1;
    goto done;
}

The function it calls is four lines long, in src/util/process.h:

GIT_INLINE(bool) git_process__is_cmdline_option(const char *str)
{
    return (str && str[0] == '-');
}

It returns true when the path begins with a dash. That check exists for a real and different reason: a path beginning with a dash can be mistaken for a command-line option by the program that receives it, which is its own class of bug. It does nothing about quotes, semicolons, backticks, or newlines, and it was never meant to.

I want to be careful not to present this as an oversight by whoever wrote it. The check is correctly scoped to the problem it names, and its comment names that problem precisely. The path is already handled as attacker-controlled at this point in the code. What is missing is an escaping step, not the understanding that one class of dangerous input can arrive here.

What Git does at the same point

The comment above the libgit2 check says “like git”. That is accurate, and following it up is the clearest way to describe what is missing.

Git the command-line program builds the same remote command in git_connect(), in connect.c. Two lines do the work:

if (looks_like_command_line_option(path))
    die(_("strange pathname '%s' blocked"), path);

strbuf_addstr(&cmd, prog);
strbuf_addch(&cmd, ' ');
sq_quote_buf(&cmd, path);

The first check is the same one. looks_like_command_line_option() in Git’s path.c is return str && str[0] == '-';, which is what libgit2’s git_process__is_cmdline_option() also is. Both projects have it, and it does the same job in both.

The difference is the line after it. sq_quote_buf() wraps the path in single quotes and rewrites every embedded quote as '\'' — close the quoted section, emit an escaped quote, reopen it. A quote in the path can no longer close the quoting, because it is no longer inside the quoting when it is written.

Git’s own header file states the separation directly. The comment on the declaration of looks_like_command_line_option() in path.h says the check has nothing to do with shell quoting, and that shell quoting should be handled separately. Git then handles it separately, on the next line.

So the accurate description is narrower than “Git quotes the path and libgit2 does not”. Both wrap the path in single quotes. Git escapes the quotes inside it; gen_proto() does not. The two defences do different jobs. The dash check stops the path being read as an option. The escaping stops it being read as syntax. libgit2’s libssh2 backend has the first one.

Which backend runs

libgit2 can talk SSH two ways, and which one a build uses is decided when it is compiled.

The libssh2 backend (ssh_libssh2.c) implements the SSH protocol in the process itself, using the libssh2 library, with no external program involved. The exec backend (ssh_exec.c) runs the system’s ssh program as a child process. The defect described here is in the first one.

The selection is made at compile time, in src/libgit2/transports/ssh.c:

#ifdef GIT_SSH_LIBSSH2
    return git_smart_subtransport_ssh_libssh2(out, owner, param);
#elif GIT_SSH_EXEC
    return git_smart_subtransport_ssh_exec(out, owner, param);

GIT_SSH_LIBSSH2 is tested first, so if it is defined the libssh2 backend is the one compiled in. In practice the two are mutually exclusive: the CMake logic that defines these macros picks one branch or the other, never both. Which branch it picks is the subject of Part 5, and the answer is less obvious than the option name suggests.


Part 3: Reaching the Code

Who ends up connected to whom

The figure below traces the whole path, from the repository the attacker publishes to the machine where the injected command runs.

Where the SSH connection actually goes ATTACKER publishes an ordinary repository VICTIM clones it, submodules included over HTTPS then, with no further input needed libgit2 reads .gitmodules and finds an ssh:// URL it opens an SSH session using the victim's own key a server the victim uses runs the injected command No traffic ever reaches the attacker.

Figure 2 — The attacker supplies text, not infrastructure. The host named in the crafted URL is one the victim already has an SSH account on, so the victim’s own key authenticates the session and the injected command runs under the victim’s account.

The attacker chooses the whole URL, including the hostname. Choosing a host the attacker controls would gain nothing, because they can already run commands on their own machine. The useful choice is a host the victim can authenticate to and the attacker cannot.

Two things have to be true of that host, and both narrow the attack considerably.

The victim must already trust its host key. Before any command is sent, _git_ssh_setup_conn() calls check_certificate(), which loads the user’s known_hosts file and looks the server up in it. A host that is not there is rejected with invalid or unknown remote ssh hostkey, and the function returns before the request is ever built. An application can supply a callback that overrides this decision, and it is the application’s choice whether to do so, but with no callback and no entry in known_hosts the connection stops there. So the attacker cannot name an arbitrary machine. They have to name one the victim has connected to before. In my own setup I added the host key to known_hosts during preparation, which is why the demonstration reaches the injection at all.

The victim’s client must answer the credentials request. libgit2 does not read an SSH agent or a private key by itself. It asks the calling application through a credentials callback, and what happens next is whatever that application does. Applications built for developer workstations commonly answer it from the agent or from a key on disk without asking, which is what makes this quiet, but that behaviour belongs to the application and not to libgit2.

Put together, the realistic target is a server the victim uses regularly enough for its key to be in known_hosts and for their tooling to authenticate to it without prompting. An internal Git server is exactly that. If a crafted URL names one, and the account on it has an ordinary login shell, the injected commands would run there under the victim’s account and from inside the network. I did not test that arrangement; I tested the same shape locally, and Part 5 sets out which server configurations stop it.

There is a further limit, and it is a real one. The attacker cannot see the output. The injected command runs on a server the attacker has no access to, and its output goes into the SSH channel that libgit2 reads and then discards as a protocol error. Anything the attacker wants to learn has to be sent somewhere the attacker can reach, by the injected command itself.

The delivery route

The path text has to get into a URL that libgit2 will use. .gitmodules is the route that needs no cooperation from the victim beyond cloning.

libgit2 does validate that URL, and it is useful to be exact about what the validation is. submodule_read_config() in src/libgit2/submodule.c reads the url key and keeps the value only when looks_like_command_line_option() returns false — that is, only when the value does not begin with a dash. That function sits a few lines above it and its whole body is the same one-character test used at the transport layer. The two are separate functions with the same meaning, and the submodule one is the older: it has been in submodule.c since v0.28.0. A rejected value is dropped with no error at all; the source comment beside it states that the project would warn here if it had an API for warnings.

So the submodule URL is checked, by that same one-character test, for that same option-injection problem. A value beginning with a quote is not a dash, so it is kept, exactly as it is kept by the transport-layer copy of the check.

A direct clone reaches the same code with no submodule involved. If a victim can be persuaded to clone a URL supplied by the attacker, that is enough. I confirmed both routes. The submodule route is the more interesting one because the victim never sees the crafted text.

There is no protocol policy behind it either. Git has protocol.allow, a configuration setting that lets an administrator forbid particular transports for submodules. libgit2 has no equivalent.

The historical parallel

This is not the first time a submodule URL has been the delivery route for code execution in a Git client. CVE-2018-17456 was a bug in Git itself where a submodule URL beginning with a dash was passed to ssh as an argument, and ssh read it as an option. A recursive clone of a repository whose .gitmodules contained such a URL could run a program of the attacker’s choosing on the machine doing the cloning. Git’s fix was to reject submodule URLs beginning with a dash. libgit2 has the matching check, added in 2018 with a commit message saying so.

What the parallel shows is how these defects recur. A defence written against one way of misreading a path does not extend to another way of misreading it. Rejecting a leading dash stops an argument from being read as an option by ssh. It says nothing about a quote being read as the end of a quoted section by a shell, because that is a different program making a different mistake at a different stage.


Part 4: Building the Proof

The environment

Every component in the setup is a real one. Nothing is mocked, the SSH server is a real OpenSSH server, and the shell that runs the injected command is a real shell.

  • Ubuntu 26.04 running under WSL2, which is a real Linux kernel, not a compatibility layer.
  • libgit2 v1.9.0, built from the release tag with USE_SSH=libssh2. The build reports SSH, using libssh2 at configuration time, and the finished library links against the system libssh2.so.1, version 1.11.1.
  • OpenSSH server running on the same machine, with a normal user account, key-based authentication, and bash as the login shell. This represents a Git server the victim has an account on.
  • A small clone program written against the libgit2 public API. It calls git_clone(), then walks the submodules and calls git_submodule_update() on each, supplying the SSH key through the standard credentials callback. It is about 130 lines and uses no private interfaces. Any application doing a recursive clone with libgit2 does the same work.
  • A prepared repository with one submodule, whose .gitmodules holds the crafted URL. The commit that records the submodule is a normal one, created by git submodule add against a real repository. Only the url line was changed afterwards.

Confirming execution three ways

I did not want to conclude “it worked” from an error message, so each stage produced evidence that could not come from anywhere else.

A file that should not exist. The first payload ran touch on a path in /tmp. The file appeared. Nothing else in the setup creates that file, and the clone that created it reported a failure.

Output that had to come from the server. The second payload ran id, hostname and date, redirecting all three into a file. The contents were uid=1000(user) gid=1000(user) followed by the group list, the machine’s hostname, and the time. That is the account the SSH session authenticated as. A file appearing does not by itself prove which account ran the command; this output does.

An interactive session. The third payload was a reverse shell — a command that starts a shell on the target machine and connects its input and output back to a program waiting elsewhere, so that whoever runs that waiting program can type commands and read the replies. The waiting program is called a listener. The listener reported an incoming connection, and the shell that arrived accepted commands and returned their output.

Both entry points

I ran the same crafted path through two different libgit2 calls.

Through git_clone() directly, with the crafted URL as the clone source, the injected command ran. Through git_submodule_update() during a recursive clone, with the crafted URL in .gitmodules, the injected command ran. The first is the simpler proof that the defect is in the SSH path construction and not in submodule handling. The second is the one that matters for delivery.

One detail from the second run is worth recording, because it cost me time. On my first attempt the submodule update returned success immediately without opening any connection. The repository I had built was missing the tree entry that records the submodule’s commit, so libgit2 had nothing to fetch and returned success. The submodule has to be recorded properly for the update to do any work. Once the repository was built correctly, the status flags changed and the fetch ran.

The demonstration

The recording below shows the reverse shell case, with the screen split in two. The left half is the attacker’s listener. The right half is the victim, running a recursive clone.

Live capture — on the right, the victim runs a recursive clone with libgit2 v1.9.0 and the libssh2 backend; libgit2 prints the crafted submodule URL and reports that it is fetching over SSH. On the left, the attacker's listener receives an interactive shell, where whoami, id and ls return the victim's own account and home directory.

On the right, the clone succeeds and libgit2 moves on to the submodule. It prints the URL it is about to use, which is where the crafted path is visible, and then reports that it is fetching over SSH. On the left, the listener receives a connection and an interactive shell arrives. The commands run in it return user, uid=1000(user), and the contents of that account’s home directory.

The right pane stays on Fetching via SSH for the rest of the recording. That is the shell holding the SSH channel open. From the point of view of someone watching only their own terminal, a clone is taking a long time.

What the demonstration does not show

The listener and the SSH server are both on the same machine as the client. That is a convenience, not a claim: it keeps the recording to one screen. The mechanism does not depend on it, because the injected command runs on whichever server the SSH session reaches, and that is decided by the hostname in the crafted URL.

The payload is a reverse shell because a reverse shell is easy to show in a recording. It is also the most conspicuous option available to an attacker.


Part 5: Impact, Stated Carefully

How much this matters to a particular installation depends on two things: how libgit2 was built, and what the receiving SSH server does with the string. This part answers both as precisely as I can, and marks the places where I could not establish an answer.

Which builds are affected

The CVE applies only where libgit2 was compiled with the libssh2 backend. That sounds like a narrow condition. It is broader than it sounds, for one specific reason.

The build option is not a plain on or off switch. cmake/SelectSSH.cmake compares USE_SSH against exact strings: exec selects the external-program backend, and both ON and libssh2 select the libssh2 backend. So a packager who writes -DUSE_SSH=ON, meaning to include SSH support, gets the libssh2 backend. Selecting the other one requires the literal lowercase word exec.

That is what the packaging I checked writes. I looked at several distributions and every one I could confirm takes the libssh2 branch: Debian and Ubuntu pass -DUSE_SSH=ON and their binary packages depend on libssh2; Alpine’s shared-library build passes the same flag; Homebrew passes it and declares an unconditional dependency on libssh2; Arch’s package links libssh2.so, which a libgit2 binary carries only on that branch. Fedora 43 and 44 also link libssh2. The one case I found on the other side is EPEL 9 and EPEL 10, whose libgit2 1.7.2 packages do not link libssh2 at all, so the affected backend is not compiled into them even though the version falls inside the advisory’s range. That is a sample, not a survey, and package configurations change.

Two facts about the affected range belong together. libgit2’s own default for USE_SSH was ON from v0.27.0 until it was changed to OFF between v1.3.0 and v1.4.0 in early 2022, which covers most of the range this CVE names. And the exec backend did not exist until v1.8.0. For v0.27.0 through the v1.7.x series, enabling SSH in libgit2 meant libssh2, because libssh2 was the only SSH backend that existed.

Language bindings vary, and the useful thing is the pattern rather than a verdict on each. pygit2’s Linux and macOS wheels are built with the libssh2 backend. Rust’s git2 crate leaves its ssh feature off by default, but Cargo itself requests that feature explicitly, and a crate anywhere in a dependency graph can turn it on for every other crate in that build. Ruby’s rugged leaves SSH off unless asked. All of them share a second path that their own defaults do not govern: when the binding links a libgit2 already installed on the machine instead of building its own, the backend is whatever the distribution chose. Package versions change over time as well. So the check that answers the question is to look at the library your program actually links, not at the default in a manifest.

Where the injected string executes, and where it does not

What happens next depends entirely on the SSH server.

The mechanism on an ordinary server is not ambiguous. OpenSSH’s sshd does not parse the exec-request string. In do_child() in session.c it builds the argument vector {shell, "-c", command, NULL} and calls execve, with shell taken from the account’s password-database entry. The comment above that code says it executes the command using the user’s shell with the -c option. That is the configuration my proof of concept ran against: a normal account, a normal login shell, no forced command. There the injected commands run.

Several common server configurations do not behave that way, and the reasons are specific.

Git’s own restricted shell stops it. If the account’s login shell is git-shell, the string reaches git-shell rather than a POSIX shell. git-shell matches the leading command against a fixed list, then calls sq_dequote() on the remainder, and sq_dequote() returns failure for any content after the closing quote. The crafted path produces exactly that shape, so git-shell exits with bad argument before running anything. It never passes the string to a shell at all.

GitLab and Gitea build an argument list instead of a command line. gitlab-shell splits the request in process, matches the first element against a fixed set of allowed commands, and forwards the repository path to Gitaly as a field in a protocol-buffer message. Outside its tests it does not import Go’s process-execution package at all. Gitea’s SSH handling likewise builds a fixed argument slice and passes the client’s string only as an environment variable. In both, no shell is involved at any stage, so there is nothing to inject into.

A forced command changes where the string goes, which is not the same as making it safe. When sshd_config sets ForceCommand, or an authorized_keys entry carries command=, sshd runs the configured command instead and puts the client’s string in the SSH_ORIGINAL_COMMAND environment variable. An environment variable is not parsed by a shell, so the semicolons and quotes in it are never treated as syntax. Whether that holds depends on the wrapper. I tested the common patterns against the exact string gen_proto() produces: eval "$SSH_ORIGINAL_COMMAND" runs the injected commands, sh -c "$SSH_ORIGINAL_COMMAND" runs them, and exec $SSH_ORIGINAL_COMMAND does not, because an unquoted expansion is subject to word splitting rather than to a second round of shell parsing. I have not measured how often the first two patterns appear, and the widely used wrappers are not among them — git-shell and gitolite both parse the string themselves. Treat this as a conditional risk in hand-written wrappers, not as a property of forced commands.

On hosted services I make no claim. ssh -T git@github.com reports that GitHub does not provide shell access, and GitHub’s SSH endpoint is not OpenSSH and is not open source, so I cannot read what it does with a repository path. I did not test this against GitHub or any other hosting provider, and nobody should. Nothing in this post should be read as a claim of code execution on any hosted Git service. The defect is in the client; whether a crafted path becomes a command is a property of the server that receives it.

The realistic high-impact case, then, is the plain one: an SSH account with an ordinary login shell on a host the victim can reach. That includes self-hosted Git servers set up with normal user accounts, and it includes any other machine the victim has an account on, because the attacker chooses the hostname.

One case is worth naming because it is the most common of all: a program that only clones over HTTPS never reaches this function, whatever backend it was built with.

The clone does not have to be a decision. Reaching this defect requires someone to clone or update a repository whose URL the attacker controls, which is a real precondition. It is not always a person choosing to act. Continuous integration systems clone on a schedule or on a push, and some editors fetch submodules when a project is opened.

The fix

libgit2 fixed this on 13 August 2026. The change is on the main branch as commit 5948ef3, and it shipped the same evening in v1.9.7 and v1.8.7. The project’s changelog describes v1.9.7 as a security release with one change.

The change is one line. gen_proto() before:

git_str_puts(request, cmd);
git_str_puts(request, " '");
git_str_puts(request, repo);
git_str_puts(request, "'");

And after:

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

git_str_puts_escaped() was already in the codebase, and it was already applied to this exact value in the other SSH backend. ssh_exec.c builds the same command for the same purpose, and it writes the path like this:

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

The fix commit says it takes inspiration from 346f28b, the October 2025 commit that added that call to ssh_exec.c. That commit’s message states the reason directly: escape the path with a single quote, the escaped character, and another single quote, to prevent misparsing on the remote side and potential command injection.

346f28b was part of pull request #7163, merged in December 2025, which rewrote the ssh_exec backend. The pull request opens by saying that a bug in the external SSH execution can cause arbitrary command execution, and that remote repository names were improperly sent to the shell without quoting. So the helper, the reasoning behind it, and the wording describing the risk all existed in the repository before this report. The fix applies them to the second backend. Both backends now escape the path.

How this was reported

I reported this to VulnCheck on 7 March 2026. VulnCheck allocated CVE-2026-5917 on 9 April 2026, took on contacting the vendor, and published its advisory on 11 August 2026 once its disclosure deadline had passed. The CVE record credits me as the finder.

I opened a security advisory on libgit2’s own repository on 13 August 2026. That is the first time I contacted the project directly. The fix was committed that afternoon, and v1.9.7 and v1.8.7 were released about three hours later. GitHub published the advisory as GHSA-qqwh-747c-fpx2 on 15 August 2026, naming v1.9.7 and v1.8.7 as the patched versions.

libgit2 published a second advisory for the same CVE on the same day, GHSA-xqj4-2j5v-rr75, crediting a different reporter. The two carry different severity ratings, so an automated tool can report either one for the same defect, depending on which record it reads.

One more thing is worth knowing if you take vulnerability data from a feed rather than from the project. As I write this, the only entry for CVE-2026-5917 in GitHub’s global advisory database is GHSA-3gwr-xcwq-7892, an automatic import of the national vulnerability database record published on 12 August 2026. It carries no version ranges and names no patched version. The CVE record itself still gives the affected range as v0.27.0 through v1.9.0, which was written before the fix existed. The two advisories on libgit2’s repository carry the accurate ranges. A scanner reading only the imported entry will not tell you that a fixed release exists.


Part 6: What I Take From It

The input was already treated as untrusted, and Git had written down what that was not enough for. The path reaching gen_proto() had passed a check whose comment says it exists to prevent injection on the remote side, and a second copy of the same check earlier in the submodule code. So the path was handled as attacker-controlled at two separate points. The comment on the equivalent function in Git’s own path.h states that the check has nothing to do with shell quoting and that shell quoting should be handled separately. That is a note left by someone who had thought about this distinction and wanted the next reader to keep the two apart. libgit2 has the check that comment is attached to. It does not have the separate handling the comment describes.

A quoting bug produces the same message as a typo. The failure here is does not appear to be a git repository. There is no crash and no warning. Every visible signal is consistent with a bad URL. When a defect produces an ordinary-looking error, nobody has a reason to investigate it, and it can remain in a codebase for years.

A second implementation has to build every defence twice. libgit2 reimplements Git’s behaviour as a library, which is genuinely useful. It also means each defence exists in two codebases, and the two copies can be at different stages of completeness at the same time. That is not a criticism of the project. It is a property of the arrangement, and it is worth knowing about if you depend on it.

A CNA handling the CVE is not the same as the project being told. I reported to VulnCheck and left vendor contact to them, and then I assumed it had happened. It had not reached the maintainers. When I contacted libgit2 myself, the fix was committed the same day. Checking that the project actually received the report was mine to do, and I did not do it. That step does not transfer to anyone else.

Reading source is still where these are found. This one is visible in twenty lines of straightforward C. It does not need a fuzzer or a debugger. It needs someone to ask what happens if the path contains the character that ends the quoting, and then to check rather than assume.


References

Kazuma Matsumoto. “CVE-2026-5917: OS Command Injection in libgit2’s SSH Backend”. 637th Research Lab, 2026-08-16. https://y637f9qq2x.com/posts/cve-2026-5917/