Security Notes
CI/CD & Supply Chain

Windows CI Runner Security: Job Objects, ConPTY, ProgramData ACLs, and Hyper-V Isolation

20 min read 10 sections 7 model answers verified 2026-10 version 1 · updated 2026-10-24

Last verified2026-10 Also see: Build platform security (runners, isolation, SSH into jobs) · GitHub & CI/CD security (workflows, OIDC, runners) · Sandbox isolation deep dive (why a VM wall beats a shared kernel) · Endpoint detection (Sysmon, the process tree) · Active Directory · Windows registry


Start here: the basics in plain words

Most CI security writing assumes Linux: namespaces, cgroups, seccomp. Windows runners are common too (building .NET, native desktop apps, drivers, game clients), and Windows does the same jobs with completely different machinery. If you port your Linux mental model straight across, you will reach for a control that does not exist and miss the one that does.

Four Windows mechanisms carry most of the weight on a build runner. Learn what each one actually guarantees:

Job object

A kernel object that groups processes so you can manage and kill them as one unit, and cap their memory, CPU and process count. It is the real answer to "a build step spawned a tree of processes and some leaked". It is a resource and lifecycle control, not a security boundary.

ConPTY

The Windows Pseudoconsole. The modern way to give a program a fake terminal so you can drive it over pipes, for example an interactive debug shell over OpenSSH. It is plumbing, and the pipes and tokens around it are the attack surface.

ProgramData ACLs

C:\ProgramData is a shared, world-readable-ish folder many services use for machine-wide state. Its default permissions let any authenticated user create files there, which turns it into a classic local privilege-escalation spot if a privileged service trusts what it finds.

Hyper-V isolation

Running a Windows container inside a tiny virtual machine with its own kernel, instead of sharing the host kernel. This is the only one of the four that Microsoft calls a security boundary.

Memory hook

"three tools, one boundary." Job objects, ConPTY and ProgramData hardening are hygiene: they contain, plumb and lock down. Only Hyper-V isolation is a wall Microsoft will stand behind for hostile code. Build the hygiene anyway, because defence in depth, but never tell an interviewer a process-isolated container holds a malicious build.


Why it matters

An interviewer reaching for Windows runner security is checking one thing: do you understand the Windows security model on its own terms, or are you a Linux engineer guessing? The giveaway answers are "I'd put it in a container" (which container? process or Hyper-V isolated?) and "I'd use cgroups" (Windows has no cgroups; it has job objects). Build runners are also a real, high-value target. They compile untrusted pull-request code while holding signing keys, deploy credentials and routes into the corporate network. A Windows runner that reuses a host between jobs, stores a registration token in a world-writable folder, or exposes an interactive debug shell carelessly, hands the next pull request everything the previous one touched.

  1. 1. Untrusted PR runs as a build stepattacknormal, expected
    • The runner was told to compile and test whatever the pull request contains
  2. 2. Step reads a secret left on the hostattackthe mistake
    • A token in C:\ProgramData, a cached credential, a signing key not scoped to the job
  3. 3. Step escalates or persistsattackprocess isolation is not a wall
    • Server Containers share the host kernel; a kernel or service bug reaches the host
  4. Defender's job: make each of those three steps faildefendthe rest of this page
    • Ephemeral host, scoped token, Hyper-V isolation, tight ACLs, process-tree detection

Job Objects: managing the build process tree

What it isA build step is never one process. msbuild launches compilers, test runners, package restores, and each of those launches more. On Linux you would put the lot in a cgroup and kill the cgroup. On Windows the equivalent is a job object: you create one, assign the top process to it, and by default every child CreateProcess call inherits the job. Kill the job, kill the tree. Cap the job, cap the tree.

