Skip to content

Commit 44b2551

Browse files
fix: refresh username on every auth attempt to prevent cross-account authentication
The connection-scoped context cached the username from the first authentication attempt and never updated it on subsequent attempts. Because SSH allows multiple auth attempts on one connection, an attacker could fail authentication as one user, then authenticate as a different user while the auth handler still saw the original username. This let a valid key for one account authorize a session belonging to another account. Move the username assignment ahead of the session-id early return so it tracks the current attempt, while leaving genuinely connection-scoped values (session id, versions, addresses) set once. Add regression tests covering the stale-username bug and the session.User() == session.Context().User() invariant. Reported by OpenAI Security Research.
1 parent ebfa259 commit 44b2551

2 files changed

Lines changed: 306 additions & 1 deletion

File tree

context.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,17 @@ func resetPermissions(ctx Context) {
127127
// this is separate from newContext because we will get ConnMetadata
128128
// at different points so it needs to be applied separately
129129
func applyConnMetadata(ctx Context, conn gossh.ConnMetadata) {
130+
// The username is per-authentication-attempt and can change between
131+
// attempts on the same connection, so it must be refreshed every time.
132+
// The remaining values are connection-scoped and set only once.
133+
ctx.SetValue(ContextKeyUser, conn.User())
134+
130135
if ctx.Value(ContextKeySessionID) != nil {
131136
return
132137
}
133138
ctx.SetValue(ContextKeySessionID, hex.EncodeToString(conn.SessionID()))
134139
ctx.SetValue(ContextKeyClientVersion, string(conn.ClientVersion()))
135140
ctx.SetValue(ContextKeyServerVersion, string(conn.ServerVersion()))
136-
ctx.SetValue(ContextKeyUser, conn.User())
137141
ctx.SetValue(ContextKeyLocalAddr, conn.LocalAddr())
138142
ctx.SetValue(ContextKeyRemoteAddr, conn.RemoteAddr())
139143
}

cross_account_test.go

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
package ssh
2+
3+
import (
4+
"crypto/ed25519"
5+
"crypto/rand"
6+
"net"
7+
"testing"
8+
9+
gossh "golang.org/x/crypto/ssh"
10+
)
11+
12+
// mockConnMetadata implements gossh.ConnMetadata for testing.
13+
type mockConnMetadata struct {
14+
user string
15+
sessionID []byte
16+
clientVersion []byte
17+
serverVersion []byte
18+
}
19+
20+
func (m mockConnMetadata) User() string { return m.user }
21+
func (m mockConnMetadata) SessionID() []byte { return m.sessionID }
22+
func (m mockConnMetadata) ClientVersion() []byte { return m.clientVersion }
23+
func (m mockConnMetadata) ServerVersion() []byte { return m.serverVersion }
24+
func (m mockConnMetadata) RemoteAddr() net.Addr { return nil }
25+
func (m mockConnMetadata) LocalAddr() net.Addr { return nil }
26+
27+
// TestApplyConnMetadataRefreshesUser verifies that applyConnMetadata updates
28+
// the username on every call, even after the session ID has already been set.
29+
// This is a regression test for the cross-account authentication bypass where
30+
// a stale username from a failed attempt persisted to subsequent attempts.
31+
func TestApplyConnMetadataRefreshesUser(t *testing.T) {
32+
t.Parallel()
33+
34+
ctx, cancel := newContext(nil)
35+
defer cancel()
36+
37+
sessionID := []byte("test-session-id")
38+
39+
// First call: alice authenticates (and fails).
40+
alice := mockConnMetadata{
41+
user: "alice",
42+
sessionID: sessionID,
43+
clientVersion: []byte("SSH-2.0-test"),
44+
serverVersion: []byte("SSH-2.0-server"),
45+
}
46+
applyConnMetadata(ctx, alice)
47+
48+
if got := ctx.User(); got != "alice" {
49+
t.Fatalf("after first call: User() = %q, want %q", got, "alice")
50+
}
51+
if ctx.Value(ContextKeySessionID) == nil {
52+
t.Fatal("session ID should be set after first call")
53+
}
54+
55+
// Second call: bob authenticates on the same connection.
56+
bob := mockConnMetadata{
57+
user: "bob",
58+
sessionID: sessionID,
59+
clientVersion: []byte("SSH-2.0-test"),
60+
serverVersion: []byte("SSH-2.0-server"),
61+
}
62+
applyConnMetadata(ctx, bob)
63+
64+
if got := ctx.User(); got != "bob" {
65+
t.Fatalf("after second call: User() = %q, want %q; stale username from first attempt persisted", got, "bob")
66+
}
67+
}
68+
69+
// TestApplyConnMetadataConnectionScopedValues verifies that truly
70+
// connection-scoped values (session ID, versions, addresses) are set once
71+
// and not overwritten on subsequent calls.
72+
func TestApplyConnMetadataConnectionScopedValues(t *testing.T) {
73+
t.Parallel()
74+
75+
ctx, cancel := newContext(nil)
76+
defer cancel()
77+
78+
first := mockConnMetadata{
79+
user: "alice",
80+
sessionID: []byte("session-1"),
81+
clientVersion: []byte("SSH-2.0-client-v1"),
82+
serverVersion: []byte("SSH-2.0-server-v1"),
83+
}
84+
applyConnMetadata(ctx, first)
85+
86+
sessionID := ctx.SessionID()
87+
clientVersion := ctx.ClientVersion()
88+
serverVersion := ctx.ServerVersion()
89+
90+
// Second call with different connection-scoped values.
91+
second := mockConnMetadata{
92+
user: "bob",
93+
sessionID: []byte("session-2"),
94+
clientVersion: []byte("SSH-2.0-client-v2"),
95+
serverVersion: []byte("SSH-2.0-server-v2"),
96+
}
97+
applyConnMetadata(ctx, second)
98+
99+
// Username must update.
100+
if got := ctx.User(); got != "bob" {
101+
t.Fatalf("User() = %q, want %q", got, "bob")
102+
}
103+
104+
// Connection-scoped values must not change.
105+
if got := ctx.SessionID(); got != sessionID {
106+
t.Fatalf("SessionID() changed: got %q, want %q", got, sessionID)
107+
}
108+
if got := ctx.ClientVersion(); got != clientVersion {
109+
t.Fatalf("ClientVersion() changed: got %q, want %q", got, clientVersion)
110+
}
111+
if got := ctx.ServerVersion(); got != serverVersion {
112+
t.Fatalf("ServerVersion() changed: got %q, want %q", got, serverVersion)
113+
}
114+
}
115+
116+
// TestCrossAccountPublicKeyAttack simulates the reported attack at the
117+
// callback level: a failed public-key attempt as alice, followed by a
118+
// successful attempt as bob signed with alice's key. The PublicKeyHandler
119+
// must see "bob" on the second attempt, not the stale "alice".
120+
func TestCrossAccountPublicKeyAttack(t *testing.T) {
121+
t.Parallel()
122+
123+
_, alicePriv, err := ed25519.GenerateKey(rand.Reader)
124+
if err != nil {
125+
t.Fatal(err)
126+
}
127+
aliceSigner, err := gossh.NewSignerFromKey(alicePriv)
128+
if err != nil {
129+
t.Fatal(err)
130+
}
131+
alicePub := aliceSigner.PublicKey()
132+
133+
accounts := map[string]gossh.PublicKey{
134+
"alice": alicePub,
135+
}
136+
137+
srv := &Server{
138+
PublicKeyHandler: func(ctx Context, key PublicKey) bool {
139+
known, ok := accounts[ctx.User()]
140+
return ok && KeysEqual(known, key)
141+
},
142+
}
143+
if err := srv.ensureHostSigner(); err != nil {
144+
t.Fatal(err)
145+
}
146+
147+
ctx, cancel := newContext(srv)
148+
defer cancel()
149+
config := srv.config(ctx)
150+
151+
sessionID := []byte("test-session-id")
152+
153+
// Attempt 1: alice offers her own key. Handler sees alice, accepts.
154+
aliceConn := mockConnMetadata{user: "alice", sessionID: sessionID}
155+
_, err = config.PublicKeyCallback(aliceConn, alicePub)
156+
if err != nil {
157+
t.Fatalf("alice's own key should be accepted: %v", err)
158+
}
159+
160+
// Simulate a failed auth so the connection continues.
161+
// (In the real protocol the server rejects and the client retries.)
162+
163+
// Attempt 2: attacker requests bob, signs with alice's key.
164+
bobConn := mockConnMetadata{user: "bob", sessionID: sessionID}
165+
_, err = config.PublicKeyCallback(bobConn, alicePub)
166+
if err == nil {
167+
t.Fatal("alice's key must NOT authenticate bob; cross-account attack succeeded")
168+
}
169+
170+
// The handler must have seen "bob", not the stale "alice".
171+
if got := ctx.User(); got != "bob" {
172+
t.Fatalf("after bob attempt: ctx.User() = %q, want %q", got, "bob")
173+
}
174+
}
175+
176+
// TestCrossAccountPasswordThenPublicKeyAttack simulates the password-priming
177+
// variant: a failed password attempt as alice, then a public-key attempt as
178+
// bob signed with alice's key.
179+
func TestCrossAccountPasswordThenPublicKeyAttack(t *testing.T) {
180+
t.Parallel()
181+
182+
_, alicePriv, err := ed25519.GenerateKey(rand.Reader)
183+
if err != nil {
184+
t.Fatal(err)
185+
}
186+
aliceSigner, err := gossh.NewSignerFromKey(alicePriv)
187+
if err != nil {
188+
t.Fatal(err)
189+
}
190+
alicePub := aliceSigner.PublicKey()
191+
192+
accounts := map[string]gossh.PublicKey{
193+
"alice": alicePub,
194+
}
195+
196+
srv := &Server{
197+
PasswordHandler: func(ctx Context, password string) bool {
198+
return false // always reject
199+
},
200+
PublicKeyHandler: func(ctx Context, key PublicKey) bool {
201+
known, ok := accounts[ctx.User()]
202+
return ok && KeysEqual(known, key)
203+
},
204+
}
205+
if err := srv.ensureHostSigner(); err != nil {
206+
t.Fatal(err)
207+
}
208+
209+
ctx, cancel := newContext(srv)
210+
defer cancel()
211+
config := srv.config(ctx)
212+
213+
sessionID := []byte("test-session-id")
214+
215+
// Attempt 1: alice fails password auth.
216+
aliceConn := mockConnMetadata{user: "alice", sessionID: sessionID}
217+
_, err = config.PasswordCallback(aliceConn, []byte("wrong-password"))
218+
if err == nil {
219+
t.Fatal("alice's password should have been rejected")
220+
}
221+
222+
// Attempt 2: attacker requests bob, signs with alice's key.
223+
bobConn := mockConnMetadata{user: "bob", sessionID: sessionID}
224+
_, err = config.PublicKeyCallback(bobConn, alicePub)
225+
if err == nil {
226+
t.Fatal("alice's key must NOT authenticate bob after failed alice password; cross-account attack succeeded")
227+
}
228+
if got := ctx.User(); got != "bob" {
229+
t.Fatalf("after bob attempt: ctx.User() = %q, want %q", got, "bob")
230+
}
231+
}
232+
233+
// TestLegitimateAuthAfterFailedAttempt verifies that bob's own key still
234+
// works after alice's failed attempt on the same connection.
235+
func TestLegitimateAuthAfterFailedAttempt(t *testing.T) {
236+
t.Parallel()
237+
238+
_, alicePriv, err := ed25519.GenerateKey(rand.Reader)
239+
if err != nil {
240+
t.Fatal(err)
241+
}
242+
aliceSigner, err := gossh.NewSignerFromKey(alicePriv)
243+
if err != nil {
244+
t.Fatal(err)
245+
}
246+
247+
_, bobPriv, err := ed25519.GenerateKey(rand.Reader)
248+
if err != nil {
249+
t.Fatal(err)
250+
}
251+
bobSigner, err := gossh.NewSignerFromKey(bobPriv)
252+
if err != nil {
253+
t.Fatal(err)
254+
}
255+
256+
accounts := map[string]gossh.PublicKey{
257+
"alice": aliceSigner.PublicKey(),
258+
"bob": bobSigner.PublicKey(),
259+
}
260+
261+
srv := &Server{
262+
PublicKeyHandler: func(ctx Context, key PublicKey) bool {
263+
known, ok := accounts[ctx.User()]
264+
return ok && KeysEqual(known, key)
265+
},
266+
}
267+
if err := srv.ensureHostSigner(); err != nil {
268+
t.Fatal(err)
269+
}
270+
271+
ctx, cancel := newContext(srv)
272+
defer cancel()
273+
config := srv.config(ctx)
274+
275+
sessionID := []byte("test-session-id")
276+
277+
// Alice fails with a throwaway key.
278+
_, throwawayPriv, err := ed25519.GenerateKey(rand.Reader)
279+
if err != nil {
280+
t.Fatal(err)
281+
}
282+
throwawaySigner, err := gossh.NewSignerFromKey(throwawayPriv)
283+
if err != nil {
284+
t.Fatal(err)
285+
}
286+
aliceConn := mockConnMetadata{user: "alice", sessionID: sessionID}
287+
_, err = config.PublicKeyCallback(aliceConn, throwawaySigner.PublicKey())
288+
if err == nil {
289+
t.Fatal("throwaway key should be rejected for alice")
290+
}
291+
292+
// Bob authenticates with his own key. Must succeed.
293+
bobConn := mockConnMetadata{user: "bob", sessionID: sessionID}
294+
_, err = config.PublicKeyCallback(bobConn, bobSigner.PublicKey())
295+
if err != nil {
296+
t.Fatalf("bob's own key must be accepted after alice's failure: %v", err)
297+
}
298+
if got := ctx.User(); got != "bob" {
299+
t.Fatalf("ctx.User() = %q, want %q", got, "bob")
300+
}
301+
}

0 commit comments

Comments
 (0)