Skip to content

Commit e052873

Browse files
drakkanthatnealpatel
authored andcommitted
ssh: fix infinite loop on large channel writes due to integer overflow
The internal 'min' helper function in channel.go incorrectly cast the input data length (int) to uint32 before comparing it with the maximum packet size. On 64-bit systems, if the data length is a multiple of 2^32 (approx. 4GB), this cast results in 0. Consequently, the function returns 0, causing the WriteExtended loop to spin indefinitely because it attempts to reserve 0 bytes while the remaining data length is still positive. This change renames the helper to 'minPayloadSize' to avoid confusion with the Go 1.21 built-in 'min' and updates the logic to use int64 for comparisons, preventing truncation and the resulting infinite loop. This issue was found during a security audit by NCC Group Cryptography Services, sponsored by Teleport. Fixes golang/go#79567 Fixes CVE-2026-39834 Change-Id: Id5bf81d9f06c7042452acffe1c76580ff878665e Reviewed-on: https://go-review.googlesource.com/c/crypto/+/781663 Reviewed-by: Neal Patel <nealpatel@google.com> Reviewed-by: Roland Shoemaker <roland@golang.org> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
1 parent b61cf85 commit e052873

2 files changed

Lines changed: 151 additions & 5 deletions

File tree

ssh/channel.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,17 @@ func (r RejectionReason) String() string {
131131
return fmt.Sprintf("unknown reason %d", int(r))
132132
}
133133

