Skip to content

8352728: InternalError loading java.security due to Windows parent folder permissions #24465

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

franferrax
Copy link
Contributor

@franferrax franferrax commented Apr 5, 2025

Hi, this is a proposal to fix 8352728.

The main idea is to replace java.nio.file.Path::toRealPath by java.io.File::getCanonicalPath for path canonicalization purposes. The rationale behind this decision is the following:

  1. In Windows, File::getCanonicalPath handles restricted permissions in parent directories. Contrarily, Path::toRealPath fails with AccessDeniedException.
  2. In Linux, File::getCanonicalPath handles non-regular files (e.g. /dev/stdin). Contrarily, Path::toRealPath fails with NoSuchFileException.

Windows Case

@martinuy and I tracked down the File::getCanonicalPath vs Path::toRealPath behaviour differences in Windows. Both methods end up calling the FindFirstFileW API inside a loop for each parent directory in the path, until they include the leaf:

NOTE: In cases in which File::getCanonicalPath gives a partially normalized path due to lack of permissions, the impact on cycle detection should be negligible: any include that leads to infinite recursion will revisit the exact same path at some point (even if not normalized).

Testing

The proposed ConfigFileTestDirPermissions test is passing, and no regressions have been found in test/jdk/java/security/Security/ConfigFileTest.java (Windows and Linux).

Also, the GitHub Actions testing run (tier1 on various platforms) has passed, and I've repeated the #16483 tested categories:

  • test/jdk/java/security/Security
  • test/jdk/javax/net/ssl/compatibility
  • test/jdk/java/security/Provider/SecurityProviderModularTest.java
  • test/jdk/javax/crypto/CryptoPermissions/CryptoPolicyFallback.java
Results

Linux:

java/security/Provider/SecurityProviderModularTest.java               Passed. Execution successful
java/security/Security/CaseInsensitiveAlgNames.java                   Passed. Execution successful
java/security/Security/ClassLoader/DeprivilegedModuleLoaderTest.java  Passed. Execution successful
java/security/Security/ClassLoaderDeadlock/ClassLoaderDeadlock.java   Passed. Execution successful
java/security/Security/ClassLoaderDeadlock/Deadlock.java              Passed. Execution successful
java/security/Security/ConfigFileTest.java                            Passed. Execution successful
java/security/Security/NoInstalledProviders.java                      Passed. Execution successful
java/security/Security/Nulls.java                                     Passed. Execution successful
java/security/Security/ProviderFiltering.java                         Passed. Execution successful
java/security/Security/SecurityPropFile/SecurityPropFile.java         Passed. Execution successful
java/security/Security/SynchronizedAccess.java                        Passed. Execution successful
java/security/Security/removing/RemoveProviderByIdentity.java         Passed. Execution successful
java/security/Security/removing/RemoveProviders.java                  Passed. Execution successful
java/security/Security/removing/RemoveStaticProvider.java             Passed. Execution successful
java/security/Security/signedfirst/DynStatic.java                     Passed. Execution successful
javax/crypto/CryptoPermissions/CryptoPolicyFallback.java              Passed. Execution successful
javax/net/ssl/compatibility/AlpnTest.java                             Passed. Execution successful
javax/net/ssl/compatibility/BasicConnectTest.java                     Passed. Execution successful
javax/net/ssl/compatibility/ClientHelloProcessing.java                Passed. Execution successful
javax/net/ssl/compatibility/HrrTest.java                              Passed. Execution successful
javax/net/ssl/compatibility/SniTest.java                              Passed. Execution successful

Windows

