Golang Path Traversal Guide:
Examples and Prevention

stackhawk

StackHawk|September 20, 2021

Brief introduction to what a path traversal is, with examples, and how to fix and prevent them in Golang.

Golang consistently features among the top 10 programming languages in use across developer communities. This popularity also makes Go applications prone to all the vulnerabilities on OWASP's prevalent web application exploits list. Although not on the list, Golang path traversal is a vulnerability worth getting to know and patching applications against before it becomes their ruin.

Golang Path Traversal Guide - Picture 1 image

This post is an exploration of the path traversal attack in the context of Golang applications. Expect an examination of a few examples to highlight the logic and severity of Golang path traversal attacks. When that's established, we'll close out with a discussion of traversal attack prevention methods. We'll use the Golang proverb "Don't just check errors, handle them gracefully" as inspiration.

Let's start by laying out a clear definition of the problem at hand.

What Is Path Traversal?

Also known as a "dot-dot-dash" attack, path traversal attacks take advantage of file path logic to access and even plant files on the server machine's directories. And a Golang path traversal attack is simply when this kind of attack happens in Golang. The short version of it all is how the inclusion of "../" components in file path declarations leaves entire directory trees open for attackers to traverse outside the application's root.

Imagine leaving your entire file storage open to hackers to use as they please. The authorization that's granted by being a user of a single Golang application on your network is enough for an attacker to grant themselves higher privileges (like adding themselves to admin ranks). At the extreme, your application could get taken over, or overwritten, to hold you at ransom when you least expect it.

Golang path traversal attacks generally fall into one of two subcategories. Both of them damage a Golang application on availability, access, and integrity levels.

Snooping

This happens when attackers make efforts to learn as much as they can about your file storage directories in search of sensitive information in files. This often means more than just application file access, extending into operating system control to eventually access every other file on your network.

Such access is possible when the following command is passed from user input:

/opt/myapplication/../../../../../../root/.ssh/authorized_keys

The above example escapes the allowed directories, climbing the file storage hierarchy all the way to the root folder. Being it's an absolute URL, it returns the contents of the keys file.

Insertion and Overwriting

The deletion of files in discovered paths is another goal attained by Golang path traversal efforts. When a file is removed, chances are the application will crash. Especially if it contains code required (called) across the application. What fun is crashing applications when you can overthrow the administration, right?

In the same way that we discovered the authorized keys in the snooping category, it's possible to change them. You can do this by introducing a file that's named the same yet contains different keys altogether.

With the definition out of the way, let's now give practical examples of both Golang path traversal attack categories.

Examples of Golang Path Traversal Attacks

The first example we'll look at is one often inherited from the use of Golang frameworks to create web applications. Consider the case where Golang aah was unknowingly providing access to files and information (relative and absolute) when an attacker implemented the dot-dot-dash command on the following lines of code:

.....
t.Log("Directory Listing - /assets")
resp, err = httpClient.Get(ts.URL + "/assets")
assert.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
body := responseBody(resp)
assert.True(t, strings.Contains(body, "<title>Listing of /assets/</title>"))
assert.True(t, strings.Contains(body, "<h1>Listing of /assets/</h1><hr>"))
assert.True(t, strings.Contains(body, `<a href="robots.txt">robots.txt</a>`))
assert.Equal(t, "", resp.Header.Get(ahttp.HeaderCacheControl))
......

To start with, the above code lacks any checks for the access level of the user on the httpClient. This is the same source from which the server gets the URL components. In addition to "/assets", the URL can be laced with sequences of dashes and dots such that the operating system jumps into other directories as with the snooping example.

Where an error would be expected, you'd get access to the entire document tree (folders) by phishing a request that lands in the root directory.

Golang Path Traversal Guide - Picture 2 image

This case study is a flag from a contributor to the aah framework and raises an issue as shown in the report above. We'll get into the fix shortly.

