mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-10 12:06:41 -04:00
224 lines
7.7 KiB
Go
224 lines
7.7 KiB
Go
//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")
|
|
)
|
|
|
|
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 and, unlike a token, cannot under-report: UAC filters the tokens of
|
|
// local administrators, and a filtered token carries Administrators as
|
|
// deny-only, which a membership check on the token would read as "not a
|
|
// member". Domain accounts are exempt from that filtering, so for them an S4U
|
|
// token is preferred because its group list is LSA's transitive expansion and
|
|
// therefore covers nested and universal groups plus the machine's own local
|
|
// groups. NetUserGetLocalGroups expands only one global-group hop but needs no
|
|
// logon, so it serves as the fallback when no token can be obtained.
|
|
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 reports whether the wanted group is among the local
|
|
// groups the account belongs to, directly or through a global group.
|
|
//
|
|
// The wanted SID is resolved to its group name once and compared against the
|
|
// enumerated names. Well-known SIDs resolve from a static table, so that lookup
|
|
// needs no domain controller, and it keeps the comparison correct for a renamed
|
|
// or localized group because both sides then carry the new name. Resolving each
|
|
// enumerated name back to a SID instead would add a lookup per group that can
|
|
// block until it times out while a domain controller is unreachable, and cannot
|
|
// change the outcome: the names enumerated here are local groups of this
|
|
// machine, whose names are unique, so a name match identifies the group.
|
|
//
|
|
// A failure to resolve the wanted SID is returned rather than reported as
|
|
// "not a member", so a privilege check built on this fails closed.
|
|
func localGroupsContainSID(username string, want *windows.SID) (bool, error) {
|
|
wantName, _, _, err := want.LookupAccount("")
|
|
if err != nil {
|
|
return false, fmt.Errorf("resolve group SID %s to a name: %w", want, err)
|
|
}
|
|
|
|
groups, err := netUserGetLocalGroups(username)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for _, group := range groups {
|
|
if strings.EqualFold(group, wantName) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// 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
|
|
}
|