Files
privatae-bastion/client-go/freeagent_drop_windows.go
T
Privatae 8822d5df85
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s
bastion 0.55.3
2026-10-25 23:16:45 +00:00

449 lines
15 KiB
Go

//go:build windows
package main
// The drop on Windows: a managed agent runs as the person who owns the granted
// folder, never as LocalSystem.
//
// WHEN IT APPLIES. Only when the bastion runs as the Windows service
// (sysservice_windows.go), which is LocalSystem. An ordinary install runs as a
// logon task under the person's own account, so the daemon already IS the
// user and there is nothing to drop.
//
// WHO. The folder's owner, read from its security descriptor, refused for the
// machine's own identities, groups, the built-in Administrator and Guest
// (faWinOwnerRefusal). That is the unix rule (root, nobody, no account) in
// Windows terms.
//
// HOW. With the owner's own token from their signed-in session
// (WTSQueryUserToken), and CreateProcessAsUser. So the agent runs while its
// owner is signed in to this machine; when they are not, the start is refused
// with a sentence saying so and retried on the supervisor's backoff. Starting it
// for an owner who is not signed in needs a token made without their password
// (an S4U logon), which this build does not make. os/exec is not used for this
// start: it cannot name the window station, and a process started from a
// service under another account's token inherits the service's, which that
// account cannot open. An empty desktop name lets the system connect the child
// to its own session's.
//
// WHERE. The state lives under the service's own state directory, inside
// LocalSystem's profile, which only SYSTEM and administrators can open; every
// ancestor of the store's directories is therefore out of the owner's reach,
// as /var/lib/privatae is on unix. The owner is given access to exactly three
// places, by a permission entry rather than by ownership: their store's state
// leaf (modify), its public cert directory (read), and the runtime (read, for
// every local user: it is the published artifact). Reaching a leaf without
// access to its ancestors relies on the "bypass traverse checking" right every
// account holds by default; where a policy has removed it, the agent fails to
// start and says so, and nothing runs as LocalSystem instead.
//
// WHAT IS NEVER DONE. Nothing is written into the person's folder (no `.agent`
// there; the agent's directory stays in the state leaf): an entry added there
// by path could be steered by a junction. And the leaf's permissions are set
// only while it is empty, or found already right: setting a directory's
// permissions also pushes them into what is inside, and the owner can put a
// junction inside their leaf.
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
// faDropSupported: only the service is LocalSystem.
func faDropSupported() bool { return runningAsService }
// faRootStateBase: under the service's state directory, in LocalSystem's
// profile (sysservice.go windowsSystemAppData).
func faRootStateBase() string {
d, err := configDirPath()
if err != nil {
return ""
}
return filepath.Join(d, "freeagent-state")
}
// faHandsOverByChown: on Windows the hand-over is a permission entry on the
// state leaf, inherited by everything later created in it.
const faHandsOverByChown = false
// faWinModify is the "Modify" right: read, write, execute and delete, without
// the right to change permissions or take ownership.
const faWinModify = windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE | windows.DELETE
const faWinReadExecute = windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE
var errOwnerNotSignedIn = errors.New("is not signed in to this machine; the agent starts when they are")
func faResolveDropOwner(root, stateDir string) (*faOwner, error) {
sd, err := windows.GetNamedSecurityInfo(root, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
if err != nil {
return nil, fmt.Errorf("cannot read the folder's owner: %v", err)
}
sid, _, err := sd.Owner()
if err != nil || sid == nil {
return nil, fmt.Errorf("cannot read the folder's owner: %v", err)
}
// Bounded and cached (sidnames.go); no answer in time refuses the start.
acct, err := faWinDropOwnerAccount(sid.String(), winAccounts, faDropSIDLane)
if err != nil {
return nil, err
}
name, full := acct.Account, sidAccountLabel(acct)
if why := faWinOwnerRefusal(sid.String(), acct.Type, full); why != "" {
return nil, errors.New(why)
}
home := faWinProfileDir(sid.String())
if home == "" {
home = stateDir
}
return &faOwner{drop: true, name: full, home: home, plat: faOwnerPlat{sid: sid.String(), user: name}}, nil
}
// faWinProfileDir is the owner's profile folder from the machine's profile
// list, or "" when the account has never signed in here.
func faWinProfileDir(sid string) string {
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\`+sid, registry.QUERY_VALUE)
if err != nil {
return ""
}
defer k.Close()
p, _, err := k.GetStringValue("ProfileImagePath")
if err != nil {
return ""
}
if x, err := registry.ExpandString(p); err == nil {
p = x
}
if st, err := os.Stat(p); err != nil || !st.IsDir() {
return ""
}
return p
}
// faHandPath gives the owner Modify on the store's state leaf: set while the
// leaf is empty, accepted when the entry is already there, refused otherwise.
// A leaf with content and no entry for this owner holds another account's
// state (the folder changed hands); it is left as it is, never re-granted, and
// the card says what to do.
func faHandPath(o *faOwner, path string) error {
sid, err := windows.StringToSid(o.plat.sid)
if err != nil {
return err
}
if ok, err := faWinGranted(path, sid, faWinModify); err != nil {
return err
} else if ok {
return nil
}
entries, err := os.ReadDir(path)
if err != nil {
return err
}
if len(entries) != 0 {
return fmt.Errorf("%s holds state made for another account, and its permissions are not changed while it has content; remove it to start this store afresh for %s", path, o.name)
}
return faWinGrant(path, sid, faWinModify, windows.TRUSTEE_IS_USER)
}
// faSharePub lets the owner read the store's public cert directory. Only the
// service writes there, so a by-path change cannot be steered.
func faSharePub(pub string, o *faOwner) error {
if o == nil || !o.drop {
return nil
}
sid, err := windows.StringToSid(o.plat.sid)
if err != nil {
return err
}
if ok, err := faWinGranted(pub, sid, faWinReadExecute); err != nil || ok {
return err
}
return faWinGrant(pub, sid, faWinReadExecute, windows.TRUSTEE_IS_USER)
}
// faShareRuntime lets every local user read the runtime (the published agent
// artifact). Only the service writes there.
func faShareRuntime(dir string) error {
users, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid)
if err != nil {
return err
}
if ok, err := faWinGranted(dir, users, faWinReadExecute); err != nil || ok {
return err
}
return faWinGrant(dir, users, faWinReadExecute, windows.TRUSTEE_IS_WELL_KNOWN_GROUP)
}
// faWinGrant adds an inheritable allow entry for sid to path's existing
// permissions, keeping what is inherited.
func faWinGrant(path string, sid *windows.SID, mask windows.ACCESS_MASK, kind windows.TRUSTEE_TYPE) error {
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
return err
}
dacl, _, err := sd.DACL()
if err != nil {
return err
}
acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{
AccessPermissions: mask,
AccessMode: windows.GRANT_ACCESS,
Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT,
Trustee: windows.TRUSTEE{
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: kind,
TrusteeValue: windows.TrusteeValueFromSID(sid),
},
}}, dacl)
if err != nil {
return err
}
return windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, acl, nil)
}
// faWinGranted reports whether path's permissions hold an allow entry for sid
// with every right in mask, inheritable to what is created inside.
func faWinGranted(path string, sid *windows.SID, mask windows.ACCESS_MASK) (bool, error) {
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
return false, err
}
dacl, _, err := sd.DACL()
if err != nil || dacl == nil {
return false, err
}
const inherits = windows.OBJECT_INHERIT_ACE | windows.CONTAINER_INHERIT_ACE
for i := uint32(0); i < uint32(dacl.AceCount); i++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, i, &ace); err != nil {
return false, err
}
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || ace.Mask&mask != mask ||
ace.Header.AceFlags&inherits != inherits || ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
continue
}
if (*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(sid) {
return true, nil
}
}
return false, nil
}
// faCheckOwnedRoot: the directory the service is about to write into is a real
// directory, not a junction or a link to somewhere else. On Windows the service
// writes only into the state leaf, whose ancestors the owner cannot change.
func faCheckOwnedRoot(_ *os.Root, dir string, _ *faOwner) error {
fi, err := os.Lstat(dir)
if err != nil {
return err
}
if fi.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 || !fi.IsDir() {
return fmt.Errorf("%s is not a plain directory — refusing to write into it", dir)
}
return nil
}
// faDropEnv: faWinDropEnv with this owner's name and profile.
func faDropEnv(o *faOwner, stateDir string) (set []string, passthrough []string) {
profile := o.home
if profile == stateDir {
profile = ""
}
return faWinDropEnv(o.plat.user, profile, stateDir)
}
// faStartChild starts the child as the owner when dropping, as itself otherwise.
func faStartChild(ctx context.Context, cmd *exec.Cmd, o *faOwner) (faChild, error) {
if o == nil || !o.drop {
if err := cmd.Start(); err != nil {
return nil, err
}
return execChild{cmd}, nil
}
if cmd.Err != nil {
return nil, cmd.Err
}
tok, err := faWinSessionToken(o.plat.sid)
if err != nil {
return nil, fmt.Errorf("%s %w", o.name, err)
}
defer tok.Close()
return faWinStartAs(ctx, tok, cmd)
}
// faWinSessionToken is the owner's token from a session they are signed in to,
// an active one before a disconnected one. The token is the one their session
// holds: for an administrator under UAC that is the limited token, not the
// elevated one.
func faWinSessionToken(sid string) (windows.Token, error) {
var sessions *windows.WTS_SESSION_INFO
var n uint32
if err := windows.WTSEnumerateSessions(0, 0, 1, &sessions, &n); err != nil {
return 0, fmt.Errorf("cannot list this machine's sessions: %w", err)
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessions)))
var other windows.Token
for _, s := range unsafe.Slice(sessions, n) {
var t windows.Token
if windows.WTSQueryUserToken(s.SessionID, &t) != nil {
continue
}
u, err := t.GetTokenUser()
if err != nil || u.User.Sid.String() != sid {
t.Close()
continue
}
if s.State == windows.WTSActive {
if other != 0 {
other.Close()
}
return t, nil
}
if other == 0 {
other = t
} else {
t.Close()
}
}
if other != 0 {
return other, nil
}
return 0, errOwnerNotSignedIn
}
// faWinStartAs creates the child under tok. Its standard input is NUL and its
// output and error go to the log the caller opened (cmd.Stdout); those two
// handles are the only ones it inherits.
func faWinStartAs(ctx context.Context, tok windows.Token, cmd *exec.Cmd) (faChild, error) {
logf, ok := cmd.Stdout.(*os.File)
if !ok || logf == nil {
return nil, errors.New("the agent's log is not a file")
}
app, err := windows.UTF16PtrFromString(cmd.Path)
if err != nil {
return nil, err
}
line, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(cmd.Args))
if err != nil {
return nil, err
}
var dir *uint16
if cmd.Dir != "" {
if dir, err = windows.UTF16PtrFromString(cmd.Dir); err != nil {
return nil, err
}
}
env := faWinEnvBlock(cmd.Env)
self := windows.CurrentProcess()
var out windows.Handle
if err := windows.DuplicateHandle(self, windows.Handle(logf.Fd()), self, &out, 0, true, windows.DUPLICATE_SAME_ACCESS); err != nil {
return nil, fmt.Errorf("cannot hand the log to the agent: %w", err)
}
defer windows.CloseHandle(out)
nulName, _ := windows.UTF16PtrFromString("NUL")
sa := &windows.SecurityAttributes{InheritHandle: 1}
sa.Length = uint32(unsafe.Sizeof(*sa))
in, err := windows.CreateFile(nulName, windows.GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
sa, windows.OPEN_EXISTING, 0, 0)
if err != nil {
return nil, fmt.Errorf("cannot open NUL for the agent: %w", err)
}
defer windows.CloseHandle(in)
attrs, err := windows.NewProcThreadAttributeList(1)
if err != nil {
return nil, err
}
defer attrs.Delete()
handles := []windows.Handle{in, out}
if err := attrs.Update(windows.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&handles[0]),
uintptr(len(handles))*unsafe.Sizeof(handles[0])); err != nil {
return nil, err
}
desktop, _ := windows.UTF16PtrFromString("")
si := &windows.StartupInfoEx{ProcThreadAttributeList: attrs.List()}
si.Cb = uint32(unsafe.Sizeof(*si))
si.Flags = windows.STARTF_USESTDHANDLES | windows.STARTF_USESHOWWINDOW
si.ShowWindow = windows.SW_HIDE
si.StdInput, si.StdOutput, si.StdErr = in, out, out
si.Desktop = desktop
var pi windows.ProcessInformation
flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.EXTENDED_STARTUPINFO_PRESENT | windows.CREATE_NO_WINDOW)
if err := windows.CreateProcessAsUser(tok, app, line, nil, nil, true, flags, &env[0], dir, &si.StartupInfo, &pi); err != nil {
return nil, fmt.Errorf("cannot start the agent as its owner: %w", err)
}
windows.CloseHandle(pi.Thread)
// FindProcess opens its own handle while pi.Process is still open, so the
// pid cannot have been reused in between.
p, err := os.FindProcess(int(pi.ProcessId))
windows.CloseHandle(pi.Process)
if err != nil {
return nil, err
}
return &winChild{p: p, ctx: ctx}, nil
}
// winChild is a child created by faWinStartAs. Waiting ends it when ctx does,
// as exec.CommandContext would.
type winChild struct {
p *os.Process
ctx context.Context
}
func (c *winChild) Pid() int { return c.p.Pid }
func (c *winChild) Wait() error {
done := make(chan struct{})
go func() {
select {
case <-c.ctx.Done():
_ = c.p.Kill()
case <-done:
}
}()
st, err := c.p.Wait()
close(done)
if err != nil {
return err
}
if !st.Success() {
return fmt.Errorf("exit status %d", st.ExitCode())
}
return nil
}
// sysProcAttr: the Windows drop does not go through a credential; faStartChild
// starts the child under the owner's token itself.
func (o *faOwner) sysProcAttr() *syscall.SysProcAttr { return nil }
// faFdSane: the open log is a plain file with one link and no reparse point. A
// hard link the child planted to a file the service can write would otherwise
// hand the child the service's append on it.
func faFdSane(f *os.File) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(windows.Handle(f.Fd()), &info); err != nil {
return err
}
if info.FileAttributes&(windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0 {
return errors.New("not a regular file")
}
if info.NumberOfLinks != 1 {
return fmt.Errorf("has %d links", info.NumberOfLinks)
}
return nil
}
func faInfoLinks(os.FileInfo) (uint64, bool) { return 0, false }