feat: authenticate Windows command launcher and signal helper

This commit is contained in:
2026-09-06 14:38:16 +00:00
parent 158fd8f3aa
commit 0383155b86
13 changed files with 1270 additions and 189 deletions
@@ -12,9 +12,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"unsafe"
@@ -47,13 +45,6 @@ var (
func stdinLineEnding() []byte { return []byte{'\r', '\n'} }
type nativeHandles struct {
process winapi.Handle
job winapi.Handle
pid uint32
close sync.Once
}
// NewSupervisor constructs the machine-wide Windows implementation. The
// service process is expected to run as LocalSystem; token selection verifies
// that assumption when a command is started and records the selected context.
@@ -94,12 +85,14 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
}
return nil, cause
}
token, identity, err := manager.selectToken(spec.Execution.GetElevated())
if err != nil {
return fail(err)
}
defer token.Close()
if err := verifyWorkingDirectory(spec.WorkingDirectory); err != nil {
return fail(err)
}
wrapperPath, cleanup, err := materializeWrapper(spec.WorkingDirectory, wrapper, manager.options.Now(), func(path string) error {
return secureWrapperFile(path, identity.UserSID)
})
@@ -118,133 +111,11 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
if err != nil {
return fail(err)
}
stdinRead, stdinWrite, stdoutRead, stdoutWrite, stderrRead, stderrWrite, err := createStandardPipes()
process, err := manager.startViaLauncher(ctx, spec, token, identity, launch, cleanup)
if err != nil {
return fail(err)
}
closeFiles := func() {
for _, file := range []*os.File{stdinRead, stdinWrite, stdoutRead, stdoutWrite, stderrRead, stderrWrite} {
if file != nil {
_ = file.Close()
}
}
}
pipesTransferred := false
defer func() {
// Parent-side handles are retained only after successful process
// creation. Any error path closes both ends here.
if !pipesTransferred {
closeFiles()
}
}()
job, err := createKillOnCloseJob()
if err != nil {
closeFiles()
return fail(fmt.Errorf("create command Job: %w", err))
}
if err := applyJobProfiles(job, manager.options.JobProfiles, spec.ExecutionProfiles); err != nil {
_ = winapi.CloseHandle(job)
return fail(err)
}
cleanupJob := true
defer func() {
if cleanupJob {
_ = winapi.CloseHandle(job)
}
}()
application, err := winapi.UTF16PtrFromString(launch.ApplicationName)
if err != nil {
return fail(err)
}
commandLine, err := winapi.UTF16FromString(launch.CommandLine)
if err != nil {
return fail(err)
}
workingDirectory, err := winapi.UTF16PtrFromString(launch.WorkingDirectory)
if err != nil {
return fail(err)
}
attributeList, err := winapi.NewProcThreadAttributeList(1)
if err != nil {
return fail(err)
}
defer attributeList.Delete()
childHandles := []winapi.Handle{winapi.Handle(stdinRead.Fd()), winapi.Handle(stdoutWrite.Fd()), winapi.Handle(stderrWrite.Fd())}
if err := attributeList.Update(winapi.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&childHandles[0]), uintptr(len(childHandles))*unsafe.Sizeof(childHandles[0])); err != nil {
return fail(err)
}
startup := winapi.StartupInfoEx{}
startup.Cb = uint32(unsafe.Sizeof(startup))
startup.Flags = winapi.STARTF_USESTDHANDLES | winapi.STARTF_USESHOWWINDOW
startup.ShowWindow = winapi.SW_HIDE
startup.StdInput = childHandles[0]
startup.StdOutput = childHandles[1]
startup.StdErr = childHandles[2]
startup.ProcThreadAttributeList = attributeList.List()
var processInfo winapi.ProcessInformation
flags := uint32(winapi.CREATE_NEW_CONSOLE | winapi.CREATE_SUSPENDED | winapi.CREATE_UNICODE_ENVIRONMENT | winapi.EXTENDED_STARTUPINFO_PRESENT)
var environmentPointer *uint16
if len(environment) > 0 {
environmentPointer = &environment[0]
}
if err := winapi.CreateProcessAsUser(token, application, &commandLine[0], nil, nil, true, flags, environmentPointer, workingDirectory, &startup.StartupInfo, &processInfo); err != nil {
return fail(fmt.Errorf("create suspended command process: %w", err))
}
// The child owns these handles after CreateProcessAsUser returns. Keep only
// the three parent ends and the process/job handles in the daemon. The
// primary thread remains suspended until the executor has durably recorded
// launch authorization and calls Process.Release.
_ = stdinRead.Close()
_ = stdoutWrite.Close()
_ = stderrWrite.Close()
if err := winapi.AssignProcessToJobObject(job, processInfo.Process); err != nil {
_ = winapi.TerminateProcess(processInfo.Process, 1)
_ = winapi.CloseHandle(processInfo.Process)
_ = winapi.CloseHandle(processInfo.Thread)
return fail(fmt.Errorf("assign command to Job: %w", err))
}
pipesTransferred = true
var threadClosed sync.Once
closeThread := func() {
threadClosed.Do(func() { _ = winapi.CloseHandle(processInfo.Thread) })
}
releaseFn := func() error {
if _, err := winapi.ResumeThread(processInfo.Thread); err != nil {
closeThread()
return fmt.Errorf("release suspended command: %w", err)
}
closeThread()
return nil
}
started := manager.options.Now()
handles := &nativeHandles{process: processInfo.Process, job: job, pid: processInfo.ProcessId}
cleanupJob = false
command := &exec.Cmd{Process: osProcess(processInfo.ProcessId)}
waitFn := func() (int32, bool, error) {
_, waitErr := winapi.WaitForSingleObject(processInfo.Process, winapi.INFINITE)
var code uint32
if err := winapi.GetExitCodeProcess(processInfo.Process, &code); err != nil && waitErr == nil {
waitErr = err
}
closeThread()
handles.close.Do(func() {
_ = winapi.CloseHandle(processInfo.Process)
_ = winapi.CloseHandle(job)
})
return int32(code), false, waitErr
}
killFn := func(code uint32) error {
err := winapi.TerminateJobObject(job, code)
closeThread()
return err
}
process := manager.registerProcess(spec.IssueUUID, identity, command, stdinWrite, stdoutRead, stderrRead, started, waitFn, killFn, releaseFn, cleanup)
process.snapshotFn = func() (supervisor.ResourceSnapshot, error) {
return queryJobSnapshot(job, manager.options.Now())
return nil, err
}
cleanup = nil
return process, nil
}
@@ -267,6 +138,31 @@ func verifyExecutable(path string) error {
return nil
}
func verifyWorkingDirectory(path string) error {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("stat working directory %q: %w", path, err)
}
if !info.IsDir() {
return fmt.Errorf("working directory %q is not a directory", path)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("working directory %q is a symlink", path)
}
name, err := winapi.UTF16PtrFromString(path)
if err != nil {
return err
}
attributes, err := winapi.GetFileAttributes(name)
if err != nil {
return fmt.Errorf("query working directory attributes %q: %w", path, err)
}
if attributes&winapi.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("working directory %q is a reparse point", path)
}
return nil
}
// secureWrapperFile replaces the inherited directory ACL with a protected
// DACL. The service writes the wrapper before this call; afterward only
// LocalSystem and the selected effective token SID can read it. This is done
@@ -311,36 +207,6 @@ func secureWrapperFile(path, effectiveSID string) error {
return winapi.SetNamedSecurityInfo(path, winapi.SE_FILE_OBJECT, winapi.OWNER_SECURITY_INFORMATION|winapi.DACL_SECURITY_INFORMATION|winapi.PROTECTED_DACL_SECURITY_INFORMATION, systemSID, nil, acl, nil)
}
func createStandardPipes() (*os.File, *os.File, *os.File, *os.File, *os.File, *os.File, error) {
security := &winapi.SecurityAttributes{Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})), InheritHandle: 1}
var stdinReadHandle, stdinWriteHandle winapi.Handle
var stdoutReadHandle, stdoutWriteHandle winapi.Handle
var stderrReadHandle, stderrWriteHandle winapi.Handle
if err := winapi.CreatePipe(&stdinReadHandle, &stdinWriteHandle, security, 0); err != nil {
return nil, nil, nil, nil, nil, nil, err
}
if err := winapi.CreatePipe(&stdoutReadHandle, &stdoutWriteHandle, security, 0); err != nil {
_ = winapi.CloseHandle(stdinReadHandle)
_ = winapi.CloseHandle(stdinWriteHandle)
return nil, nil, nil, nil, nil, nil, err
}
if err := winapi.CreatePipe(&stderrReadHandle, &stderrWriteHandle, security, 0); err != nil {
for _, handle := range []winapi.Handle{stdinReadHandle, stdinWriteHandle, stdoutReadHandle, stdoutWriteHandle} {
_ = winapi.CloseHandle(handle)
}
return nil, nil, nil, nil, nil, nil, err
}
for _, handle := range []winapi.Handle{stdinWriteHandle, stdoutReadHandle, stderrReadHandle} {
if err := winapi.SetHandleInformation(handle, winapi.HANDLE_FLAG_INHERIT, 0); err != nil {
for _, closeHandle := range []winapi.Handle{stdinReadHandle, stdinWriteHandle, stdoutReadHandle, stdoutWriteHandle, stderrReadHandle, stderrWriteHandle} {
_ = winapi.CloseHandle(closeHandle)
}
return nil, nil, nil, nil, nil, nil, err
}
}
return os.NewFile(uintptr(stdinReadHandle), "rvbox-stdin-read"), os.NewFile(uintptr(stdinWriteHandle), "rvbox-stdin-write"), os.NewFile(uintptr(stdoutReadHandle), "rvbox-stdout-read"), os.NewFile(uintptr(stdoutWriteHandle), "rvbox-stdout-write"), os.NewFile(uintptr(stderrReadHandle), "rvbox-stderr-read"), os.NewFile(uintptr(stderrWriteHandle), "rvbox-stderr-write"), nil
}
func createKillOnCloseJob() (winapi.Handle, error) {
job, err := winapi.CreateJobObject(nil, nil)
if err != nil {
@@ -900,18 +766,32 @@ func (manager *execSupervisor) Signal(ctx context.Context, process supervisor.Pr
return supervisor.SignalOutcome{}, errors.New("unsupported signal")
}
if signal == supervisor.SignalTerm {
// Each command has its own hidden console. The helper path is kept in
// this short-lived call and is deliberately best-effort: a session that
// has already exited or a policy that denies AttachConsole is recorded,
// then the bounded grace period ends in an explicit Job kill.
breakDelivered, breakErr := sendControlBreak(native.pid)
// Each command has its own hidden console. The short-lived canonical
// helper is preferred; the direct attach path remains a last-resort
// diagnostic fallback when helper creation is unavailable.
breakDelivered, breakErr := false, error(nil)
helperUsed := native.signalFn != nil
if native.signalFn != nil {
breakDelivered, breakErr = native.signalFn(ctx)
if breakErr != nil && ctx.Err() == nil {
// Keep TERM best-effort if the helper itself cannot be started;
// the fallback still checks the immutable PID/creation evidence.
breakDelivered, breakErr = sendControlBreakVerified(native.pid, native.creation)
}
} else {
breakDelivered, breakErr = sendControlBreakVerified(native.pid, native.creation)
}
if breakErr != nil && ctx.Err() != nil {
return supervisor.SignalOutcome{}, ctx.Err()
}
if breakDelivered {
select {
case <-native.done:
return supervisor.SignalOutcome{Delivered: true, Detail: "CTRL_BREAK delivered", ObservedAt: manager.options.Now()}, nil
detail := "CTRL_BREAK delivered"
if helperUsed {
detail = "CTRL_BREAK delivered by authenticated signal helper"
}
return supervisor.SignalOutcome{Delivered: true, Detail: detail, ObservedAt: manager.options.Now()}, nil
default:
}
}
@@ -933,11 +813,9 @@ func (manager *execSupervisor) Signal(ctx context.Context, process supervisor.Pr
return supervisor.SignalOutcome{Delivered: true, Escalated: signal == supervisor.SignalTerm, Detail: detail, ObservedAt: manager.options.Now()}, nil
}
// sendControlBreak is the native equivalent of the signal-helper mode. The
// production helper is normally a separate short-lived rvbox.exe invocation;
// this direct implementation keeps the same verified PID/console boundary
// for the first service build and never addresses a process by a caller-
// supplied PID. The PID comes only from execProcess metadata.
// sendControlBreak is the last-resort local fallback for the authenticated
// signal-helper path. It never addresses a caller-supplied PID: the PID comes
// only from immutable execProcess metadata captured during preparation.
func sendControlBreak(pid uint32) (bool, error) {
if pid == 0 {
return false, errors.New("command has no verified console PID")
@@ -962,6 +840,25 @@ func sendControlBreak(pid uint32) (bool, error) {
return true, nil
}
func sendControlBreakVerified(pid uint32, creation uint64) (bool, error) {
if creation == 0 {
return false, errors.New("command has no verified process creation time")
}
process, err := winapi.OpenProcess(winapi.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return false, err
}
actual, err := processCreation(process)
_ = winapi.CloseHandle(process)
if err != nil {
return false, err
}
if actual != creation {
return false, errors.New("command PID was reused")
}
return sendControlBreak(pid)
}
func (manager *execSupervisor) Snapshot(ctx context.Context, process supervisor.Process) (supervisor.ResourceSnapshot, error) {
if process == nil {
return supervisor.ResourceSnapshot{}, ErrProcessNotFound