Feb 15, 2026·7 min read·16 visits
Soft Serve trusted the identity of an 'offered' public key before verifying the signature. An attacker can offer an Admin key (fail signature) then offer their own key (pass signature), but the server retains the Admin identity in the session context. Result: Full Account Takeover.
A critical authentication bypass in Charmbracelet's Soft Serve allows attackers to impersonate administrators by exploiting a logic flaw in the SSH handshake state management. By offering a victim's key before authenticating with their own, an attacker can trick the server into granting the victim's privileges.
Soft Serve is a beautiful piece of software. It’s a self-hostable Git server for the command line, built by Charmbracelet, a team known for making the terminal look better than most GUIs. It uses SSH for everything—authentication, repository access, and even the UI itself (via TUI). It's slick, modern, and written in Go.
But here's the thing about modern SSH servers: managing state is a nightmare. Unlike HTTP, which is stateless (mostly), SSH is a persistent tunnel with a complex handshake dance. If you trip over your own feet during that dance, you faceplant hard. And that is exactly what happened here.
CVE-2026-24058 isn't a buffer overflow. We aren't smashing the stack or spraying the heap. We are gaslighting the server. We are performing a Jedi Mind Trick. We tell the server, "I am the Admin," and even though we can't prove it, the server nods and writes it down. Later, when we actually prove we are "Nobody," the server looks at its notes, sees "Admin," and hands over the keys to the kingdom. It is a classic State Pollution vulnerability, and it is devastatingly simple.
To understand this bug, you need to understand a nuance of the SSH protocol: Public Key Authentication is a two-step process.
authorized_keys list. If yes, it says "Sure, prove you own it."Most SSH clients try multiple keys. They might offer Key A, realize they don't have the passphrase, and then offer Key B.
The vulnerability in Soft Serve (specifically in how it used the wish middleware) was a failure to clean up the workspace. When the client performed Step 1 (The Offer) with a victim's key, the server's PublicKeyHandler immediately resolved that key to a User ID and stuffed it into the session context (proto.ContextKeyUser).
> [!WARNING] > The server committed the sin of premature optimization: it assumed that if you offer a key, you will eventually sign with it.
If the signature verification failed (because you don't actually have the Admin's private key), the SSH protocol correctly rejected that specific authentication attempt. However, the Session Context was already polluted. The variable proto.ContextKeyUser still held the Admin's user object. When the attacker subsequently authenticated with their own valid, low-privilege key, the server verified the crypto correctly but forgot to clear the stale "Admin" user from the context. The code proceeded to authorize the session based on the stale data, not the key that actually passed verification.
Let's look at the logic flow that caused this. The vulnerability lived in the intersection of the middleware chain and the context handling.
In the vulnerable version, the PublicKeyHandler looked something like this (simplified for clarity):
// VULNERABLE LOGIC
func PublicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
// 1. Resolve user from the offered key
user, err := backend.UserByPublicKey(key)
if err == nil {
// CRITICAL FLAW: Setting the user in the context during the handshake
// simply because the key exists in the database.
ctx.SetValue(proto.ContextKeyUser, user)
}
return true // Allow the key to proceed to signature check
}The fix involves a philosophical shift: Don't trust the context during the handshake. The patch (Commit 8539f9a) moves the identity resolution to after the connection is fully established, using the key that actually signed the request.
Here is a snippet of the regression test added in the patch, which perfectly illustrates the attack flow:
// From pkg/ssh/middleware_test.go (The Fix)
// Step 1: Attacker offers Admin Key
// The mock context gets polluted
mockCtx.SetValue(proto.ContextKeyUser, adminUser)
// Step 2: Signature Fails
// ... SSH protocol loop continues ...
// Step 3: Attacker offers Own Key (Valid)
// The middleware now MUST ignore the context set in Step 1
// THE FIX:
// Instead of reading ctx.Value(proto.ContextKeyUser),
// we look up the user based on the FINALLY VALIDATED key:
authenticatedUser, err := be.UserByPublicKey(mockCtx, s.PublicKey())The developers also added a fingerprint check (pubkey-fp) to ensure the key used for authorization matches the key used for authentication. If they don't match, it's a dead giveaway of this exact bypass attempt.
Exploiting this requires a custom SSH client or a script, as standard clients like OpenSSH might not give you the granular control needed to force the "Offer-Fail-Offer-Pass" sequence in the exact order without disconnecting. However, writing a script using Python's paramiko or Go's golang.org/x/crypto/ssh is trivial.
The Attack Chain:
.keys (e.g., https://github.com/torvalds.keys). Now you have the Admin's public key.SSH_MSG_USERAUTH_REQUEST offering the Admin's public key. The server sees the key, finds the Admin user in its DB, and sets Context.User = Admin.SSH_MSG_USERAUTH_REQUEST with your own public key (which you previously registered as a normal user). You provide a valid signature. The server verifies it.The Payoff: The server logic runs: "Okay, crypto checks out. Who is this session for? Oh, I see Context.User is set to Admin. Welcome back, boss!"
You now have full administrative access to the Git server. You can push malicious commits to protected branches, delete repositories, or modify server configurations if the TUI allows it.
This is a Critical severity issue (CVSS 8.1). In a Git server context, identity is everything.
The vulnerability is particularly dangerous because it leaves very few logs. The SSH logs will show a failed auth attempt for the Admin (which looks like a typo) followed by a successful auth for the User. Unless you are correlating the internal session state with the auth logs, it looks like standard behavior.
The patch is straightforward but essential. You must upgrade Soft Serve to v0.11.3 or later immediately.
go install github.com/charmbracelet/soft-serve/cmd/soft@v0.11.3For Go developers using charmbracelet/ssh or gliderlabs/ssh: Never store authorization state during the authentication handshake. The handshake is a volatile, untrusted zone. Only establish identity once the cryptographic handshake is fully complete and signed.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:U| Product | Affected Versions | Fixed Version |
|---|---|---|
Soft Serve Charmbracelet | <= 0.11.2 | 0.11.3 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-289 |
| Attack Vector | Network (SSH) |
| CVSS Score | 8.1 (Critical) |
| Impact | Authentication Bypass / Privilege Escalation |
| Exploit Status | PoC Available (Regression Test) |
| Affected Component | PublicKeyHandler / Wish Middleware |
Authentication Bypass by Alternate Name