Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/ssh/server/command_execution_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi

// prepareCommandEnv prepares environment variables for command execution on Windows
func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string {
username, domain := s.parseUsername(localUser.Username)
username, domain := parseUsername(localUser.Username)
userEnv, err := s.getUserEnvironment(logger, username, domain)
if err != nil {
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
Expand Down Expand Up @@ -383,7 +383,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
return false
}

username, domain := s.parseUsername(localUser.Username)
username, domain := parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)

req := PtyExecutionRequest{
Expand Down
15 changes: 15 additions & 0 deletions client/ssh/server/privileges_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build !windows

package server

// isProcessElevated is only meaningful on Windows; other platforms use the
// effective UID check in isCurrentProcessPrivileged.
func isProcessElevated() bool {
return false
}

// isWindowsAccountPrivileged is only reachable on Windows. Fail closed if it
// is ever called on another platform.
func isWindowsAccountPrivileged(string) bool {
return true
}
231 changes: 231 additions & 0 deletions client/ssh/server/privileges_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
//go:build windows

package server

import (
"fmt"
"strings"
"unsafe"

log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)

var (
netapi32 = windows.NewLazySystemDLL("netapi32.dll")
procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups")

// lookupGroupSID resolves a group name to its SID, indirected for testing.
lookupGroupSID = func(name string) (*windows.SID, error) {
sid, _, _, err := windows.LookupSID("", name)
return sid, err
}
)

const (
// lgIncludeIndirect makes NetUserGetLocalGroups also return local groups
// the user belongs to through a global group.
lgIncludeIndirect = 0x1
maxPreferredLength = 0xFFFFFFFF
)

// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0.
type localGroupUsersInfo0 struct {
name *uint16
}

// isProcessElevated reports whether the current process token is elevated
// (TokenElevation): true for elevated administrators, the built-in
// Administrator, administrators with UAC disabled, and SYSTEM; false for
// standard users and administrators running with a UAC-filtered token.
func isProcessElevated() bool {
return windows.GetCurrentProcessToken().IsElevated()
}

// isWindowsAccountPrivileged reports whether the account is privileged on this
// machine: a well-known service account, a built-in Administrator (RID 500),
// or a member of the local Administrators group, directly or through nested
// groups. Evaluation errors count as privileged so policy checks fail closed.
func isWindowsAccountPrivileged(username string) bool {
sid, _, _, err := windows.LookupSID("", username)
if err != nil {
log.Warnf("privilege check: SID lookup for %q failed, treating as privileged: %v", username, err)
return true
}

if isPrivilegedUserSID(sid) {
return true
}

member, err := isLocalAdminsMember(username)
if err != nil {
log.Warnf("privilege check: cannot determine Administrators membership for %q, treating as privileged: %v", username, err)
return true
}
return member
}

// isPrivilegedUserSID reports whether the SID itself identifies a privileged
// principal, without consulting group membership.
func isPrivilegedUserSID(sid *windows.SID) bool {
wellKnown := []windows.WELL_KNOWN_SID_TYPE{
windows.WinLocalSystemSid,
windows.WinLocalServiceSid,
windows.WinNetworkServiceSid,
windows.WinBuiltinAdministratorsSid,
}
for _, sidType := range wellKnown {
if sid.IsWellKnown(sidType) {
return true
}
}
return isBuiltinAdministratorSID(sid)
}

// isBuiltinAdministratorSID reports whether the SID is a machine or domain
// built-in Administrator account (S-1-5-21-...-500). RID 500 is reserved for
// that account; it can be renamed but cannot be removed from the
// Administrators group.
func isBuiltinAdministratorSID(sid *windows.SID) bool {
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
return false
}
count := sid.SubAuthorityCount()
if count < 2 || sid.SubAuthority(0) != 21 {
return false
}
return sid.SubAuthority(uint32(count-1)) == 500
}

