CVE-2023-25168 (published 2023-02-09T00:16Z)

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.

CVSS v3 base score: 8.2, HIGH (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:N/I:H/A:H)

Weakness information:
cwe_id: CWE-59
cwe_name: Improper Link Resolution Before File Access ('Link Following')
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.
extended_description: 
            
            
         
         

Fix information:
repo_url: https://github.com/pterodactyl/wings
commit_details: repo_url: https://github.com/pterodactyl/wings
author: Matthew Penner
author_date: 2023-02-08 14:20:13-07:00
msg: server(filesystem): Delete tweaks
repository: repo_url: https://github.com/pterodactyl/wings
repo_name: pterodactyl/wings
description: The server control plane for Pterodactyl Panel. Written from the ground-up with security, speed, and stability in mind.
repo_language: Go
owner: pterodactyl
file_changes: filename: filesystem.go
change_type: ModificationType.MODIFY
diff: @@ -387,10 +387,9 @@ 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
@@ -407,25 +406,65 @@ func (fs *Filesystem) Delete(p string) error {
 		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)
 }

programming_language: Go
filename: filesystem_test.go
change_type: ModificationType.MODIFY
diff: @@ -537,6 +537,80 @@ func TestFilesystem_Delete(t *testing.T) {
 			}
 		})
 
+		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()
 

programming_language: Go