Skip to content
欢迎扫码关注公众号

JEP 486: Permanently Disable the Security Manager | 永久禁用安全管理器

概述

安全管理员多年来已不是保护客户端 Java 代码的主要方式,它很少被用来保护服务器端代码,并且维护成本高昂。因此,我们通过 JEP 411 (2021) 将其弃用以待在 Java 17 中移除。作为移除安全管理员的下一步,我们将修订 Java 平台规范,使开发人员无法启用它以及其他平台类不引用它。这一变化对绝大多数应用程序、库和工具没有影响。我们将在未来的版本中删除安全管理员 API。

目标

  • 移除启动 Java 运行时 (java -Djava.security.manager ...) 时启用安全管理员的能力。

  • 移除应用程序运行期间安装安全管理员的能力 (System.setSecurityManager(...))。

  • 改进目前将资源访问决策委托给安全管理员的数百个 JDK 类的可维护性。

  • 修订安全管理员 API 的规范,使其实现的行为都表现为从未启用过安全管理员。

  • 在此版本中保留安全管理员 API,以便依赖它的现有代码维护者有时间迁移出去。

非目标

  • 不打算为目标提供任何安全管理员功能的替代方案,特别是沙盒化 Java 代码或拦截调用 Java 平台 API 的能力。

动机

自首次发布以来,安全管理员一直是 Java 平台的一个特性。它基于 最小权限原则:默认情况下代码是不受信任的,因此不能访问文件系统或网络等资源,开发人员通过授予特定代码访问特定资源的权限来建立信任。理论上,这可以保护机器和应用程序免受包含意外漏洞或恶意编写的代码的侵害。然而实际上,权限方案非常复杂,以至于安全管理员总是默认禁用,而且使用极为罕见。

尽管安全管理员默认是禁用的,但最小权限模型在 Java 平台库中引入了非凡的复杂性。从网络、I/O、JDBC 到 XML、AWT 和 Swing,这些库在安全管理员启用的情况下必须实现最小权限模型:

  • 超过 1,000 个方法在安全管理员启用时必须检查访问资源的权限。例如,FileOutputStream 类的 构造函数 委托给安全管理员,后者应用复杂的算法来决定是否允许访问。

  • 超过 1,200 个方法在安全管理员启用时必须提升它们的权限。例如,如果一个应用程序没有读取文件的权限,但它调用了 java.time.LocalDateTime.now(),那么 java.time 代码必须 声明自己的更强权限 才能读取 JDK 的内部时区数据库文件。

OpenJDK 核心库小组 花费大量时间和精力审查这些方法的每一处变更。每个新 API 都必须考虑到最小权限模型进行设计,并仔细审计其实施。但是,实际启用安全管理员的应用程序数量极少。更糟糕的是,根据我们的经验,其中大多数应用程序盲目地授予其代码所有权限,从而放弃了最小权限模型带来的好处。

因此,我们通过 JEP 411 (2021) 在 Java 17 中将安全管理员弃用以待移除。除了最终弃用安全管理员 API 及相关 API 外,我们还修改了 JDK,在启用安全管理员时发出警告信息。这些更改旨在为用户和开发人员准备在未来版本中移除安全管理员。

弃用安全管理员几乎没有影响

随着开发者和企业从 JDK 8 和 JDK 11 升级,JDK 17 及之后的版本得到了广泛采用。我们在 Java 生态系统中几乎看不到关于这些版本在启用安全管理员时发出的 警告 的讨论。这表明安全管理员对当前的 Java 开发者几乎完全无关紧要。当我们说,在 JEP 411 中,

"自从引入安全管理员以来的四分之一个世纪里,其采用率一直很低",

以及,

" 总之,对于使用安全管理员开发现代 Java 应用程序没有显著的兴趣。”

我们似乎是对的。自 JDK 17 发布以来,少数支持安全管理员的框架和工具的维护者已经移除了对其的支持;包括 DerbyAntSpotBugsTomcat。Jakarta EE 的维护者 删除了 EE 应用程序需要支持安全管理员的要求。据我们所知,没有任何新项目支持安全管理员。

继续前进