JOBOBJECT_EXTENDED_LIMIT_INFORMATIONstruct
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEflag
JOB_OBJECT_LIMIT_ACTIVE_PROCESSflag
JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTIONflag
JOB_OBJECT_LIMIT_BREAKAWAY_OKflag
JOBOBJECT_EXTENDED_LIMIT_INFORMATION
The limit block you fill in and pass to SetInformationJobObject: memory cap, active-process cap, and the flags below
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
When the last handle to the job closes, every process in it is terminated. This is how you guarantee no build process outlives the job that owns it
JOB_OBJECT_LIMIT_ACTIVE_PROCESS
Cap the number of concurrent processes, so a fork bomb in a build step cannot exhaust the host
JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION
Stop a crashed child from popping the Windows error dialog that would otherwise wedge the job
JOB_OBJECT_LIMIT_BREAKAWAY_OK
Allow a child created with CREATE_BREAKAWAY_FROM_JOB to leave the job. The hole you must think about

Nested job objects (the ability to put a job inside a job) arrived in Windows 8 and Windows Server 2012. Before that a process could belong to only one job, which made it hard to layer a per-step job inside a per-runner job. Modern runners nest freely.

The breakaway holeBy default, children stay in the job. But if the job was created with JOB_OBJECT_LIMIT_BREAKAWAY_OK, a child launched with the CREATE_BREAKAWAY_FROM_JOB creation flag escapes the job, which means it survives your "kill the job" cleanup and dodges your limits. The defensive setting is to not set JOB_OBJECT_LIMIT_BREAKAWAY_OK and not set JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK. With neither flag present, a child's attempt to break away or reassign itself to another job simply fails. A malicious step cannot opt out of a limit the kernel is enforcing on the job.

In practiceA Go-based runner supervisor on Windows creates the job, sets kill-on-close, and assigns the agent process before it ever runs user build steps:

go
// golang.org/x/sys/windows
job, _ := windows.CreateJobObject(nil, nil)
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
info.BasicLimitInformation.LimitFlags =
    windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE |   // ← no build process outlives the job
    windows.JOB_OBJECT_LIMIT_ACTIVE_PROCESS         // ← cap the process count
info.BasicLimitInformation.ActiveProcessLimit = 512
windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
    uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)))
windows.AssignProcessToJobObject(job, agentProcessHandle)
// Note: BREAKAWAY flags are deliberately NOT set ← a step cannot leave the job
🎯

On the job. Reviewing a Windows runner supervisor, check three things. Is the agent assigned to a job at all? Is KILL_ON_JOB_CLOSE set, so a crashed or hung step cannot leave a zombie compiler holding a mapped secret? And is BREAKAWAY_OK set anywhere? If it is, ask why, because that is the line that lets a build step survive your teardown. The smell is a runner that "kills the process it launched" by PID rather than killing a job, since that misses the whole tree.

Warning

A job object is a containment and lifecycle tool, not a trust boundary. It stops a build step from exhausting the host and guarantees clean teardown. It does not stop a step that has found a local privilege-escalation bug from becoming SYSTEM. Microsoft's own security-servicing list names AppContainer and the VM boundary as security boundaries; it does not list job objects. Treat them as the Windows answer to cgroups, not to namespaces plus a VM.


ConPTY and interactive debug sessions

What it isSometimes an engineer needs to SSH into a stuck build to look around. On Linux that is a pseudo-terminal (PTY). Windows got the same idea late: the Pseudoconsole, or ConPTY, shipped in the Windows 10 October 2018 update. A host program calls CreatePseudoConsole with two pipe ends (one for input, one for output), then launches a child, usually cmd.exe or powershell.exe, attached to that pseudoconsole through the PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE startup attribute. The child thinks it is talking to a real console; the host program is really reading and writing the pipes. OpenSSH's Windows server (sshd) uses exactly this to give you an interactive shell.

  1. 1. Debug service calls CreatePseudoConsoleosgets input pipe + output pipe
    • Two anonymous or named pipes carry keystrokes in and screen updates out
  2. 2. Launches cmd.exe attached to the pseudoconsoleosvia STARTUPINFOEX attribute
    • The child runs under whatever token the service hands it — this is the security-critical choice
  3. 3. Where it goes wrongattacknamed pipe + token
    • A predictable or weakly-ACLed named pipe another process can connect to; a child launched with a more privileged token than the debugging user should have
  4. 4. Lock it downdefendtoken + pipe + audit
    • Launch the shell under the ephemeral, unprivileged job identity; restrict the pipe's ACL; log every session

