CVE-2023-25168: Improper Link Resolution Before File Access ('Link Following') (CWE-59)
value: Wings is Pterodactyl's server control plane. This vulnerability can be used to delete files and directories recursively on the host system.  This vulnerability can be combined with `GHSA-p8r3-83r8-jwj5` to overwrite files on the host system. In order to use this exploit, an attacker must have an existing "server" allocated and controlled by Wings. This vulnerability has been resolved in version `v1.11.4` of Wings, and has been back-ported to the 1.7 release series in `v1.7.4`. Anyone running `v1.11.x` should upgrade to `v1.11.4` and anyone running `v1.7.x` should upgrade to `v1.7.4`. There are no known workarounds for this issue.
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.2
Published: 2023-02-09T00:16Z
Repository: https://github.com/pterodactyl/wings
Language: Go
Files changed: server/filesystem/filesystem.go, server/filesystem/filesystem_test.go
Commit date: 2023-02-08 14:20:13 -0700
Commit message: server(filesystem): Delete tweaks

Diff: diff --git a/server/filesystem/filesystem.go b/server/filesystem/filesystem.go
index 1d906f0d..ccc2daa4 100644
--- a/server/filesystem/filesystem.go
+++ b/server/filesystem/filesystem.go
@@ -385,14 +385,13 @@ func (fs *Filesystem) TruncateRootDirectory() error {
 }
 
 // Delete removes a file or folder from the system. Prevents the user from
 // accidentally (or maliciously) removing their root server data directory.
 func (fs *Filesystem) Delete(p string) error {
-	wg := sync.WaitGroup{}
 	// This is one of the few (only?) places in the codebase where we're explicitly not using
 	// the SafePath functionality when working with user provided input. If we did, you would
-	// not be able to delete a file that is a symlink pointing to a location outside of the data
+	// not be able to delete a file that is a symlink pointing to a location outside the data
 	// directory.
 	//
 	// We also want to avoid resolving a symlink that points _within_ the data directory and thus
 	// deleting the actual source file for the symlink rather than the symlink itself. For these
 	// purposes just resolve the actual file path using filepath.Join() and confirm that the path
@@ -405,29 +404,69 @@ func (fs *Filesystem) Delete(p string) error {
 	// Block any whoopsies.
 	if resolved == fs.Path() {
 		return errors.New("cannot delete root server directory")
 	}
 
-	if st, err := os.Lstat(resolved); err != nil {
+	st, err := os.Lstat(resolved)
+	if err != nil {
 		if !os.IsNotExist(err) {
 			fs.error(err).Warn("error while attempting to stat file before deletion")
+			return err
 		}
-	} else {
-		if !st.IsDir() {
-			fs.addDisk(-st.Size())
-		} else {
-			wg.Add(1)
-			go func(wg *sync.WaitGroup, st os.FileInfo, resolved string) {
-				defer wg.Done()
-				if s, err := fs.DirectorySize(resolved); err == nil {
-					fs.addDisk(-s)
+
+		// The following logic is used to handle a case where a user attempts to
+		// delete a file that does not exist through a directory symlink.
+		// We don't want to reveal that the file does not exist, so we validate
+		// the path of the symlink and return a bad path error if it is invalid.
+
+		// The requested file or 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(resolved), "/")
+
+		// Range over all the path parts and form directory paths 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 {
+				if !fs.unsafeIsInDataDirectory(t) {
+					return NewBadPathResolution(p, t)
 				}
-			}(&wg, st, resolved)
+				break
+			}
 		}
+
+		// Always return early if the file does not exist.
+		return nil
 	}
 
-	wg.Wait()
+	// If the file is not a symlink, we need to check that it is not within a
+	// symlinked directory that points outside the data directory.
+	if st.Mode()&os.ModeSymlink == 0 {
+		ep, err := filepath.EvalSymlinks(resolved)
+		if err != nil {
+			if !os.IsNotExist(err) {
+				return err
+			}
+		} else if !fs.unsafeIsInDataDirectory(ep) {
+			return NewBadPathResolution(p, ep)
+		}
+	}
+
+	if st.IsDir() {
+		if s, err := fs.DirectorySize(resolved); err == nil {
+			fs.addDisk(-s)
+		}
+	} else {
+		fs.addDisk(-st.Size())
+	}
 
 	return os.RemoveAll(resolved)
 }
 
 type fileOpener struct {
diff --git a/server/filesystem/filesystem_test.go b/server/filesystem/filesystem_test.go
index 7a175c02..23f43bfe 100644
--- a/server/filesystem/filesystem_test.go
+++ b/server/filesystem/filesystem_test.go
@@ -535,10 +535,84 @@ func TestFilesystem_Delete(t *testing.T) {
 				g.Assert(err).IsNotNil()
 				g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
 			}
 		})
 
+		g.It("deletes a symlink but not it's target within the root directory", func() {
+			// Symlink to a file inside the root directory.
+			err := os.Symlink(filepath.Join(rfs.root, "server/source.txt"), filepath.Join(rfs.root, "server/symlink.txt"))
+			g.Assert(err).IsNil()
+
+			// Delete the symlink itself.
+			err = fs.Delete("symlink.txt")
+			g.Assert(err).IsNil()
+
+			// Ensure the symlink was deleted.
+			_, err = os.Lstat(filepath.Join(rfs.root, "server/symlink.txt"))
+			g.Assert(err).IsNotNil()
+
+			// Ensure the symlink target still exists.
+			_, err = os.Lstat(filepath.Join(rfs.root, "server/source.txt"))
+			g.Assert(err).IsNil()
+		})
+
+		g.It("does not delete files symlinked outside of the root directory", func() {
+			// Create a file outside the root directory.
+			err := rfs.CreateServerFileFromString("/../source.txt", "test content")
+			g.Assert(err).IsNil()
+
+			// Create a symlink to the file outside the root directory.
+			err = os.Symlink(filepath.Join(rfs.root, "source.txt"), filepath.Join(rfs.root, "/server/symlink.txt"))
+			g.Assert(err).IsNil()
+
+			// Delete the symlink. (This should pass as we will delete the symlink itself, not it's target)
+			err = fs.Delete("symlink.txt")
+			g.Assert(err).IsNil()
+
+			// Ensure the file outside the root directory still exists.
+			_, err = os.Lstat(filepath.Join(rfs.root, "source.txt"))
+			g.Assert(err).IsNil()
+		})
+
+		g.It("does not delete files symlinked through a directory outside of the root directory", func() {
+			// Create a directory outside the root directory.
+			err := os.Mkdir(filepath.Join(rfs.root, "foo"), 0o755)
+			g.Assert(err).IsNil()
+
+			// Create a file inside the directory that is outside the root.
+			err = rfs.CreateServerFileFromString("/../foo/source.txt", "test content")
+			g.Assert(err).IsNil()
+
+			// Symlink the directory that is outside the root to a file inside the root.
+			err = os.Symlink(filepath.Join(rfs.root, "foo"), filepath.Join(rfs.root, "server/symlink"))
+			g.Assert(err).IsNil()
+
+			// Delete a file inside the symlinked directory.
+			err = fs.Delete("symlink/source.txt")
+			g.Assert(err).IsNotNil()
+			g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
+
+			// Ensure the file outside the root directory still exists.
+			_, err = os.Lstat(filepath.Join(rfs.root, "foo/source.txt"))
+			g.Assert(err).IsNil()
+		})
+
+		g.It("returns an error when trying to delete a non-existent file symlinked through a directory outside of the root directory", func() {
+			// Create a directory outside the root directory.
+			err := os.Mkdir(filepath.Join(rfs.root, "foo2"), 0o755)
+			g.Assert(err).IsNil()
+
+			// Symlink the directory that is outside the root to a file inside the root.
+			err = os.Symlink(filepath.Join(rfs.root, "foo2"), filepath.Join(rfs.root, "server/symlink"))
+			g.Assert(err).IsNil()
+
+			// Delete a file inside the symlinked directory.
+			err = fs.Delete("symlink/source.txt")
+			g.Assert(err).IsNotNil()
+			g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
+		})
+
 		g.AfterEach(func() {
 			rfs.reset()
 
 			atomic.StoreInt64(&fs.diskUsed, 0)
 			atomic.StoreInt64(&fs.diskLimit, 0)

Vulnerable code: 	wg := sync.WaitGroup{}
	// not be able to delete a file that is a symlink pointing to a location outside of the data
	if st, err := os.Lstat(resolved); err != nil {
	} else {
		if !st.IsDir() {
			fs.addDisk(-st.Size())
		} else {
			wg.Add(1)
			go func(wg *sync.WaitGroup, st os.FileInfo, resolved string) {
				defer wg.Done()
				if s, err := fs.DirectorySize(resolved); err == nil {
					fs.addDisk(-s)
			}(&wg, st, resolved)
	wg.Wait()
Fixed code: 	// not be able to delete a file that is a symlink pointing to a location outside the data
	st, err := os.Lstat(resolved)
	if err != nil {
			return err

		// The following logic is used to handle a case where a user attempts to
		// delete a file that does not exist through a directory symlink.
		// We don't want to reveal that the file does not exist, so we validate
		// the path of the symlink and return a bad path error if it is invalid.

		// The requested file or 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(resolved), "/")

		// Range over all the path parts and form directory paths 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 {
				if !fs.unsafeIsInDataDirectory(t) {
					return NewBadPathResolution(p, t)
				break
			}

		// Always return early if the file does not exist.
		return nil
	// If the file is not a symlink, we need to check that it is not within a
	// symlinked directory that points outside the data directory.
	if st.Mode()&os.ModeSymlink == 0 {
		ep, err := filepath.EvalSymlinks(resolved)
		if err != nil {
			if !os.IsNotExist(err) {
				return err
			}
		} else if !fs.unsafeIsInDataDirectory(ep) {
			return NewBadPathResolution(p, ep)
		}
	}

	if st.IsDir() {
		if s, err := fs.DirectorySize(resolved); err == nil {
			fs.addDisk(-s)
		}
	} else {
		fs.addDisk(-st.Size())
	}
		g.It("deletes a symlink but not it's target within the root directory", func() {
			// Symlink to a file inside the root directory.
			err := os.Symlink(filepath.Join(rfs.root, "server/source.txt"), filepath.Join(rfs.root, "server/symlink.txt"))
			g.Assert(err).IsNil()

			// Delete the symlink itself.
			err = fs.Delete("symlink.txt")
			g.Assert(err).IsNil()

			// Ensure the symlink was deleted.
			_, err = os.Lstat(filepath.Join(rfs.root, "server/symlink.txt"))
			g.Assert(err).IsNotNil()

			// Ensure the symlink target still exists.
			_, err = os.Lstat(filepath.Join(rfs.root, "server/source.txt"))
			g.Assert(err).IsNil()
		})

		g.It("does not delete files symlinked outside of the root directory", func() {
			// Create a file outside the root directory.
			err := rfs.CreateServerFileFromString("/../source.txt", "test content")
			g.Assert(err).IsNil()

			// Create a symlink to the file outside the root directory.
			err = os.Symlink(filepath.Join(rfs.root, "source.txt"), filepath.Join(rfs.root, "/server/symlink.txt"))
			g.Assert(err).IsNil()

			// Delete the symlink. (This should pass as we will delete the symlink itself, not it's target)
			err = fs.Delete("symlink.txt")
			g.Assert(err).IsNil()

			// Ensure the file outside the root directory still exists.
			_, err = os.Lstat(filepath.Join(rfs.root, "source.txt"))
			g.Assert(err).IsNil()
		})

		g.It("does not delete files symlinked through a directory outside of the root directory", func() {
			// Create a directory outside the root directory.
			err := os.Mkdir(filepath.Join(rfs.root, "foo"), 0o755)
			g.Assert(err).IsNil()

			// Create a file inside the directory that is outside the root.
			err = rfs.CreateServerFileFromString("/../foo/source.txt", "test content")
			g.Assert(err).IsNil()

			// Symlink the directory that is outside the root to a file inside the root.
			err = os.Symlink(filepath.Join(rfs.root, "foo"), filepath.Join(rfs.root, "server/symlink"))
			g.Assert(err).IsNil()

			// Delete a file inside the symlinked directory.
			err = fs.Delete("symlink/source.txt")
			g.Assert(err).IsNotNil()
			g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()

			// Ensure the file outside the root directory still exists.
			_, err = os.Lstat(filepath.Join(rfs.root, "foo/source.txt"))
			g.Assert(err).IsNil()
		})

		g.It("returns an error when trying to delete a non-existent file symlinked through a directory outside of the root directory", func() {
			// Create a directory outside the root directory.
			err := os.Mkdir(filepath.Join(rfs.root, "foo2"), 0o755)
			g.Assert(err).IsNil()

			// Symlink the directory that is outside the root to a file inside the root.
			err = os.Symlink(filepath.Join(rfs.root, "foo2"), filepath.Join(rfs.root, "server/symlink"))
			g.Assert(err).IsNil()

			// Delete a file inside the symlinked directory.
			err = fs.Delete("symlink/source.txt")
			g.Assert(err).IsNotNil()
			g.Assert(IsErrorCode(err, ErrCodePathResolution)).IsTrue()
		})