The second example applies to web applications as well as Golang OS native apps. Dubbed the Zip-Slip, this is a clever way through which hackers can implement path traversal hits on your Golang applications. Here's how it works:

  1. The attackers hide files inside a .zip archive folder.

  2. Any number of system commands can hide as file names. For example, ../../../../../root/.ssh/authorized_keys will be a file name.

  3. The attacker extracts the archive folder inside your server through any file import form options.

  4. The attack springs into action as soon as a file with its command appears anywhere along the flow of the program.

Go ahead and scan your file storage for such files. If any appear, chances are the attack has long been active. Now for some remediation strategies.

Preventing Golang Path Traversal Attacks

It's never too late to patch your applications with the following protections against Golang path traversal attacks. Keep in mind how the use of open-source Go packages as part of your main source code can add vulnerabilities along with the desired features. This means you must keep watch and test new packages before committing them to your main source code.

Validate User Input

Your primary line of defense is validating user input. Before using a file specification, verify that it is valid and points to a resource the user is entitled to access.

This implies that you’ve selected a limited set of directories and provided to the code. A configuration value is the best option so you can adjust based on where you’re running the code.

So, now your application has a list to validate user-specified values against. How do you do that in Golang?

Clean FilePaths

First, remove any relative qualifiers from the path to ensure you’re validating the exact location. Golang’s filepath package has a function for this: filepath.Clean().

Clean performs a lexical transformation on the string you pass it. It uses the target operating system rules to factor out as many relative path indicators as possible. It’s important to note that It doesn’t verify the path.

Here it is working on the example snooping example above.

func main() {
c := filepath.Clean(
"/opt/myapplication/../../root/.ssh/authorized_keys"
)
fmt.Println("Cleaned path: " + c)
}

This code outputs the cleaned path:

Cleaned path: /root/.ssh/authorized_keys

So now, when you compare the path to your list of allowed paths, you’re looking at the simplest path. But it hasn’t been verified against the host yet.

Canonicalize Paths

What if the user specifies a path that doesn’t exist or points to a link?  Depending on the circumstances it could lead to a false negative or be part of an attack. You need to canonicalize the path before verifying and using it. 

Golang’s EvalSymlinks fixes these problems. It’s not a complete security check, but it does help you evaluate your user’s input.

Let’s create a function for verifying paths, and add the new check to it.

func main() {

  r, err := verifyPath("/opt/myapplication/../../root/.ssh/authorized_keys")

  if err != nil {
     fmt.Println("Error " + err.Error())
  } else {
     fmt.Println("Canonical: " + r)
  }
}

func verifyPath(path string) (string, error) {
  c := filepath.Clean(path)
  fmt.Println("Cleaned path: " + c)

  r, err := filepath.EvalSymlinks(c)
  if err != nil {
     fmt.Println("Error " + err.Error())
     return c, errors.New("Unsafe or invalid path specified")
  } else {
     fmt.Println("Canonical: " + r)
     return r, nil
  }

}

Here’s the output:

Cleaned path: /root/.ssh/authorized_keys
Error lstat /root/.ssh: permission denied
Error Unsafe or invalid path specified

Since I ran this code on a Linux system, it called lstat on the result passed from Clean(). Lstat failed because my user doesn’t have scan access to the /root directory.

Looking at the docs, you’ll see that EvalSymLinks() runs Clean() on its results. In this case, we wouldn’t have had any results to clean. Running it beforehand gives you more information to log about user input.

So, in this case, EvalSymLinks told us to ignore the path since it pointed to a privileged location. This particular example won't get an attacker very far if you’re applying the principle of least privilege to your deployments.

Let’s look at a situation where canonicalizing might not be enough.

r, err := verifyPath("/opt/myapplication/foo")
if err != nil {
fmt.Println("Error " + err.Error())
} else {
fmt.Println("Canonical: " + r)
}

On my system, I see this:

Cleaned path: /opt/myapplication/foo
Canonical: /etc/hosts
Error Unsafe or invalid path specified

