feat: authenticate Windows command launcher and signal helper
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user