绝大多数的应用程序、库和工具不需要安全管理员,不推荐使用安全管理员,不使用安全管理员,如果其他代码使用安全管理员则无法工作。现在是 Java 生态系统采取下一步行动并完全停止使用安全管理员的时候了。

因此,我们将修订安全管理员的规范,使开发者无法启用它,并且我们也将修订其他 Java 平台库的规范,使其不会将资源访问决策委托给它。为了与仍然使用它的少量应用程序、库和工具保持兼容,我们将保留 java.lang.SecurityManager 类的一个最小版本。我们将在未来的版本中移除这个类。

移除安全管理员将改善 Java 安全性

我们相信,绝大多数 Java 开发者更希望看到 OpenJDK 核心库小组专注于互联网应用所需的实用安全特性。上述对规范的修订将允许我们从 JDK 代码库中移除安全管理员的实现,连同数千个权限检查和特权提升一起。反过来,这将使更多的贡献者时间和精力可用于其他工作,例如:

大多数当代安全威胁涉及恶意数据,而安全管理员对此防御能力不足。移除安全管理员的实现将使更多贡献者的时间和精力可用于直接对抗恶意数据的安全特性,例如:

  • 更安全的序列化 — 反序列化过程涉及到解释可能被恶意构造的数据流。2017 年,Java 9 引入了 反序列化过滤器,以便开发者可以防止恶意数据被反序列化。从长远来看,朝着更好的序列化方法的工作已经在进行中 向更好的序列化方式迈进

  • 更严格的 XML 处理 — XML 文档可以引用 Internet 上的任何位置的文档类型定义 (DTD),导致 JDK 打开到不受信任机器的网络连接。在 JDK 23 中,应用程序开发者可以通过启动 Java 运行时并指定一个禁止出站连接的 配置文件 来锁定 XML 处理:java -Djava.xml.config.file=...

Java 代码沙盒化

沙盒化 是指以不同于其他代码的权限运行一些 Java 代码的能力。每个代码片段的权限由安全策略决定,该策略由安全管理员执行。以受限权限运行的代码,例如不受信任或潜在敌对的代码,通常被称为处于 沙盒中

历史上,安全管理员曾被用来沙盒化小程序;我们从未推荐过将其用于整个应用程序的沙盒化。Java 应用程序应以与原生应用程序相同的方式进行沙盒化,使用 JDK 之外的技术,如 容器虚拟机监控程序,以及操作系统机制,如 macOS 应用程序沙盒Linux seccomp 功能。像安全管理员一样,这些技术可以限制应用程序如何使用本地和远程资源;它们可以,例如,阻止代码访问网络以窃取数据。然而,与安全管理员不同的是,这些技术有广泛的采用,并且相对容易学习和有效使用。

拦截对 Java 平台 API 的调用

少量的应用程序使用安全管理员不是为了执行安全策略,而是作为一种拦截对 Java 平台 API 调用的手段。在没有安全策略和权限检查的情况下,对 Java 平台 API 的调用不再被视为一种安全问题。真正的恶意代码有无数种方法绕过安全管理员对 API 调用的拦截。尽管如此,一些应用程序发现拦截功能很有用,特别是用于阻止对如 System::exit 等方法的调用。

我们设计、原型化并评估了多种机制,应用程序可以使用这些机制替代安全管理员来拦截对 Java 平台 API 的调用。我们发现用例过于广泛,要求也过于分散,无法支持引入这样的机制。OpenJDK 核心库小组不愿意维护跨 JDK 的任意数量的 API 拦截点且这些点的要求定义不清。

在大多数情况下,我们发现看似需要拦截的问题可以通过使用源代码修改、静态代码分析和重写或基于代理的类加载时动态代码重写技术在 JDK 之外充分解决。参见 附录 中一个使用动态代码重写拦截对 System::exit 调用的代理示例。

旧版 Java 发布中的安全管理员

安全管理员将继续在 JDK 24 之前的每个版本中可用。由于极其重视稳定性而对采用新版本保持警惕的应用部署者可能永远不会升级到 JDK 24,因此将永远不会受到 JDK 24 或之后版本中安全管理员更改的影响。

描述

在 JDK 24 中,我们将:

  • 移除启动时启用安全管理员的能力,
  • 移除运行时安装自定义安全管理员的能力,并
  • 在未来版本中移除 API 之前,使安全管理员 API 失效。

