Directory Traversal: Symlink Vulnerability

by Admin 43 views
Directory Traversal: Symlink Vulnerability

🚨 The Critical Flaw: Incomplete Path Validation

Hey guys! Let's dive into a critical security issue: the incomplete path validation in the readFileContents() function. This vulnerability allows attackers to perform a symlink-based directory traversal, potentially leading to unauthorized access to sensitive files. It's super important, so let's break it down.

The Heart of the Problem

The readFileContents() function, located in src/file-reader.ts, is supposed to securely read file contents. It uses Path.resolve() and fs_realpath() to handle the file paths, trying to make sure everything stays within a designated base directory. The issue? The code doesn't fully validate that the final resolved path actually stays within the allowed boundaries. Think of it like this: you set up a safe zone, but there's a secret back door that lets people sneak out!

Specifically, the code is missing a crucial check: after resolving any symlinks and absolute paths, it doesn't confirm the resulting path still resides within the intended base directory. Attackers can use this gap to their advantage. They could create symbolic links (symlinks, for short) that point outside the allowed directory. If the readFileContents() function processes the path of the symlink, it will follow the symlink, and effectively read a file outside of what should be permitted. This can lead to some seriously bad outcomes, like someone reading your /etc/passwd file, which contains user account details. This is the critical point we need to address.

Understanding the Code (and the Vulnerability)

Let's look at the vulnerable code snippet (from src/file-reader.ts, lines 72-77):

async readFileContents(path: string): Promise<Buffer> {
  debug('request to read %s', path)
  // CODENTIAL: Path.resolve() can be bypassed with ../ sequences and symlinks
  path = Path.resolve(this.basepath, path)
  // CODENTIAL: fs_realpath resolves symlinks but does NOT verify final path is within basepath
  path = await fs_realpath(path)
  // CODENTIAL: No validation that the resolved path is still within basepath boundaries
  return fs_readfile(path)
}

As you can see, the code first resolves the path relative to this.basepath using Path.resolve(). Then, fs_realpath() resolves any symbolic links. However, there's no check to ensure that the final path remains within the bounds of this.basepath. This is the vulnerability's sweet spot. An attacker crafts a path that, after symlink resolution, leads outside the intended directory.

💥 The Impact: What Could Go Wrong

So, what's the big deal? Well, this vulnerability could have some nasty consequences, let's look at it:

  • Information Disclosure: Attackers could read sensitive files like /etc/passwd, /etc/shadow (containing password hashes), or other configuration files that could reveal system secrets.
  • Privilege Escalation: If the application runs with elevated privileges, an attacker could potentially read configuration files or even overwrite them to gain further access.
  • Data Breach: Depending on what the application stores or accesses, attackers could potentially access and steal sensitive user data.

Basically, it opens the door for a lot of trouble!

🛠️ The Solution: Secure Path Validation

The fix is relatively straightforward, but incredibly important. The key is to add proper path validation. Here’s the solution (also in src/file-reader.ts, lines 72-77):

async readFileContents(path: string): Promise<Buffer> {
  debug('request to read %s', path)
  // CODENTIAL: Resolve path against basepath
  path = Path.resolve(this.basepath, path)
  // CODENTIAL: Canonicalize the path by resolving symlinks
  path = await fs_realpath(path)
  // CODENTIAL: Canonicalize the basepath as well for accurate comparison
  const realBasepath = await fs_realpath(this.basepath)
  // CODENTIAL: Verify the resolved path is within basepath by checking path prefix
  if (!path.startsWith(realBasepath + Path.sep) && path !== realBasepath) {
    throw new Error('Access denied: path is outside the allowed base directory')
  }
  return fs_readfile(path)
}

The Fix in Detail

The fix involves these key steps:

  1. Resolve the path: The code resolves the incoming path using Path.resolve() relative to the basepath. This handles relative paths (like ../) and potential path manipulation attempts.
  2. Canonicalize the Path: It calls fs_realpath() to resolve any symbolic links in the path. This turns any symlinks into their real file system locations.
  3. Canonicalize the Basepath: It also uses fs_realpath() on the basepath itself. This step is essential to make sure you're comparing apples to apples. If the basepath itself includes symlinks, this resolves them.
  4. The Critical Check: The code then compares the resolved path with the resolved basepath. It checks if the resolved path starts with the resolved basepath plus a path separator (Path.sep). This is what stops the directory traversal. Let's break down this part:
    • path.startsWith(realBasepath + Path.sep): This is the core of the security fix. It makes sure that the resolved path begins with the allowed base directory and a path separator, preventing access to files outside that directory.
    • path !== realBasepath: This part handles the case where the requested file is the base directory itself. It allows access if and only if the requested path exactly matches the base directory (after symlink resolution).
  5. Access Denied: If the resolved path doesn't fall within the basepath boundaries, the code throws an error ("Access denied"). This prevents the file from being read and terminates the process.

Why This Works

This approach effectively blocks directory traversal attacks because it ensures that no matter what path manipulation tricks an attacker uses (symlinks, ../ sequences, etc.), the final resolved path must reside within the basepath or access is denied.

🧪 Testing and Reproducing the Issue

To make sure this vulnerability is real (and that the fix works!), let’s walk through the steps to reproduce the issue and demonstrate the effectiveness of the secure implementation. This is really useful for us to understand it better.

Steps to Reproduce the Vulnerability

  1. Set up the Environment: Create a file reader instance with a basepath set to a directory like /app/files.
  2. Create a Symlink: Inside /app/files, create a symbolic link pointing to a sensitive file outside that directory (e.g., /etc/passwd).
  3. Exploit the Vulnerability: Call readFileContents() with the path to the symlink (e.g., ./sensitive_link).
  4. Observe the Result: You'll see that the function successfully reads and returns the contents of the sensitive file from outside the /app/files directory, which should not happen.
  5. Verify the Data: Confirm that the returned content contains data you should not have access to (like user information from /etc/passwd).
  6. Test the Secure Version: Compare this with the secure implementation. The secure version should block access and throw an