Whoa! It turns out /opt/myapplication/foo is soft linked to /etc/hosts on my system. While my code wouldn’t be able to alter the hosts file, an attacker could do some snooping by retrieving the contents.

Establish a Trusted Root

Sanitizing input is an elementary and necessary step, as is running your apps with accounts that only have the privileges they need to get the job done. But there’s still more: limit you applications to a safe location.

Create a safe area on the file system and limit requests to store or retrieve files to that location. 

Filepath’s Dir makes it easy to recurse up a file spec toward the root and see if it’s sitting in or out of a safe location.

Here’s how we can use that:

func inTrustedRoot(path string, trustedRoot string) error {
for path != "/" {
path = filepath.Dir(path)
if path == trustedRoot {
return nil
}
}
return errors.New("path is outside of trusted root")
}

InTrustedRoot recurses up the file spec. If it sees trustedRoot it stops and returns nil (no error) for success. If it makes it to the top, we’re outside of the safe location and it returns an error

So, we can add another check to our new function. It evaluates the canonical path against our trusted root.

func verifyPath(path string) (string, error) {

	// Read from config in the real world
	trustedRoot := "/var/www/files"

	c := filepath.Clean(path)
	fmt.Println("Cleaned path: " + c)

	r, err := filepath.EvalSymlinks(c)
	if err != nil {
		fmt.Println("Error " + err.Error())
		return c, errors.New("Unsafe or invalid path specified")
	}

	err = inTrustedRoot(r, trustedRoot)
	if err != nil {
		fmt.Println("Error " + err.Error())
		return r, errors.New("Unsafe or invalid path specified")
	} else {
		return r, nil
	}
}

We’re limiting the web application to storing and retrieving files from “/var/www/files.” As the code comment says, we’d want to read the trusted root from configuration, instead of hardcoding it. We’d direct the error messages and other outputs to logs instead of the console, too.

So let’s check out a couple of locations.

func main() {

	badPlace := "/etc/hosts"
	safePlace := "/var/www/files/freds_files/mystuff.txt"

	r, err := verifyPath(badPlace)

	if err != nil {
		fmt.Println("Error " + err.Error())
	} else {
		fmt.Println("Canonical: " + r)
	}

	r, err = verifyPath(safePlace)

	if err != nil {
		fmt.Println("Error " + err.Error())
	} else {
		fmt.Println("Canonical: " + r)
	}
}

Here’s the output:

Cleaned path: /etc/hosts
Error path is outside of trusted root
Error Unsafe or invalid path specified
Cleaned path: /var/www/files/freds_files/mystuff.txt
Canonical: /var/www/files/freds_files/mystuff.txt

Separate Code from Documents

Another way to protect yourself from Golang path traversal attacks is using different storage options for code and documents. For example, you could place the files on AWS S3 or Google Cloud Storage. While this might slow performance down, it adds an extra layer of access control between hackers and information. It reduces the impact of path traversal attacks since the files are stored offsite and away from sensitive OS resources.

Add Automated Security Testing to Your Pipeline

How to Prevent Golang Path Traversal Attacks Before Production

It'd be more impressive to prevent attacks before they've evolved into active issues. The best line of thinking here would be to include checks during every commit. Never trust the end user too much to assume your use cases are all-encompassing of their intentions. To be realistic, such checks will only slow your CI/CD process down (albeit by a few seconds). Worse is if you handle checks manually or with a single test script, which is naive considering how wide the attack angle is with path traversal attacks.

It, therefore, makes sense to include automation checks in your workflow as developers append new lines. This is where StackHawk comes in handy. Knowing what parts of your code need patching before it goes live will save you a lot of costs. Let alone the embarrassment of sensitive documents leaking to the public as was the case with the WikiLeaks saga.

This post was written by Taurai Mutimutema. Taurai is a systems analyst with a knack for writing, which was probably sparked by the need to document technical processes during code and implementation sessions. He enjoys learning new technology and talks about tech even more than he writes.


StackHawk  |  September 20, 2021