在 JDK 24 中启用安全管理员是一个错误

在 JDK 24 中,您不能在启动时启用安全管理员,也不能在运行时安装自定义安全管理员。

  • 启动时启用安全管理员是错误的,例如通过以下方式:

    bash
    $ java -Djava.security.manager                  -jar app.jar
    $ java -Djava.security.manager=""               -jar app.jar
    $ java -Djava.security.manager=allow            -jar app.jar
    $ java -Djava.security.manager=default          -jar app.jar
    $ java -Djava.security.manager=com.foo.CustomSM -jar app.jar

    尝试这样做会导致 JVM 报告错误然后退出:

    java
    Error occurred during initialization of VM
    java.lang.Error: A command line option has attempted to allow or enable the Security Manager. Enabling a Security Manager is not supported.
            at java.lang.System.initPhase3(java.base@24/System.java:2067)

    您无法抑制此错误消息,也无法将其简化为 JDK 17 至 23 中的警告

    (上面显示的五个 java -D... 调用分别将系统属性 java.security.manager 设置为空字符串、空字符串、字符串 allow、字符串 default 以及自定义安全管理员的类名。)

  • 禁用运行时安装自定义安全管理员不是错误,例如通过:

    bash
    $ java -jar app.jar
    $ java -Djava.security.manager=disallow -jar app.jar

    启动时不会发出任何警告或错误消息,应用程序将在没有安全管理员的情况下运行,就像以前一样。

    java.security.manager 的默认值 从 JDK 18 开始就是 disallow,因此 java -jar app.jarjava -Djava.security.manager=disallow -jar app.jar 含义相同。)

  • 运行时通过调用 System::setSecurityManager 安装安全管理员是一个错误。尝试这样做会导致 JVM 抛出带有详细信息的消息的 UnsupportedOperationException

    Setting a Security Manager is not supported

如何确定应用程序是否启用了安全管理员

如果您不确定您的应用程序是否启用了安全管理员,可以做以下几件事来查明:

  • 检查脚本或文档,查看应用是否通过命令行选项允许或启用安全管理员,或者是否需要安装和配置策略文件。

  • 在 JDK 17 至 23 上运行应用程序,并查找控制台上关于安全管理员已被弃用并在未来版本中移除的 警告信息

  • 使用命令行选项 -Djava.security.manager=disallow 在 JDK 17 至 23 上运行应用程序。如果应用程序通过 System::setSecurityManager 方法安装了自定义安全管理员,则 JVM 将抛出 UnsupportedOperationException

  • 使用 JDK 17 至 23 中的 jdeprscan 工具扫描已弃用的安全管理员 API 的使用情况,如 System::setSecurityManagerjava.security.Policy::setPolicy

使安全管理器 API 失效的渲染

安全管理器 API 包括:

  • java.lang.SecurityManager 类中的方法,
  • java.security 包中的 AccessControllerAccessControlContextPolicyProtectionDomain 类中的方法,以及
  • java.lang.System 类中的 getSecurityManagersetSecurityManager 方法。

我们并不是从 Java 24 中删除这些方法;而是降低它们的行为效能。根据具体情况,它们将返回 nullfalse,或者传递调用者的请求,或者无条件地抛出 SecurityExceptionUnsupportedOperationException。这些行为变化对于大多数使用安全管理器 API 的库是兼容的,但对非常少部分的库不兼容:有关详情,请参阅 给库维护者的建议。完整的行文变化可以在 这里 找到。我们将在未来的版本中移除安全管理器 API。

除了改变 API 的行为外,我们还将:

Java 平台 API 其他地方的变化

平台上大约有 1,000 个构造函数和方法在启用安全管理器且未授予适当权限的情况下被指定为抛出 SecurityException。它们跨越了 264 个类、73 个包和 25 个模块。例如,java.base 中有 640 个方法被指定为抛出 SecurityException

在 Java 24 中,我们将修订所有此类构造函数和方法的规范以删除提及 SecurityException 的内容,因为该异常现在永远不会被抛出。修订后的构造函数和方法的完整列表可在此 获得。