What goes wrongTwo things. First, named pipes. If the debug plumbing uses a named pipe with a predictable name and a loose ACL, a different process on the host can connect to it. Named-pipe impersonation is a long-standing Windows escalation pattern: a service that connects to a pipe can be made to impersonate the client on the other end, and if the privileged side can be lured into connecting to an attacker-controlled pipe, the attacker gains that side's token. Second, token choice. The shell you spawn runs under whatever token you give it. If the debug service runs as SYSTEM and spawns the interactive shell under its own token for convenience, you have just handed an interactive SYSTEM shell to whoever can reach the session, on a machine that is about to run untrusted code.

🎯

On the job. When a team adds "SSH into the runner to debug" on Windows, ask who the shell runs as. The correct answer is the same unprivileged, per-job identity the build ran under, inside the same job object, so the debug session can see and do exactly what the build could and nothing more. The dangerous answer is "the sshd service account", which is usually over-privileged. Also confirm the session is recorded, because ConPTY output is just a pipe; a well-built runner tees it to an audit log rather than trusting the user not to clear history.

Caution

Do not treat terminal-side logging as a complete audit trail. A user with a shell can disable command echo, pipe through base64, or run a child process that never touches the console. On Linux the robust answer is kernel-level execution logging (an eBPF or auditd execve hook). On Windows the equivalent is process-creation auditing with command lines (event 4688) and Sysmon event 1, captured by the kernel, not by the terminal. See detection below.


ProgramData DACLs and machine token storage

The pieces you need to knowA DACL (Discretionary Access Control List) is the list of "who may do what" attached to a Windows file or folder: a series of ACEs (Access Control Entries) like "BUILTIN\Administrators: Full Control". Inheritance means a new file usually copies its parent folder's inheritable ACEs. C:\ProgramData is the machine-wide, all-users application-data folder, the system-level counterpart to each user's AppData.

What goes wrongThe default ACL on C:\ProgramData lets the Authenticated Users group (or Users) create files and subfolders. New subfolders then inherit permissions that can leave them writable by ordinary users. If a privileged service, or a runner installer running as SYSTEM, writes a sensitive file there (a runner registration token, a cached credential, a config that names a script to execute) under a subfolder that a normal user can write to, then any code on the box, including an untrusted build step running as a low-privileged user, can read or tamper with it. Many real local-privilege-escalation findings are exactly this shape: a SYSTEM service reads or executes a file from a location an unprivileged user could overwrite.

In practiceLock the runner's own directory down with explicit ACEs rather than trusting inheritance. icacls is the tool:

text
:: Take ownership and remove inherited permissive ACEs, then grant only who needs it
icacls C:\actions-runner\_token /inheritance:r                       :: ← drop inherited ACEs
icacls C:\actions-runner\_token /grant:r "SYSTEM:(OI)(CI)F"          :: service identity
icacls C:\actions-runner\_token /grant:r "BUILTIN\Administrators:(OI)(CI)F"
:: NOTE: no ACE for Users / Authenticated Users ← a build step cannot read the token

:: Verify — the output should NOT list BUILTIN\Users or "Authenticated Users"
icacls C:\actions-runner\_token
C:\actions-runner\_token NT AUTHORITY\SYSTEM:(OI)(CI)(F)
                         BUILTIN\Administrators:(OI)(CI)(F)          ← and nothing else

The SDDL (Security Descriptor Definition Language) form of the same idea, for setting it programmatically, is a descriptor with ACEs only for SY (SYSTEM) and BA (Built-in Administrators) and none for AU (Authenticated Users) or WD (Everyone).

Memory hook

"DPAPI is not an ACL." DPAPI (the Data Protection API) encrypts a secret so only a given user or the machine can decrypt it. That protects the secret's confidentiality at rest, but a machine-scoped DPAPI blob can be decrypted by anything running on that machine. If an untrusted build step runs on the same host, DPAPI machine scope does not keep the secret from it. The ACL on where the blob lives is what keeps the step out. Use both; never lean on DPAPI alone.

🎯

