usbip: fix correctness findings from protocol audit

Windows export now reports the real USB link speed, probed from the
parent hub (IOCTL_USB_GET_NODE_CONNECTION_INFORMATION_EX and _V2 for
SuperSpeedPlus), so SuperSpeed devices route to the correct root-hub
speed domain instead of advertising speed=0.

- protocol: pin DeviceInfoTruncated/DeviceInterface wire sizes with
  two-sided compile-time assertions so a struct change fails the build
  instead of silently mis-bounding the reader
- server: bound inbound connections with a handshake read deadline and a
  per-iteration idle deadline on the control loop, plus write deadlines
  on control writes; clear the deadline before the conn becomes a data
  session
- server: serialize import reservation under reconcileAccess so a reserve
  cannot interleave a reconcile pass that would release a busy device
- data: validate CMD_SUBMIT iso descriptor offset/length against the
  transfer buffer before forwarding to a platform engine
- darwin: make darwinUSBHostDevice.Close idempotent via sync.Once to
  avoid a double close/free under concurrent shutdown
- windows: guard windowsExport.device with a mutex and hand the claimed
  handle to a single closer
This commit is contained in:
世界
2026-06-09 14:38:23 +08:00
parent b3fb32abc0
commit c3946c00a3
8 changed files with 321 additions and 14 deletions
+24
View File
@@ -294,6 +294,12 @@ func readUSBIPPayload(r io.Reader, direction uint32, bufferLength int32, packetC
if err != nil {
return nil, nil, err
}
if command {
err = validateUSBIPIsoDescriptorRanges(isoPackets, bufferLength)
if err != nil {
return nil, nil, err
}
}
return buffer, isoPackets, nil
}
@@ -399,3 +405,21 @@ func validateUSBIPIsoPacketCount(count int32) error {
}
return nil
}
// validateUSBIPIsoDescriptorRanges rejects CMD_SUBMIT iso descriptors whose
// offset/length fall outside the declared transfer buffer before they reach a
// platform host engine. The IN copy-back path already clamps; the submit path
// did not, leaving a remote peer's offsets unchecked against the buffer.
func validateUSBIPIsoDescriptorRanges(packets []IsoPacketDescriptor, bufferLength int32) error {
for i := range packets {
offset := packets[i].Offset
length := packets[i].Length
if offset < 0 || length < 0 {
return E.New("USB/IP iso descriptor has negative offset/length: offset ", offset, ", length ", length)
}
if int64(offset)+int64(length) > int64(bufferLength) {
return E.New("USB/IP iso descriptor exceeds transfer buffer: offset ", offset, ", length ", length, ", buffer ", bufferLength)
}
}
return nil
}
+46 -7
View File
@@ -95,8 +95,9 @@ func (h *windowsExportHost) Close() error {
_ = monitor.Close()
}
for _, exp := range exports {
if exp.device != nil {
_ = exp.device.Close()
device := exp.takeDevice()
if device != nil {
_ = device.Close()
}
}
return nil
@@ -191,9 +192,9 @@ func (h *windowsExportHost) FinishImport(busid string) (bool, error) {
if !ok {
return false, nil
}
if exp.device != nil {
_ = exp.device.Close()
exp.device = nil
device := exp.takeDevice()
if device != nil {
_ = device.Close()
}
return false, nil
}
@@ -248,7 +249,27 @@ type windowsExport struct {
info vboxusb.USBDeviceInfo
entry DeviceEntry
logger log.ContextLogger
device *vboxusb.Device
deviceAccess sync.Mutex
device *vboxusb.Device
}
// setDevice records the claimed handle once NewServerDataSession opens it.
func (e *windowsExport) setDevice(device *vboxusb.Device) {
e.deviceAccess.Lock()
e.device = device
e.deviceAccess.Unlock()
}
// takeDevice atomically hands the claimed handle to exactly one caller and
// clears the field, so FinishImport and Close racing on shutdown cannot both
// close the same handle.
func (e *windowsExport) takeDevice() *vboxusb.Device {
e.deviceAccess.Lock()
device := e.device
e.device = nil
e.deviceAccess.Unlock()
return device
}
func newWindowsExport(info vboxusb.USBDeviceInfo, logger log.ContextLogger) *windowsExport {
@@ -256,6 +277,7 @@ func newWindowsExport(info vboxusb.USBDeviceInfo, logger log.ContextLogger) *win
Info: DeviceInfoTruncated{
BusNum: info.BusNumber,
DevNum: info.Address,
Speed: windowsSpeedToProtocol(info.Speed),
IDVendor: info.VendorID,
IDProduct: info.ProductID,
BCDDevice: info.Revision,
@@ -268,6 +290,23 @@ func newWindowsExport(info vboxusb.USBDeviceInfo, logger log.ContextLogger) *win
return &windowsExport{info: info, entry: entry, logger: logger}
}
func windowsSpeedToProtocol(speed vboxusb.DeviceSpeed) uint32 {
switch speed {
case vboxusb.SpeedLow:
return SpeedLow
case vboxusb.SpeedFull:
return SpeedFull
case vboxusb.SpeedHigh:
return SpeedHigh
case vboxusb.SpeedSuper:
return SpeedSuper
case vboxusb.SpeedSuperPlus:
return SpeedSuperPlus
default:
return SpeedUnknown
}
}
func (e *windowsExport) BusID() string {
return e.info.BusID
}
@@ -316,6 +355,6 @@ func (e *windowsExport) NewServerDataSession(ctx context.Context, conn net.Conn)
_ = device.Close()
return nil, E.New("windows usbip: device ", e.info.BusID, " is already claimed by another handle")
}
e.device = device
e.setDevice(device)
return newUserspaceURBSession(ctx, e.logger, conn, newVBoxUSBEngine(device)), nil
}
+15
View File
@@ -6,6 +6,7 @@ import (
"encoding/binary"
"io"
"strings"
"unsafe"
E "github.com/sagernet/sing/common/exceptions"
)
@@ -29,6 +30,20 @@ const (
deviceInterfaceWireSize = 4
)
// DeviceInfoTruncated and DeviceInterface are serialized field-for-field
// by binary.Write with no padding, so their in-memory size equals their
// wire size. These two-sided constant assertions pin the hand-coded wire
// bounds to the structs: a field added, removed, or resized changes the
// kernel-visible layout and fails the build here instead of silently
// mis-bounding the reader, whose only other safety net is the privileged
// interop suite.
const (
_ = uint(unsafe.Sizeof(DeviceInfoTruncated{})) - deviceInfoWireSize
_ = deviceInfoWireSize - uint(unsafe.Sizeof(DeviceInfoTruncated{}))
_ = uint(unsafe.Sizeof(DeviceInterface{})) - deviceInterfaceWireSize
_ = deviceInterfaceWireSize - uint(unsafe.Sizeof(DeviceInterface{}))
)
const (
SpeedUnknown uint32 = 0
SpeedLow uint32 = 1
+15
View File
@@ -199,6 +199,9 @@ func (s *ServerService) handleStandardConn(conn net.Conn, header OpHeader) {
s.logger.Debug("read import body: ", err)
break
}
// The connection becomes a data session below; drop the handshake
// read deadline so URB traffic is not bounded by it.
_ = conn.SetReadDeadline(time.Time{})
closeConn = !s.handleImportBusID(conn, busid)
default:
s.logger.Debug(fmt.Sprintf("unknown opcode 0x%04x", header.Code))
@@ -222,12 +225,17 @@ func (s *ServerService) handleControlConn(conn net.Conn) {
s.logger.Debug("unsupported control version ", hello.Version)
return
}
// The handshake read deadline from dispatchConn has served its purpose;
// readControlConn installs its own per-iteration idle deadline.
_ = conn.SetReadDeadline(time.Time{})
sub := s.ledger.Subscribe(conn)
defer s.ledger.Unsubscribe(sub)
_ = conn.SetWriteDeadline(time.Now().Add(controlWriteTimeout))
err = writeControlMessage(conn, controlFrame{
Type: controlFrameAck,
Version: controlProtocolVersion,
}, nil)
_ = conn.SetWriteDeadline(time.Time{})
if err != nil {
s.logger.Debug("write control ack: ", err)
return
@@ -241,7 +249,9 @@ func (s *ServerService) handleControlConn(conn net.Conn) {
case <-readDone:
return
case message := <-sub.send:
_ = conn.SetWriteDeadline(time.Now().Add(controlWriteTimeout))
err = writeControlMessage(conn, message.Frame, message.Payload)
_ = conn.SetWriteDeadline(time.Time{})
if err != nil {
s.logger.Debug("write control frame: ", err)
return
@@ -267,7 +277,12 @@ func (s *ServerService) buildDevListEntries() []DeviceEntry {
}
func (s *ServerService) handleImportBusID(conn net.Conn, busid string) bool {
// Serialize the reservation against an in-flight Reconcile pass: both take
// reconcileAccess before inventoryAccess, so a reserve cannot interleave a
// pass that would otherwise release and close a just-reserved device.
s.reconcileAccess.Lock()
export, ok, reason := s.ledger.TryReserveForImport(busid)
s.reconcileAccess.Unlock()
if !ok {
s.logger.Info("import rejected (", busid, ": ", reason, ")")
_ = WriteOpRepImport(conn, OpRepImport, OpStatusError, nil)
+10
View File
@@ -11,6 +11,11 @@ import (
E "github.com/sagernet/sing/common/exceptions"
)
// serverHandshakeTimeout bounds how long an unauthenticated peer may hold a
// goroutine before sending its preface/hello. The post-handshake control loop
// uses controlReadTimeout, which tolerates the client's controlPingInterval.
const serverHandshakeTimeout = 10 * time.Second
func (s *ServerService) acceptLoop(ln net.Listener) {
for {
conn, err := ln.Accept()
@@ -41,6 +46,7 @@ func (s *ServerService) acceptLoop(ln net.Listener) {
func (s *ServerService) dispatchConn(conn net.Conn) {
cancelClose := closeConnOnContextDone(s.ctx, conn)
defer cancelClose()
_ = conn.SetReadDeadline(time.Now().Add(serverHandshakeTimeout))
var prefix [controlPrefaceSize]byte
_, err := io.ReadFull(conn, prefix[:])
if err != nil {
@@ -59,6 +65,10 @@ func (s *ServerService) readControlConn(sub *exportSubscriber, done chan<- struc
defer close(done)
var reader controlReader
for {
err := sub.conn.SetReadDeadline(time.Now().Add(controlReadTimeout))
if err != nil {
return
}
message, err := reader.read(sub.conn)
if err != nil {
return
+11 -7
View File
@@ -13,6 +13,7 @@ import "C"
import (
"runtime/cgo"
"sync"
"unsafe"
E "github.com/sagernet/sing/common/exceptions"
@@ -110,8 +111,9 @@ type darwinUSBHostDeviceInfo struct {
}
type darwinUSBHostDevice struct {
handle *C.box_usbhost_device_t
info darwinUSBHostDeviceInfo
handle *C.box_usbhost_device_t
info darwinUSBHostDeviceInfo
closeOnce sync.Once
}
func darwinCopyUSBHostDevices() ([]darwinUSBHostDeviceInfo, error) {
@@ -394,11 +396,13 @@ func box_usbip_darwin_usb_event(ref C.uintptr_t) {
}
func (d *darwinUSBHostDevice) Close() {
if d.handle == nil {
return
}
C.box_usbhost_device_close(d.handle)
d.handle = nil
d.closeOnce.Do(func() {
if d.handle == nil {
return
}
C.box_usbhost_device_close(d.handle)
d.handle = nil
})
}
func (d *darwinUSBHostDevice) control(setup [8]byte, buffer []byte) (int32, int32, []byte, error) {