
Rage Against the Authentication State Machine
This blogpost describes our journey through discovering CVE-2024-28080, an authentication bypass vulnerability in Gitblit, “an open-source, pure Java stack for managing, viewing, and serving Git repositories”. The vulnerability affects the SSH service and can only be exploited for users that have at least one public key assigned to their account. Version 1.10 released on 14 June 2025 fixes this and two other vulnerabilities.
Story time: why we used Gitblit in the first place
Many years ago, before the era of Gitea, the options for self-hosted web interfaces around Git were not that great. Ruby projects such as Redmine and GitLab were a nightmare from a sysadmin perspective, all depending on tainting the system SSHd scope with handing Git pushes over SSH. It was in this context where picking a Java-based solution seemed like a nice idea – dropping a single JAR file and spawning a JVM process handled web and SSH access. Latter was critical, as this SSH service was also part of the solution, running in the name of the same service user, independently from the system-wide SSH used for sysadmin purposes.
How we found the bug
The SSH journey of most people involve starting with username/password combination, with a later upgrade to public key-based authentication, stored in keyfiles, optionally proxied by an SSH Agent. The next logical step involves storing private keys on dedicated devices (tokens, smartcards), where the secret parts of a keypair generated on said device can never leave the hardware in an ideal world.
Integrating hardware solutions with software is always a challenge, thus we managed to trigger a scenario, where the OpenSSH client failed to complete the public key authentication. Gitblit promptly fell back to password-based authentication, which I did not prefer – so I just pressed ↵ Enter
without typing anything (basically submitting an empty password), while I mentally prepared myself for Gitblit to refuse my connection so that I can try again after debugging my Yubikey.
Imagine my surprise, when the Git command completed successfully – taking things like ControlMaster
into account, I had to repeat the unintentional experiment described above. I asked Buherátor to repeat on his machine to confirm, since what happened defied the model I had in my mind regarding SSH public key authentication in every way possible. In this regard, my discovery of this vulnerability is a bit similar to our friend David
Schütz accidentally finding a $70k Google Pixel Lock Screen Bypass.
How SSH public key authentication works
Diving into the RFC 4252 describing the SSH Authentication Protocol and reading through the verbose output of the OpenSSH client revealed that public key authentication had multiple stages in SSH.
- The client sends a Public Key Authentication Request with the username and a public key to the server.
- The server confirms whether this public key can be used to authenticate as the username sent by the client. (If not, the client goes back to the previous step with another key, or abandons trying to authenticate using public keys.)
- The client – knowing that the public key got accepted by the server – prepares a challenge, following a strict protocol in the RFC and then signs it using the private key.
- The signature is sent to the server, which can calculate the same challenge and verify that the signature is valid.
Looking at the steps above, it becomes obvious why CVE-2016-20012 is disputed by the OpenSSH project – the protocol design did not take an attacker model like this into consideration. An attacker knowing that a certain username can authenticate with a specific public key is arguably a pretty weak oracle.
How the bug happened
When we confirmed the issue internally, the next step was trying to triage, which component(s) were the root cause of this unwanted behavior. As mentioned before, Gitblit did not use the SSHd used by the rest of the operating system, nor did it reinvent the wheel – but rather it included an Apache library called MINA SSHD ,
a 100% pure java library to support the SSH protocols on both the client and server side. It does not aim at being a replacement for the SSH client or SSH server from Unix operating systems, but rather provides support for Java based applications requiring SSH support.
Our first task was to find out whether the bug was in MINA – which could affect numerous projects depending on it – or if it was specific to Gitblit. A quick trial of another MINA-based project resulted in an early negative signal, leading the investigation into the Gitblit codebase – more specifically, the integration with MINA.
How MINA interfaces with the Gitblit and other applications
The diagram below illustrates the relationship between the Authenticator implementation within the Gitblit code and the Apache MINA SSH daemon component, all residing within a single JVM process. The MINA SSHD receives incoming TCP connections over the listener socket, and processes packetized SSH protocol streams using its own state machine. When an SSH client tries to authenticate, the Authenticator implementation gets invoked using a standardized Java interface, and the result returned from said method affects the next transition within the SSH server-side state machine.
┌──────────────────────────────────────────────────────────┐
│ │
│ JVM process started for Gitblit │
│ │
│ │
│ ┌────────────────────────┐ │
│ ┌──────────────────┐ │ │ │
│ │ │ │ Apache MINA SSH daemon │ │
│ │ Authenticator │ │ │ │
│ │ implementation ├────(o────┤ ┌ ─ ─ ─ ─ ─ ─ ┐ ┌──────┴─┴─────┐
│ │ (Gitblit code) │ │ SSH │ TCP listener │
│ │ │ │ │ server-side │ │ socket │
│ └──────────────────┘ │ state machine └──────┬─┬─────┘
│ │ └ ─ ─ ─ ─ ─ ─ ┘ │ │
│ └────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Diving into the codebase, the application (in our case, Gitblit) provides a class that implements the Apache MINA interface PublickeyAuthenticator
that contains a single method with a clear purpose.
boolean authenticate(String username, PublicKey key, ServerSession session)
As the Javadoc linked above mentions, “PublickeyAuthenticator
is used on the server side to authenticate user public keys”, and this method should “check the validity of a public key” by returning “a boolean indicating if authentication succeeded or not”.
However, the devil is in the details – taking the above model of the SSH protocol design regarding public key authentication into account means that this method gets called twice during the authentication process. This is not a problem in itself, as the method has to return true
both times before the MINA SSH daemon considers an authentication successful and advances its own internal state machine.
A potentical problem arises from the fact that in most programming languages (including Java) any function can have side effects – the return value is not the only way for it to influence the state of the program outside this method. Even if a method looks like a “getter” kind of function (which only reads from memory and returns the result of a computation based on the result of those reads), Java and most other mainstream languages offer no guarantees.
How Gitblit handles public key authentication
Fortunately, the codebase of Gitblit is easier to follow than contemporary “best practice” Java projects that use Dependency Injection and other borderline black magic approaches. The public key authenticator class is simply passed to a classic setter of the SSH daemon instance, wrapped in a cache layer in SshDaemon.java
.
public SshDaemon(IGitblit gitblit, WorkQueue workQueue) {
// ...
sshd = SshServer.setUpDefaultServer();
// ...
if (authMethods.contains(AUTH_PUBLICKEY)) {
SshKeyAuthenticator keyAuthenticator = new SshKeyAuthenticator(gitblit.getPublicKeyManager(), gitblit);
sshd.setPublickeyAuthenticator(new CachingPublicKeyAuthenticator(keyAuthenticator));
The issue lies within the integration between Gitblit code and the Apache MINA library. In the class SshKeyAuthenticator
the authenticate()
method gets called before signature verification happens – however, if the SSH client presents a public key that belongs to the username they try to authenticate as, Gitblit assigns the user and key information through an SshDaemonClient
instance to the ServerSession
object. See the code snippet below, all comments are mine.
public class SshKeyAuthenticator implements PublickeyAuthenticator {
// ...
public boolean authenticate(String username, PublicKey suppliedKey, ServerSession session) {
SshDaemonClient client = session.getAttribute(SshDaemonClient.KEY);
// ...
for (SshKey key : keys) { // `keys` contain the public keys of `username`
if (key.getPublicKey().equals(suppliedKey)) {
UserModel user = authManager.authenticate(username, key);
if (user != null) {
client.setUser(user); // <= see client.getUser() below
client.setKey(key);
return true;
}
This is less of an issue for “well-behaving”, legitimate clients, who can actually sign a challenge after the server accepts their public key, as they really do have the private part of the keypair. Below is a sequence diagram that shows this happy path.
┌────────┐ ┌──────┐ ┌───────────────┐ ┌───────────────────┐ ┌────────────┐
│ SSH │ │ MINA │ │ SshKey- │ │ UsernamePassword- │ │ SshDaemon- │
│ client │ │ SSHD │ │ Authenticator │ │ Authenticator │ │ Client │
└────────┘ └──────┘ └───────────────┘ └───────────────────┘ └────────────┘
│ │ │ │ │
│Can I log │ │ │ │
in as Bob
│ with K? │authenticate(│ │ │
────────▶┌─┐ "Bob", K)
│ │ ├──────────▶┌┴┐ setUser([Bob]) │ │
yes, │ │ true │ ├─────────────────────────────────────▷┌─┐
│ continue│ │◀─ ─ ─ ─ ─ ┴┬┘ │ │ │
◀─ ─ ─ ─ ┤ │ │ │
│ │ │ │ │ │ │
Challenge│ │ │ │
│ signed │ │ │ │ │ │
with K │ │uthenticate( │ │
│────────▶│ │ "Bob", K) │ │ │ │
│ ├──────────▶┌─┐ setUser([Bob]) │ │
│ you're │ │ true │ ├───────────────────┼─────────────────▷│ │
logged in│ │◀─ ─ ─ ─ ─ ┴─┘ │ │
│◀─ ─ ─ ─ ┴┬┘ │ │ │ │
│ │
│ │ │ │ │ │
└─┘
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │
This ServerSession
object is the last parameter of the function signature shown above, and while having such opportunities for MINA SSHd host applications can have reasonable use-cases, this is exactly the way to introduce the aforementioned side effects. Because of this, even if the signature verification fails (e.g. because the attacker does not indeed have access to the private key), sending any password when the client inevitably falls back to password-based authentication makes the authenticate()
method of the class UsernamePasswordAuthenticator
to return early with true
– bypassing the actual validation of the password.
public class UsernamePasswordAuthenticator implements PasswordAuthenticator {
// ...
public boolean authenticate(String username, String password, ServerSession session) {
SshDaemonClient client = session.getAttribute(SshDaemonClient.KEY);
if (client.getUser() != null) { // <= see client.setUser() above
log.info("{} has already authenticated!", username);
return true;
}
The sequence diagram below demonstrates the difference from the happy path and how the attacker exploits the state transitions during login to impersonate a user without possession of their private key or password.
┌────────┐ ┌──────┐ ┌───────────────┐ ┌───────────────────┐ ┌────────────┐
│ SSH │ │ MINA │ │ SshKey- │ │ UsernamePassword- │ │ SshDaemon- │
│ client │ │ SSHD │ │ Authenticator │ │ Authenticator │ │ Client │
└────────┘ └──────┘ └───────────────┘ └───────────────────┘ └────────────┘
│ │ │ │ │
│Can I log │ │ │ │
in as Bob
│ with K? │authenticate(│ │ │
────────▶┌─┐ "Bob", K)
│ │ ├──────────▶┌┴┐ setUser([Bob]) │ │
yes, │ │ true │ ├─────────────────────────────────────▷┌─┐
│ continue│ │◀─ ─ ─ ─ ─ ┴┬┘ │ │ │
◀─ ─ ─ ─ ┤ │ │ │
│ │ │ │ │ │ │
Can I log│ │ SshDaemon- │ │
│in as Bob│ │ │ │ Client instance │ │
with │ │ statefully keeps │ │
│ password│ │ │ │ track of [Bob] │ │
"lol"? │ │ │ │
│────────▶│ │ │ │ │ │
│ │ authenticate("Bob", "lol") │ │
│ │ ├────────────┼──────────────────▶┌┴┐ getUser() │ │
│ │ │ ├───────────────▶┌┴┐│
│ │ │ │ │ │ [Bob] │ ││
you're │ │ true │ │◀ ─ ─ ─ ─ ─ ─ ─ ┴┬┘│
│logged in│ │◀ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┴┬┘ │ │
◀─ ─ ─ ─ ┴─┘ └─┘
│ │ │ │ │
│ │ │ │ │
The issue can be demonstrated by configuring an SSH client (e.g. OpenSSH through editing ~/.ssh/config
) to use the public half of the key only. This way, the SSH client will first send an SSH_MSG_USERAUTH_REQUEST
message without a signature, causing SshKeyAuthenticator.authenticate()
to be called. Immediately afterwards, libcrypto
will error out as it cannot use the public key for signing, causing the client to fall back to password authentication. At this point, just pressing ↵ Enter
without entering any password results in a successful authentication.
We tested this both on the standalone JAR and the Docker image, both being the then-latest version. Based on the code, we think that all versions prior to v1.10 that include public-key SSH authentication were affected. Below is a video demonstration of exploiting the vulnerability on v1.9.3.
Exploitation in practice: how easy it is to get public keys
To exploit this vulnerability, and impersonate a user, the attacker needs to know their username and public key. Getting the username is the easy part, as it has relatively low entropy, thus variations of a person’s legal names and nicknames could be brute-forced relatively quick. The public key on the other hand is public in their name, thus most people are comfortable with scattering it everywhere: authorized_keys
files, and public Git hosting platforms like GitHub.
While some of these are more protected than others, I get surprised regularly when I give talks or do trainings and there are always a handful of people in the audience that did not know about GitHub making the public keys of every user available at https://github.com/<username>.keys
. (I guess they are one of that day’s lucky ten thousand people.) I hope that learning about this vulnerability at least helps more people consider using separate keypairs for different purposes to improve their privacy – on modern servers (cf. Azure DevOps only supporting RSA) ed25519-sk
and ecdsa-sk
can be used to generate infinite amount of keys with a single cheap FIDO hardware token since 2020.
Conclusion
We think that this vulnerability is a nice example of how state machines are tightly related to security – see also the awesome research paper Smashing the state machine: the true potential of web race conditions by James Kettle from the PortSwigger team. While most of the spotlight is on web-based interactions like implementations of Multi-Factor Authentication and Single Sign-On solutions, the case of Gitblit demonstrates that non-HTTP protocols could be affected as well.
Another lesson that could be learnt from all this is one regarding integration of dependencies and inter-component interaction around security-critical code. The choice of using Apache MINA to implement the SSH daemon functionality was and is a sane one, as it abstracts a non-core functionality of a Git server. However, as Joel Spolsky would say, this abstraction is a leaky one, and while in some cases, it would “only” affect performance, in this case, it lead to a vulnerability.
In hindsight, it’s not trivial to come up with a nice way to assign the blame, as the Apache MINA documentation did not lie in their API documentation regarding the use of authenticate()
. On the other hand, developers choose to use an SSH implementation exactly because they are not familiar with the low-level peculiarities like how public-key authentication gets negotiated. This leads to one of my pet peeves: if a piece of functionality works, few bother to check whether it does more than strictly required – even though, attackers are interested in exactly these kinds of extra features.
Timeline
- 2024-02-22 first e-mail sent to
gitblitorg@gmail.com
with full description of the vulnerability - 2024-02-25 first e-mail response received with questions regarding reproduction of the issue, developer also mentions they’re the only one working on the project
- 2024-02-27 developer confirms the vulnerability and can reproduce it on the
master
branch as well - 2024-03-03 CVE-2024-28080 gets assigned to the vulnerability
- 2024-06-14 developer sends a nightly build that should have fixed the vulnerability, but we can reproduce the issue on it as well
- 2024-06-18 experimentation revealed that the wrong nighty build was provided – we test on the proper one and can confirm that the fix is effective, notifying the developer and asking about possibilities of a release date
- 2024-07-22 last reply from the developer in the year 2024
- 2024-12-20 ping sent to the developer
- 2025-04-22 planned release on the one-year anniversary of the fix sent to the developer
- 2025-05-09 developer acknowledges our plan
- 2025-06-14 developer releases v1.10.0 that fixes the vulnerability (along with two other vulnerabilities)
- 2025-08-29 this blogpost gets published