CVE-2023-25168 (Go)

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.

Weakness:
CWE-59: 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.

Repository: https://github.com/pterodactyl/wings

Vulnerable code:
file_path: server/filesystem/filesystem.go
snippet: 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
	// 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
	// exists within the data directory.
	resolved := fs.unsafeFilePath(p)
	if !fs.unsafeIsInDataDirectory(resolved) {
		return NewBadPathResolution(p, resolved)
	}

	// Block any whoopsies.
	if resolved == fs.Path() {
		return errors.New("cannot delete root server directory")
	}

	if st, err := os.Lstat(resolved); err != nil {
		if !os.IsNotExist(err) {
			fs.error(err).Warn("error while attempting to stat file before deletion")
		}
	} 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()

	return os.RemoveAll(resolved)
}

Fixed code:
file_path: server/filesystem/filesystem.go
snippet: func (fs *Filesystem) Delete(p string) error {
	// 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 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
	// exists within the data directory.
	resolved := fs.unsafeFilePath(p)
	if !fs.unsafeIsInDataDirectory(resolved) {
		return NewBadPathResolution(p, resolved)
	}

	// Block any whoopsies.
	if resolved == fs.Path() {
		return errors.New("cannot delete root server directory")
	}

	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
		}

		// 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())
	}

	return os.RemoveAll(resolved)
}