以下是 java.io.FileOutputStream构造函数 规范更改的一个示例(带有删除线的文本表示被删除的部分):

public FileOutputStream(String name)
         throws FileNotFoundException

创建一个文件输出流来写入具有指定名称的文件。会创建一个新的 FileDescriptor 对象来代表这个文件连接。

~~ 首先,如果存在安全管理器,则以其参数 name 调用其 checkWrite 方法。~~

如果文件存在但是一个目录而不是常规文件,不存在但无法创建,或者由于任何其他原因无法打开,则抛出 FileNotFoundException。

实现要求:

使用参数 name 调用此构造函数等同于调用 new FileOutputStream(name, false)。

参数:

name - 系统依赖的文件名。

抛出:

FileNotFoundException - 如果文件存在但是一个目录而不是常规文件,不存在但无法创建,或者由于任何其他原因无法打开
SecurityException - 如果存在安全管理器且其 checkWrite 方法拒绝写入文件的访问权限。

另请参见:

SecurityManager.checkWrite(java.lang.String)

给支持安全管理器的库维护者的建议

少数库被设计为在启用安全管理器时使用它。这些库通常采用两种惯用法:

  • 调用 System::getSecurityManager 来检查是否启用了安全管理器,如果是,则调用 SecurityManager::checkPermission 来检查是否应授予或拒绝操作:

    java
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        sm.checkPermission(...);
    }
  • 调用 AccessController::doPrivileged 以不同于调用代码的权限执行代码:

    java
    SomeReturnValue v = AccessController.doPrivileged(() -> {
        ...
        return theResult;
    });

在 JDK 24 中,当从未启用安全管理器时,System::getSecurityManagerAccessController::doPrivileged 方法的行为与 JDK 17 未启用安全管理器时相同:

  • System::getSecurityManager 返回 null,以及
  • 六个 AccessController::doPrivileged 方法立即执行给定的操作。

因此,少量调用这些方法的库无需修改即可在 JDK 24 上运行。然而,我们强烈建议新版本的这些库不要调用这些方法,这些方法将在未来的版本中被移除。

极少数库使用安全管理器 API 的高级部分来实现自定义执行环境。例如,库可能会调用 AccessController::checkPermission 来强制执行自己的权限模型,或者调用 Policy::setPolicy 使自定义安全管理器将某些资源视为不可访问。

在 JDK 24 中,这些方法总是实现一个不允许访问所有资源的执行环境。结果,它们的行为与 JDK 17 中的行为不同:

  • AccessController::checkPermission 总是抛出 AccessControlException
  • Policy::setPolicy 总是抛出 UnsupportedOperationException
  • SecurityManager::check* 方法总是抛出 SecurityException

有关安全管理器 API 行为变化的完整信息,请参阅 此处

未来的工作

移除相关 API

我们不会在 Java 24 中从 Java 平台 API 中移除任何类或方法。在未来版本中,我们将移除我们在 Java 17 中弃用的安全管理器 API。在后续版本中,我们可能进一步弃用并移除 java.langjava.security 包中的其他类和方法。

  • 我们现在没有弃用 SecurityException,因为它在与安全管理器无关的情况下在 JDK 的其他地方使用,尽管其规范说明是“由安全管理器抛出来指示安全违规”。以下是一些(误)用示例:

    • 如果正在定义的类名称以“java.”开头,java.lang.ClassLoader::defineClass 会抛出 SecurityException

    • java.lang.Class 的构造函数的 Constructor 对象上调用 java.lang.reflect.Constructor::setAccessible 会抛出异常。

    • 签名不正确的签名 JAR 条目会导致 java.util.java.JarInputStream 抛出异常。

    重新审查这些误用后,我们可能会在未来的版本中弃用 SecurityException

  • 在未来的版本中,我们将弃用 Permission 及其相关类,如 BasicPermissionPermissionCollectionPermissions,以及 java.security 包之外的 Permission 子类,如 java.lang.RuntimePermissionjava.net.NetPermissionjava.lang.reflect.ReflectPermission

  • 在未来的版本中,我们将弃用 PrivilegedActionPrivilegedExceptionActionPrivilegedActionException。由于这些类出现在与安全管理器无关的 javax.security.auth.Subject 类的方法签名中,所以在 Java 17 中没有弃用这些类。在 Java 18 中,我们添加了不使用 Privileged* 类的替换方法到 javax.security.auth.Subject。最终,我们将删除旧方法和 Privileged* 类。