java/security/Provider/SecurityProviderModularTest.java               Passed. Execution successful
java/security/Security/CaseInsensitiveAlgNames.java                   Passed. Execution successful
java/security/Security/ClassLoaderDeadlock/ClassLoaderDeadlock.java   Passed. Execution successful
java/security/Security/ClassLoaderDeadlock/Deadlock.java              Passed. Execution successful
java/security/Security/ClassLoader/DeprivilegedModuleLoaderTest.java  Passed. Execution successful
java/security/Security/ConfigFileTest.java                            Passed. Execution successful
java/security/Security/ConfigFileTestDirPermissions.java              Passed. Execution successful
java/security/Security/NoInstalledProviders.java                      Passed. Execution successful
java/security/Security/Nulls.java                                     Passed. Execution successful
java/security/Security/ProviderFiltering.java                         Passed. Execution successful
java/security/Security/SecurityPropFile/SecurityPropFile.java         Passed. Execution successful
java/security/Security/SynchronizedAccess.java                        Passed. Execution successful
java/security/Security/removing/RemoveProviderByIdentity.java         Passed. Execution successful
java/security/Security/removing/RemoveProviders.java                  Passed. Execution successful
java/security/Security/removing/RemoveStaticProvider.java             Passed. Execution successful
java/security/Security/signedfirst/DynStatic.java                     Passed. Execution successful
javax/crypto/CryptoPermissions/CryptoPolicyFallback.java              Passed. Execution successful
javax/net/ssl/compatibility/AlpnTest.java                             Passed. Execution successful
javax/net/ssl/compatibility/BasicConnectTest.java                     Passed. Execution successful
javax/net/ssl/compatibility/ClientHelloProcessing.java                Passed. Execution successful
javax/net/ssl/compatibility/HrrTest.java                              Passed. Execution successful
javax/net/ssl/compatibility/SniTest.java                              Passed. Execution successful

Testing Appendix

I could not make a fully automated symlinks resolution test in Windows, so I'm posting here a PowerShell extended version of ConfigFileTestDirPermissions. This test requires user interaction, to accept UAC elevation when creating the symlink. To run it, just paste the whole snippet in a non-elevated PowerShell terminal at the root of a built jdk repository.

