diff --git a/experimental/boxdd/platform_windows.go b/experimental/boxdd/platform_windows.go index beb56a39a..964198f98 100644 --- a/experimental/boxdd/platform_windows.go +++ b/experimental/boxdd/platform_windows.go @@ -4,8 +4,11 @@ package main import ( "context" + "io" "net/netip" "os" + "os/user" + "path/filepath" "runtime" "sync" "syscall" @@ -20,6 +23,7 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" + "github.com/tailscale/go-winio" "golang.org/x/sys/windows" ) @@ -172,11 +176,11 @@ func (p *windowsPlatformInterface) CloseNeighborMonitor(listener adapter.Neighbo } func (p *windowsPlatformInterface) UsePlatformShell() bool { - return false + return listenAddress == "" } func (p *windowsPlatformInterface) CheckPlatformShell() error { - return os.ErrInvalid + return nil } func (p *windowsPlatformInterface) OpenShellSession(user *adapter.PlatformUser, command string, environ []string, term string, rows int32, cols int32) (adapter.ShellSession, error) { @@ -184,11 +188,29 @@ func (p *windowsPlatformInterface) OpenShellSession(user *adapter.PlatformUser, } func (p *windowsPlatformInterface) LookupUser(username string) (*adapter.PlatformUser, error) { - return nil, os.ErrInvalid + requestedUser, err := user.Lookup(username) + if err != nil { + return nil, E.Cause(err, "lookup Windows user") + } + return &adapter.PlatformUser{ + Username: requestedUser.Username, + Uid: os.Getuid(), + Gid: os.Getgid(), + HomeDir: requestedUser.HomeDir, + }, nil } func (p *windowsPlatformInterface) LookupSFTPServer() (string, error) { - return "", os.ErrInvalid + for _, sftpPath := range []string{ + filepath.Join(os.Getenv("SystemRoot"), "System32", "OpenSSH", "sftp-server.exe"), + filepath.Join(os.Getenv("ProgramFiles"), "OpenSSH", "sftp-server.exe"), + } { + _, err := os.Stat(sftpPath) + if err == nil { + return sftpPath, nil + } + } + return "", E.New("sftp-server not found") } func (p *windowsPlatformInterface) ReadSystemSSHHostKey() ([]byte, error) { @@ -199,6 +221,14 @@ func (p *windowsPlatformInterface) TailscaleHostname() string { return "" } +func (p *windowsPlatformInterface) AcquireWindowsUserToken(localUser *adapter.PlatformUser) (windows.Token, io.Closer, error) { + requestedUser, err := user.Lookup(localUser.Username) + if err != nil { + return 0, nil, E.Cause(err, "lookup Windows user") + } + return acquireWindowsUserSession(requestedUser) +} + func (p *windowsPlatformInterface) UsePlatformBridge() bool { return false } @@ -471,7 +501,9 @@ func runImpersonated(token windows.Token, operation func() error) error { func querySessionImpersonationToken(sessionID uint32) (windows.Token, error) { var primaryToken windows.Token - err := windows.WTSQueryUserToken(sessionID, &primaryToken) + err := winio.RunWithPrivileges([]string{seTcbPrivilege}, func() error { + return windows.WTSQueryUserToken(sessionID, &primaryToken) + }) if err != nil { return 0, E.Cause(err, "query session user token") } diff --git a/experimental/boxdd/user_token_windows.go b/experimental/boxdd/user_token_windows.go new file mode 100644 index 000000000..6fafe90e4 --- /dev/null +++ b/experimental/boxdd/user_token_windows.go @@ -0,0 +1,376 @@ +//go:build windows + +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "io" + "os/user" + "strings" + "syscall" + "unsafe" + + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/tailscale/util/winutil" + "github.com/sagernet/tailscale/util/winutil/winenv" + + winio "github.com/tailscale/go-winio" + "golang.org/x/sys/windows" +) + +const ( + windowsLogonSource = "singbox" + kerberosPackageName = "Kerberos" + msv1PackageName = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0" + kerbS4ULogon int32 = 12 + msv1S4ULogonMessage int32 = 12 + s4uCheckLogonHours uint32 = 0x2 + networkLogon int32 = 3 + tokenSourceLength = 8 + seBackupPrivilege = "SeBackupPrivilege" + seRestorePrivilege = "SeRestorePrivilege" +) + +type ( + lsaHandle windows.Handle + lsaOperationalMode uint32 +) + +type kerberosS4ULogon struct { + MessageType int32 + Flags uint32 + ClientUPN windows.NTUnicodeString + ClientRealm windows.NTUnicodeString +} + +type msv1S4ULogon struct { + MessageType int32 + Flags uint32 + UserPrincipalName windows.NTUnicodeString + DomainName windows.NTUnicodeString +} + +type tokenSource struct { + SourceName [tokenSourceLength]byte + SourceIdentifier windows.LUID +} + +type quotaLimits struct { + PagedPoolLimit uintptr + NonPagedPoolLimit uintptr + MinimumWorkingSetSize uintptr + MaximumWorkingSetSize uintptr + PagefileLimit uintptr + TimeLimit int64 +} + +func acquireWindowsUserSession(requestedUser *user.User) (windows.Token, io.Closer, error) { + var ( + primaryToken windows.Token + profile *winutil.UserProfile + ) + err := winio.RunWithPrivileges([]string{seTcbPrivilege, seBackupPrivilege, seRestorePrivilege}, func() error { + impersonationToken, err := logonWindowsUserS4U(requestedUser) + if err != nil { + return err + } + defer impersonationToken.Close() + primaryToken, err = duplicatePrimaryToken(impersonationToken) + if err != nil { + return err + } + tokenUser, err := primaryToken.GetTokenUser() + if err != nil { + return E.Cause(err, "query S4U token user") + } + if !strings.EqualFold(tokenUser.User.Sid.String(), requestedUser.Uid) { + return E.New("S4U token identity does not match requested Windows user") + } + profile, err = winutil.LoadUserProfile(primaryToken, requestedUser) + if err != nil { + return E.Cause(err, "load Windows user profile") + } + return nil + }) + if err != nil { + if primaryToken != 0 { + err = E.Errors(err, primaryToken.Close()) + } + return 0, nil, err + } + return primaryToken, common.Closer(func() error { + profileError := winio.RunWithPrivileges([]string{seBackupPrivilege, seRestorePrivilege}, profile.Close) + return E.Errors(profileError, primaryToken.Close()) + }), nil +} + +func logonWindowsUserS4U(requestedUser *user.User) (token windows.Token, err error) { + processName, err := windows.NewNTString(windowsLogonSource) + if err != nil { + return 0, err + } + var ( + handle lsaHandle + mode lsaOperationalMode + ) + status := lsaRegisterLogonProcess(processName, &handle, &mode) + if status != 0 { + return 0, E.Cause(status, "register LSA logon process") + } + defer func() { + closeStatus := lsaDeregisterLogonProcess(handle) + if closeStatus != 0 { + err = E.Errors(err, E.Cause(closeStatus, "deregister LSA logon process")) + } + }() + + username, domainUser, err := classifyWindowsUser(requestedUser.Username) + if err != nil { + return 0, err + } + var ( + packageName string + authenticationInformation unsafe.Pointer + authenticationInformationLength uint32 + ) + if domainUser { + if !winenv.IsDomainJoined() { + return 0, E.New("cannot log on as a domain user from a Windows device that is not domain joined") + } + packageName = kerberosPackageName + upn, err := samAccountNameToUPN(username) + if err != nil { + return 0, E.Cause(err, "resolve Windows user principal name") + } + upn16, err := windows.UTF16FromString(upn) + if err != nil { + return 0, err + } + logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[kerberosS4ULogon](upn16) + logonInfo.MessageType = kerbS4ULogon + logonInfo.Flags = s4uCheckLogonHours + winutil.SetNTString(&logonInfo.ClientUPN, buffers[0]) + authenticationInformation = unsafe.Pointer(logonInfo) + authenticationInformationLength = logonInfoLen + } else { + packageName = msv1PackageName + username16, err := windows.UTF16FromString(username) + if err != nil { + return 0, err + } + thisComputer := []uint16{'.', 0} + logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[msv1S4ULogon](username16, thisComputer) + logonInfo.MessageType = msv1S4ULogonMessage + logonInfo.Flags = s4uCheckLogonHours + winutil.SetNTString(&logonInfo.UserPrincipalName, buffers[0]) + winutil.SetNTString(&logonInfo.DomainName, buffers[1]) + authenticationInformation = unsafe.Pointer(logonInfo) + authenticationInformationLength = logonInfoLen + } + + packageString, err := windows.NewNTString(packageName) + if err != nil { + return 0, err + } + var packageID uint32 + status = lsaLookupAuthenticationPackage(handle, packageString, &packageID) + if status != 0 { + return 0, E.Cause(status, "lookup LSA authentication package") + } + + var source tokenSource + copy(source.SourceName[:], windowsLogonSource) + err = allocateLocallyUniqueID(&source.SourceIdentifier) + if err != nil { + return 0, E.Cause(err, "allocate LSA logon identifier") + } + originName, err := windows.NewNTString(windowsLogonSource) + if err != nil { + return 0, err + } + var ( + profileBuffer uintptr + profileBufferLength uint32 + logonID windows.LUID + quotas quotaLimits + subStatus windows.NTStatus + ) + status = lsaLogonUser( + handle, + originName, + networkLogon, + packageID, + authenticationInformation, + authenticationInformationLength, + nil, + &source, + &profileBuffer, + &profileBufferLength, + &logonID, + &token, + "as, + &subStatus, + ) + if profileBuffer != 0 { + defer lsaFreeReturnBuffer(profileBuffer) + } + if status != 0 { + return 0, E.New("S4U logon for ", requestedUser.Username, " failed: ", status, ", substatus: ", subStatus) + } + return token, nil +} + +func classifyWindowsUser(username string) (sanitizedUsername string, domainUser bool, err error) { + domain, account, hasDomain := strings.Cut(username, `\`) + if !hasDomain { + return username, false, nil + } + if domain == "." { + return account, false, nil + } + computerName, err := windows.ComputerName() + if err != nil { + return "", false, E.Cause(err, "query Windows computer name") + } + if strings.EqualFold(domain, computerName) { + return account, false, nil + } + return username, true, nil +} + +func samAccountNameToUPN(samAccountName string) (string, error) { + _, account, _ := strings.Cut(samAccountName, `\`) + upn, err := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameUserPrincipal, 50) + if err == nil { + return upn, nil + } + canonicalName, canonicalError := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameCanonical, 50) + if canonicalError != nil { + return "", E.Errors(err, canonicalError) + } + domain, _, found := strings.Cut(canonicalName, "/") + if !found || domain == "" { + return "", E.New("invalid canonical domain name for ", samAccountName) + } + return account + "@" + domain, nil +} + +func duplicatePrimaryToken(impersonationToken windows.Token) (windows.Token, error) { + securityDescriptor, err := windows.GetSecurityInfo( + windows.Handle(impersonationToken), + windows.SE_KERNEL_OBJECT, + windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return 0, E.Cause(err, "query S4U token security") + } + securityAttributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: securityDescriptor, + } + var primaryToken windows.Token + err = windows.DuplicateTokenEx( + impersonationToken, + 0, + &securityAttributes, + windows.SecurityImpersonation, + windows.TokenPrimary, + &primaryToken, + ) + if err != nil { + return 0, E.Cause(err, "duplicate S4U primary token") + } + return primaryToken, nil +} + +var ( + modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll") + modSecur32 = windows.NewLazySystemDLL("secur32.dll") + + procAllocateLocallyUniqueID = modAdvapi32.NewProc("AllocateLocallyUniqueId") + procLsaDeregisterLogonProcess = modSecur32.NewProc("LsaDeregisterLogonProcess") + procLsaFreeReturnBuffer = modSecur32.NewProc("LsaFreeReturnBuffer") + procLsaLogonUser = modSecur32.NewProc("LsaLogonUser") + procLsaLookupAuthenticationPackage = modSecur32.NewProc("LsaLookupAuthenticationPackage") + procLsaRegisterLogonProcess = modSecur32.NewProc("LsaRegisterLogonProcess") +) + +func allocateLocallyUniqueID(luid *windows.LUID) error { + result, _, callError := syscall.SyscallN(procAllocateLocallyUniqueID.Addr(), uintptr(unsafe.Pointer(luid))) + if result == 0 { + if callError == 0 { + return syscall.EINVAL + } + return callError + } + return nil +} + +func lsaDeregisterLogonProcess(handle lsaHandle) windows.NTStatus { + result, _, _ := syscall.SyscallN(procLsaDeregisterLogonProcess.Addr(), uintptr(handle)) + return windows.NTStatus(result) +} + +func lsaFreeReturnBuffer(buffer uintptr) windows.NTStatus { + result, _, _ := syscall.SyscallN(procLsaFreeReturnBuffer.Addr(), buffer) + return windows.NTStatus(result) +} + +func lsaLookupAuthenticationPackage(handle lsaHandle, packageName *windows.NTString, packageID *uint32) windows.NTStatus { + result, _, _ := syscall.SyscallN( + procLsaLookupAuthenticationPackage.Addr(), + uintptr(handle), + uintptr(unsafe.Pointer(packageName)), + uintptr(unsafe.Pointer(packageID)), + ) + return windows.NTStatus(result) +} + +func lsaRegisterLogonProcess(processName *windows.NTString, handle *lsaHandle, mode *lsaOperationalMode) windows.NTStatus { + result, _, _ := syscall.SyscallN( + procLsaRegisterLogonProcess.Addr(), + uintptr(unsafe.Pointer(processName)), + uintptr(unsafe.Pointer(handle)), + uintptr(unsafe.Pointer(mode)), + ) + return windows.NTStatus(result) +} + +func lsaLogonUser( + handle lsaHandle, + originName *windows.NTString, + logonType int32, + authenticationPackage uint32, + authenticationInformation unsafe.Pointer, + authenticationInformationLength uint32, + localGroups *windows.Tokengroups, + sourceContext *tokenSource, + profileBuffer *uintptr, + profileBufferLength *uint32, + logonID *windows.LUID, + token *windows.Token, + quotas *quotaLimits, + subStatus *windows.NTStatus, +) windows.NTStatus { + result, _, _ := syscall.SyscallN( + procLsaLogonUser.Addr(), + uintptr(handle), + uintptr(unsafe.Pointer(originName)), + uintptr(logonType), + uintptr(authenticationPackage), + uintptr(authenticationInformation), + uintptr(authenticationInformationLength), + uintptr(unsafe.Pointer(localGroups)), + uintptr(unsafe.Pointer(sourceContext)), + uintptr(unsafe.Pointer(profileBuffer)), + uintptr(unsafe.Pointer(profileBufferLength)), + uintptr(unsafe.Pointer(logonID)), + uintptr(unsafe.Pointer(token)), + uintptr(unsafe.Pointer(quotas)), + uintptr(unsafe.Pointer(subStatus)), + ) + return windows.NTStatus(result) +} diff --git a/protocol/tailscale/tailssh/server.go b/protocol/tailscale/tailssh/server.go index 660f892ef..f39163716 100644 --- a/protocol/tailscale/tailssh/server.go +++ b/protocol/tailscale/tailssh/server.go @@ -556,7 +556,7 @@ func (s *Server) handleSession(session gliderssh.Session) { session.Exit(1) return } - err = verifyShellIdentity(localUser) + err = verifyShellIdentity(s.platformInterface, localUser) if err != nil { s.logger.Warn("shell rejected for ", localUser.Username, ": ", err) fmt.Fprintf(session.Stderr(), "%s\r\n", err) @@ -565,17 +565,13 @@ func (s *Server) handleSession(session gliderssh.Session) { } var agentSocketPath string if connInfo.action.AllowAgentForwarding && !s.disableForwarding && gliderssh.AgentRequested(session) { - agentListener, listenErr := gliderssh.NewAgentListener() - if listenErr == nil { + agentListener, err := newAgentListener(localUser) + if err == nil { defer agentListener.Close() agentSocketPath = agentListener.Addr().String() - // The agent socket is created as the server identity; hand it to the - // target user so SSH_AUTH_SOCK is reachable after privileges drop. - prepareErr := prepareAgentSocket(agentSocketPath, localUser.Uid, localUser.Gid) - if prepareErr != nil { - s.logger.Warn("prepare agent socket: ", prepareErr) - } go gliderssh.ForwardAgentConnections(agentListener, session) + } else { + s.logger.Warn("create agent listener: ", err) } } env := s.buildEnvironment(session, connInfo, localUser) @@ -748,7 +744,7 @@ func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, conn s.serveBuiltinSFTP(ctx, session, localUser) return } - err = verifyShellIdentity(localUser) + err = verifyShellIdentity(s.platformInterface, localUser) if err != nil { s.logger.Warn("sftp rejected for ", localUser.Username, ": ", err) fmt.Fprintf(session.Stderr(), "%s\r\n", err) @@ -758,7 +754,7 @@ func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, conn env := s.buildEnvironment(session, connInfo, localUser) sftpSession, err := s.backend.OpenSession(shellRequest{ User: localUser, - Command: sftpCommand(sftpPath), + Command: sftpCommand(sftpPath, localUser.Shell), Env: env, }) if err != nil { @@ -811,8 +807,11 @@ func (s *Server) buildEnvironment(session gliderssh.Session, connInfo *sshConnIn "USER="+localUser.Username, "HOME="+localUser.HomeDir, "SHELL="+localUser.Shell, - "PATH="+defaultPathEnv(), ) + defaultPath := defaultPathEnv(s.platformInterface) + if defaultPath != "" { + env = append(env, "PATH="+defaultPath) + } env = append(env, platformEnvironment(localUser)...) remoteAddr := session.RemoteAddr() localAddr := session.LocalAddr() diff --git a/protocol/tailscale/tailssh/server_helper_unix.go b/protocol/tailscale/tailssh/server_helper_unix.go index 75f4d9ab3..880759b03 100644 --- a/protocol/tailscale/tailssh/server_helper_unix.go +++ b/protocol/tailscale/tailssh/server_helper_unix.go @@ -3,6 +3,7 @@ package tailssh import ( + "net" "os" "path/filepath" "strconv" @@ -22,7 +23,7 @@ func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) // verifyShellIdentity is a no-op on Unix: spawned shells and sftp-server drop to the // requested user via setCredential, so the child already runs as that user. -func verifyShellIdentity(_ *adapter.PlatformUser) error { +func verifyShellIdentity(_ adapter.PlatformInterface, _ *adapter.PlatformUser) error { return nil } @@ -30,7 +31,7 @@ func systemHostKeyPath() string { return "/etc/ssh/ssh_host_ed25519_key" } -func defaultPathEnv() string { +func defaultPathEnv(_ adapter.PlatformInterface) string { return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } @@ -38,31 +39,40 @@ func userSocketDirectories(localUser *adapter.PlatformUser) []string { return gliderssh.UserSocketDirectories(localUser.HomeDir, strconv.Itoa(localUser.Uid)) } -// prepareAgentSocket hands the agent-forwarding socket to the target user so -// SSH_AUTH_SOCK stays reachable after the shell drops privileges. No-op when the -// shell runs as the server identity. -func prepareAgentSocket(socketPath string, uid, gid int) error { - if uid < 0 || uid == os.Getuid() { - return nil - } - err := os.Chown(socketPath, uid, gid) +func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) { + listener, err := gliderssh.NewAgentListener() if err != nil { - return err + return nil, err + } + socketPath := listener.Addr().String() + if localUser.Uid < 0 || localUser.Uid == os.Getuid() { + return listener, nil + } + err = os.Chown(socketPath, localUser.Uid, localUser.Gid) + if err != nil { + listener.Close() + return nil, err } err = os.Chmod(socketPath, 0o600) if err != nil { - return err + listener.Close() + return nil, err } // Make the MkdirTemp parent traversable so the dropped-privilege child can // reach the socket. - return os.Chmod(filepath.Dir(socketPath), 0o755) + err = os.Chmod(filepath.Dir(socketPath), 0o755) + if err != nil { + listener.Close() + return nil, err + } + return listener, nil } func platformEnvironment(_ *adapter.PlatformUser) []string { return nil } -func sftpCommand(sftpPath string) string { +func sftpCommand(sftpPath, _ string) string { return sftpPath + " 2>/dev/null" } diff --git a/protocol/tailscale/tailssh/server_helper_windows.go b/protocol/tailscale/tailssh/server_helper_windows.go index c94bedb20..4792fc711 100644 --- a/protocol/tailscale/tailssh/server_helper_windows.go +++ b/protocol/tailscale/tailssh/server_helper_windows.go @@ -3,6 +3,9 @@ package tailssh import ( + "crypto/rand" + "fmt" + "net" "os" "os/user" "strings" @@ -12,6 +15,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/tailscale/util/winutil" + winio "github.com/tailscale/go-winio" "golang.org/x/sys/windows" ) @@ -19,10 +23,6 @@ func isPrivilegedUser() bool { return winutil.IsCurrentProcessElevated() } -// requestedUserMatchesProcess reports whether the ACL-mapped user is the same Windows -// account the sing-box process runs as. Windows has no impersonation wired up, so a -// session always runs with the process identity; this is the only case where the -// identity it runs as equals the requested one. func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) { tokenUser, err := windows.GetCurrentProcessToken().GetTokenUser() if err != nil { @@ -36,9 +36,13 @@ func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) return strings.EqualFold(tokenUser.User.Sid.String(), requested.Uid), nil } -// verifyShellIdentity refuses a spawned shell/SFTP session whose ACL-mapped user differs -// from the process identity it would actually run as, since Windows has no impersonation. -func verifyShellIdentity(localUser *adapter.PlatformUser) error { +func verifyShellIdentity(platformInterface adapter.PlatformInterface, localUser *adapter.PlatformUser) error { + if platformInterface != nil && platformInterface.UsePlatformShell() { + _, loaded := platformInterface.(windowsUserTokenProvider) + if loaded { + return nil + } + } match, err := requestedUserMatchesProcess(localUser) if err != nil { return err @@ -53,7 +57,10 @@ func systemHostKeyPath() string { return "" } -func defaultPathEnv() string { +func defaultPathEnv(platformInterface adapter.PlatformInterface) string { + if platformInterface != nil && platformInterface.UsePlatformShell() { + return "" + } systemRoot := os.Getenv("SystemRoot") return systemRoot + `\system32;` + systemRoot + `;` + systemRoot + `\System32\Wbem` } @@ -62,10 +69,22 @@ func userSocketDirectories(localUser *adapter.PlatformUser) []string { return []string{localUser.HomeDir, os.TempDir()} } -// prepareAgentSocket is a no-op on Windows: shells run as the server identity, so -// the agent socket needs no ownership change. -func prepareAgentSocket(_ string, _, _ int) error { - return nil +func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) { + requestedUser, err := user.Lookup(localUser.Username) + if err != nil { + return nil, E.Cause(err, "lookup requested user") + } + pipePath := `\\.\pipe\sing-box-tailssh-agent-` + rand.Text() + securityDescriptor := fmt.Sprintf(`D:P(A;;GA;;;SY)(A;;GRGW;;;%s)`, requestedUser.Uid) + listener, err := winio.ListenPipe(pipePath, &winio.PipeConfig{ + SecurityDescriptor: securityDescriptor, + InputBufferSize: 64 * 1024, + OutputBufferSize: 64 * 1024, + }) + if err != nil { + return nil, E.Cause(err, "listen on agent pipe") + } + return listener, nil } func platformEnvironment(localUser *adapter.PlatformUser) []string { @@ -80,8 +99,11 @@ func platformEnvironment(localUser *adapter.PlatformUser) []string { return env } -func sftpCommand(sftpPath string) string { - return sftpPath +func sftpCommand(sftpPath, shell string) string { + if isPowerShell(shell) { + return `& "` + sftpPath + `"` + } + return `"` + sftpPath + `"` } func sshSignalToSyscall(sig gliderssh.Signal) int { diff --git a/protocol/tailscale/tailssh/session_windows.go b/protocol/tailscale/tailssh/session_windows.go index c66000997..0b6db19ce 100644 --- a/protocol/tailscale/tailssh/session_windows.go +++ b/protocol/tailscale/tailssh/session_windows.go @@ -16,18 +16,45 @@ import ( "github.com/sagernet/tailscale/util/winutil" "github.com/sagernet/tailscale/util/winutil/conpty" + "github.com/tailscale/go-winio" "golang.org/x/sys/windows" ) -func selectShellBackend(_ adapter.PlatformInterface) shellBackend { - return &windowsShellBackend{} +const ( + seAssignPrimaryToken = "SeAssignPrimaryTokenPrivilege" + seIncreaseQuota = "SeIncreaseQuotaPrivilege" +) + +type windowsUserTokenProvider interface { + AcquireWindowsUserToken(user *adapter.PlatformUser) (windows.Token, io.Closer, error) } -func CheckServerSupport(_ adapter.PlatformInterface) (string, error) { +func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend { + backend := &windowsShellBackend{} + if platformInterface != nil && platformInterface.UsePlatformShell() { + backend.userTokenProvider, _ = platformInterface.(windowsUserTokenProvider) + } + return backend +} + +func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) { + if platformInterface != nil && platformInterface.UsePlatformShell() { + _, loaded := platformInterface.(windowsUserTokenProvider) + if !loaded { + return "", E.New("platform shell does not provide Windows user tokens") + } + err := platformInterface.CheckPlatformShell() + if err != nil { + return "", err + } + } return "", nil } -func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) { +func lookupSFTPServer(platformInterface adapter.PlatformInterface) (string, error) { + if platformInterface != nil && platformInterface.UsePlatformShell() { + return platformInterface.LookupSFTPServer() + } sftpPath, err := exec.LookPath("sftp-server") if err != nil { return "", E.New("sftp-server not found") @@ -35,20 +62,48 @@ func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) { return sftpPath, nil } -type windowsShellBackend struct{} +type windowsShellBackend struct { + userTokenProvider windowsUserTokenProvider +} -func (b *windowsShellBackend) OpenSession(request shellRequest) (shellSession, error) { +func (b *windowsShellBackend) OpenSession(request shellRequest) (session shellSession, err error) { + var ( + userToken windows.Token + userResource io.Closer + ) + if b.userTokenProvider != nil { + userToken, userResource, err = b.userTokenProvider.AcquireWindowsUserToken(request.User) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + err = E.Errors(err, userResource.Close()) + } + }() + userEnvironment, err := userToken.Environ(false) + if err != nil { + return nil, E.Cause(err, "query user environment") + } + request.Env = mergeWindowsEnvironment(userEnvironment, request.Env) + } shell := request.User.Shell if request.Term != "" { - session, err := openConPTYSession(request, shell) - if err == nil { - return session, nil - } - if !errors.Is(err, conpty.ErrUnsupported) { + session, err = openConPTYSession(request, shell, userToken) + if err != nil && !errors.Is(err, conpty.ErrUnsupported) { return nil, err } } - return openPipeSession(request, shell) + if request.Term == "" || err != nil { + session, err = openPipeSession(request, shell, userToken) + if err != nil { + return nil, err + } + } + if userResource != nil { + session = &windowsUserShellSession{shellSession: session, resource: userResource} + } + return session, nil } func (b *windowsShellBackend) Close() error { @@ -59,15 +114,51 @@ func buildCommandLine(shell, command string) string { if command == "" { return `"` + shell + `"` } - base := strings.ToLower(filepath.Base(shell)) - switch base { - case "pwsh.exe", "powershell.exe": + if isPowerShell(shell) { // -NoProfile/-NonInteractive keep the invoking user's PowerShell profile from // writing into the (binary) SFTP/stdout stream and corrupting it. return `"` + shell + `" -NoLogo -NoProfile -NonInteractive -Command ` + command - default: - return `"` + shell + `" /c ` + command } + return `"` + shell + `" /c ` + command +} + +func isPowerShell(shell string) bool { + switch strings.ToLower(filepath.Base(shell)) { + case "pwsh", "pwsh.exe", "powershell", "powershell.exe": + return true + default: + return false + } +} + +func mergeWindowsEnvironment(baseEnvironment, overrideEnvironment []string) []string { + environment := make([]string, 0, len(baseEnvironment)+len(overrideEnvironment)) + variableIndex := make(map[string]int, len(baseEnvironment)+len(overrideEnvironment)) + for _, variables := range [][]string{baseEnvironment, overrideEnvironment} { + for _, variable := range variables { + name := windowsEnvironmentName(variable) + index, loaded := variableIndex[name] + if loaded { + environment[index] = variable + } else { + variableIndex[name] = len(environment) + environment = append(environment, variable) + } + } + } + return environment +} + +func windowsEnvironmentName(variable string) string { + start := 0 + if strings.HasPrefix(variable, "=") { + start = 1 + } + separator := strings.IndexByte(variable[start:], '=') + if separator == -1 { + return strings.ToLower(variable) + } + return strings.ToLower(variable[:start+separator]) } // clampConsoleDimension keeps a client-supplied window dimension within the @@ -83,7 +174,7 @@ func clampConsoleDimension(value uint16) int16 { return int16(value) } -func createShellProcess(shell string, request shellRequest, startupInfo *windows.StartupInfo, inheritHandles bool, createProcessFlags uint32) (windows.Handle, error) { +func createShellProcess(shell string, request shellRequest, startupInfo *windows.StartupInfo, inheritHandles bool, createProcessFlags uint32, userToken windows.Token) (windows.Handle, error) { cmdLine := buildCommandLine(shell, request.Command) cmdLine16, err := windows.UTF16PtrFromString(cmdLine) if err != nil { @@ -105,28 +196,40 @@ func createShellProcess(shell string, request shellRequest, startupInfo *windows // NewEnvBlock requires the variables sorted case-insensitively by name. envCopy := slices.Clone(request.Env) slices.SortFunc(envCopy, func(a, b string) int { - aName, _, _ := strings.Cut(a, "=") - bName, _, _ := strings.Cut(b, "=") - return strings.Compare(strings.ToLower(aName), strings.ToLower(bName)) + return strings.Compare(windowsEnvironmentName(a), windowsEnvironmentName(b)) }) envBlock := winutil.NewEnvBlock(envCopy) var processInfo windows.ProcessInformation - // request.User only sets HomeDir and Env here; the child inherits the sing-box - // process identity because Windows impersonation is not implemented. Sessions - // whose requested user differs from the process identity are refused before - // reaching this point (verifyShellIdentity in handleSession/handleSFTP). - err = windows.CreateProcess( - exe16, - cmdLine16, - nil, - nil, - inheritHandles, - createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP, - envBlock, - dir16, - startupInfo, - &processInfo, - ) + if userToken == 0 { + err = windows.CreateProcess( + exe16, + cmdLine16, + nil, + nil, + inheritHandles, + createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP, + envBlock, + dir16, + startupInfo, + &processInfo, + ) + } else { + err = winio.RunWithPrivileges([]string{seAssignPrimaryToken, seIncreaseQuota}, func() error { + return windows.CreateProcessAsUser( + userToken, + exe16, + cmdLine16, + nil, + nil, + inheritHandles, + createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP, + envBlock, + dir16, + startupInfo, + &processInfo, + ) + }) + } if err != nil { return 0, E.Cause(err, "create process") } @@ -134,6 +237,15 @@ func createShellProcess(shell string, request shellRequest, startupInfo *windows return processInfo.Process, nil } +type windowsUserShellSession struct { + shellSession + resource io.Closer +} + +func (s *windowsUserShellSession) Close() error { + return E.Errors(s.shellSession.Close(), s.resource.Close()) +} + type conptyShellSession struct { console *conpty.PseudoConsole input io.WriteCloser @@ -143,7 +255,7 @@ type conptyShellSession struct { exitCode uint32 } -func openConPTYSession(request shellRequest, shell string) (shellSession, error) { +func openConPTYSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) { cols := request.Cols rows := request.Rows if cols == 0 { @@ -171,7 +283,7 @@ func openConPTYSession(request shellRequest, shell string) (shellSession, error) console.Close() return nil, E.Cause(err, "resolve startup info") } - process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags) + process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken) startupInfoBuilder.Close() if err != nil { console.Close() @@ -202,7 +314,11 @@ func (s *conptyShellSession) waitProcess() { } func (s *conptyShellSession) Read(p []byte) (int, error) { - return s.output.Read(p) + n, err := s.output.Read(p) + if errors.Is(err, os.ErrClosed) { + return n, io.EOF + } + return n, err } func (s *conptyShellSession) Write(p []byte) (int, error) { @@ -261,7 +377,7 @@ type pipeShellSession struct { exitCode uint32 } -func openPipeSession(request shellRequest, shell string) (shellSession, error) { +func openPipeSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) { var stdinR, stdinW windows.Handle err := windows.CreatePipe(&stdinR, &stdinW, nil, 0) if err != nil { @@ -304,7 +420,7 @@ func openPipeSession(request shellRequest, shell string) (shellSession, error) { windows.CloseHandle(stdoutR) return nil, E.Cause(err, "resolve startup info") } - process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags) + process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken) startupInfoBuilder.Close() if err != nil { windows.CloseHandle(stdinW)