568 lines
18 KiB
Go
568 lines
18 KiB
Go
//go:build windows
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unsafe"
|
|
|
|
E "github.com/sagernet/sing/common/exceptions"
|
|
|
|
winioProcess "github.com/tailscale/go-winio/pkg/process"
|
|
"golang.org/x/sys/windows"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials"
|
|
)
|
|
|
|
const (
|
|
daemonExecutableName = "sing-box-daemon.exe"
|
|
applicationExecutableName = "sing-box.exe"
|
|
workerPipePrefix = `\\.\pipe\sing-box-worker.`
|
|
)
|
|
|
|
type windowsTransportCredentials struct {
|
|
daemon *Daemon
|
|
daemonSigner []byte
|
|
daemonExecutable windows.Handle
|
|
expectedWorkerPath string
|
|
expectedApplicationPath string
|
|
}
|
|
|
|
type windowsAuthenticatedConnection struct {
|
|
net.Conn
|
|
daemon *Daemon
|
|
identity peerIdentity
|
|
process windows.Handle
|
|
processImage windows.Handle
|
|
parentProcess windows.Handle
|
|
parentProcessImage windows.Handle
|
|
close sync.Once
|
|
closeError error
|
|
}
|
|
|
|
type fileDescriptorConnection interface {
|
|
Fd() uintptr
|
|
}
|
|
|
|
func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) {
|
|
if listenAddress != "" {
|
|
return nil, nil
|
|
}
|
|
transportCredentials := &windowsTransportCredentials{daemon: daemon}
|
|
err := transportCredentials.initializeServerIdentity()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
daemon.platform.(*windowsPlatformInterface).daemonSigner = transportCredentials.daemonSigner
|
|
return []grpc.ServerOption{grpc.Creds(transportCredentials)}, nil
|
|
}
|
|
|
|
func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) {
|
|
return peerIdentity{}, E.New("missing Windows peer authentication")
|
|
}
|
|
|
|
func (c *windowsTransportCredentials) ClientHandshake(ctx context.Context, authority string, rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
|
return nil, nil, E.New("Windows local process credentials do not support client handshakes")
|
|
}
|
|
|
|
func (c *windowsTransportCredentials) ServerHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
|
connection, authenticationInformation, err := c.serverHandshake(rawConnection)
|
|
if err != nil {
|
|
serviceLogError(E.Cause(err, "reject Windows daemon connection"))
|
|
}
|
|
return connection, authenticationInformation, err
|
|
}
|
|
|
|
func (c *windowsTransportCredentials) serverHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
|
descriptorConnection, loaded := rawConnection.(fileDescriptorConnection)
|
|
if !loaded {
|
|
return nil, nil, E.New("daemon endpoint is not a Windows named pipe")
|
|
}
|
|
var processID uint32
|
|
err := windows.GetNamedPipeClientProcessId(windows.Handle(descriptorConnection.Fd()), &processID)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "identify named pipe client")
|
|
}
|
|
process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, processID)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "open named pipe client process")
|
|
}
|
|
keepProcess := false
|
|
defer func() {
|
|
if !keepProcess {
|
|
windows.CloseHandle(process)
|
|
}
|
|
}()
|
|
identity, err := processIdentity(process, processID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
workerImagePath, err := winioProcess.QueryFullProcessImageName(process, winioProcess.ImageNameFormatWin32Path)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "query named pipe client executable")
|
|
}
|
|
processImage, err := openLockedExecutable(workerImagePath)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "open named pipe client executable")
|
|
}
|
|
keepProcessImage := false
|
|
defer func() {
|
|
if !keepProcessImage {
|
|
windows.CloseHandle(processImage)
|
|
}
|
|
}()
|
|
processImageFinalPath, err := finalWindowsPath(processImage)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "resolve named pipe client executable")
|
|
}
|
|
if !strings.EqualFold(processImageFinalPath, c.expectedWorkerPath) {
|
|
return nil, nil, E.New("named pipe client is not the installed sing-box worker")
|
|
}
|
|
sameExecutable, err := sameWindowsFile(processImage, c.daemonExecutable)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !sameExecutable {
|
|
return nil, nil, E.New("named pipe client worker executable was replaced")
|
|
}
|
|
workerSigner, err := authenticodeSigner(processImageFinalPath, processImage)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "authenticate sing-box worker")
|
|
}
|
|
if !bytes.Equal(workerSigner, c.daemonSigner) {
|
|
return nil, nil, E.New("sing-box worker and daemon have different signing certificates")
|
|
}
|
|
parentProcessID, err := processParentID(process)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
err = validateWorkerProcessRole(process, parentProcessID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
parentProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, parentProcessID)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "open sing-box worker parent process")
|
|
}
|
|
keepParentProcess := false
|
|
defer func() {
|
|
if !keepParentProcess {
|
|
windows.CloseHandle(parentProcess)
|
|
}
|
|
}()
|
|
parentIdentity, err := processIdentity(parentProcess, parentProcessID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if parentIdentity.UserID != identity.UserID || parentIdentity.SessionID != identity.SessionID {
|
|
return nil, nil, E.New("sing-box worker and application have different process identities")
|
|
}
|
|
parentCreationTime, err := processCreationTime(parentProcess)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
workerCreationTime, err := processCreationTime(process)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if parentCreationTime >= workerCreationTime {
|
|
return nil, nil, E.New("sing-box worker parent was created after the worker")
|
|
}
|
|
parentImagePath, err := winioProcess.QueryFullProcessImageName(parentProcess, winioProcess.ImageNameFormatWin32Path)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "query sing-box worker parent executable")
|
|
}
|
|
parentProcessImage, err := openLockedExecutable(parentImagePath)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "open sing-box worker parent executable")
|
|
}
|
|
keepParentProcessImage := false
|
|
defer func() {
|
|
if !keepParentProcessImage {
|
|
windows.CloseHandle(parentProcessImage)
|
|
}
|
|
}()
|
|
expectedApplication, err := openLockedExecutable(c.expectedApplicationPath)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "open installed application executable")
|
|
}
|
|
defer windows.CloseHandle(expectedApplication)
|
|
parentImageFinalPath, err := finalWindowsPath(parentProcessImage)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "resolve sing-box worker parent executable")
|
|
}
|
|
expectedApplicationFinalPath, err := finalWindowsPath(expectedApplication)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "resolve installed application executable")
|
|
}
|
|
if !strings.EqualFold(parentImageFinalPath, expectedApplicationFinalPath) {
|
|
return nil, nil, E.New("sing-box worker parent is not the installed application")
|
|
}
|
|
sameApplication, err := sameWindowsFile(parentProcessImage, expectedApplication)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !sameApplication {
|
|
return nil, nil, E.New("sing-box worker parent executable was replaced")
|
|
}
|
|
err = validateApplicationProcessRole(parentProcess, expectedApplication)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
applicationSigner, err := authenticodeSigner(parentImageFinalPath, parentProcessImage)
|
|
if err != nil {
|
|
return nil, nil, E.Cause(err, "authenticate sing-box application")
|
|
}
|
|
if !bytes.Equal(applicationSigner, c.daemonSigner) {
|
|
return nil, nil, E.New("sing-box application and daemon have different signing certificates")
|
|
}
|
|
workerWaitResult, err := windows.WaitForSingleObject(process, 0)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
parentWaitResult, err := windows.WaitForSingleObject(parentProcess, 0)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if workerWaitResult != uint32(windows.WAIT_TIMEOUT) || parentWaitResult != uint32(windows.WAIT_TIMEOUT) {
|
|
return nil, nil, E.New("sing-box worker or application exited during authentication")
|
|
}
|
|
parentIdentity.ProcessID = parentProcessID
|
|
connection := &windowsAuthenticatedConnection{
|
|
Conn: rawConnection,
|
|
daemon: c.daemon,
|
|
identity: parentIdentity,
|
|
process: process,
|
|
processImage: processImage,
|
|
parentProcess: parentProcess,
|
|
parentProcessImage: parentProcessImage,
|
|
}
|
|
keepProcess = true
|
|
keepProcessImage = true
|
|
keepParentProcess = true
|
|
keepParentProcessImage = true
|
|
c.daemon.registerPeerConnection(connection)
|
|
authenticationInformation := &peerAuthInfo{
|
|
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity},
|
|
identity: parentIdentity,
|
|
}
|
|
return connection, authenticationInformation, nil
|
|
}
|
|
|
|
func (c *windowsTransportCredentials) Info() credentials.ProtocolInfo {
|
|
return credentials.ProtocolInfo{
|
|
SecurityProtocol: "windows-local-process",
|
|
SecurityVersion: "1",
|
|
}
|
|
}
|
|
|
|
func (c *windowsTransportCredentials) Clone() credentials.TransportCredentials {
|
|
return &windowsTransportCredentials{
|
|
daemon: c.daemon,
|
|
daemonSigner: c.daemonSigner,
|
|
daemonExecutable: c.daemonExecutable,
|
|
expectedWorkerPath: c.expectedWorkerPath,
|
|
expectedApplicationPath: c.expectedApplicationPath,
|
|
}
|
|
}
|
|
|
|
func (c *windowsTransportCredentials) OverrideServerName(serverNameOverride string) error {
|
|
return nil
|
|
}
|
|
|
|
func (c *windowsTransportCredentials) initializeServerIdentity() error {
|
|
executablePath, err := os.Executable()
|
|
if err != nil {
|
|
return E.Cause(err, "locate daemon executable")
|
|
}
|
|
executable, err := openLockedExecutable(executablePath)
|
|
if err != nil {
|
|
return E.Cause(err, "open daemon executable")
|
|
}
|
|
keepExecutable := false
|
|
defer func() {
|
|
if !keepExecutable {
|
|
windows.CloseHandle(executable)
|
|
}
|
|
}()
|
|
finalPath, err := finalWindowsPath(executable)
|
|
if err != nil {
|
|
return E.Cause(err, "resolve daemon executable")
|
|
}
|
|
_, applicationPath, err := installedApplicationPath(finalPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.daemonExecutable = executable
|
|
c.expectedWorkerPath = finalPath
|
|
c.expectedApplicationPath = applicationPath
|
|
c.daemonSigner, err = authenticodeSigner(finalPath, executable)
|
|
if err != nil {
|
|
return E.Cause(err, "authenticate daemon executable")
|
|
}
|
|
keepExecutable = true
|
|
return nil
|
|
}
|
|
|
|
func processIdentity(process windows.Handle, processID uint32) (peerIdentity, error) {
|
|
var token windows.Token
|
|
err := windows.OpenProcessToken(process, windows.TOKEN_QUERY, &token)
|
|
if err != nil {
|
|
return peerIdentity{}, E.Cause(err, "open named pipe client token")
|
|
}
|
|
defer token.Close()
|
|
user, err := token.GetTokenUser()
|
|
if err != nil {
|
|
return peerIdentity{}, E.Cause(err, "query named pipe client user")
|
|
}
|
|
userID := user.User.Sid.String()
|
|
if userID == "" {
|
|
return peerIdentity{}, E.New("named pipe client has an invalid user SID")
|
|
}
|
|
var sessionID uint32
|
|
err = windows.ProcessIdToSessionId(processID, &sessionID)
|
|
if err != nil {
|
|
return peerIdentity{}, E.Cause(err, "query named pipe client session")
|
|
}
|
|
if sessionID == 0 {
|
|
return peerIdentity{}, E.New("named pipe client is not in an interactive session")
|
|
}
|
|
return peerIdentity{UserID: userID, ProcessID: processID, SessionID: sessionID}, nil
|
|
}
|
|
|
|
func validateApplicationProcessRole(process windows.Handle, expectedApplication windows.Handle) error {
|
|
arguments, err := processCommandLine(process)
|
|
if err != nil {
|
|
return E.Cause(err, "query named pipe client command line")
|
|
}
|
|
for _, argument := range arguments[1:] {
|
|
normalizedArgument := strings.ToLower(argument)
|
|
if normalizedArgument == "--type" || strings.HasPrefix(normalizedArgument, "--type=") {
|
|
return E.New("named pipe client is an Electron child process")
|
|
}
|
|
}
|
|
applicationParent, err := processParentIsApplication(process, expectedApplication)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if applicationParent {
|
|
return E.New("named pipe client is a child of the sing-box application")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateWorkerProcessRole(process windows.Handle, parentProcessID uint32) error {
|
|
arguments, err := processCommandLine(process)
|
|
if err != nil {
|
|
return E.Cause(err, "query sing-box worker command line")
|
|
}
|
|
if len(arguments) != 8 ||
|
|
arguments[1] != "worker" ||
|
|
arguments[2] != "--socket" ||
|
|
arguments[4] != "--parent-pid" ||
|
|
arguments[6] != "--daemon-relay-socket" {
|
|
return E.New("named pipe client is not a sing-box worker process")
|
|
}
|
|
if !strings.HasPrefix(strings.ToLower(arguments[3]), strings.ToLower(workerPipePrefix)) ||
|
|
!strings.HasPrefix(strings.ToLower(arguments[7]), strings.ToLower(workerPipePrefix)) ||
|
|
strings.EqualFold(arguments[3], arguments[7]) {
|
|
return E.New("sing-box worker has invalid private pipe paths")
|
|
}
|
|
commandParentProcessID, err := strconv.ParseUint(arguments[5], 10, 32)
|
|
if err != nil || uint32(commandParentProcessID) != parentProcessID {
|
|
return E.New("sing-box worker has an invalid parent process ID")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func processCommandLine(process windows.Handle) ([]string, error) {
|
|
var bufferLength uint32
|
|
queryError := windows.NtQueryInformationProcess(
|
|
process,
|
|
windows.ProcessCommandLineInformation,
|
|
nil,
|
|
0,
|
|
&bufferLength,
|
|
)
|
|
if bufferLength == 0 {
|
|
if queryError != nil {
|
|
return nil, queryError
|
|
}
|
|
return nil, E.New("named pipe client has an empty command line buffer")
|
|
}
|
|
buffer := make([]byte, bufferLength)
|
|
queryError = windows.NtQueryInformationProcess(
|
|
process,
|
|
windows.ProcessCommandLineInformation,
|
|
unsafe.Pointer(&buffer[0]),
|
|
uint32(len(buffer)),
|
|
&bufferLength,
|
|
)
|
|
if queryError != nil {
|
|
return nil, queryError
|
|
}
|
|
commandLine := (*windows.NTUnicodeString)(unsafe.Pointer(&buffer[0]))
|
|
if commandLine.Buffer == nil || commandLine.Length == 0 || commandLine.Length%2 != 0 {
|
|
return nil, E.New("named pipe client has an invalid command line")
|
|
}
|
|
commandLineString := windows.UTF16ToString(unsafe.Slice(commandLine.Buffer, int(commandLine.Length/2)))
|
|
return windows.DecomposeCommandLine(commandLineString)
|
|
}
|
|
|
|
func processParentIsApplication(process windows.Handle, expectedApplication windows.Handle) (bool, error) {
|
|
parentProcessID, err := processParentID(process)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if parentProcessID == 0 {
|
|
return false, nil
|
|
}
|
|
parentProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, parentProcessID)
|
|
if err != nil {
|
|
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
|
|
return false, nil
|
|
}
|
|
return false, E.Cause(err, "open named pipe client parent")
|
|
}
|
|
defer windows.CloseHandle(parentProcess)
|
|
parentImagePath, err := winioProcess.QueryFullProcessImageName(parentProcess, winioProcess.ImageNameFormatWin32Path)
|
|
if err != nil {
|
|
return false, E.Cause(err, "query named pipe client parent executable")
|
|
}
|
|
parentImage, err := openLockedExecutable(parentImagePath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer windows.CloseHandle(parentImage)
|
|
return sameWindowsFile(parentImage, expectedApplication)
|
|
}
|
|
|
|
func processParentID(process windows.Handle) (uint32, error) {
|
|
var processInformation windows.PROCESS_BASIC_INFORMATION
|
|
processInformationLength := uint32(unsafe.Sizeof(processInformation))
|
|
err := windows.NtQueryInformationProcess(
|
|
process,
|
|
windows.ProcessBasicInformation,
|
|
unsafe.Pointer(&processInformation),
|
|
processInformationLength,
|
|
&processInformationLength,
|
|
)
|
|
if err != nil {
|
|
return 0, E.Cause(err, "query named pipe client parent")
|
|
}
|
|
parentProcessID := uint32(processInformation.InheritedFromUniqueProcessId)
|
|
if parentProcessID == 0 || uintptr(parentProcessID) != processInformation.InheritedFromUniqueProcessId {
|
|
return 0, nil
|
|
}
|
|
return parentProcessID, nil
|
|
}
|
|
|
|
func openLockedExecutable(path string) (windows.Handle, error) {
|
|
pathPointer, err := windows.UTF16PtrFromString(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return windows.CreateFile(
|
|
pathPointer,
|
|
windows.GENERIC_READ,
|
|
windows.FILE_SHARE_READ,
|
|
nil,
|
|
windows.OPEN_EXISTING,
|
|
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
|
|
0,
|
|
)
|
|
}
|
|
|
|
func finalWindowsPath(file windows.Handle) (string, error) {
|
|
buffer := make([]uint16, windows.MAX_LONG_PATH)
|
|
for {
|
|
length, err := windows.GetFinalPathNameByHandle(file, &buffer[0], uint32(len(buffer)), 0)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if length < uint32(len(buffer)) {
|
|
return normalizeWindowsPath(windows.UTF16ToString(buffer[:length])), nil
|
|
}
|
|
buffer = make([]uint16, length+1)
|
|
}
|
|
}
|
|
|
|
func normalizeWindowsPath(path string) string {
|
|
if strings.HasPrefix(path, `\\?\UNC\`) {
|
|
return `\\` + path[len(`\\?\UNC\`):]
|
|
}
|
|
return strings.TrimPrefix(path, `\\?\`)
|
|
}
|
|
|
|
func sameWindowsFile(first windows.Handle, second windows.Handle) (bool, error) {
|
|
var firstInformation windows.ByHandleFileInformation
|
|
err := windows.GetFileInformationByHandle(first, &firstInformation)
|
|
if err != nil {
|
|
return false, E.Cause(err, "query named pipe client executable identity")
|
|
}
|
|
var secondInformation windows.ByHandleFileInformation
|
|
err = windows.GetFileInformationByHandle(second, &secondInformation)
|
|
if err != nil {
|
|
return false, E.Cause(err, "query installed application executable identity")
|
|
}
|
|
return firstInformation.VolumeSerialNumber == secondInformation.VolumeSerialNumber &&
|
|
firstInformation.FileIndexHigh == secondInformation.FileIndexHigh &&
|
|
firstInformation.FileIndexLow == secondInformation.FileIndexLow, nil
|
|
}
|
|
|
|
func (c *windowsAuthenticatedConnection) peerConnectionIdentity() peerIdentity {
|
|
return c.identity
|
|
}
|
|
|
|
func (c *windowsAuthenticatedConnection) duplicateImpersonationToken() (windows.Token, error) {
|
|
var processToken windows.Token
|
|
err := windows.OpenProcessToken(c.parentProcess, windows.TOKEN_QUERY|windows.TOKEN_DUPLICATE, &processToken)
|
|
if err != nil {
|
|
return 0, E.Cause(err, "open application token")
|
|
}
|
|
defer processToken.Close()
|
|
return duplicateImpersonationToken(processToken)
|
|
}
|
|
|
|
func (d *Daemon) duplicatePeerImpersonationToken(identity peerIdentity) (windows.Token, error) {
|
|
d.peerAccess.Lock()
|
|
defer d.peerAccess.Unlock()
|
|
for connection, connectionIdentity := range d.peerConnections {
|
|
if connectionIdentity != identity {
|
|
continue
|
|
}
|
|
windowsConnection, loaded := connection.(*windowsAuthenticatedConnection)
|
|
if !loaded {
|
|
continue
|
|
}
|
|
return windowsConnection.duplicateImpersonationToken()
|
|
}
|
|
return 0, E.New("authenticated application connection is no longer available")
|
|
}
|
|
|
|
func (c *windowsAuthenticatedConnection) Close() error {
|
|
c.close.Do(func() {
|
|
c.daemon.unregisterPeerConnection(c)
|
|
c.closeError = E.Errors(
|
|
c.Conn.Close(),
|
|
windows.CloseHandle(c.parentProcessImage),
|
|
windows.CloseHandle(c.parentProcess),
|
|
windows.CloseHandle(c.processImage),
|
|
windows.CloseHandle(c.process),
|
|
)
|
|
})
|
|
return c.closeError
|
|
}
|
|
|
|
var (
|
|
_ credentials.TransportCredentials = (*windowsTransportCredentials)(nil)
|
|
_ peerConnection = (*windowsAuthenticatedConnection)(nil)
|
|
)
|