CVE-2023-25152: Improper Link Resolution Before File Access ('Link Following') (CWE-59)
value: Wings is Pterodactyl's server control plane. Affected versions are subject to a vulnerability which can be used to create new files and directory structures on the host system that previously did not exist, potentially allowing attackers to change their resource allocations, promote their containers to privileged mode, or potentially add ssh authorized keys to allow the attacker access to a remote shell on the target machine.   In order to use this exploit, an attacker must have an existing "server" allocated and controlled by the Wings Daemon. This vulnerability has been resolved in version `v1.11.3` of the Wings Daemon, and has been back-ported to the 1.7 release series in `v1.7.3`. Anyone running `v1.11.x` should upgrade to `v1.11.3` and anyone running `v1.7.x` should upgrade to `v1.7.3`. There are no known workarounds for this vulnerability.

### Workarounds

None at this time.  




CWE description: The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource.
CVSS v3 base score: 8.8
Published: 2023-02-08T19:15Z
Repository: https://github.com/pterodactyl/wings
Language: Go
Files changed: server/filesystem/filesystem.go, server/filesystem/path.go, server/filesystem/path_test.go
Commit date: 2023-02-07 19:14:35 -0700
Commit message: server(filesystem): SafePath tweaks

Diff: diff --git a/server/filesystem/filesystem.go b/server/filesystem/filesystem.go
index a23367b..1d906f0 100644
--- a/server/filesystem/filesystem.go
+++ b/server/filesystem/filesystem.go
@@ -163,11 +163,11 @@ func (fs *Filesystem) Writefile(p string, r io.Reader) error {
 	sz, err := io.CopyBuffer(file, r, buf)
 
 	// Adjust the disk usage to account for the old size and the new size of the file.
 	fs.addDisk(sz - currentSize)
 
-	return fs.Chown(cleaned)
+	return fs.unsafeChown(cleaned)
 }
 
 // Creates a new directory (name) at a specified path (p) for the server.
 func (fs *Filesystem) CreateDirectory(name string, p string) error {
 	cleaned, err := fs.SafePath(path.Join(p, name))
@@ -221,32 +221,37 @@ func (fs *Filesystem) Rename(from string, to string) error {
 func (fs *Filesystem) Chown(path string) error {
 	cleaned, err := fs.SafePath(path)
 	if err != nil {
 		return err
 	}
+	return fs.unsafeChown(cleaned)
+}
 
+// unsafeChown chowns the given path, without checking if the path is safe. This should only be used
+// when the path has already been checked.
+func (fs *Filesystem) unsafeChown(path string) error {
 	if fs.isTest {
 		return nil
 	}
 
 	uid := config.Get().System.User.Uid
 	gid := config.Get().System.User.Gid
 
 	// Start by just chowning the initial path that we received.
-	if err := os.Chown(cleaned, uid, gid); err != nil {
+	if err := os.Chown(path, uid, gid); err != nil {
 		return errors.Wrap(err, "server/filesystem: chown: failed to chown path")
 	}
 
 	// If this is not a directory we can now return from the function, there is nothing
 	// left that we need to do.
-	if st, err := os.Stat(cleaned); err != nil || !st.IsDir() {
+	if st, err := os.Stat(path); err != nil || !st.IsDir() {
 		return nil
 	}
 
 	// If this was a directory, begin walking over its contents recursively and ensure that all
 	// of the subfiles and directories get their permissions updated as well.
-	err = godirwalk.Walk(cleaned, &godirwalk.Options{
+	err := godirwalk.Walk(path, &godirwalk.Options{
 		Unsorted: true,
 		Callback: func(p string, e *godirwalk.Dirent) error {
 			// Do not attempt to chown a symlink. Go's os.Chown function will affect the symlink
 			// so if it points to a location outside the data directory the user would be able to
 			// (un)intentionally modify that files permissions.
@@ -259,11 +264,10 @@ func (fs *Filesystem) Chown(path string) error {
 			}
 
 			return os.Chown(p, uid, gid)
 		},
 	})
-
 	return errors.Wrap(err, "server/filesystem: chown: failed to chown during walk function")
 }
 
 func (fs *Filesystem) Chmod(path string, mode os.FileMode) error {
 	cleaned, err := fs.SafePath(path)
diff --git a/server/filesystem/path.go b/server/filesystem/path.go
index 8a439c3..3952e5d 100644
--- a/server/filesystem/path.go
+++ b/server/filesystem/path.go
@@ -1,9 +1,10 @@
 package filesystem
 
 import (
 	"context"
+	iofs "io/fs"
 	"os"
 	"path/filepath"
 	"strings"
 	"sync"
 
@@ -31,62 +32,37 @@ func (fs *Filesystem) IsIgnored(paths ...string) error {
 // path it is returned. If they managed to "escape" an error will be returned.
 //
 // This logic is actually copied over from the SFTP server code. Ideally that eventually
 // either gets ported into this application, or is able to make use of this package.
 func (fs *Filesystem) SafePath(p string) (string, error) {
-	var nonExistentPathResolution string
-
 	// Start with a cleaned up path before checking the more complex bits.
 	r := fs.unsafeFilePath(p)
 
 	// At the same time, evaluate the symlink status and determine where this file or folder
 	// is truly pointing to.
 	ep, err := filepath.EvalSymlinks(r)
 	if err != nil && !os.IsNotExist(err) {
 		return "", errors.Wrap(err, "server/filesystem: failed to evaluate symlink")
 	} else if os.IsNotExist(err) {
-		// The requested directory doesn't exist, so at this point we need to iterate up the
-		// path chain until we hit a directory that _does_ exist and can be validated.
-		parts := strings.Split(filepath.Dir(r), "/")
-
-		var try string
-		// Range over all of the path parts and form directory pathings from the end
-		// moving up until we have a valid resolution or we run out of paths to try.
-		for k := range parts {
-			try = strings.Join(parts[:(len(parts)-k)], "/")
-
-			if !fs.unsafeIsInDataDirectory(try) {
-				break
-			}
-
-			t, err := filepath.EvalSymlinks(try)
-			if err == nil {
-				nonExistentPathResolution = t
-				break
-			}
-		}
-	}
-
-	// If the new path doesn't start with their root directory there is clearly an escape
-	// attempt going on, and we should NOT resolve this path for them.
-	if nonExistentPathResolution != "" {
-		if !fs.unsafeIsInDataDirectory(nonExistentPathResolution) {
-			return "", NewBadPathResolution(p, nonExistentPathResolution)
+		// The target of one of the symlinks (EvalSymlinks is recursive) does not exist.
+		// So we get what target path does not exist and check if it's within the data
+		// directory. If it is, we return the original path, otherwise we return an error.
+		pErr, ok := err.(*iofs.PathError)
+		if !ok {
+			return "", errors.Wrap(err, "server/filesystem: failed to evaluate symlink")
 		}
-
-		// If the nonExistentPathResolution variable is not empty then the initial path requested
-		// did not exist and we looped through the pathway until we found a match. At this point
-		// we've confirmed the first matched pathway exists in the root server directory, so we
-		// can go ahead and just return the path that was requested initially.
-		return r, nil
+		ep = pErr.Path
 	}
 
 	// If the requested directory from EvalSymlinks begins with the server root directory go
 	// ahead and return it. If not we'll return an error which will block any further action
 	// on the file.
 	if fs.unsafeIsInDataDirectory(ep) {
-		return ep, nil
+		// Returning the original path here instead of the resolved path ensures that
+		// whatever the user is trying to do will work as expected. If we returned the
+		// resolved path, the user would be unable to know that it is in fact a symlink.
+		return r, nil
 	}
 
 	return "", NewBadPathResolution(p, r)
 }
 
diff --git a/server/filesystem/path_test.go b/server/filesystem/path_test.go
index ebdb71a..ecb9627 100644
--- a/server/filesystem/path_test.go
+++ b/server/filesystem/path_test.go
@@ -113,10 +113,18 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
 
 	if err := os.Symlink(filepath.Join(rfs.root, "malicious.txt"), filepath.Join(rfs.root, "/server/symlinked.txt")); err != nil {
 		panic(err)
 	}
 
+	if err := os.Symlink(filepath.Join(rfs.root, "malicious_does_not_exist.txt"), filepath.Join(rfs.root, "/server/symlinked_does_not_exist.txt")); err != nil {
+		panic(err)
+	}
+
+	if err := os.Symlink(filepath.Join(rfs.root, "/server/symlinked_does_not_exist.txt"), filepath.Join(rfs.root, "/server/symlinked_does_not_exist2.txt")); err != nil {
+		panic(err)
+	}
+
 	if err := os.Symlink(filepath.Join(rfs.root, "/malicious_dir"), filepath.Join(rfs.root, "/server/external_dir")); err != nil {
 		panic(err)
 	}
 
 	g.Describe("Writefile", func() {
@@ -126,10 +134,26 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
 			err := fs.Writefile("symlinked.txt", r)
 			g.Assert(err).IsNotNil()
 			g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
 		})
 
+		g.It("cannot write to a non-existent file symlinked outside the root", func() {
+			r := bytes.NewReader([]byte("testing what the fuck"))
+
+			err := fs.Writefile("symlinked_does_not_exist.txt", r)
+			g.Assert(err).IsNotNil()
+			g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
+		})
+
+		g.It("cannot write to chained symlinks with target that does not exist outside the root", func() {
+			r := bytes.NewReader([]byte("testing what the fuck"))
+
+			err := fs.Writefile("symlinked_does_not_exist2.txt", r)
+			g.Assert(err).IsNotNil()
+			g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
+		})
+
 		g.It("cannot write a file to a directory symlinked outside the root", func() {
 			r := bytes.NewReader([]byte("testing"))
 
 			err := fs.Writefile("external_dir/foo.txt", r)
 			g.Assert(err).IsNotNil()

Vulnerable code: 	return fs.Chown(cleaned)
	if err := os.Chown(cleaned, uid, gid); err != nil {
	if st, err := os.Stat(cleaned); err != nil || !st.IsDir() {
	err = godirwalk.Walk(cleaned, &godirwalk.Options{

	var nonExistentPathResolution string

		// The requested directory doesn't exist, so at this point we need to iterate up the
		// path chain until we hit a directory that _does_ exist and can be validated.
		parts := strings.Split(filepath.Dir(r), "/")

		var try string
		// Range over all of the path parts and form directory pathings from the end
		// moving up until we have a valid resolution or we run out of paths to try.
		for k := range parts {
			try = strings.Join(parts[:(len(parts)-k)], "/")

			if !fs.unsafeIsInDataDirectory(try) {
				break
			}

			t, err := filepath.EvalSymlinks(try)
			if err == nil {
				nonExistentPathResolution = t
				break
			}
		}
	}

	// If the new path doesn't start with their root directory there is clearly an escape
	// attempt going on, and we should NOT resolve this path for them.
	if nonExistentPathResolution != "" {
		if !fs.unsafeIsInDataDirectory(nonExistentPathResolution) {
			return "", NewBadPathResolution(p, nonExistentPathResolution)

		// If the nonExistentPathResolution variable is not empty then the initial path requested
		// did not exist and we looped through the pathway until we found a match. At this point
		// we've confirmed the first matched pathway exists in the root server directory, so we
		// can go ahead and just return the path that was requested initially.
		return r, nil
		return ep, nil
Fixed code: 	return fs.unsafeChown(cleaned)
	return fs.unsafeChown(cleaned)
}
// unsafeChown chowns the given path, without checking if the path is safe. This should only be used
// when the path has already been checked.
func (fs *Filesystem) unsafeChown(path string) error {
	if err := os.Chown(path, uid, gid); err != nil {
	if st, err := os.Stat(path); err != nil || !st.IsDir() {
	err := godirwalk.Walk(path, &godirwalk.Options{
	iofs "io/fs"
		// The target of one of the symlinks (EvalSymlinks is recursive) does not exist.
		// So we get what target path does not exist and check if it's within the data
		// directory. If it is, we return the original path, otherwise we return an error.
		pErr, ok := err.(*iofs.PathError)
		if !ok {
			return "", errors.Wrap(err, "server/filesystem: failed to evaluate symlink")
		ep = pErr.Path
		// Returning the original path here instead of the resolved path ensures that
		// whatever the user is trying to do will work as expected. If we returned the
		// resolved path, the user would be unable to know that it is in fact a symlink.
		return r, nil
	if err := os.Symlink(filepath.Join(rfs.root, "malicious_does_not_exist.txt"), filepath.Join(rfs.root, "/server/symlinked_does_not_exist.txt")); err != nil {
		panic(err)
	}

	if err := os.Symlink(filepath.Join(rfs.root, "/server/symlinked_does_not_exist.txt"), filepath.Join(rfs.root, "/server/symlinked_does_not_exist2.txt")); err != nil {
		panic(err)
	}

		g.It("cannot write to a non-existent file symlinked outside the root", func() {
			r := bytes.NewReader([]byte("testing what the fuck"))

			err := fs.Writefile("symlinked_does_not_exist.txt", r)
			g.Assert(err).IsNotNil()
			g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
		})

		g.It("cannot write to chained symlinks with target that does not exist outside the root", func() {
			r := bytes.NewReader([]byte("testing what the fuck"))

			err := fs.Writefile("symlinked_does_not_exist2.txt", r)
			g.Assert(err).IsNotNil()
			g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
		})