// isLocalAdminsMember reports whether the account is a member of the local
// Administrators group. Local accounts are checked against the local SAM,
// which is authoritative for them. For domain accounts an S4U identification
// token is preferred because its group list is LSA's transitive expansion
// (nested and universal groups included); NetUserGetLocalGroups expands only
// one global-group hop but needs no logon, so it serves as fallback.
func isLocalAdminsMember(username string) (bool, error) {
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
if err != nil {
return false, fmt.Errorf("create Administrators SID: %w", err)
}

account, domain := parseUsername(username)
if NewPrivilegeDropper().isLocalUser(domain) {
return localGroupsContainSID(account, adminSid)
}

member, s4uErr := s4uTokenIsMember(account, domain, adminSid)
if s4uErr == nil {
return member, nil
}
log.Debugf("privilege check: S4U membership check for %q failed, falling back to local group enumeration: %v", username, s4uErr)

member, err = localGroupsContainSID(buildUserCpn(account, domain), adminSid)
if err != nil {
return false, fmt.Errorf("S4U check: %w; local group enumeration: %w", s4uErr, err)
}
return member, nil
}

// s4uTokenIsMember obtains an S4U token for the account and checks whether the
// given SID is enabled in it.
func s4uTokenIsMember(account, domain string, sid *windows.SID) (bool, error) {
token, err := generateS4UUserToken(log.NewEntry(log.StandardLogger()), account, domain)
if err != nil {
return false, err
}
defer func() {
if err := windows.CloseHandle(token); err != nil {
log.Debugf("close S4U token: %v", err)
}
}()
return windows.Token(token).IsMember(sid)
}

// localGroupsContainSID enumerates the local groups the account belongs to
// (including membership through global groups) and reports whether the wanted
// SID is among them. Group names are resolved to SIDs so the comparison is not
// sensitive to localization.
//
// A group whose name does not resolve is compared against the wanted SID's own
// account name rather than skipped: skipping it would under-report membership,
// which for a privilege check means reporting a privileged account as
// unprivileged. If neither comparison is possible the error is returned so the
// caller can fail closed.
func localGroupsContainSID(username string, want *windows.SID) (bool, error) {
groups, err := netUserGetLocalGroups(username)
if err != nil {
return false, err
}

wantName, _, _, wantNameErr := want.LookupAccount("")

for _, group := range groups {
groupSid, err := lookupGroupSID(group)
if err == nil {
if groupSid.Equals(want) {
return true, nil
}
continue
}

if wantNameErr != nil {
return false, fmt.Errorf("resolve group %q: %w (and resolve wanted SID to a name: %w)", group, err, wantNameErr)
}

// Local group names are unique per machine, so a name mismatch is
// conclusive even though the SID is unavailable.
if strings.EqualFold(group, wantName) {
return true, nil
}
log.Debugf("local group %q does not resolve to a SID and does not match %q: %v", group, wantName, err)
}
return false, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// netUserGetLocalGroups returns the names of the local groups the account is a
// member of, including indirect membership through global groups.
func netUserGetLocalGroups(username string) ([]string, error) {
name16, err := windows.UTF16PtrFromString(username)
if err != nil {
return nil, fmt.Errorf("convert username: %w", err)
}

var buf *byte
var entriesRead, totalEntries uint32
status, _, _ := procNetUserGetLocalGroups.Call(
0, // local server
uintptr(unsafe.Pointer(name16)),
0, // level 0: LOCALGROUP_USERS_INFO_0
lgIncludeIndirect,
uintptr(unsafe.Pointer(&buf)),
maxPreferredLength,
uintptr(unsafe.Pointer(&entriesRead)),
uintptr(unsafe.Pointer(&totalEntries)),
)
if status != 0 {
return nil, fmt.Errorf("NetUserGetLocalGroups for %q: status %d", username, status)
}
if buf == nil {
return nil, nil
}
defer func() {
if err := windows.NetApiBufferFree(buf); err != nil {
log.Debugf("free NetApi buffer: %v", err)
}
}()

// MAX_PREFERRED_LENGTH makes the API allocate as much as it needs, so a
// short read is not expected. Report it rather than silently returning a
// subset of the account's groups.
if entriesRead != totalEntries {
return nil, fmt.Errorf("NetUserGetLocalGroups for %q returned %d of %d groups", username, entriesRead, totalEntries)
}

entries := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buf)), entriesRead)
groups := make([]string, 0, entriesRead)
for _, entry := range entries {
groups = append(groups, windows.UTF16PtrToString(entry.name))
}
return groups, nil
}
Loading
Loading