On the job. Grep a Windows runner image for writes under C:\ProgramData by the setup script, and for any secret path that lacks an explicit icacls /inheritance:r. The combination of "SYSTEM writes a token here" and "the folder inherits Users: write" is the finding. The fix is ephemeral hosts (the token never outlives the job) plus a hard ACL for the lifetime it does exist.


Credential hygiene and workload identity

The strongest version of "lock down the token on disk" is "do not have a long-lived token on disk". Replace static cloud credentials and reusable registration secrets with short-lived identity minted per job.

OIDC workload identity

the runner proves who it is to the cloud with a short-lived, signed token describing this exact workflow run, and exchanges it for cloud credentials valid for minutes. Nothing durable to steal from C:\ProgramData. The mechanics and the trust-policy traps are covered in depth on the GitHub & CI/CD security page; the Windows-specific point is simply that the OIDC token lives in the job's environment, so a co-located build step that can read another job's environment or token cache defeats it. That is another argument for one job per ephemeral host.

Ephemeral runner registration

register the agent with a just-in-time, single-use token, run one job, deregister and destroy the host. A reused Windows host is the root cause behind most "the next pull request stole my secret" incidents.

Unique per-job OS credentials

run each job under its own freshly-created, unprivileged local account, not a shared service account, so file ACLs and token scoping actually separate one job from the next.

📰

Real incident — PHP git server compromise (2021). Attackers pushed two malicious commits to PHP's self-hosted git server, disguised as typo fixes, that would have planted a backdoor in the language runtime shipped to millions of sites. It was a build-and-release-infrastructure compromise, not a Windows-specific one, but the lesson transfers directly: infrastructure that builds and signs code is a top-tier target, and the project's response was to abandon self-hosted build infrastructure for that trust boundary. On your runners, assume the build host is a target of the same class and keep nothing on it that outlives the job.


Sandboxing untrusted builds: three options, one boundary

Windows offers three ways to contain a build step. They are not equivalent, and the difference is the whole interview answer.

Restricted token / AppContainer

AppContainer is a sandbox that strips a process down to an explicit set of capabilities and its own isolated storage and registry view; a restricted token removes groups and privileges from a token. Good for confining a single known app. Microsoft lists the AppContainer sandbox as a security boundary.

Windows Server Container (process isolation)

A container that shares the host kernel, isolating file system, registry, network ports, process IDs and the Object Manager namespace per container. Fast and light. Microsoft explicitly says this is NOT a security boundary and not for hostile multi-tenant workloads.

Hyper-V isolation

The same container image, but run inside a highly optimised virtual machine with its own kernel. Hardware-level isolation between the container, its neighbours and the host. Microsoft lists the VM boundary as a security boundary. Select it with docker run --isolation=hyperv.

Why process isolation is not enoughA Windows Server Container shares one kernel with the host and every other container on it, exactly like a Linux runc container. One kernel bug reachable from inside, and the game is over for the whole host. Microsoft is unusually blunt about this: in its security-servicing criteria, Windows Server Containers are listed as a non-boundary, and the guidance is to use Hyper-V isolation for anything untrusted. You can see the difference without a debugger. A process-isolated container's processes are visible on the host; a Hyper-V-isolated container's are not, because they live inside a guest VM:

text
:: process-isolated: the container's ping is the SAME process on the host
docker run -d --isolation=process mcr.microsoft.com/windows/servercore ping -t localhost
get-process ping    ->  Id 3964   ← visible on the host; one shared kernel

:: hyperv-isolated: the host sees only the VM worker process, not the guest's ping
docker run -d --isolation=hyperv mcr.microsoft.com/windows/servercore ping -t localhost
get-process ping    ->  Cannot find a process with the name "ping"   ← it's inside the VM
get-process vmwp    ->  Id 2376   ← the VM worker process is all the host sees
🎯

On the job. If a Windows CI platform runs untrusted pull-request builds in process-isolated Server Containers on a shared host, that is the finding, full stop. The fix is --isolation=hyperv (or a full ephemeral VM per job). Expect a cost and start-time objection; the honest trade is that the microVM costs you seconds of boot and some density, and buys you a hardware boundary Microsoft will actually stand behind for hostile code. On bare-metal AMD EPYC build fleets this is the standard pattern: a thin Windows host whose only job is to launch Hyper-V-isolated, single-use build VMs.


