Jenkins on Windows Server: what not to do
I recently had to work with a somewhat unusual Jenkins instance and what it taught me has less to do with any single flaw than with the way a handful of individually reasonable decisions, laid end to end, draw a straight path from the humblest pipeline all the way to the domain controller. I tell the case because it is exemplary in the strict sense: it is a catalogue of what not to do, each mistake is fixable and the danger lives in their accumulation, not in any one of them.
The setting, to frame it: a Jenkins controller running on Windows Server, joined to the Active Directory domain, with pipelines executed on the controller itself. An embedded Jetty listening on 8080, directly reachable. Domain credentials crossing the wire in cleartext to Jetty. No updates for several years. A collection of plugins, several of them without a fix. And, a recent addition, an nginx sitting beside it that terminates TLS but does not redirect 80 to 443 and above all protects nothing while 8080 stays open behind it. Code that could be synced from internal or third-party sources was executed and/or built inside the pipelines with no prior audit. Taken separately, each point is debatable (barely). Taken together, they are a ramp.
The controller is not a worker
The first received idea to undo is that the controller can host pipelines.
The Jenkins documentation is explicit here and I paraphrase without betraying it: any pipeline that runs on the built-in node has the same filesystem access as the Jenkins process, so to JENKINS_HOME, the secrets, the credentials, the configuration.
Which means that as long as the built-in node’s executor count is not zero, any pipeline is potentially a takeover of the controller and all it takes is a poisoned dependency or a slightly curious Jenkinsfile.
This is not a hardening subtlety, it is the first notch: pipelines go on agents, the controller orchestrates and compiles nothing.
And since the question always comes up, let me settle it: standing up a Jenkins controller on Windows to do Windows builds is an idea that feels practical and is not. Windows pipelines need Windows agents, not a Windows controller. The controller has no reason to sit on a domain-joined host and it is precisely that domain membership that turns a local compromise into a directory problem.
Why each factor makes the next worse
A domain-joined host that runs arbitrary code is a machine from which you dump secrets out of memory, relay NTLM, abuse the machine account to move laterally. SMB signing and LDAP channel binding are not on by default on versions of Windows Server that are still very common and the 2025-2026 revival of coercion and relay attacks is anything but theoretical. Put “the controller runs un-audited pipelines” next to “the controller is domain-joined” and you have wired the most exposed surface of the CI to the most sensitive surface of the whole IS.
The 8080 in the open adds the dumbest and most effective layer. TLS terminated by nginx protects nothing if you can talk to Jetty directly over HTTP on 8080 and that is where the credentials of users and service accounts authenticating against the directory travel, in clear. That scenario needs no exploit, only a listener on the right segment: the most trivial access and the most profitable.
The plugins, finally, are each a surface. The ones you do not use (a forge connector here, an email plugin there) widen the attack without buying anything and the ones left without a fix are documented doors. An instance eighteen to twenty-four months behind the security advisories is not “a bit dated”, it is a public list of entry paths.
The scenarios, from dumbest to most complete
I have ordered them by rising sophistication and the most instructive is the first, because it demands no skill.
The most trivial: an attacker sits on the segment, listens to the cleartext traffic to 8080, grabs the credentials authenticating against the directory and replays them to move. No exploit, no RCE, no sophistication, just the 8080 in clear coupled with directory authentication.
Then supply chain via pipeline: you poison a dependency or a build step, the pipeline runs on the controller, the code runs with the Jenkins process’s access and the secrets follow. Simple trigger, major impact.
Then the full chain to the directory: some entry point (a vulnerable plugin, an exposed CLI, a pipeline), code execution on the Windows host, credential extraction from memory, relay or pass-the-hash, reaching a domain controller and from there the whole IS. The point is not that any step is clever, it is that none of them is: each is documented and ordinary and it is their chaining, made possible by the accumulation above, that creates the severity.
CVSS does not tell you the risk
A note on method, because it has more than once helped me make the urgency land with people reading the scores one by one. A vulnerability’s CVSS is computed out of context and the real risk depends on three things the score ignores: exposure, exploitability in this specific environment and impact once placed back in the architecture. A built-in node with executors, a domain-joined host, an 8080 in clear, plugins without a fix: none is necessarily “critical” on the scale and their convergence is. You rate the chaining, not the brick.
How you get out of it
In order of urgency, short term first.
Jetty listening exclusively on 127.0.0.1, the reverse proxy as the single entry point, 80 redirected to 443, HSTS.
The built-in node’s executors at zero, builds on isolated agents.
Jenkins and the plugins updated and everything unused uninstalled.
The CLI disabled if nobody uses it.
Network access to the controller restricted to the sources that have a reason to reach it.
Medium term, the target architecture and this is where “Windows joined to the domain” disappears for good. A dedicated Linux instance, immutable, off-domain, running the CI and its reverse proxy declaratively, on hardened base images, scanned and signed. Ephemeral, isolated build agents. Modern authentication, OIDC or SSO rather than NTLM and LDAP. Secrets externalised to a vault. And strict segmentation between the CI and the rest of the IS, so that even a controller compromise is no longer a ticket to the directory.
A word on the “let’s switch tools” reflex. Migrating to another CI engine fixes nothing if the deployment architecture stays the same: a domain-joined host, non-isolated builds, ill-considered network exposure. The subject was never the tool, it was always the deployment model and isolation.
The moral, if you need one
None of the mistakes in this case is exotic and that is exactly what makes it useful. An outdated controller that compiles, a domain-joined host, a cleartext port, forgotten plugins: taken one by one, you file them under “look at it later”. Laid end to end, they wire the most innocuous pipeline to the heart of the IS and the hardening work is less about patching a heroic flaw than about breaking the chain, starting with the cheapest link to remove, namely the 8080 you close and the executors you set to zero.
[edit: 2026] The Ansible implementation of all this: the jenkins-agent role .