531 lines
18 KiB
Go
531 lines
18 KiB
Go
//go:build windows
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"unsafe"
|
|
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
// Windows enumeration for the observe plane.
|
|
//
|
|
// This is the gap that mattered most: the bastion already SHIPS a Windows
|
|
// binary and enforces its grants there, but both enumerators were /proc and
|
|
// `ss`, so a Windows box observed nothing at all while reporting healthy. On
|
|
// the fleets where "we have machines we have no visibility of" is the actual
|
|
// problem, those machines are overwhelmingly Windows.
|
|
//
|
|
// Nothing here is a new dependency: golang.org/x/sys was already in the module
|
|
// graph, and every struct layout below is taken from ITS definitions via
|
|
// unsafe.Offsetof rather than hardcoded — so a layout change upstream moves
|
|
// our offsets with it instead of silently corrupting a read.
|
|
//
|
|
// Two enumerators:
|
|
//
|
|
// processes CreateToolhelp32Snapshot, then the PEB for the command line.
|
|
// Toolhelp gives the image name only, and on Windows the agent
|
|
// worth catching (claude-code, an npm package) runs as node.exe —
|
|
// so without the command line the common case is invisible.
|
|
// connections GetExtendedTcpTable (iphlpapi), IPv4 and IPv6. Deliberately
|
|
// NOT `netstat -ano`: netstat's STATE column is localized, so
|
|
// parsing it would silently find nothing on a non-English
|
|
// Windows. The API returns a numeric state instead.
|
|
//
|
|
// Every per-process read is best-effort. Reading another process's memory
|
|
// needs rights the bastion may not hold (a process of another user, or an
|
|
// elevated one, when the bastion is not itself elevated). A refusal yields an
|
|
// empty command line or delegation tag, which is the SAME conservative outcome
|
|
// as Linux when /proc/PID/environ is unreadable: the agent is still observed,
|
|
// and an unreadable tag reads as unrequested, which surfaces it rather than
|
|
// hiding it.
|
|
|
|
const (
|
|
agentScanSupported = true
|
|
agentScanKind = "toolhelp+iphlpapi"
|
|
)
|
|
|
|
// Offsets are derived from the x/sys/windows struct definitions, never written
|
|
// down as literals: PEB.ProcessParameters, RTL_USER_PROCESS_PARAMETERS's
|
|
// CommandLine and Environment, and NTUnicodeString.Buffer.
|
|
var (
|
|
_peb windows.PEB
|
|
_rupp windows.RTL_USER_PROCESS_PARAMETERS
|
|
_ntus windows.NTUnicodeString
|
|
|
|
offPEBProcessParameters = unsafe.Offsetof(_peb.ProcessParameters)
|
|
offRUPPCommandLine = unsafe.Offsetof(_rupp.CommandLine)
|
|
offRUPPEnvironment = unsafe.Offsetof(_rupp.Environment)
|
|
offNTUSBuffer = unsafe.Offsetof(_ntus.Buffer)
|
|
|
|
ptrSize = int(unsafe.Sizeof(uintptr(0)))
|
|
)
|
|
|
|
// winEnvReadSizes: the environment block has no length we can read portably
|
|
// (the size field's offset moved across Windows releases), so we read a bounded
|
|
// window and stop at the block's own double-NUL terminator. A read that runs
|
|
// off the end of the mapped region fails wholesale, so we step down rather than
|
|
// give up — a 4 KiB environment is still worth having.
|
|
var winEnvReadSizes = []int{64 * 1024, 16 * 1024, 4 * 1024}
|
|
|
|
const winCmdlineMax = 32 * 1024
|
|
|
|
func readRemote(h windows.Handle, addr uintptr, n int) ([]byte, error) {
|
|
if addr == 0 || n <= 0 {
|
|
return nil, errors.New("remote read: bad address or size")
|
|
}
|
|
buf := make([]byte, n)
|
|
var got uintptr
|
|
if err := windows.ReadProcessMemory(h, addr, &buf[0], uintptr(n), &got); err != nil {
|
|
return nil, err
|
|
}
|
|
if int(got) < n {
|
|
return buf[:got], nil
|
|
}
|
|
return buf, nil
|
|
}
|
|
|
|
func readRemotePtr(h windows.Handle, addr uintptr) (uintptr, error) {
|
|
b, err := readRemote(h, addr, ptrSize)
|
|
if err != nil || len(b) < ptrSize {
|
|
if err == nil {
|
|
err = errors.New("remote read: short pointer")
|
|
}
|
|
return 0, err
|
|
}
|
|
if ptrSize == 8 {
|
|
return uintptr(binary.LittleEndian.Uint64(b)), nil
|
|
}
|
|
return uintptr(binary.LittleEndian.Uint32(b)), nil
|
|
}
|
|
|
|
// winOpenForRead opens a process for the two rights the reads below need.
|
|
// PROCESS_QUERY_LIMITED_INFORMATION (not the full QUERY_INFORMATION) is the
|
|
// least privilege that still answers NtQueryInformationProcess.
|
|
func winOpenForRead(pid int) (windows.Handle, error) {
|
|
return windows.OpenProcess(
|
|
windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ,
|
|
false, uint32(pid))
|
|
}
|
|
|
|
// winImagePath is the KERNEL's record of which file a process runs, from an
|
|
// already-open handle: QueryFullProcessImageName, which reads the image path
|
|
// the loader recorded rather than anything in the process's own memory.
|
|
//
|
|
// NOT Toolhelp32's PROCESSENTRY32.ExeFile — the `exe` local in scanProcesses
|
|
// below, and origin_windows.go:50. That field is a BASENAME with no directory
|
|
// at all, so "claude.exe" under C:\Users\user\AppData\... and "claude.exe"
|
|
// dropped in %TEMP% are one string. A basename cannot be an identity.
|
|
//
|
|
// Flags 0, never PROCESS_NAME_NATIVE: the Win32 form (C:\...) is what a person
|
|
// reading the page recognises and what another tool on the box would print.
|
|
// The native form names the same file through a device path
|
|
// (\Device\HarddiskVolume3\...) whose volume numbering is not stable across
|
|
// boots.
|
|
//
|
|
// Two buffer sizes, the shape winEnvReadSizes above already uses. MAX_PATH is
|
|
// not the limit on a machine with long paths enabled, so one try is not enough;
|
|
// past 4096 there is no point trying, because sanitizeExePath refuses a path
|
|
// that long anyway (agents_parse.go) and the extra pass would only produce a
|
|
// value this function throws away. A refusal — a process of another user, or an
|
|
// elevated one when the bastion is not — yields "".
|
|
func winImagePath(h windows.Handle) string {
|
|
for _, n := range []uint32{windows.MAX_PATH, 4096} {
|
|
buf := make([]uint16, n)
|
|
size := n
|
|
err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size)
|
|
if err == nil {
|
|
return sanitizeExePath(windows.UTF16ToString(buf[:size]))
|
|
}
|
|
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
|
|
return "" // a refusal, not a small buffer: a bigger one changes nothing
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// winProcessParams walks PBI -> PEB -> ProcessParameters and returns the
|
|
// remote address of the parameter block.
|
|
func winProcessParams(h windows.Handle) (uintptr, error) {
|
|
var pbi windows.PROCESS_BASIC_INFORMATION
|
|
if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation,
|
|
unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), nil); err != nil {
|
|
return 0, err
|
|
}
|
|
peb := uintptr(unsafe.Pointer(pbi.PebBaseAddress))
|
|
if peb == 0 {
|
|
return 0, errors.New("process params: no PEB")
|
|
}
|
|
return readRemotePtr(h, peb+offPEBProcessParameters)
|
|
}
|
|
|
|
// winCmdline reads a process's full command line out of its PEB.
|
|
func winCmdline(h windows.Handle) string {
|
|
params, err := winProcessParams(h)
|
|
if err != nil || params == 0 {
|
|
return ""
|
|
}
|
|
us, err := readRemote(h, params+offRUPPCommandLine, int(unsafe.Sizeof(_ntus)))
|
|
if err != nil || len(us) < int(offNTUSBuffer)+ptrSize {
|
|
return ""
|
|
}
|
|
length := int(binary.LittleEndian.Uint16(us[0:2])) // bytes, not code units
|
|
if length <= 0 {
|
|
return ""
|
|
}
|
|
if length > winCmdlineMax {
|
|
length = winCmdlineMax
|
|
}
|
|
bufAddr, err := readRemotePtr(h, params+offRUPPCommandLine+offNTUSBuffer)
|
|
if err != nil || bufAddr == 0 {
|
|
return ""
|
|
}
|
|
raw, err := readRemote(h, bufAddr, length)
|
|
if err != nil || len(raw) < 2 {
|
|
return ""
|
|
}
|
|
u16 := make([]uint16, len(raw)/2)
|
|
for i := range u16 {
|
|
u16[i] = uint16(raw[i*2]) | uint16(raw[i*2+1])<<8
|
|
}
|
|
return windows.UTF16ToString(u16)
|
|
}
|
|
|
|
// winEnvValue reads one variable out of a process's environment block.
|
|
func winEnvValue(h windows.Handle, key string) string {
|
|
params, err := winProcessParams(h)
|
|
if err != nil || params == 0 {
|
|
return ""
|
|
}
|
|
envAddr, err := readRemotePtr(h, params+offRUPPEnvironment)
|
|
if err != nil || envAddr == 0 {
|
|
return ""
|
|
}
|
|
for _, n := range winEnvReadSizes {
|
|
raw, err := readRemote(h, envAddr, n)
|
|
if err != nil || len(raw) == 0 {
|
|
continue
|
|
}
|
|
if v := parseWinEnvBlock(raw, key); v != "" {
|
|
return v
|
|
}
|
|
// A successful read that simply lacks the key is an answer, not a
|
|
// reason to retry smaller.
|
|
return ""
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// winParents caches the pid -> parent map from the last Toolhelp walk, so the
|
|
// shared classifier's per-pid parent lookups cost nothing extra.
|
|
var winParents = struct {
|
|
sync.Mutex
|
|
m map[int]int
|
|
}{m: map[int]int{}}
|
|
|
|
func procParent(pid int) int {
|
|
winParents.Lock()
|
|
defer winParents.Unlock()
|
|
return winParents.m[pid]
|
|
}
|
|
|
|
// winCreationTime is when a process started, in 100ns FILETIME units.
|
|
func winCreationTime(pid int) (int64, bool) {
|
|
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
defer windows.CloseHandle(h)
|
|
var created, exited, kernel, user windows.Filetime
|
|
if err := windows.GetProcessTimes(h, &created, &exited, &kernel, &user); err != nil {
|
|
return 0, false
|
|
}
|
|
return created.Nanoseconds(), true
|
|
}
|
|
|
|
// processParents walks a fresh snapshot for the CONTAIN path, and keeps only
|
|
// parent links that can be true.
|
|
//
|
|
// Toolhelp's ParentProcessID is the pid of the process that CREATED this one,
|
|
// recorded once and never updated. When that creator exits, Windows recycles
|
|
// its pid; a later process (say, the agent being contained) can receive it,
|
|
// and every orphan the old creator left behind then reads as the agent's
|
|
// child. On a kill path that is an unrelated process terminated. A real
|
|
// parent is always older than its child, so a link is kept only when both
|
|
// creation times are readable and the child started after the parent; an
|
|
// unreadable process contributes no link at all (fail closed: no kill). Linux
|
|
// and macOS reparent orphans to a live process and need none of this.
|
|
func processParents() map[int]int {
|
|
out := map[int]int{}
|
|
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
defer windows.CloseHandle(snap)
|
|
var e windows.ProcessEntry32
|
|
e.Size = uint32(unsafe.Sizeof(e))
|
|
raw := map[int]int{}
|
|
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
|
|
if pid, pp := int(e.ProcessID), int(e.ParentProcessID); pid > 0 && pp > 0 {
|
|
raw[pid] = pp
|
|
}
|
|
}
|
|
created := map[int]int64{}
|
|
timeOf := func(pid int) (int64, bool) {
|
|
if t, ok := created[pid]; ok {
|
|
return t, t > 0
|
|
}
|
|
t, ok := winCreationTime(pid)
|
|
if !ok {
|
|
created[pid] = 0
|
|
return 0, false
|
|
}
|
|
created[pid] = t
|
|
return t, true
|
|
}
|
|
for pid, pp := range raw {
|
|
ct, okc := timeOf(pid)
|
|
pt, okp := timeOf(pp)
|
|
if okc && okp && ct >= pt {
|
|
out[pid] = pp
|
|
}
|
|
}
|
|
winParents.Lock()
|
|
winParents.m = raw // classification (not killing) may use the raw links
|
|
winParents.Unlock()
|
|
return out
|
|
}
|
|
|
|
// scanProcesses: an error is a snapshot that did not happen (Toolhelp can
|
|
// fail transiently), never an empty box; the heartbeat withholds `agents`.
|
|
func scanProcesses() (map[int]agentObs, error) {
|
|
out := map[int]agentObs{}
|
|
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("process snapshot: %w", err)
|
|
}
|
|
defer windows.CloseHandle(snap)
|
|
|
|
var e windows.ProcessEntry32
|
|
e.Size = uint32(unsafe.Sizeof(e))
|
|
self := os.Getpid()
|
|
parents := map[int]int{}
|
|
defer func() {
|
|
winParents.Lock()
|
|
winParents.m = parents
|
|
winParents.Unlock()
|
|
}()
|
|
// The loop ends on ERROR_NO_MORE_FILES; any other error — Process32First
|
|
// refusing a snapshot it just handed out — is a walk that did not happen.
|
|
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
|
|
pid := int(e.ProcessID)
|
|
if pp := int(e.ParentProcessID); pid > 0 && pp > 0 {
|
|
parents[pid] = pp
|
|
}
|
|
if pid <= 0 || pid == self {
|
|
continue
|
|
}
|
|
exe := windows.UTF16ToString(e.ExeFile[:])
|
|
base := binBase(exe)
|
|
_, direct := agentBinaries[base]
|
|
_, autoDirect := automationBinaries[base]
|
|
if !direct && !autoDirect && !interpreters[base] {
|
|
continue // not a candidate: never pay for a memory read
|
|
}
|
|
// argv[0] comes from Toolhelp (always right); the raw command line is
|
|
// appended as one element purely so the substring rules can see it.
|
|
// No tokenizing: Windows command lines are a single quoted string, and
|
|
// splitting one on spaces mangles "C:\Program Files\..." into a
|
|
// basename that matches nothing.
|
|
// ONE handle for all three reads. Opening the process per field cost
|
|
// three OpenProcess/CloseHandle round trips for every candidate on
|
|
// every beat, and could even disagree with itself if the pid was
|
|
// recycled between them.
|
|
argv := []string{exe}
|
|
cmdline, user, token, image := "", "", "", ""
|
|
if h, herr := winOpenForRead(pid); herr == nil {
|
|
cmdline = winCmdline(h)
|
|
user = winTokenUser(h)
|
|
token = winAgentToken(h)
|
|
image = winImagePath(h)
|
|
windows.CloseHandle(h)
|
|
} else if h, herr := windows.OpenProcess(
|
|
windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)); herr == nil {
|
|
// The image path needs strictly LESS than the reads above:
|
|
// QueryFullProcessImageName takes QUERY_LIMITED_INFORMATION alone,
|
|
// while the PEB walk also needs VM_READ. So a process whose memory
|
|
// this bastion may not read can still say which FILE it is running —
|
|
// and that is the difference between an observation the user can
|
|
// mark as theirs and one they cannot (sanction.go refuses to mark an
|
|
// observation with no path). Without this branch, every process that
|
|
// refused the combined open would be permanently unsanctionable.
|
|
image = winImagePath(h)
|
|
windows.CloseHandle(h)
|
|
}
|
|
if cmdline != "" {
|
|
argv = append(argv, cmdline)
|
|
}
|
|
sig, auto, ok := matchSignature(argv)
|
|
if !ok {
|
|
continue
|
|
}
|
|
shown := cmdline
|
|
if shown == "" {
|
|
shown = exe
|
|
}
|
|
out[pid] = agentObs{PID: pid, User: user, Signature: sig,
|
|
Cmdline: truncate(shown, 200), Exe: image,
|
|
DelegationToken: token, raw: shown, automation: auto}
|
|
}
|
|
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
|
return nil, fmt.Errorf("process walk: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ── connections: GetExtendedTcpTable ────────────────────────────────────────
|
|
|
|
var (
|
|
modIphlpapi = windows.NewLazySystemDLL("iphlpapi.dll")
|
|
procGetExtendedTcpTable = modIphlpapi.NewProc("GetExtendedTcpTable")
|
|
)
|
|
|
|
const (
|
|
winAFInet = 2 // AF_INET
|
|
winAFInet6 = 23 // AF_INET6
|
|
// TCP_TABLE_OWNER_PID_CONNECTIONS — connected sockets with their owning pid.
|
|
winTCPTableOwnerPIDConnections = 4
|
|
winErrInsufficientBuffer = 122
|
|
)
|
|
|
|
// extendedTCPTable fetches the owner-pid TCP table for one address family.
|
|
// Two-pass sizing, with a couple of retries because the table can grow between
|
|
// the sizing call and the read.
|
|
func extendedTCPTable(af uint32) ([]byte, error) {
|
|
var size uint32
|
|
r, _, _ := procGetExtendedTcpTable.Call(0, uintptr(unsafe.Pointer(&size)), 0,
|
|
uintptr(af), winTCPTableOwnerPIDConnections, 0)
|
|
if r != 0 && r != winErrInsufficientBuffer {
|
|
return nil, syscall.Errno(r)
|
|
}
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
if size == 0 {
|
|
return nil, nil
|
|
}
|
|
buf := make([]byte, size)
|
|
r, _, _ = procGetExtendedTcpTable.Call(uintptr(unsafe.Pointer(&buf[0])),
|
|
uintptr(unsafe.Pointer(&size)), 0, uintptr(af), winTCPTableOwnerPIDConnections, 0)
|
|
if r == 0 {
|
|
return buf, nil
|
|
}
|
|
if r != winErrInsufficientBuffer {
|
|
return nil, syscall.Errno(r)
|
|
}
|
|
// size now holds the larger requirement; loop and try again.
|
|
}
|
|
return nil, errors.New("GetExtendedTcpTable: table kept growing")
|
|
}
|
|
|
|
func scanConnections() []connObs {
|
|
self := os.Getpid()
|
|
conns := []connObs{}
|
|
for _, fam := range []struct {
|
|
af uint32
|
|
v6 bool
|
|
}{{winAFInet, false}, {winAFInet6, true}} {
|
|
buf, err := extendedTCPTable(fam.af)
|
|
if err != nil || len(buf) == 0 {
|
|
continue // one family failing must not lose the other
|
|
}
|
|
conns = append(conns, parseTCPTable(buf, fam.v6, self)...)
|
|
}
|
|
return conns
|
|
}
|
|
|
|
// ── per-process detail ──────────────────────────────────────────────────────
|
|
|
|
// winTokenUser resolves the owner of an ALREADY-OPEN process handle, so a
|
|
// caller holding one handle can read every field from it.
|
|
func winTokenUser(h windows.Handle) string {
|
|
var tok windows.Token
|
|
if err := windows.OpenProcessToken(h, windows.TOKEN_QUERY, &tok); err != nil {
|
|
return ""
|
|
}
|
|
defer tok.Close()
|
|
tu, err := tok.GetTokenUser()
|
|
if err != nil || tu.User.Sid == nil {
|
|
return ""
|
|
}
|
|
account, domain, _, err := tu.User.Sid.LookupAccount("")
|
|
if err != nil {
|
|
return tu.User.Sid.String()
|
|
}
|
|
if domain != "" {
|
|
return domain + `\` + account
|
|
}
|
|
return account
|
|
}
|
|
|
|
func procUser(pid int) string {
|
|
h, err := winOpenForRead(pid)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer windows.CloseHandle(h)
|
|
return winTokenUser(h)
|
|
}
|
|
|
|
func procCmdline(pid int) string {
|
|
h, err := winOpenForRead(pid)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer windows.CloseHandle(h)
|
|
return truncate(strings.TrimSpace(winCmdline(h)), 200)
|
|
}
|
|
|
|
// procExe is the KERNEL's executable path for one pid. It opens with
|
|
// QUERY_LIMITED_INFORMATION alone — the least right that answers
|
|
// QueryFullProcessImageName, and less than winOpenForRead asks for — so a
|
|
// process whose memory is closed to this bastion still reports which file it
|
|
// runs. "" when even that handle is refused.
|
|
func procExe(pid int) string {
|
|
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer windows.CloseHandle(h)
|
|
return winImagePath(h)
|
|
}
|
|
|
|
// winAgentToken reads the delegation tag out of an already-open process
|
|
// handle, under either accepted name. One handle, both names: the environment
|
|
// block is read per call, and re-opening the process for the second name could
|
|
// land on a recycled pid.
|
|
func winAgentToken(h windows.Handle) string {
|
|
for _, name := range agentTokenEnvNames {
|
|
if tok := winEnvValue(h, name); tok != "" {
|
|
return tok
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// procEnvToken reads the host-issued delegation tag (PRIVATAE_AGENT_TOKEN, or
|
|
// its older name; see agentTokenEnvNames) from the agent's environment block.
|
|
func procEnvToken(pid int) string {
|
|
h, err := winOpenForRead(pid)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer windows.CloseHandle(h)
|
|
return winAgentToken(h)
|
|
}
|