The Windows Problem


If you’re trying to develop software for people to use, it makes sense to target as many platforms as possible. When I started ZVM, that was one of the major reasons why I chose Go over Zig as its crafting language. That decision has paid off in droves. I have shipped a Windows, MacOS, Solaris, BSD, and Linux binary every release of ZVM, supporting x86, arm64 and more. However, this portability is a double edged sword. When you’re developing a cross-platform application, eventually you’ll run across an operating-system-dependent problem that can’t be abstracted away by the runtime. You’ll need to make syscalls, or change your I.O. operation paths. For me, that problem was symlinks on Windows.

Unlike on Unix-based systems like MacOS and Linux, you need to be an Administrator to create any symlink on Windows. It doesn’t matter if you’re in a restricted folder or not.

This user right should only be assigned to trusted users. Symbolic links (symlinks) can expose security vulnerabilities in applications that aren’t designed to handle symbolic links.

I didn’t even know. I do develop on Windows 10 with WSL, but I’m an administrator. It doesn’t affect me. A contributor, Nithin, brought it up in an issue (#59) and shortly after created a pull request. Nithin is also the contributor of ZVM’s excellent PowerShell and Command Prompt install scripts.

Nithin’s pull request worked, but it involved shipping two new scripts with ZVM installs: elevate.cmd and elevate.vbs. The two script combo would let ZVM elevate and rerun its command if it hit a permission error on Windows. I merged his request, and I almost launched. But, the dependencies didn’t appeal to me. I don’t want to have to ship more than I have to. So, I figured out how to add self-escalation to ZVM.

// Symlink is a wrapper around Go's os.Symlink,
// but with automatic privilege escalation on windows
// for systems that do not support non-admin symlinks.
func Symlink(oldname, newname string) error {
	if err := os.Symlink(oldname, newname); err != nil {

		if errors.Is(err, &os.LinkError{}) {
			if runtime.GOOS == "windows" {
				if !isAdmin() {
					if err := becomeAdmin(); err != nil {
						if err := os.Symlink(oldname, newname); err != nil {
							return errors.Join(ErrEscalatedSymlink, err)
						}
					} else {
						return errors.Join(ErrWinEscToAdmin, err)
					}
				}
			}
		}

	}

	return nil
}

meta.Symlink is a wrapper around Go’s os.Symlink, but with automatic privilege escalation on Windows for systems that do not support non-admin symlinks. I used Go’s build flags to separate the definitions so if you’re using ZVM v0.6.0 on Unix, you’re actually running:

func Symlink(oldname, newname string) error {
	return os.Symlink(oldname, newname)
}

Thoughts

This was not an easy easy problem to solve. I found only one article that focused on self escalation, an example written in pseudocode, and resources to embed Windows manifests in Go binaries. The manifest angle is going to be interesting. I can add a lot of metadata Windows can use to make ZVM more trustworthy. Titles in alerts, descriptions in Properties. Eventually, I had to actually think and develop my own solution. ZVM will rerun the exact command you imputed, but this time it will attempt to escalate itself to a Window’s Administrative Process. It involves calling out to cmd.exe and shelling out a copy of itself, but I’m working on a much simpler solution that should work for Windows 10 and up.

The isAdmin() and becomeAdmin() functions are ripped from Stack Overflow—why reinvent the wheel?

GitHub stats for repository: tristanisham/zvm