134-
func min(a uint32, b int) uint32 {
135-
if a < uint32(b) {
136-
return a
134+
// minPayloadSize returns min(limit, length) clamped to a uint32. It is used
135+
// to compute the size of the next channel data packet from the remaining
136+
// payload. The comparison is done in int64 because length is an int — on
137+
// 64-bit systems len(data) can exceed 2^32, and a direct uint32(length)
138+
// cast would silently truncate to 0 at every multiple of 2^32, causing
139+
// WriteExtended's loop to spin without making progress.
140+
func minPayloadSize(limit uint32, length int) uint32 {
141+
if int64(length) > int64(limit) {
142+
return limit
137143
}
138-
return uint32(b)
144+
return uint32(length)
139145
}
140146

141147
type channelDirection uint8
@@ -251,7 +257,7 @@ func (ch *channel) WriteExtended(data []byte, extendedCode uint32) (n int, err e
251257
ch.writeMu.Unlock()
252258

253259
for len(data) > 0 {
254-
space := min(ch.maxRemotePayload, len(data))
260+
space := minPayloadSize(ch.maxRemotePayload, len(data))
255261
if space, err = ch.remoteWin.reserve(space); err != nil {
256262
return n, err
257263
}

ssh/channel_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package ssh
6+
7+
import (
8+
"math/bits"
9+
"testing"
10+
"time"
11+
"unsafe"
12+
)
13+
14+
func TestMinPayloadSize(t *testing.T) {
15+
// 4 GiB (2^32). Declared as a var (not a const) so that int(bigPayload)
16+
// is a runtime conversion: a constant conversion would fail to compile
17+
// on 32-bit platforms with "constant 4294967296 overflows int". On
18+
// 32-bit the value truncates to 0 at runtime, but the is64Bit cases
19+
// that reference it are skipped by the runtime check below.
20+
var bigPayload int64 = 1 << 32
21+
22+
tests := []struct {
23+
name string
24+
maxPayload uint32
25+
dataLen int
26+
want uint32
27+
is64Bit bool // Flag to run only on 64-bit architectures
28+
}{
29+
{
30+
name: "Normal Case - Data fits in payload",
31+
maxPayload: 32768,
32+
dataLen: 1000,
33+
want: 1000,
34+
},
35+
{
36+
name: "Normal Case - Data larger than payload",
37+
maxPayload: 32768,
38+
dataLen: 50000,
39+
want: 32768,
40+
},
41+
{
42+
name: "Boundary Case - Data zero",
43+
maxPayload: 32768,
44+
dataLen: 0,
45+
want: 0,
46+
},
47+
{
48+
name: "Overflow Case - Data is exactly 4GB (1<<32)",
49+
maxPayload: 32768,
50+
dataLen: int(bigPayload),
51+
want: 32768,
52+
is64Bit: true,
53+
},
54+
{
55+
name: "Overflow Case - Data is 4GB + small amount",
56+
maxPayload: 32768,
57+
dataLen: int(bigPayload + 100),
58+
want: 32768,
59+
is64Bit: true,
60+
},
61+
}
62+
63+
is64Bit := bits.UintSize == 64
64+
65+
for _, tt := range tests {
66+
t.Run(tt.name, func(t *testing.T) {
67+
if tt.is64Bit && !is64Bit {
68+
t.Skip("Skipping test requiring 64-bit int")
69+
}
70+
got := minPayloadSize(tt.maxPayload, tt.dataLen)
71+
if got != tt.want {
72+
t.Errorf("minPayloadSize(%d, %d) = %d; want %d", tt.maxPayload, tt.dataLen, got, tt.want)
73+
}
74+
})
75+
}
76+
}
77+
78+
// TestWriteExtendedNoInfiniteLoopOnLargeWrite is an end-to-end regression
79+
// test for the integer-overflow bug in WriteExtended. Before the fix, a
80+
// write whose len(data) was a multiple of 2^32 caused minPayloadSize to
81+
// return 0; WriteExtended then spun forever, reserving 0 bytes per
82+
// iteration and never advancing the data slice.
83+
//
84+
// We exercise the real WriteExtended path with a slice whose declared
85+
// length is exactly 2^32. Allocating 4 GiB is unnecessary: each iteration
86+
// only reads up to maxRemotePayload bytes from the head of the slice, and
87+
// the loop blocks in remoteWin.reserve() once the channel window is
88+
// exhausted — before the slice base advances past the underlying buffer.
89+
//
90+
// With the fix, the loop blocks in reserve(); we detect that via
91+
// waitWriterBlocked(), then close the window to let WriteExtended return.
92+
// With the bug, the loop never blocks and the test times out.
93+
//
94+
//go:nocheckptr
95+
func TestWriteExtendedNoInfiniteLoopOnLargeWrite(t *testing.T) {
96+
if bits.UintSize < 64 {
97+
t.Skip("test requires 64-bit int to construct a slice with len >= 2^32")
98+
}
99+
100+
reader, writer, mux := channelPair(t)
101+
defer reader.Close()
102+
defer writer.Close()
103+
defer mux.Close()
104+
105+
// Sized to hold the full pre-update remote window so that no iteration
106+
// reads past the backing buffer before reserve() blocks.
107+
backing := make([]byte, channelWindowSize)
108+
var bigLen int64 = 1 << 32
109+
bigSlice := unsafe.Slice(&backing[0], int(bigLen))
110+
111+
done := make(chan int, 1)
112+
go func() {
113+
n, _ := writer.Write(bigSlice)
114+
done <- n
115+
}()
116+
117+
blocked := make(chan struct{})
118+
go func() {
119+
writer.remoteWin.waitWriterBlocked()
120+
close(blocked)
121+
}()
122+
123+
select {
124+
case <-blocked:
125+
// Good — the loop made progress and is now blocked in reserve().
126+
// Close the window to let WriteExtended return.
127+
writer.remoteWin.close()
128+
case <-time.After(2 * time.Second):
129+
t.Fatal("WriteExtended did not block in reserve within 2s — minPayloadSize likely returned 0 (integer overflow regression)")
130+
}
131+
132+
select {
133+
case n := <-done:
134+
if n == 0 {
135+
t.Fatalf("WriteExtended returned n=0; expected progress")
136+
}
137+
case <-time.After(2 * time.Second):
138+
t.Fatal("WriteExtended did not return after closing the window")
139+
}
140+
}

0 commit comments

Comments
 (0)