ConfigFileTestDirPermissionsEx PowerShell test
function ConfigFileTestDirPermissionsEx {
    # Ensures java.security is loaded and symlinks are resolved in Windows,
    # even when the user does not have permissions on a parent directory.

    # Make sure we run non-elevated
    $user = [Security.Principal.WindowsIdentity]::GetCurrent()
    $adminRole = [Security.Principal.WindowsBuiltInRole]::Administrator
    $principal = New-Object Security.Principal.WindowsPrincipal($user)
    if ($principal.IsInRole($adminRole)) {
        throw "Must run non-elevated!"
    }

    $originalJdk = Get-Item -ErrorAction SilentlyContinue "build/*/images/jdk"
    # Make sure a built JDK image is found
    if (![System.IO.Directory]::Exists($originalJdk.FullName)) {
        throw "Could not find a built image, must run from the jdk repo root"
    }

    # Create temporary directory
    $tempDirName = "JDK-8352728-tmp-" + (New-Guid).ToString("N")
    $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) $tempDirName
    New-Item $tempDir -ItemType Directory | Out-Null

    try {
        # Copy the jdk to a different directory
        $jdk = Join-Path $tempDir "jdk-parent-dir/jdk"
        Copy-Item -Recurse $originalJdk $jdk

        # Create an extra.properties file with a relative include in it
        $include = Join-Path $tempDir "relatively.included.properties"
        $testProperty = "test.property.name=test_property_value"
        Out-File -Encoding ascii $include -InputObject $testProperty
        $extra = Join-Path $tempDir "extra.properties"
        $content = "include " + (Split-Path -Leaf $include)
        Out-File -Encoding ascii $extra -InputObject $content

        # Create a symlink to extra.properties, from the jdk directory
        $mainPropsDir = Join-Path $jdk "conf/security"
        $mainProps = Join-Path $mainPropsDir "java.security"
        $link = Join-Path $mainPropsDir "link.to.extra.properties"
        Start-Process -Wait -Verb RunAs -WindowStyle Hidden "cmd.exe" @(
            "/c", "mklink", $link, $extra
        )

        # Include link.to.extra.properties from java.security
        $content = "`ninclude " + (Split-Path -Leaf $link)
        Out-File -Encoding ascii -Append $mainProps -InputObject $content

        # Remove current user permissions from jdk-parent-dir
        $parent = Split-Path -Parent $jdk
        $newAcl = New-Object System.Security.AccessControl.DirectorySecurity
        $newAcl.SetAccessRule((New-Object `
            System.Security.AccessControl.FileSystemAccessRule(
                $user.Name, "FullControl", "Deny"
            )
        ))
        $originalAcl = Get-Acl $parent
        Set-Acl $parent $newAcl

        try {
            # Make sure the permissions are affecting the current user
            $java = Join-Path $jdk "bin/java.exe"
            $stderrFile = Join-Path $tempDir "StandardError.txt"
            $realPath = Join-Path $tempDir "RealPath.java"
            Out-File -Encoding ascii $realPath -InputObject @"
            public final class RealPath {
                public static void main(String[] args) throws Exception {
                    java.nio.file.Path.of(args[0]).toRealPath();
                }
            }
"@
            $proc = Start-Process -Wait -WindowStyle Hidden -PassThru `
                                  -RedirectStandardError $stderrFile $java @(
                $realPath, $mainProps
            )
            $stderrContent = Get-Content $stderrFile
            if ($proc.ExitCode -eq 0) {
                throw "Directory should affect the user, expected to fail"
            }
            if (($stderrContent -match "AccessDeniedException").Length -eq 0) {
                throw "Failure was not an AccessDeniedException"
            }

            # Execute the copied jdk, ensuring java.security.Security is
            # loaded (i.e. use -XshowSettings:security:properties)
            $proc = Start-Process -Wait -WindowStyle Hidden -PassThru `
                                  -RedirectStandardError $stderrFile $java @(
                "-Djava.security.debug=properties",
                "-XshowSettings:security:properties",
                "-version"
            )
            $stderrContent = Get-Content $stderrFile
            Write-Output $stderrContent
            if ($proc.ExitCode -ne 0) {
                throw "Execution failed"
            }
            if (($stderrContent -match $testProperty).Length -eq 0) {
                throw "Expected '$testProperty' property not found"
            }
            Write-Output "TEST PASS - OK"
        } finally {
            Set-Acl $parent $originalAcl
        }
    } finally {
        Remove-Item -Recurse -Force $tempDir
    }
}

ConfigFileTestDirPermissionsEx

Progress

  • Change must be properly reviewed (1 review required, with at least 1 Reviewer)
  • Change must not contain extraneous whitespace
  • Commit message must refer to an issue

Issue

  • JDK-8352728: InternalError loading java.security due to Windows parent folder permissions (Bug - P4)

Reviewing

Using git

Checkout this PR locally:
$ git fetch https://git.openjdk.org/jdk.git pull/24465/head:pull/24465
$ git checkout pull/24465

Update a local copy of the PR:
$ git checkout pull/24465
$ git pull https://git.openjdk.org/jdk.git pull/24465/head

Using Skara CLI tools

Checkout this PR locally:
$ git pr checkout 24465

View PR using the GUI difftool:
$ git pr show -t 24465

Using diff file

Download this PR as a diff file:
https://git.openjdk.org/jdk/pull/24465.diff

Using Webrev

Link to Webrev Comment

@bridgekeeper
Copy link

bridgekeeper bot commented Apr 5, 2025

👋 Welcome back fferrari! A progress list of the required criteria for merging this PR into master will be added to the body of your pull request. There are additional pull request commands available for use with this pull request.

@openjdk
Copy link

openjdk bot commented Apr 5, 2025

❗ This change is not yet ready to be integrated.
See the Progress checklist in the description for automated requirements.

@openjdk
Copy link

openjdk bot commented Apr 5, 2025

@franferrax The following label will be automatically applied to this pull request:

  • security

When this pull request is ready to be reviewed, an "RFR" email will be sent to the corresponding mailing list. If you would like to change these labels, use the /label pull request command.

@franferrax franferrax marked this pull request as ready for review April 10, 2025 00:21
@openjdk openjdk bot added the rfr Pull request is ready for review label Apr 10, 2025
@mlbridge
Copy link

mlbridge bot commented Apr 10, 2025

Webrevs

@AlanBateman
Copy link
Contributor

AlanBateman commented Apr 10, 2025

I don't think this change should be integrated before more investigation. I think start by finding out why this code is using toRealPath. For the two cases listed, it looks like toRealPath is correctly failing for the first, but for /dev/stdin then please bring it to nio-dev to discuss how special devices should be handled by that method.

@franferrax
Copy link
Contributor Author

Hi @AlanBateman.

I don't think this change should be integrated before more investigation.

Ok, makes sense.

I think start by finding out why this code is using toRealPath.

The usage of Path::toRealPath was introduced by the 8319332: Security properties files inclusion proposal for the following reasons:

  1. Weak reason: detect cyclic re-inclusion through an alias of the same file (e.g. symlink, or alternative case in case-insensitive filesystems). This would only make cycle detection trigger earlier, but is not strictly necessary (infinite recursion will lead to path repetition even if not normalized).
  2. Weak reason: resolve a relative path passed through -Djava.security.properties=relative.props against the current working directory (stack: loadAll, loadExtra, loadExtraHelper, loadExtraFromPath, loadFromPath). This resolution could be done with Path::toAbsolutePath, but this case is also subject to the 3ʳᵈ reason.
  3. Strong reason: resolve symlinks, so that properties files use their original path to resolve relative includes. The rationale behind this is that the writer of the original properties file is the one who reasoned where relative includes should resolve to. On the other hand, the writer of the symlink just wants to use the original file with all its includes, without having to replicate anything else. This case is exercised by the PowerShell test on this PR's description.

For the two cases listed, it looks like toRealPath is correctly failing for the first […]

Yes, that was our impression, and that's why we are not proposing any fix to Path::toRealPath: there's nothing wrong with failing if normalization is not complete. But File::getCanonicalPath takes a best-effort approach that is more suitable to our needs.

[…] but for /dev/stdin then please bring it to nio-dev to discuss how special devices should be handled by that method.

I will investigate the Linux case, I had skipped it because File::getCanonicalPath looked like the only alternative on Windows, while it is also working on Linux.

@franferrax
Copy link
Contributor Author

Hi again @AlanBateman,

I've been doing some research on Linux, debugging the following sample:

GetContentAndRealPath.java

import java.nio.file.Files;
import java.nio.file.Path;

import static java.nio.charset.StandardCharsets.UTF_8;

public final class GetContentAndRealPath {
    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            throw new Exception("Please specify a file path");
        }
        Path path = Path.of(args[0]);

        System.out.println("%(java) Content:");
        System.out.println("%(java) => " +
                new String(Files.readAllBytes(path), UTF_8).trim());

        System.out.println("%(java)");
        System.out.println("%(java) File::getCanonicalPath:");
        System.out.println("%(java) => " +
                path.toFile().getCanonicalFile().toPath());

        System.out.println("%(java)");
        System.out.println("%(java) Path::toRealPath:");
        System.out.println("%(java) => " + path.toRealPath());
    }
}

The first thing to note is that /dev/stdin is not a problem per se, the actual problem is when it is provided by an anonymous pipe. So the following works fine:

$ java GetContentAndRealPath.java /dev/stdin </dev/null
%(java) Content:
%(java) => 
%(java)
%(java) File::getCanonicalPath:
%(java) => /dev/null
%(java)
%(java) Path::toRealPath:
%(java) => /dev/null

In Bash, there are various ways to provide stdin through an anonymous pipe:

# https://www.gnu.org/software/bash/manual/bash.html#Pipelines
echo Pipelines | java GetContentAndRealPath.java /dev/stdin

# https://www.gnu.org/software/bash/manual/bash.html#Here-Strings
java GetContentAndRealPath.java /dev/./stdin <<<Here-Strings

# https://www.gnu.org/software/bash/manual/bash.html#Here-Documents
java GetContentAndRealPath.java /etc/../dev/stdin <<EOF
Here-Documents
EOF

# https://www.gnu.org/software/bash/manual/bash.html#Process-Substitution
java GetContentAndRealPath.java <(echo Process-Substitution)

Here-Documents example:

$ java GetContentAndRealPath.java /etc/../dev/stdin <<EOF
Here-Documents
EOF
%(java) Content:
%(java) => Here-Documents
%(java)
%(java) File::getCanonicalPath:
%(java) => /dev/stdin
%(java)
%(java) Path::toRealPath:
Exception in thread "main" java.nio.file.NoSuchFileException: /etc/../dev/stdin
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
	at java.base/sun.nio.fs.UnixPath.toRealPath(UnixPath.java:834)
	at GetContentAndRealPath.main(GetContentAndRealPath.java:24)

Please note how File::getCanonicalPath resolves /etc/../dev/stdin/dev/stdin while Path::toRealPath fails with NoSuchFileException.

glibc's realpath()

I traced this behaviour inside glibc's realpath(), with the following GDB script:

gdb -q --nx java <<'EOF' 2>&1 | grep --color=never '^%(\w*)\|^Exception\|^	at'
# Settings
set debuginfod enabled on
set breakpoint pending on
handle SIGSEGV nostop noprint pass

# Break once at JDK_Canonicalize(), if the path starts with /dev/fd/
tbreak JDK_Canonicalize if ((int) strncmp(orig, "/dev/fd/", 8)) == 0

# Start java with a Process-Substitution anonymous pipe
run GetContentAndRealPath.java <(echo Process-Substitution)

# Stopped at JDK_Canonicalize() add a trace for each glibc's realpath() call
# https://github.com/bminor/glibc/blob/glibc-2.39/stdlib/canonicalize.c#L431
break canonicalize.c:431
commands
  python begin_realpath()
  continue
end

# Inside glibc's realpath() implementation, also trace each readlink() result
# https://github.com/bminor/glibc/blob/glibc-2.39/stdlib/canonicalize.c#L311
break canonicalize.c:311
commands
  python after_readlink()
  continue
end

############################################################
python
from errno import errorcode

def begin_realpath():
    name = gdb.parse_and_eval('name').string('utf-8')
    print(f'%(glibc)  realpath("{name}")')

def after_readlink():
    rname = gdb.parse_and_eval('rname').string('utf-8')
    print(f'%(glibc)    readlink("{rname}") -> ', end='')
    n = int(gdb.parse_and_eval('n'))
    if n >= 0:
        buf = gdb.parse_and_eval('buf').string('utf-8')[:n]
        print(f'"{buf}"')
    else:
        errno = int(gdb.parse_and_eval('errno'))
        print(f'{errorcode[errno]} ({errno})')
end
############################################################

# Resume execution from JDK_Canonicalize()
continue
EOF

It produces the following output:

%(java) Content:
%(java) => Process-Substitution
%(java)
%(java) File::getCanonicalPath:
%(glibc)  realpath("/dev/fd/63")
%(glibc)    readlink("/dev") -> EINVAL (22)
%(glibc)    readlink("/dev/fd") -> "/proc/self/fd"
%(glibc)    readlink("/proc") -> EINVAL (22)
%(glibc)    readlink("/proc/self") -> "1390261"
%(glibc)    readlink("/proc/1390261") -> EINVAL (22)
%(glibc)    readlink("/proc/1390261/fd") -> EINVAL (22)
%(glibc)    readlink("/proc/1390261/fd/63") -> "pipe:[18671624]"
%(glibc)    readlink("/proc/1390261/fd/pipe:[18671624]") -> ENOENT (2)
%(glibc)  realpath("/dev/fd")
%(glibc)    readlink("/dev") -> EINVAL (22)
%(glibc)    readlink("/dev/fd") -> "/proc/self/fd"
%(glibc)    readlink("/proc") -> EINVAL (22)
%(glibc)    readlink("/proc/self") -> "1390261"
%(glibc)    readlink("/proc/1390261") -> EINVAL (22)
%(glibc)    readlink("/proc/1390261/fd") -> EINVAL (22)
%(java) => /proc/1390261/fd/63
%(java)
%(java) Path::toRealPath:
%(glibc)  realpath("/dev/fd/63")
%(glibc)    readlink("/dev") -> EINVAL (22)
%(glibc)    readlink("/dev/fd") -> "/proc/self/fd"
%(glibc)    readlink("/proc") -> EINVAL (22)
%(glibc)    readlink("/proc/self") -> "1390261"
%(glibc)    readlink("/proc/1390261") -> EINVAL (22)
%(glibc)    readlink("/proc/1390261/fd") -> EINVAL (22)
%(glibc)    readlink("/proc/1390261/fd/63") -> "pipe:[18671624]"
%(glibc)    readlink("/proc/1390261/fd/pipe:[18671624]") -> ENOENT (2)
Exception in thread "main" java.nio.file.NoSuchFileException: /dev/fd/63
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
	at java.base/sun.nio.fs.UnixPath.toRealPath(UnixPath.java:834)
	at GetContentAndRealPath.main(GetContentAndRealPath.java:24)

We can see that realpath() invokes readlink() several times for the partially normalized path. Anonymous pipes live in the PipeFS virtual filesystem, which isn't mounted in userspace. However, anonymous pipes do have a dentry which gives them a dname with the pipe:[<i_ino>] format (Code: readlink syscalldo_readlinkatvfs_readlinkproc_pid_readlinkdo_proc_readlinkd_pathpipefs_dname. Also, there are some higher level explanations here and here).

When readlink() resolves /proc/<pid>/fd/63, it returns the pipe:[18543635] link target, which makes the symlink look "broken" (from the userspace perspective) and this ultimately makes realpath() fail with ENOENT (2).

However, the unresolved /proc/<pid>/fd/0 link works, as the Linux Kernel can access the anonymous pipe behind it:

fferrari@vmhost:~$ echo TEST | sleep 20 &>/dev/null &disown
[1] 1386980
fferrari@vmhost:~$ cat $(realpath /proc/$(pgrep sleep)/fd/0)
cat: '/proc/1386980/fd/pipe:[18629664]': No such file or directory
fferrari@vmhost:~$ cat /proc/$(pgrep sleep)/fd/0
TEST

OpenJDK APIs difference

We can see in UnixNativeDispatcher::realpath0 that Path::toRealPath translates the glibc's realpath() failure immediately.

On the other hand File::getCanonicalPath goes through UnixFileSystem::canonicalize0 and JDK_Canonicalize, which retries realpath() until some subpath works. This aligns with the previous GDB experiment, which showed File::getCanonicalPath is doing an additional realpath("/dev/fd") call when realpath("/dev/fd/63") fails.

Given it just follows the glibc's behaviour, I don't think a Path::toRealPath change is justifiable for an nio-dev request. For example, an equivalent discussion has been raised for Rust, and the developers aren't considering making any change.

File::getCanonicalPath seems to take the best-effort approach (both in Linux and Windows), whereas Path::toRealPath is stricter.

@AlanBateman
Copy link
Contributor

AlanBateman commented Apr 15, 2025

File::getCanonicalPath seems to take the best-effort approach (both in Linux and Windows), whereas Path::toRealPath is stricter.

Path::toRealPath is doing the right thing, and consistent with realpath(2). The issue with File::getCanonicalXXX is that it is specified to return a canonical file even if it doesn't exist, so this is why you see a lot more code to compute a result.

Maybe the recursive include check them maybe it should use the file key instead.

Update copyright year, improve comments and use File::toPath to convert
back to Path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
rfr Pull request is ready for review security [email protected]
Development

Successfully merging this pull request may close these issues.

2 participants