移除或修订相关特性

各种早期的 Java 平台特性围绕移动对象的愿景设计。它们使用序列化在 JVM 之间移动代码和数据,并假设应用程序将启用安全管理器来防御恶意序列化的对象。这一愿景并未得到广泛认可。鉴于序列化的 基本缺陷 和安全管理器的最少使用,我们已经移除了这些特性或计划这样做:

  • JMX 管理小程序 ("m-lets") 在 Java 5 中引入,允许远程 MBeans 动态加载并在启用安全管理器的情况下执行。实际上,m-lets 几乎没有使用。我们已在 Java 20 中 将 m-let API 弃用为可移除,并在 Java 23 中将其 移除

  • JNDI 支持从 LDAP 数据库 (RFC 2713) 重建序列化对象。此 JNDI 功能自 Java 6 以来默认禁用,但可以通过系统属性启用。安全地使用此功能依赖于启用安全管理器,因此一旦移除安全管理器,将无法安全地使用该功能。因此,我们将在未来的版本中 移除此功能,连同 JNDI RMI 注册表服务提供程序的远程类加载功能一并移除。

  • RMI 支持 动态代码加载,但仅在启用安全管理器时启用。RMI 的此功能自 2013 年以来默认禁用。随着安全管理器的移除,不再可能使用此功能。我们可能会在未来的版本中将其移除。

另外,javax.xml API 允许直接在 XSLT 和 XPath 文档中嵌入 Java 源代码作为扩展函数。此功能 默认启用,但历史上,在使用安全管理器运行时,它是禁用的。我们将在未来的版本中默认禁用此功能,这是更严格的 XML 处理努力的一部分。

测试

安全管理器 API 的广度及其在 JDK 代码库中的深度支持体现在自 JDK 1.0 以来为其开发的大约 4,000 个测试中。这些测试分为三类:

  • 直接测试安全管理器功能的测试,例如确保权限被正确执行。

  • 解决安全漏洞的测试,这些通常确保特定漏洞不再可能允许不受信任的代码(如小程序)逃脱沙箱。

  • 符合性测试,确保安全管理器实现符合其 API 规范。

永久禁用安全管理器将使这些测试变得无关紧要,因为该功能将不再受支持且沙箱的概念将不复存在。包括测试在内,我们将删除超过 50,000 行代码。

替代方案

安全管理器 API 中的许多 check* 方法总是 抛出异常,以避免无条件地允许那些过去需要权限检查的操作,因此可能不允许这样做。这对于应用程序维护者来说可能会有些不便,他们可能需要采取一些纠正措施。另一种方案是让这些方法总是成功,但这会让应用程序在没有通知维护者的情况下以不安全的方式运行。

风险和假设

  • 在 JDK 24 中,在命令行尝试启用安全管理器会立即导致错误信息并且应用程序不会启动。如果一个应用程序无法启动,则下游系统可能会失败,业务流程可能会受到影响。我们假设应用程序维护者能够通过更新他们的 java 命令行来响应这个错误,以避免给出 -Djava.security.manager 选项,并使用其他机制缓解安全问题。

    (当我们从 JDK 中移除一个特性时,我们传统上拒绝任何相关的命令行选项。这包括使用 java -D... 设置系统属性,比如 java.security.manager。例如,当扩展机制在 JDK 9 中被移除时,设置 java.ext.dirs 系统属性会导致错误。这迫使应用程序维护者迅速移除过时的选项,避免 JDK 在一组令人困惑或误导性的选项下运行的情况。)

  • 存在一个风险,即依赖 javax.security.auth API 的框架仍在使用 Subject 类中已弃用的方法,即 doAsgetSubject。我们在 Java 17 和 18 中弃用了这些方法,因为它们的签名使用了安全管理器 API 中已弃用的类。我们在 Java 18 中引入了 doAsgetSubject 的替换方法。由于 getSubject 从 Java 23 开始 抛出 UnsupportedOperationException,我们假设框架已经意识到弃用并正在努力采用替代方案,例如 HADOOP-19212

