diff --git a/cmd/internal/build_boxdd/main.go b/cmd/internal/build_boxdd/main.go index 70a0b708..ed978649 100644 --- a/cmd/internal/build_boxdd/main.go +++ b/cmd/internal/build_boxdd/main.go @@ -1,6 +1,9 @@ package main import ( + "bytes" + "crypto/sha256" + "encoding/hex" "flag" "os" "os/exec" @@ -9,6 +12,7 @@ import ( "strings" "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/common/windivert" "github.com/sagernet/sing-box/log" E "github.com/sagernet/sing/common/exceptions" ) @@ -93,6 +97,53 @@ func build() error { if err != nil { return E.Cause(err, "build sing-box daemon") } + if operatingSystem == "windows" { + err = stageWinDivertDriver(architecture, filepath.Dir(absoluteOutputPath)) + if err != nil { + return err + } + } + return nil +} + +func stageWinDivertDriver(architecture string, outputDirectory string) error { + var assetName, assetDigest string + switch architecture { + case "amd64": + assetName, assetDigest = windivert.Asset64Name, windivert.Asset64SHA256 + case "386": + assetName, assetDigest = windivert.Asset32Name, windivert.Asset32SHA256 + } + for _, name := range []string{windivert.Asset64Name, windivert.Asset32Name} { + if name == assetName { + continue + } + err := os.Remove(filepath.Join(outputDirectory, name)) + if err != nil && !os.IsNotExist(err) { + return E.Cause(err, "remove stale ", name) + } + } + if assetName == "" { + return nil + } + assetDirectory := filepath.Join("common", "windivert", "assets") + content, err := os.ReadFile(filepath.Join(assetDirectory, assetName)) + if err != nil { + return E.Cause(err, "read ", assetName) + } + checksum := sha256.Sum256(content) + if hex.EncodeToString(checksum[:]) != assetDigest { + return E.New(assetName, " does not match the digest declared in common/windivert") + } + targetPath := filepath.Join(outputDirectory, assetName) + staged, err := os.ReadFile(targetPath) + if err == nil && bytes.Equal(staged, content) { + return nil + } + err = os.WriteFile(targetPath, content, 0o644) + if err != nil { + return E.Cause(err, "write ", assetName) + } return nil } @@ -112,6 +163,9 @@ func buildTags(operatingSystem string, architecture string, cgoEnabled bool) ([] return nil, E.Cause(err, "read build tags") } tags := strings.Split(strings.TrimSpace(string(content)), ",") + if operatingSystem == "windows" { + tags = append(tags, "with_external_windivert") + } if debugEnabled { tags = append(tags, "debug") } diff --git a/common/windivert/address_test.go b/common/windivert/address_test.go index bfc99558..cb427f13 100644 --- a/common/windivert/address_test.go +++ b/common/windivert/address_test.go @@ -38,7 +38,6 @@ func TestAddressSetTCPChecksum(t *testing.T) { require.Equal(t, uint32(0), addr.bits) } -// Setters must not disturb sibling bits. func TestAddressFlagBitsIndependent(t *testing.T) { t.Parallel() var addr Address diff --git a/common/windivert/assets_386.go b/common/windivert/assets_386.go index d1dac4cd..490c024a 100644 --- a/common/windivert/assets_386.go +++ b/common/windivert/assets_386.go @@ -2,9 +2,7 @@ package windivert -import _ "embed" - -//go:embed assets/WinDivert32.sys -var sysBytes []byte - -func driverSysName() string { return "WinDivert32.sys" } +const ( + driverAssetName = Asset32Name + driverAssetDigest = Asset32SHA256 +) diff --git a/common/windivert/assets_amd64.go b/common/windivert/assets_amd64.go index 3ff6c143..e0a497d5 100644 --- a/common/windivert/assets_amd64.go +++ b/common/windivert/assets_amd64.go @@ -2,9 +2,7 @@ package windivert -import _ "embed" - -//go:embed assets/WinDivert64.sys -var sysBytes []byte - -func driverSysName() string { return "WinDivert64.sys" } +const ( + driverAssetName = Asset64Name + driverAssetDigest = Asset64SHA256 +) diff --git a/common/windivert/assets_unsupported.go b/common/windivert/assets_unsupported.go index 189de58e..6fc6d3dc 100644 --- a/common/windivert/assets_unsupported.go +++ b/common/windivert/assets_unsupported.go @@ -2,6 +2,7 @@ package windivert -var sysBytes []byte - -func driverSysName() string { return "" } +const ( + driverAssetName = "" + driverAssetDigest = "" +) diff --git a/common/windivert/driver_asset_embedded_windows.go b/common/windivert/driver_asset_embedded_windows.go new file mode 100644 index 00000000..8ebd693c --- /dev/null +++ b/common/windivert/driver_asset_embedded_windows.go @@ -0,0 +1,7 @@ +//go:build windows && !with_external_windivert + +package windivert + +func driverAsset() ([]byte, error) { + return sysBytes, nil +} diff --git a/common/windivert/driver_asset_external_windows.go b/common/windivert/driver_asset_external_windows.go new file mode 100644 index 00000000..b36fac7a --- /dev/null +++ b/common/windivert/driver_asset_external_windows.go @@ -0,0 +1,29 @@ +//go:build windows && with_external_windivert + +package windivert + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + + E "github.com/sagernet/sing/common/exceptions" +) + +func driverAsset() ([]byte, error) { + executablePath, err := os.Executable() + if err != nil { + return nil, E.Cause(err, "windivert: locate executable") + } + assetPath := filepath.Join(filepath.Dir(executablePath), driverAssetName) + content, err := os.ReadFile(assetPath) + if err != nil { + return nil, E.Cause(err, "windivert: read ", assetPath) + } + digest := sha256.Sum256(content) + if hex.EncodeToString(digest[:]) != driverAssetDigest { + return nil, E.New("windivert: ", assetPath, " does not match the WinDivert ", AssetVersion, " digest") + } + return content, nil +} diff --git a/common/windivert/driver_asset_windows.go b/common/windivert/driver_asset_windows.go index d1b5de70..b351725c 100644 --- a/common/windivert/driver_asset_windows.go +++ b/common/windivert/driver_asset_windows.go @@ -11,33 +11,44 @@ import ( "strconv" E "github.com/sagernet/sing/common/exceptions" - - "golang.org/x/sys/windows" ) -func extractVerified() (string, *os.File, error) { - if len(sysBytes) == 0 { - return "", nil, E.New("windivert: unsupported architecture ", runtime.GOARCH) +func driverFilePath() (string, error) { + if driverAssetName == "" { + return "", E.New("windivert: unsupported architecture ", runtime.GOARCH) } - base, err := os.UserCacheDir() if err != nil { - return "", nil, E.Cause(err, "windivert: locate user cache dir") + return "", E.Cause(err, "windivert: locate user cache dir") } - dir := filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion) - err = os.MkdirAll(dir, 0o755) - if err != nil { - return "", nil, E.Cause(err, "windivert: mkdir ", dir) - } - target := filepath.Join(dir, driverSysName()) + return filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion, driverAssetName), nil +} +func openVerifiedDriver() (string, *os.File, error) { + target, err := driverFilePath() + if err != nil { + return "", nil, err + } + assetContent, err := driverAsset() + if err != nil { + return "", nil, err + } + err = os.MkdirAll(filepath.Dir(target), 0o755) + if err != nil { + return "", nil, E.Cause(err, "windivert: mkdir ", filepath.Dir(target)) + } + + var ( + sysFile *os.File + content []byte + ) for attempt := 0; ; attempt++ { - sysFile, err := openDriverFile(target) + sysFile, err = openDriverFile(target) if err != nil { if !os.IsNotExist(err) { return "", nil, E.Cause(err, "windivert: open ", target) } - err = writeDriverFile(target) + err = writeDriverFile(target, assetContent) if err != nil { return "", nil, err } @@ -46,48 +57,28 @@ func extractVerified() (string, *os.File, error) { return "", nil, E.Cause(err, "windivert: open ", target) } } - content, err := io.ReadAll(sysFile) + content, err = io.ReadAll(sysFile) if err != nil { sysFile.Close() return "", nil, E.Cause(err, "windivert: read ", target) } - if bytes.Equal(content, sysBytes) { + if bytes.Equal(content, assetContent) { return target, sysFile, nil } sysFile.Close() if attempt > 0 { return "", nil, E.New("windivert: driver file ", target, " is being concurrently modified") } - err = writeDriverFile(target) + err = writeDriverFile(target, assetContent) if err != nil { return "", nil, err } } } -func openDriverFile(path string) (*os.File, error) { - pathW, err := windows.UTF16PtrFromString(path) - if err != nil { - return nil, err - } - handle, err := windows.CreateFile( - pathW, - windows.GENERIC_READ, - windows.FILE_SHARE_READ, - nil, - windows.OPEN_EXISTING, - windows.FILE_ATTRIBUTE_NORMAL, - 0, - ) - if err != nil { - return nil, err - } - return os.NewFile(uintptr(handle), path), nil -} - -func writeDriverFile(target string) error { +func writeDriverFile(target string, content []byte) error { temporaryPath := target + ".tmp-" + strconv.Itoa(os.Getpid()) - err := os.WriteFile(temporaryPath, sysBytes, 0o644) + err := os.WriteFile(temporaryPath, content, 0o644) if err != nil { return E.Cause(err, "windivert: write ", filepath.Base(target)) } diff --git a/common/windivert/driver_asset_windows_test.go b/common/windivert/driver_asset_windows_test.go new file mode 100644 index 00000000..33b93436 --- /dev/null +++ b/common/windivert/driver_asset_windows_test.go @@ -0,0 +1,61 @@ +//go:build windows && !with_external_windivert + +package windivert + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// The image lock on a loaded .sys can outlive SERVICE_STOPPED by tens of +// seconds (observed on GitHub-hosted runners), and on current runner images +// it blocks renames as well as writes and deletes. +func setTempDriverCache(t *testing.T) { + t.Helper() + dir, err := os.MkdirTemp("", "sing-box-windivert-test-") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(dir) }) + t.Setenv("LocalAppData", dir) +} + +func TestIntegrationTamperedCacheRepaired(t *testing.T) { + setTempDriverCache(t) + stopDriver(t) + + target, err := driverFilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + require.NoError(t, os.WriteFile(target, []byte("planted payload, not the WinDivert driver"), 0o644)) + + h := openHandle(t, nil, FlagSendOnly) + require.NoError(t, h.Close()) + + content, err := os.ReadFile(target) + require.NoError(t, err) + require.True(t, bytes.Equal(content, sysBytes), "cached driver was not repaired to the embedded asset") +} + +func TestIntegrationDriverFileLockedWhileHeld(t *testing.T) { + setTempDriverCache(t) + + sysPath, sysFile, err := openVerifiedDriver() + require.NoError(t, err) + defer sysFile.Close() + + writeErr := os.WriteFile(sysPath, []byte("overwrite attempt"), 0o644) + require.Error(t, writeErr) + require.True(t, errors.Is(writeErr, windows.ERROR_SHARING_VIOLATION), + "expected sharing violation, got %v", writeErr) + + evil := sysPath + ".evil" + require.NoError(t, os.WriteFile(evil, []byte("replacement attempt"), 0o644)) + defer os.Remove(evil) + renameErr := os.Rename(evil, sysPath) + require.Error(t, renameErr) +} diff --git a/common/windivert/driver_windows.go b/common/windivert/driver_windows.go index 6a402278..f38a5a8d 100644 --- a/common/windivert/driver_windows.go +++ b/common/windivert/driver_windows.go @@ -4,6 +4,7 @@ package windivert import ( "errors" + "os" "runtime" "time" @@ -73,7 +74,7 @@ func installAndOpenDevice() (windows.Handle, error) { return 0, fatalErr } - sysPath, sysFile, err := extractVerified() + sysPath, sysFile, err := openVerifiedDriver() if err != nil { return 0, err } @@ -130,11 +131,8 @@ func tryInstallService(manager windows.Handle, serviceNameW, sysPathW *uint16) e err = windows.StartService(service, 0, nil) if err == nil { - // Mark for deletion so the driver unregisters when the last handle - // closes or on next reboot. Matches the upstream DLL's behavior: - // only the process that actually started the service takes on the - // cleanup responsibility. If another process already started it, - // we leave DeleteService to them. + // Upstream WinDivert.dll marks the service for deletion only in the + // process whose StartService succeeded. _ = windows.DeleteService(service) return nil } @@ -142,9 +140,8 @@ func tryInstallService(manager windows.Handle, serviceNameW, sysPathW *uint16) e return nil } if errors.Is(err, windows.ERROR_SERVICE_DISABLED) { - // The disabled check precedes the running check: a running service - // marked for deletion reports ERROR_SERVICE_DISABLED instead of - // ERROR_SERVICE_ALREADY_RUNNING. The device is nonetheless up. + // StartService on a running service that is marked for deletion + // reports ERROR_SERVICE_DISABLED, not ERROR_SERVICE_ALREADY_RUNNING. var status windows.SERVICE_STATUS queryErr := windows.QueryServiceStatus(service, &status) if queryErr == nil && status.CurrentState == windows.SERVICE_RUNNING { @@ -157,6 +154,18 @@ func tryInstallService(manager windows.Handle, serviceNameW, sysPathW *uint16) e func openOrCreateService(manager windows.Handle, serviceNameW, sysPathW *uint16) (windows.Handle, error) { service, err := windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) if err == nil { + err = windows.ChangeServiceConfig( + service, + windows.SERVICE_NO_CHANGE, + windows.SERVICE_NO_CHANGE, + windows.SERVICE_NO_CHANGE, + sysPathW, + nil, nil, nil, nil, nil, nil, + ) + if err != nil { + windows.CloseServiceHandle(service) + return 0, E.Cause(err, "windivert: point service at ", driverAssetName) + } return service, nil } service, err = windows.CreateService( @@ -188,3 +197,23 @@ func wrapDriverInstallError(err error) error { } return E.Cause(err, "windivert: create service") } + +func openDriverFile(path string) (*os.File, error) { + pathW, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile( + pathW, + windows.GENERIC_READ, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(handle), path), nil +} diff --git a/common/windivert/embed_386.go b/common/windivert/embed_386.go new file mode 100644 index 00000000..249acb39 --- /dev/null +++ b/common/windivert/embed_386.go @@ -0,0 +1,8 @@ +//go:build windows && 386 && !with_external_windivert + +package windivert + +import _ "embed" + +//go:embed assets/WinDivert32.sys +var sysBytes []byte diff --git a/common/windivert/embed_amd64.go b/common/windivert/embed_amd64.go new file mode 100644 index 00000000..cf87ac4b --- /dev/null +++ b/common/windivert/embed_amd64.go @@ -0,0 +1,8 @@ +//go:build windows && amd64 && !with_external_windivert + +package windivert + +import _ "embed" + +//go:embed assets/WinDivert64.sys +var sysBytes []byte diff --git a/common/windivert/embed_unsupported.go b/common/windivert/embed_unsupported.go new file mode 100644 index 00000000..70f9d186 --- /dev/null +++ b/common/windivert/embed_unsupported.go @@ -0,0 +1,5 @@ +//go:build windows && !amd64 && !386 && !with_external_windivert + +package windivert + +var sysBytes []byte diff --git a/common/windivert/filter.go b/common/windivert/filter.go index 04cab2a0..aafcfb2f 100644 --- a/common/windivert/filter.go +++ b/common/windivert/filter.go @@ -60,32 +60,24 @@ const ( ) type filterInst struct { - field uint16 // 11 bits used - test uint8 // 5 bits used + field uint16 + test uint8 success uint16 failure uint16 neg bool arg [4]uint32 } -// Filter is a typed specification of packets to capture. It replaces -// WinDivert's filter string language. -// -// Zero value = "reject all" (match nothing), suitable for send-only handles. type Filter struct { insts []filterInst - anyInsts []filterInst // trailing OR block: any match accepts - flags uint64 // filter flags for STARTUP ioctl + anyInsts []filterInst + flags uint64 } -// reject returns a filter that matches no packet. The empty insts slice -// is encoded as a single rejecting instruction by encode(). func reject() *Filter { return &Filter{} } -// OutboundTCP returns a filter matching outbound TCP packets on the given -// 5-tuple. Both addresses must share an address family (IPv4 or IPv6). func OutboundTCP(src, dst netip.AddrPort) (*Filter, error) { if !src.IsValid() || !dst.IsValid() { return nil, E.New("windivert: filter: invalid address port") @@ -96,8 +88,6 @@ func OutboundTCP(src, dst netip.AddrPort) (*Filter, error) { f := &Filter{ flags: filterFlagOutbound, } - // Insts chain as AND: each test's failure = REJECT, success = next inst. - // The final inst's success = ACCEPT. f.add(fieldOutbound, testEQ, argUint32(1)) if src.Addr().Is4() { f.flags |= filterFlagIP @@ -214,21 +204,17 @@ func (f *Filter) addAny(field uint16, test uint8, arg [4]uint32) { func argUint32(v uint32) [4]uint32 { return [4]uint32{v, 0, 0, 0} } -// argIPv4 encodes an IPv4 address for IP_SRCADDR/IP_DSTADDR. The driver -// compares against an IPv4-mapped-IPv6 form: {host_order_u32, 0x0000FFFF, -// 0, 0} (see sys/windivert.c windivert_get_ipv4_addr and the IPv4_SRCADDR -// val-word construction). Omitting the 0x0000FFFF marker causes the EQ -// test to fail for every packet. +// The driver compares IP_SRCADDR/IP_DSTADDR against an IPv4-mapped-IPv6 +// form: {host_order_u32, 0x0000FFFF, 0, 0} (sys/windivert.c +// windivert_get_ipv4_addr). func argIPv4(addr netip.Addr) [4]uint32 { b := addr.As4() return [4]uint32{binary.BigEndian.Uint32(b[:]), 0x0000FFFF, 0, 0} } -// argIPv6 encodes an IPv6 address for IPV6_SRCADDR/IPV6_DSTADDR. The -// driver stores the address as four host-order uint32s in REVERSED word -// order: val[0]=low (bytes 12..15), val[3]=high (bytes 0..3). See -// sys/windivert.c windivert_outbound_network_v6_classify val-word -// construction. +// The driver stores IPV6_SRCADDR/IPV6_DSTADDR as four host-order uint32s in +// reversed word order: val[0]=low (bytes 12..15), val[3]=high (bytes 0..3) +// (sys/windivert.c windivert_outbound_network_v6_classify). func argIPv6(addr netip.Addr) [4]uint32 { b := addr.As16() return [4]uint32{ @@ -239,15 +225,9 @@ func argIPv6(addr netip.Addr) [4]uint32 { } } -// encode serializes the Filter to the on-wire WINDIVERT_FILTER[] format -// plus the filter_flags for STARTUP ioctl. insts chain as AND (failure -// rejects); anyInsts follow as an OR block (success accepts, failure falls -// through to the next alternative). func (f *Filter) encode() ([]byte, uint64, error) { total := len(f.insts) + len(f.anyInsts) if total == 0 { - // "Reject all" — one instruction, ZERO == 0 is always true, but we - // invert by setting both success and failure to REJECT. return encodeInst(filterInst{ field: fieldZero, test: testEQ, diff --git a/common/windivert/handle_windows.go b/common/windivert/handle_windows.go index bb99ce9f..cbb82b67 100644 --- a/common/windivert/handle_windows.go +++ b/common/windivert/handle_windows.go @@ -14,17 +14,6 @@ import ( "golang.org/x/sys/windows" ) -// Handle owns a WinDivert kernel device handle plus a private event for -// overlapped I/O. Methods on *Handle are not safe for concurrent use -// across goroutines (there is a single shared event per Handle). -// -// addr is a per-Handle Address buffer the IOCTL struct embeds a pointer -// to. It lives on the heap (as a field of a heap-allocated Handle) so -// the pointer value stored as bytes in the ioctl buffer remains valid -// across stack growth between buildIoctl* and the DeviceIoControl -// syscall — stack-local Address values are not safe for this pattern -// because Go's escape analysis does not see the pointer through the -// unsafe.Pointer → uintptr → bytes conversion. type Handle struct { device windows.Handle event windows.Handle @@ -36,9 +25,6 @@ type Handle struct { sendAddrs []Address } -// Filter may be nil for "reject all", suitable for send-only handles. -// Requires Administrator on first call per process (installs the kernel -// driver via SCM); subsequent calls reuse the running driver. func Open(filter *Filter, layer Layer, priority int16, flags Flag) (*Handle, error) { err := validateOpenArgs(layer, priority, flags) if err != nil { @@ -105,8 +91,6 @@ func validateOpenArgs(layer Layer, priority int16, flags Flag) error { func (h *Handle) initialize(layer Layer, priority int16, flags Flag) error { in := buildIoctlInitialize(layer, priority, flags) - // WINDIVERT_VERSION is a 64-byte packed struct; only the first 20 - // bytes (magic, major, minor, bits) carry data, the rest is reserved. var outBuf [versionStructSize]byte binary.LittleEndian.PutUint64(outBuf[0:8], magicDLL) binary.LittleEndian.PutUint32(outBuf[8:12], versionMajor) @@ -137,7 +121,6 @@ func (h *Handle) startup(filterBin []byte, filterFlags uint64) error { return nil } -// If the handle is closed mid-Recv the error wraps ERROR_OPERATION_ABORTED. func (h *Handle) Recv(buf []byte) (int, Address, error) { if len(buf) == 0 { return 0, Address{}, E.New("windivert: recv: zero-length buffer") @@ -158,12 +141,9 @@ const BatchMax = 255 const addressSize = uint32(unsafe.Sizeof(Address{})) -// RecvBatch receives up to BatchMax packets in one ioctl. The driver packs -// packets back-to-back into buf with no padding and copies exactly each -// packet's IP total length, so boundaries are recovered by walking the IP -// length fields. It returns as soon as at least one packet is available; -// it never waits to fill the batch. The returned Address slice is owned by -// the Handle and is overwritten by the next RecvBatch. +// The driver packs packets back-to-back into buf with no padding and copies +// exactly each packet's IP total length, and returns as soon as at least one +// packet is available. func (h *Handle) RecvBatch(buf []byte) (int, []Address, error) { if len(buf) < MTUMax { return 0, nil, E.New("windivert: recv batch: buffer smaller than MTUMax") @@ -181,9 +161,8 @@ func (h *Handle) RecvBatch(buf []byte) (int, []Address, error) { return int(n), h.recvAddrs[:h.recvAddrsLen/addressSize], nil } -// SendBatch injects the packets packed back-to-back in buf, one Address per -// packet. The driver recovers packet boundaries from the IP total-length -// fields and rejects the whole batch if they do not add up to len(buf). +// The driver recovers packet boundaries from the IP total-length fields and +// rejects the whole batch if they do not add up to len(buf). func (h *Handle) SendBatch(buf []byte, addrs []Address) (int, error) { if len(addrs) == 0 || len(addrs) > BatchMax { return 0, E.New("windivert: send batch: invalid packet count ", len(addrs)) @@ -223,7 +202,6 @@ func (h *Handle) Send(packet []byte, addr *Address) (int, error) { return int(n), nil } -// Idempotent. Aborts any in-flight I/O on the handle. func (h *Handle) Close() error { h.closing.Do(func() { var errs []error @@ -288,15 +266,8 @@ const ioctlSize = 16 // carry data; the rest is reserved zero padding. const versionStructSize = 64 -// doIoctl performs a single synchronous (blocking) overlapped -// DeviceIoControl. The handle is opened with FILE_FLAG_OVERLAPPED so -// DeviceIoControl may return ERROR_IO_PENDING; we then wait for -// completion via GetOverlappedResult. Event is passed in so callers can -// reuse it across calls on the same handle (avoids per-call CreateEvent). -// No explicit ResetEvent is needed: NtDeviceIoControlFile clears the -// event to nonsignaled before queuing each request, and on synchronous -// completion (DeviceIoControl returns success) lpBytesReturned is -// already filled, so GetOverlappedResult is skipped entirely. +// NtDeviceIoControlFile clears the event to nonsignaled before queuing each +// request, so one event can be reused across calls without ResetEvent. func doIoctl(handle windows.Handle, code uint32, in []byte, out []byte, event windows.Handle) (uint32, error) { var overlapped windows.Overlapped overlapped.HEvent = event @@ -344,10 +315,8 @@ func buildIoctlStartup(filterFlags uint64) [ioctlSize]byte { return buf } -// buildIoctlRecv packs a user-space pointer to a WINDIVERT_ADDRESS into -// the ioctl struct. The driver dereferences it to write the address for -// the received packet. Caller must keep the Address alive via -// runtime.KeepAlive. +// The driver dereferences the packed pointer to write the received packet's +// WINDIVERT_ADDRESS. func buildIoctlRecv(addr *Address) [ioctlSize]byte { var buf [ioctlSize]byte binary.LittleEndian.PutUint64(buf[0:8], uint64(uintptr(unsafe.Pointer(addr)))) @@ -355,10 +324,8 @@ func buildIoctlRecv(addr *Address) [ioctlSize]byte { return buf } -// buildIoctlRecvBatch additionally passes addr_len_ptr, a pointer to the -// Address array capacity in bytes; the driver overwrites it with the bytes -// actually written (packet count × 80). Caller must keep both pointees -// alive via runtime.KeepAlive. +// addr_len_ptr carries the Address array capacity in bytes; the driver +// overwrites it with the bytes actually written (packet count × 80). func buildIoctlRecvBatch(addrs *Address, addrsLen *uint32) [ioctlSize]byte { var buf [ioctlSize]byte binary.LittleEndian.PutUint64(buf[0:8], uint64(uintptr(unsafe.Pointer(addrs)))) diff --git a/common/windivert/handle_windows_test.go b/common/windivert/handle_windows_test.go index 14955074..4047b1b8 100644 --- a/common/windivert/handle_windows_test.go +++ b/common/windivert/handle_windows_test.go @@ -101,9 +101,7 @@ func TestValidateOpenArgsFlags(t *testing.T) { require.NoError(t, validateOpenArgs(LayerNetwork, 0, 0)) require.NoError(t, validateOpenArgs(LayerNetwork, 0, FlagSendOnly)) require.NoError(t, validateOpenArgs(LayerNetwork, 0, FlagSniff)) - // Sniff and send-only describe contradictory handle roles. require.Error(t, validateOpenArgs(LayerNetwork, 0, FlagSniff|FlagSendOnly)) - // Unknown flag bits must be rejected to surface caller mistakes early. require.Error(t, validateOpenArgs(LayerNetwork, 0, Flag(0x10))) require.Error(t, validateOpenArgs(LayerNetwork, 0, FlagSendOnly|Flag(0x10))) } diff --git a/common/windivert/integration_windows_test.go b/common/windivert/integration_windows_test.go index 7ca65646..fd91fece 100644 --- a/common/windivert/integration_windows_test.go +++ b/common/windivert/integration_windows_test.go @@ -1,14 +1,12 @@ -//go:build windows +//go:build windows && !with_external_windivert package windivert import ( - "bytes" "errors" "log" "net/netip" "os" - "path/filepath" "testing" "time" @@ -37,26 +35,18 @@ func openHandle(t *testing.T, filter *Filter, flags Flag) *Handle { return h } -// A send-only handle installs+opens the driver but does not attach a -// receive filter, so it exercises the full driver-install path without -// diverting any live traffic on the host. func TestIntegrationOpenSendOnly(t *testing.T) { h := openHandle(t, nil, FlagSendOnly) require.NoError(t, h.Close()) } -// Close is idempotent per the doc contract. func TestIntegrationCloseTwice(t *testing.T) { h := openHandle(t, nil, FlagSendOnly) require.NoError(t, h.Close()) require.NoError(t, h.Close()) } -// Recv must unblock when the handle is closed concurrently. Without this, -// the spoofer's run goroutine could deadlock on shutdown. func TestIntegrationRecvAbortsOnClose(t *testing.T) { - // A filter no live traffic will match, so Recv blocks indefinitely - // until Close aborts the overlapped I/O. filter, err := OutboundTCP( netip.MustParseAddrPort("10.255.255.254:1"), netip.MustParseAddrPort("10.255.255.253:2"), @@ -71,7 +61,6 @@ func TestIntegrationRecvAbortsOnClose(t *testing.T) { errCh <- recvErr }() - // Let Recv reach the blocking DeviceIoControl before Close races in. time.Sleep(200 * time.Millisecond) require.NoError(t, h.Close()) @@ -85,18 +74,8 @@ func TestIntegrationRecvAbortsOnClose(t *testing.T) { } } -func cachedDriverPath(t *testing.T) string { - t.Helper() - base, err := os.UserCacheDir() - require.NoError(t, err) - return filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion, driverSysName()) -} - // The driver does not unload when the last handle closes: it stays running -// (and the memory manager keeps its backing image write-locked) until -// explicitly stopped, like `sc stop WinDivert`. The install-time -// DeleteService mark then removes the record once the last SCM handle -// closes. +// until explicitly stopped, like `sc stop WinDivert`. func stopDriver(t *testing.T) { t.Helper() manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT) @@ -122,7 +101,7 @@ func stopDriver(t *testing.T) { require.True(t, errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST), "open driver service: %v", err) } // SCM can report SERVICE_STOPPED before the driver finishes deleting its - // device object. Wait for the absence acquireDevice uses to trigger install. + // device object. require.Eventually(t, func() bool { device, openErr := openDevice() if openErr == nil { @@ -135,68 +114,6 @@ func stopDriver(t *testing.T) { }, 60*time.Second, 200*time.Millisecond, "driver device remained openable after stop") } -// The image lock on the cached .sys can outlive SERVICE_STOPPED by tens of -// seconds (observed on GitHub-hosted runners), and on current runner images -// it blocks renames as well as writes and deletes. Tests that need to tamper -// with the cache therefore redirect it to a directory the kernel has never -// loaded a driver from. Not t.TempDir: once StartService maps a .sys from -// the directory, the image lock makes the cleanup RemoveAll fail the test. -func setTempDriverCache(t *testing.T) { - t.Helper() - dir, err := os.MkdirTemp("", "sing-box-windivert-test-") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(dir) }) - t.Setenv("LocalAppData", dir) -} - -// A foreign .sys planted in the user-writable cache must never reach -// StartService: the install path has to detect the mismatch against the -// embedded asset and repair the file before handing it to SCM. -func TestIntegrationTamperedCacheRepaired(t *testing.T) { - setTempDriverCache(t) - // The driver left running by earlier tests would satisfy Open without - // touching the cache; stop it so the install path runs. - stopDriver(t) - - target := cachedDriverPath(t) - require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) - require.NoError(t, os.WriteFile(target, []byte("planted payload, not the WinDivert driver"), 0o644)) - - h := openHandle(t, nil, FlagSendOnly) - require.NoError(t, h.Close()) - - content, err := os.ReadFile(target) - require.NoError(t, err) - require.True(t, bytes.Equal(content, sysBytes), "cached driver was not repaired to the embedded asset") -} - -// The verified handle must lock the file against writers and renames until -// install completes; without this, the file could be swapped between -// verification and the kernel mapping it. -func TestIntegrationDriverFileLockedWhileHeld(t *testing.T) { - // A fresh cache directory guarantees the failures asserted below can - // only come from the handle extractVerified holds, not a kernel image - // lock left by earlier tests. - setTempDriverCache(t) - - sysPath, sysFile, err := extractVerified() - require.NoError(t, err) - defer sysFile.Close() - - writeErr := os.WriteFile(sysPath, []byte("overwrite attempt"), 0o644) - require.Error(t, writeErr) - require.True(t, errors.Is(writeErr, windows.ERROR_SHARING_VIOLATION), - "expected sharing violation, got %v", writeErr) - - evil := sysPath + ".evil" - require.NoError(t, os.WriteFile(evil, []byte("replacement attempt"), 0o644)) - defer os.Remove(evil) - renameErr := os.Rename(evil, sysPath) - require.Error(t, renameErr) -} - -// Two concurrent Open calls must both succeed: the first wins the driver -// install race, the second reuses the already-running service. func TestIntegrationConcurrentOpen(t *testing.T) { stopDriver(t) start := make(chan struct{}) diff --git a/common/windivert/windivert.go b/common/windivert/windivert.go index 3a3e5927..36135fc9 100644 --- a/common/windivert/windivert.go +++ b/common/windivert/windivert.go @@ -1,21 +1,19 @@ -// Package windivert provides a pure-Go binding to the WinDivert kernel -// driver on Windows (amd64 and 386). User-mode WinDivert calls are -// reimplemented in Go; only the signed kernel driver is embedded as an -// asset, since SCM-installed drivers must live on disk. The on-disk copy -// is verified byte-for-byte against the embedded asset on every install -// and held open deny-write while the kernel loads it. -// -// Administrator is required for the first Open in a process so SCM can -// load the driver. Upstream: https://github.com/basil00/WinDivert v2.2.2, -// redistributed under its LGPL v3 option; see assets/LICENSE.txt. +// Upstream: https://github.com/basil00/WinDivert v2.2.2, redistributed +// under its LGPL v3 option; see assets/LICENSE.txt. package windivert import "unsafe" const AssetVersion = "2.2.2" -// MTUMax is WINDIVERT_MTU_MAX from windivert.h (40 + 0xFFFF). Suitable as -// a single-packet receive buffer size. +const ( + Asset64Name = "WinDivert64.sys" + Asset32Name = "WinDivert32.sys" + Asset64SHA256 = "8da085332782708d8767bcace5327a6ec7283c17cfb85e40b03cd2323a90ddc2" + Asset32SHA256 = "2f43f4251be4d72dd56c91bf6cce475d379eb9ba6c4dda2be3022ea633d5e807" +) + +// WINDIVERT_MTU_MAX from windivert.h. const MTUMax = 40 + 0xFFFF type Layer uint32 @@ -25,11 +23,7 @@ const LayerNetwork Layer = 0 type Flag uint64 const ( - // FlagSniff opens a passive observer: the driver copies matching packets - // to userspace without removing them from the network stack. Send is not - // required (and not allowed) on a sniffing handle. - FlagSniff Flag = 0x0001 - // FlagSendOnly opens a write-only injection handle; Recv is not allowed. + FlagSniff Flag = 0x0001 FlagSendOnly Flag = 0x0008 ) @@ -38,13 +32,9 @@ const ( PriorityLowest int16 = -30000 ) -// Address mirrors WINDIVERT_ADDRESS from windivert.h (80 bytes, -// little-endian on both amd64 and 386): -// -// 0: INT64 Timestamp -// 8: UINT32 bitfield: Layer:8 | Event:8 | flags | Reserved1:8 -// 12: UINT32 Reserved2 -// 16: 64 bytes union (WINDIVERT_DATA_NETWORK / FLOW / SOCKET / REFLECT) +// WINDIVERT_ADDRESS from windivert.h: bits packs Layer:8 | Event:8 | flags | +// Reserved1:8, and the trailing 64 bytes are a union of +// WINDIVERT_DATA_NETWORK / FLOW / SOCKET / REFLECT. type Address struct { Timestamp int64 bits uint32 @@ -54,7 +44,6 @@ type Address struct { var _ [80]byte = [unsafe.Sizeof(Address{})]byte{} -// Bit positions inside the Address's packed flags word. const ( addrBitOutbound = 17 addrBitIPv6 = 20 @@ -73,9 +62,6 @@ func setFlagBit(bits uint32, pos uint, v bool) uint32 { func (a *Address) IPv6() bool { return getFlagBit(a.bits, addrBitIPv6) } -// SetIPv6 declares the address family of a packet built for injection. The -// driver reads it to select the IPv6 network layer; a received address -// already carries it, but a from-scratch injection address must set it. func (a *Address) SetIPv6(v bool) { a.bits = setFlagBit(a.bits, addrBitIPv6, v) }