Network isolation and egress

Windows has its own network-filtering stack. The Windows Filtering Platform (WFP) is the kernel framework that Windows Firewall and third-party filters build on; the Host Network Service (HNS) manages container networking. The security goals are the same as on Linux: stop a build step from reaching the cloud metadata endpoint, pivoting into private networks, or calling home, while still letting it fetch packages.

Block the metadata endpoint.

A WFP or host-firewall rule denying the build identity any route to 169.254.169.254 (the cloud instance-metadata address) closes the single most common cloud-credential-theft path.

Default-deny egress through a proxy.

Route all build egress through a forward proxy that allows only your package mirrors and registries by hostname, and denies the rest, with a dedicated egress IP pool so the allowlist on the far side is stable. The design is the same one the untrusted-workload egress control page develops for Linux; WFP and HNS are the Windows levers for it.

Tip

On Windows the metadata-endpoint block and the egress proxy are the two highest-value network controls, in that order. Everything else (port isolation, per-container networks) is hygiene on top.


Detection and observability

If you cannot prevent, detect. Windows gives you kernel-sourced telemetry that a build step cannot quietly suppress from inside.

Event 4688Security log
Sysmon ID 1ProcessCreate
Sysmon ID 7ImageLoad
Sysmon ID 10ProcessAccess
Sysmon ID 11FileCreate
Sysmon ID 17 / 18PipeEvent
ETW Microsoft-Windows-Kernel-ProcessKernel ETW
Event 4688
A new process was created. Enable "Include command line in process creation events" (the ProcessCreationIncludeCmdLine_Enabled policy) so you capture the full command line, not just the image name
Sysmon ID 1
Full command line, hashes, and ProcessGUID + parent ProcessGUID so you can rebuild the exact tree even after PID reuse
Sysmon ID 7
A DLL was loaded — catches unsigned or unexpected modules loaded into a build process
Sysmon ID 10
One process opened another — the signal for credential-dumping style access to LSASS
Sysmon ID 11
A file was written — watch runner secret paths and startup/persistence locations
Sysmon ID 17 / 18
A named pipe was created / connected — the signal for the pipe-impersonation pattern around ConPTY debug sessions
ETW Microsoft-Windows-Kernel-Process
Process and thread lifecycle straight from the kernel, under the runner's correlation ID

In practiceA Sysmon rule that flags any process breaking out of the build job's expected tree, or any named pipe created outside the known debug-service name, turns the breakaway and pipe-impersonation patterns into alerts:

xml
<Sysmon schemaversion="4.82">
  <EventFiltering>
    <RuleGroup groupRelation="or">
      <!-- A pipe created by anything other than our debug service is suspicious -->
      <PipeEvent onmatch="exclude">
        <Image condition="is">C:\actions-runner\debug-sshd.exe</Image>  <!-- ← our known server -->
      </PipeEvent>
      <!-- Credential-access style access into LSASS from a build process -->
      <ProcessAccess onmatch="include">
        <TargetImage condition="end with">lsass.exe</TargetImage>        <!-- ← alert -->
      </ProcessAccess>
    </RuleGroup>
  </EventFiltering>
</Sysmon>
🎯

On the job. The single most useful Windows runner detection is a clean process tree with command lines. Enable 4688 command-line capture (or Sysmon ID 1), tag every event with the job's correlation ID, and alert on any process whose parent chain does not lead back to the agent, which is exactly what a breakaway or an injected process looks like. Sysmon's ProcessGUID survives PID reuse, so the tree stays correct even on a busy runner.


Interview Questions

Q
Why does Microsoft not consider Windows Server process containers a hard security boundary, and how does Hyper-V isolation resolve this for multi-tenant CI?
Model answer

A Server Container shares the host kernel with every other container, so one kernel bug reachable from inside a malicious build escapes to the host, and Microsoft's own security-servicing list names Server Containers as a non-boundary. Hyper-V isolation runs the same container image inside a tiny per-container virtual machine with its own kernel, which Microsoft does list as a boundary, so you select --isolation=hyperv for any untrusted pull-request build. The cost is a few seconds of VM boot and lower density; the gain is a hardware wall instead of a shared kernel.