附录

代理 是一种 Java 程序,可以在应用程序运行时更改应用程序的代码。通过在类加载时转换方法的字节码或在类被加载后重新定义类,代理实现了这一点。

这是一个阻止代码调用 System::exit 的代理。该代理声明了一个 premain 方法,此方法在应用程序的 main 方法之前由 JVM 运行。此方法注册一个 转换器,它在类从类路径或模块路径加载时转换类文件。转换器将每次对 System.exit(int) 的调用重写为 throw new RuntimeException("System.exit not allowed")

转换器使用 类文件 API 读取和写入类文件中的字节码,这是 JDK 23 的一个预览特性。有关详细信息,请参阅 java.lang.classfile。代理的源代码导入了类文件 API 和其他带有 模块导入声明 的 Java API,这些也是 JDK 23 的预览特性。

java
import module java.base;
import module java.instrument;

public class BlockSystemExitAgent {
    /*
     * 在应用程序启动前,注册一个类文件转换器。
     */
    public static void premain(String agentArgs, Instrumentation inst) {
        var transformer = new ClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader      loader,
                                    String           className,
                                    Class<?>         classBeingRedefined,
                                    ProtectionDomain protectionDomain,
                                    byte[]           classBytes) {
                if (loader != null && loader != ClassLoader.getPlatformClassLoader()) {
                    return blockSystemExit(classBytes);
                } else {
                    return null;
                }
            }
        };
        inst.addTransformer(transformer, true);
    }

    /*
     * 将每个对 System::exit(int) 的 invokestatic 重写为 RuntimeException 的 athrow。
     */
    private static byte[] blockSystemExit(byte[] classBytes) {
        var modified = new AtomicBoolean();
        ClassFile cf = ClassFile.of(ClassFile.DebugElementsOption.DROP_DEBUG);
        ClassModel classModel = cf.parse(classBytes);

        Predicate<MethodModel> invokesSystemExit =
            methodModel -> methodModel.code()
                                        .map(codeModel ->
                                                codeModel.elementStream()
                                                        .anyMatch(BlockSystemExitAgent::isInvocationOfSystemExit))
                                        .orElse(false);

        CodeTransform rewriteSystemExit =
            (codeBuilder, codeElement) -> {
                if (isInvocationOfSystemExit(codeElement)) {
                    var runtimeException = ClassDesc.of("java.lang.RuntimeException");
                    codeBuilder.new_(runtimeException)                    
                                .dup()
                                .ldc("System.exit not allowed")
                                .invokespecial(runtimeException,
                                    "<init>",
                                    MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V"),
                                    false)
                                .athrow();
                    modified.set(true);
                } else {
                    codeBuilder.with(codeElement);
                }
            };

        ClassTransform ct = ClassTransform.transformingMethodBodies(invokesSystemExit, rewriteSystemExit);
        byte[] newClassBytes = cf.transform(classModel, ct);
        if (modified.get()) {
            return newClassBytes;
        } else {
            return null;
        }
    }

    private static boolean isInvocationOfSystemExit(CodeElement codeElement) {
        return codeElement instanceof InvokeInstruction i
                && i.opcode() == Opcode.INVOKESTATIC
                && "java/lang/System".equals(i.owner().asInternalName())
                && "exit".equals(i.name().stringValue())
                && "(I)V".equals(i.type().stringValue());
    }
}

你必须将代理打包到一个 JAR 文件中,并在启动应用程序时通过 -javaagent 选项指定它:

bash
# 使用 JDK 23 的预览功能编译代理到 agentclasses 目录
$ javac --enable-preview --release 23 -d agentclasses BlockSystemExitAgent.java

# 创建 JAR 文件清单于 agent.mf
$ cat > agent.mf << EOF
Premain-Class: BlockSystemExitAgent
Can-Retransform-Classes: true
EOF

# 创建代理 JAR(注意 -C agentclasses 后面有一个句点)
$ jar --create --file=BlockSystemExitAgent.jar --manifest=agent.mf -C agentclasses .

# 使用代理 JAR 运行应用程序,并启用 JDK 23 的预览功能
$ java --enable-preview -javaagent:BlockSystemExitAgent.jar -jar app.jar