Unlimited Coffee: independent discovery of a 9.1 severity CVE#
TL;DR: I found a bug in terminal.shop where improper SSH public key verification let me impersonate other users’ accounts, which in turn allowed me to get unlimited free coffee.
The Accidental Discovery#
I wanted to try out terminal.shop - a coffee shop that runs entirely over SSH. You connect via ssh terminal.shop, browse their catalog in a slick terminal UI, and order coffee. Your identity is your SSH key fingerprint, so there's no account creation flow. Just connect and buy coffee.
I had ordered coffee from them before (shoutout nil blend), but they had recently released a monthly subscription which I wanted to try out so I connected, set up a subscription, and closed my terminal. A few minutes later I came back to check on my order status. Fresh session. No order history. No subscription. The account was gone.
After some head-scratching, I found the culprit in my SSH config:
Host *
PubkeyAuthentication noI'd added this years ago for reasons I no longer remember, and it meant my client was never offering a public key. The server was falling back to keyboard-interactive authentication and assigning me a random UUID as my identity on every connection. My subscription was tied to an ephemeral account that I could not access.
Annoying, but that's my own fault. I added PubkeyAuthentication for terminal.shop, reconnected with my actual key, and moved on.
Or I would have, if I hadn't started wondering: *how exactly is this server handling authentication?*
Reading the Source#
terminal.shop is open source, so I cloned the repo and started reading. The SSH server uses charmbracelet/wish (and maybe effect-ts+opentui in the future 👀), a library for building SSH apps, and the authentication setup looked like this:
wish.WithPublicKeyAuth(func(_ ssh.Context, key ssh.PublicKey) bool {
return true
}),
wish.WithKeyboardInteractiveAuth(
func(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) bool {
return true
},
),Both callbacks return true unconditionally - the server accepts any public key and also allows 0-input keyboard-interactive as a fallback; perfect for an app that wants to allow anyone to use it. The interesting part was how the fingerprint (from which the user identity is derived) got assigned:
fingerprint := uuid.New().String()
publicKey := s.PublicKey()
if publicKey != nil {
hash := md5.Sum(publicKey.Marshal())
fingerprint = hex.EncodeToString(hash[:])
}If there's a public key, hash it and use that as the fingerprint. Otherwise, use a random UUID. Simple enough, and it explained my ephemeral account problem - without pubkey auth, I was getting a new UUID every time.
Poking at the Handshake#
I started experimenting locally. My first question was simple: what exactly goes into publicKey.Marshal()? If I could see the raw bytes being hashed, maybe I could manipulate it to recover my lost account.
A quick nix-shell later, I had a go dev environment setup, added some logging, and connected with various keys, watching what came through. The marshaled data was just the key type and the raw pubkey bytes - the same data you'd see in an authorized_keys file. Nothing surprising there.
Then I tried something different and tried pointing my SSH client at a file that wasn't an SSH key, just some random text.
echo "AAAA0x41414141..." > pubkey
chmod 0600 pubkey
ssh -i pubkey localhost -p 2222I expected this to fail immediately, or at least to result in a nil s.PublicKey() since I couldn't complete the pubkey authentication. Instead, the connection succeeded via keyboard-interactive fallback, but s.PublicKey() returned the gibberish key I'd offered. The server had stored the public key when it was presented, and never cleared it when authentication fell back to a different method.
This meant the fingerprint derivation logic was broken. It wasn't checking whether public key authentication succeeded - it was checking whether a public key was offered. And I could offer any public key I wanted.
The Authentication Gap#
To understand why this is exploitable, you need to understand how SSH public key authentication actually works. The process has two distinct phases: offering a key and proving ownership.
The critical flaw is in the "Signature Invalid or Missing" branch. When public key authentication fails and the server falls back to keyboard-interactive, the session still contains the public key that was offered during the failed attempt. The application code then reads this key, hashes it, and uses it as the user's identity - even though the user never proved they control the associated private key.
Tracing the Bug Upstream#
The terminal.shop code was using s.PublicKey() exactly as you'd expect from reading the API - check if it's nil, and if not, use it as the authenticated identity. If this was broken, the bug had to be in the underlying library.
I cloned charmbracelet/ssh and started digging through the authentication flow. The library is a fork of gliderlabs/ssh, which wraps Go's golang.org/x/crypto/ssh package to provide a higher-level API for building SSH servers.
I searched the gliderlabs/ssh issues and found issue #242, which described exactly this problem: the PublicKey() method returns keys that were offered but never verified, and applications that trust this value can be tricked into accepting forged identities.
The issue referenced CVE-2024-45337, a vulnerability in Go's golang.org/x/crypto/ssh package itself. The root cause was in application/library misuse of ServerConfig.PublicKeyCallback, storing authorization material out of band and then using it outside of the server's authorization context. Applications that assume the callback indicates successful authentication - or that trust session state populated during the callback - can be exploited.
The Exploit#
Once I understood the vulnerability, exploitation was trivial. GitHub publishes every user's SSH public keys at https://github.com/<username>.keys (checkout mine: https://github.com/gigamonster256.keys)- it's a legitimate feature used by tools like cloud-init to provision VM access. But combined with this bug, it meant I could log in as any GitHub user:
curl https://github.com/thdxr.keys | head -n1 > pubkey && chmod 0600 pubkey && ssh terminal.shop -oIdentityAgent=none -i pubkeyThe terminal.shop developers had their accounts allowlisted to bypass payment on checkout. Their public keys were on GitHub. I could order unlimited free coffee as any of them (barring ones who used GitHub tokens for authentication and didn't have any SSH keys uploaded.)
the associated logic allowed any of the developer's user IDs to skip payment when checking out:
const needsPayment = ![
"usr_01J1JGH7NH2HZ6DGAGT8SK2KE3",
"usr_01J1KHKPA8QK82MBHQDBQP78XK",
"usr_01J1KHPJ88QEFEQ6K27QA9C4WN",
"usr_01JG4BDDCKTY6CYWF6JXKVPNNT",
].includes(userID);snippet of call to terminal API after logging in and generating an API token for a compromised account:
{
"data": {
"profile": {
"user": {
"id": "usr_01J1JGH7NH2HZ6DGAGT8SK2KE3",
"name": <redacted>,
"email": <redacted>,
"fingerprint": <redacted>,
"stripeCustomerID": <redacted>
}
},
...Impact#
I wanted to understand how broadly it could be exploited against real users. terminal.shop was being marketed through x.com, YouTube, and livestreams, so finding potential victims was straightforward: search for tweets showing off received coffee bags, check if the poster had a GitHub account linked in their bio, and pull their public keys.
The target demographic - developers who think SSH-based shopping is cool - overlapped almost perfectly with people who have GitHub accounts with SSH keys configured. Of the 46 accounts I tested this way, 20 had existing terminal.shop accounts with order history and saved credit cards. That's a 43% hit rate for full account takeover, requiring nothing but a tweet and a one-liner.
An attacker could have viewed order history and shipping addresses, placed orders on saved payment methods, or modified subscriptions. The combination of publicly-available SSH keys and a developer-focused product meant the attack surface was substantial.
Resolution#
Getting the fix deployed was relatively straightforward. The go team had already ensured that golang.org/x/[email protected] and later could not be misused, I emailed the charm team about the library-level issue, and they opened PR #33 to fix the underlying problem.
I also got in contact with the terminal.shop developers and the application-level fix moved fingerprint assignment into the authentication callbacks themselves:
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
hash := md5.Sum(key.Marshal())
fingerprint := hex.EncodeToString(hash[:])
ctx.SetValue("fingerprint", fingerprint)
return true
}),
wish.WithKeyboardInteractiveAuth(
func(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) bool {
ctx.SetValue("fingerprint", uuid.NewString())
return true
},
),They also later disabled the ability to create subscriptions without a verified SSH key, closing off the ephemeral account footgun that started this whole journey.
Conclusions#
A forgotten SSH config option led to an inaccessible subscription, which led to source code spelunking, which led to a realization about how SSH handshakes work, which led to a one-liner that could impersonate any GitHub user on a coffee shop. The underlying issue - exposing offered credentials as verified credentials - is subtle enough that it made it into the Go standard library's SSH implementation and multiple downstream libraries.
Library authors: Don't expose offered credentials to consumers without authentication as well as authorization checks. If your API populates session state during an incomplete authentication attempt, make sure consuming code can distinguish between "this data was verified" and "this data was merely presented." - A tagged union to pattern match on would be a great… uh, nevermind this is was Go.
Application developers: Be skeptical of session state after authentication. Ensure that presence of a credential means it was verified in some way, a middleware or some other mechanism.
Users: Audit your SSH config. My Host * catchall with PubkeyAuthentication no caused my original ephemeral account problem and led me to discover this vulnerability. If you have restrictive defaults, make sure they're actually what you want, and consider explicit overrides for hosts that need specific authentication methods.
Bonus Exploit#
A while later I happened upon Why UUIDs won't protect your secrets - an article about IDOR - Indirect Object Reference and started looking at my personal projects as well as projects I recently contributed to for cases of IDOR. Looking through terminal.shop code, all API endpoints are secured to only return items owned by the authenticated user by means a SQL join on Actor.userID() (a method populated by the bearer token authentication middleware) or equivalent WHERE clause… except the order resource's getOrderById endpoint. This endpoint did not have the Actor.userID() check and allowed any authenticated user to lookup any order provided they knew the order's ID to lookup. The terminal.shop uses resource prefixed ulids for their drizzle ORM ID type so the only way to get an order's ID from another account would be to randomly guess… or have a list of order IDs from a previous exploit :).
I verified the exploit worked on production systems and emailed a patch:
diff --git a/packages/core/src/order/order.ts b/packages/core/src/order/order.ts
index 33fd03e3a6..e275843e20 100644
--- a/packages/core/src/order/order.ts
+++ b/packages/core/src/order/order.ts
@@ -247,7 +247,9 @@
productVariantTable,
eq(orderItemTable.productVariantID, productVariantTable.id),
)
- .where(eq(orderTable.id, input))
+ .where(
+ and(eq(orderTable.id, input), eq(orderTable.userID, Actor.userID())),
+ )
.then((rows): Info | undefined =>
rows.length === 0
? undefinedit was fixed shortly afterwards (right next to another terminal.shop exploit)