Nightjar

Escaping a root directory with Windows device paths in Go's path/filepath

Identifier
CVE-2023-45283
Software
Go path/filepath
Affected
Go before 1.20.11; Go 1.21.0 through 1.21.3
Fixed in
Go 1.20.11 and 1.21.4
Disclosed
07 November 2023

On Windows, \??\c:\x is c:\x.

Not "resolves to" in some loose sense. It is another spelling of the same file. Code that decides whether a user-supplied path may be opened has to know that spelling. Go's path/filepath didn't, until November 2023.

Nobody is publicly credited for this one. The golang-announce message for Go 1.21.4 and 1.20.11 names no reporter. Neither do the vulnerability database entries. The tracking issue, golang/go#63713, was filed by a member of the Go team. That is how every Go security issue gets filed, so it tells you nothing about who found it. Sometimes the record just doesn't say.

Windows path namespaces#

Windows has two path namespaces stacked on each other. The kernel uses the NT object namespace. Everything you write deals in Win32 path syntax. C: is not a kernel concept. It is a symbolic link in the object namespace, and the Win32 layer translates between the two on every call.

That translation does work you mostly want. It converts forward slashes, strips trailing dots and spaces, expands . and .., and maps a handful of legacy device names. It also imposes limits. That is why the escape hatch exists. Prefix a path with \\?\ and the Win32 layer stops normalising. The rest of the string goes through more or less as written.

\??\ is a second door to the same place. The release announcement calls it "a Root Local Device path prefix". The advisory states the equivalence plainly. A path beginning with \??\ is equivalent to one beginning with \\?\. And \??\c:\x is equivalent to the more common c:\x.

three strings, one file c:\x \\?\c:\x \??\c:\x Win32 normalises the string Win32 passes it through same door, other spelling the file x on drive C path/filepath knew the first two spellings
Three strings, one file. The Win32 layer normalises the first and passes the other two through.

Here is why that matters to a string library. A lot of security-relevant path handling looks like this:

go
p := filepath.Clean(userInput)
if !filepath.IsLocal(p) {
    return errForbidden
}
full := filepath.Join(root, p)

Clean and Join and IsLocal never touch the disk. They are pure string functions. The whole scheme rests on them agreeing with the operating system about what a given string means. If they disagree, the check above validates one path and the open call afterwards opens a different one.

one string, two readers p = \??\c:\x checked as a string opened as a file IsAbs(p) = false so: relative, join it under root open(p) opens c:\x \??\ = Root Local Device the guard and the kernel disagree about which file p names
The guard reads the string and calls it relative. The kernel reads the same string and leaves your root directory.

The four affected functions#

Four functions were wrong in the same way. GO-2023-2185 and the release announcement give the specifics.

what the four calls returned call before after Clean on \a\..\??\b \??\b .\??\b Join of \, ??, b \??\b \.\??\b IsAbs on \??\c:\x false true VolumeName on \??\c:\x no volume name \??\c: before: the \??\ prefix is treated as ordinary text
The four results before Go 1.20.11 and 1.21.4, and after.

Read the first row slowly. It is the sharpest one. \a\..\??\b contains no device prefix. It is a rooted path into a directory called a, then up one, then down into ?? and b. ?? is a legal directory name. Cleaning that path is supposed to be a syntactic simplification, the same as turning /a/../b into /b on Unix. And it was syntactically correct. Cancelling \a\ against .. leaves \??\b. The output just happens to mean something entirely different from the input.

a correct simplification with a different meaning in \a \.. \?? \b down one, then up one: cancel out \?? \b now the first component a prefix, not a directory called ?? no byte was added and no byte was reordered
Cancelling two components moves ?? to the front of the path, where Windows reads it as a device prefix.

Join had the same problem from the other direction. Hand it a root and an attacker-controlled element. Join("\\", "??", "b") builds a device path out of two innocent pieces.

The fix is to spot the shape and defuse it by inserting .\. That is a no-op as a path component, and it stops ?? from sitting immediately after the leading backslash. The change is in join in path_windows.go, and the comment says exactly what it is for:

go
// If the path is \ and the next path element is ??,
// add an extra .\ to create \.\?? rather than \??\
// (a Root Local Device path).
if b.Len() == 1 && pathHasPrefixFold(e, "??") {
    b.WriteString(`.\`)
}

IsAbs returning false is the one I would have been most likely to ship myself. It reads like a harmless inaccuracy. It is not. IsAbs is what code uses to decide "this is a relative path, so it's safe to join under my root". Answering "no" for a path that reaches the entire filesystem is the worst possible way to be wrong.

Reserved names in IsLocal#

The same release fixed a second, smaller thing, CVE-2023-45284, against IsLocal.

Windows reserves a set of device names that exist in every directory: CON, PRN, AUX, NUL, COM1 through COM9, LPT1 through LPT9. Open a file called COM1 in any folder and you don't get a file. You get the serial port. IsLocal is meant to report those as non-local, so callers don't hand one to open thinking they've got a plain relative filename.

It missed two variants. Reserved names with a trailing space, so "COM1 " slipped through. And COM or LPT followed by a superscript numeral rather than an ordinary digit: COM¹, COM², COM³. The vulnerability report lists both as reserved names that IsLocal failed to catch. The patch makes it report them as non-local.

Both are the same species of bug as \??\. Windows has more than one way to spell a name. The library knew about fewer of them than the operating system did.

The regression#

Go 1.20.11 and 1.21.4 shipped the \??\ fix on 7 November 2023. In doing so they changed how the Windows volume name is worked out. Issue #64028 landed shortly after: filepath.Clean on \\?\C:\ had started returning \\?\C:, dropping the trailing backslash. That contradicts Clean's documented behaviour. The documentation says the returned path ends in a slash only when it represents a root directory.

A companion report, issue #64101, noted that VolumeName on the closely related \\?\C: had gone from \\?\C: to \\?. It was closed as not planned, on the grounds that \\?\C: is not a usable Windows path anyway.

Then there is the bookkeeping. GO-2023-2185 now carries two affected ranges instead of one. The first ends where you would expect, at 1.20.11 and 1.21.4. The second begins at exactly those two versions and runs to 1.20.12 and 1.21.5. So the releases that fixed the original bug are also listed as affected by it. The advisory's own update gives the reason: those releases "inadvertently changed the definition of the volume name in Windows paths". The advisory for the original bug swallowed the regression its own patch caused.

That is the accurate way to record it. A security fix to path parsing changed the definition of a volume name. Half the package depends on that definition. The second-order effects took another point release to settle. That is CVE-2023-45283 (golang/go issue #63713), and it took four releases across two branches to finish. CVE-2023-45284, the IsLocal bug filed under the same issue, was done in the first pair.

Sources

  1. 1issue #63713: path/filepath insecure parsing of Windows pathsgithub.com
  2. 2Go vulnerability report GO-2023-2185: path/filepath mishandles Windows \??\ device pathspkg.go.dev
  3. 3Go vulnerability report GO-2023-2186: IsLocal misses reserved Windows device namespkg.go.dev
  4. 4golang-announce: [security] Go 1.21.4 and Go 1.20.11 are releasedgroups.google.com