Q
How can a malicious build step break out of a Windows Job Object process tree, and how do you configure the job to prevent it?
Model answer

The only supported way out is CREATE_BREAKAWAY_FROM_JOB, and that works only if the job itself was created with the JOB_OBJECT_LIMIT_BREAKAWAY_OK (or silent-breakaway) flag. So the defence is simply to not set either breakaway flag, at which point a child's attempt to leave the job fails in the kernel. Pair that with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so no build process outlives the job, and remember a job object is a lifecycle and resource control, not a trust boundary, so it is defence in depth beneath Hyper-V isolation, not a substitute.

Q
What security risks arise from interactive build debugging via OpenSSH and ConPTY on Windows runners, specifically around named pipes and token privileges?
Model answer

ConPTY drives the child shell over pipes, so two things matter: the named pipe's ACL and the token the shell runs under. A predictable, loosely-ACLed pipe invites named-pipe impersonation, where a privileged process tricked into connecting hands its token to the other end; and a shell spawned under the sshd service account, often SYSTEM, gives an interactive high-privilege shell on a box about to run untrusted code. The fix is to run the debug shell under the same ephemeral unprivileged per-job identity inside the same job object, tighten the pipe ACL, and record the session with kernel process auditing rather than trusting terminal history.

Q
Why is C:\ProgramData a common privilege-escalation vector on Windows runners, and how should you configure NTFS DACLs for a registration token?
Model answer

C:\ProgramData lets authenticated users create files and subfolders by default, and those subfolders can inherit permissions writable by ordinary users, so if a SYSTEM service writes a runner token or executes a script from there, an unprivileged build step can read or tamper with it. The fix is to strip inheritance on the runner's own directory with icacls /inheritance:r and grant explicit full control only to SYSTEM and Administrators, with no ACE for Users or Authenticated Users, so the SDDL names only SY and BA. The stronger fix is ephemeral hosts so the token never outlives one job, and DPAPI is not a substitute because a machine-scoped blob decrypts for anything on the box.

Q
How does an AppContainer differ from a restricted token and a job object, and which combination is the most practical defence in depth for native Windows build steps?
Model answer

A job object groups and limits a process tree (lifecycle and resources, not a boundary); a restricted token removes groups and privileges from a token to weaken what a process can do; and an AppContainer is a full sandbox giving a process an explicit capability set and its own isolated storage and registry, which Microsoft does list as a security boundary. For native build steps the practical stack is Hyper-V isolation as the outer wall, a job object for clean teardown and resource caps, and a per-job unprivileged account (optionally restricted-token or AppContainer-confined) inside, so no single layer is load-bearing alone.

Q
How do you implement OIDC workload identity on Windows runners to avoid persisting cloud credentials?
Model answer

The runner requests a short-lived, signed OIDC token describing this specific workflow run and exchanges it with the cloud for credentials valid for minutes, so nothing durable sits in C:\ProgramData or a DPAPI cache for the next job to steal. The Windows-specific catch is that the OIDC token lives in the job's environment and token cache, so a co-located build step that can read another job's environment defeats it, which is why you pair OIDC with one ephemeral host per job and a unique per-job account. Harden the cloud trust policy to pin the exact repository and ref, not a wildcard, or the short-lived token is scoped too broadly to matter.

Q
Which Windows event logs and ETW providers are critical for detecting process breakaway, escalation or named-pipe impersonation on build runners?
Model answer

Security event 4688 with command-line capture enabled, or Sysmon event 1, gives you the full process tree with command lines straight from the kernel, which a build step cannot suppress from inside; Sysmon's ProcessGUID keeps that tree correct even after PID reuse. For the specific patterns, Sysmon events 17 and 18 catch named-pipe create and connect around ConPTY debug sessions, event 10 catches process-access into LSASS, and the Microsoft-Windows-Kernel-Process ETW provider gives raw process and thread lifecycle. The detection that pays off most is alerting on any process whose parent chain does not lead back to the build agent, because that is what a breakaway or injected process looks like.