跳到主要内容

JEP 486: 永久禁用安全管理器

QWen Max 中英对照 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 调用的能力。

动机

安全管理员(Security Manager)从 Java 平台的第一个版本开始就是其特性之一。它基于最小特权原则:代码默认是不受信任的,因此它不能访问文件系统或网络等资源,开发人员通过授予特定代码访问特定资源的权限来对其表示信任。理论上,这可以保护机器和应用程序免受包含意外漏洞或恶意设计的代码的侵害。然而在实践中,权限方案非常复杂,以至于安全管理员默认情况下总是被禁用,其使用也非常罕见。

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

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

  • 当启用安全管理器时,超过 1,200 种方法必须提升其权限。例如,如果应用程序没有读取文件的权限,但它调用了 java.time.LocalDateTime.now(),那么 java.time 代码必须断言其自身更强的权限 以便读取 JDK 的内部时区数据库文件。

OpenJDK Core Libraries Group 投入了大量的时间和精力来审查对这些方法的每一处修改。在设计每个新的 API 时,以及仔细审核其实现时,都必须牢记最小权限模型。然而,实际上只有极少数应用程序启用了安全管理员。更糟糕的是,根据我们的经验,大多数应用程序盲目地授予其代码所有权限,从而放弃了最小权限模型带来的好处。

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

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

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

“自从 Security Manager 推出以来的四分之一世纪中,其采用率一直很低”,

和,

“总之,使用安全管理员(Security Manager)开发现代 Java 应用程序没有显著的兴趣。”

自从 JDK 17 发布以来,少数支持安全管理员的框架和工具的维护者已经移除了对它的支持;这些包括 DerbyAntSpotBugsTomcat。Jakarta EE 的维护者 移除了对 EE 应用程序支持安全管理员的要求。我们没有发现任何新的项目支持安全管理员。

继续前进

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

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

移除安全管理者将改进 Java 安全性

我们相信,绝大多数 Java 开发人员希望看到 OpenJDK 核心库组专注于 Internet 应用程序所需的实用安全功能。对规范的上述修改将允许我们从 JDK 代码库中移除安全管理器的实现,以及数千个权限检查和特权提升。这样一来,将会有更多贡献者的时间和精力可以用于其他工作,例如

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

  • 更安全的序列化 —— 反序列化过程涉及解释可能带有恶意的数据流。2017 年,Java 9 引入了反序列化过滤器,使开发人员能够从一开始就防止恶意数据被反序列化。从长远来看,已经着手开展更好的序列化方法

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

沙箱化 Java 代码

沙箱是一种能够以不同于其他代码的权限来运行某些 Java 代码的能力。每段代码的权限由安全策略决定,该策略由安全管理器强制执行。以受限权限运行的代码(例如不受信任或潜在敌对的代码)通常被称为在沙箱中

历史上,安全管理器用于对小程序(applet)进行沙箱处理;我们从未推荐过将其用于整个应用程序的沙箱处理。Java 应用程序应该像原生应用程序一样被沙箱化,使用 JDK 之外的技术,如容器虚拟机监控程序以及操作系统机制,例如 macOS App SandboxLinux 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 中,你不能在启动时启用安全管理员,也不能在运行时安装自定义的安全管理员。

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

    $ 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 报告错误并退出:

    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 和自定义安全管理员的类名。)

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

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

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

    java.security.manager 的默认值 从 JDK 18 开始就是 disallow,所以 java -jar app.jar 等同于 java -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,或者传递调用者请求,或者无条件抛出 SecurityException。完整的行为变更列表可在此处获得这里

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

  • 删除系统策略文件,即 conf/security/java.policy

  • 使特定于安全管理员的系统属性被忽略,特别是 java.security.policyjdk.security.filePermCompat。我们将在之后记录受影响的系统属性的完整列表。

  • 使特定于安全管理员的安全属性被忽略,特别是 policy.providerpackage.accesspackage.definition。我们将在之后记录受影响的安全属性的完整列表。

  • 使 java.security.debug 系统属性的 accesspolicy 选项被忽略,因为它们已不再适用。有关如何使用此系统属性的信息,请参阅《安全开发者指南》中的故障排除安全问题

Java 平台 API 的其他更改

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

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

这是 java.io.FileOutputStream构造函数 的规范更改示例(删除的文本被划掉):

public FileOutputStream(String name)
throws FileNotFoundException
java

创建一个文件输出流,用于写入具有指定名称的文件。将创建一个新的 FileDescriptor 对象来表示此文件连接。

首先,如果存在安全管理器,就用 name 作为参数调用其 checkWrite 方法。

如果文件存在但是一个目录而不是普通文件,或者文件不存在且无法创建,或者由于其他原因无法打开,则会抛出 FileNotFoundException

实现要求:
使用参数 name 调用此构造函数等同于调用 new FileOutputStream(name, false)
参数:
name - 系统相关的文件名。
抛出:
FileNotFoundException - 如果文件存在但是一个目录而不是普通文件,或者文件不存在且无法创建,或者由于其他原因无法打开
SecurityException - 如果存在安全管理器,并且其 checkWrite 方法拒绝写入文件的访问权限。
另请参见:
SecurityManager.checkWrite(java.lang.String)

维护支持安全管理员的库的建议

少数库被设计为在启用了安全管理员的情况下使用它。这些库通常采用两种习惯用法:

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

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

    SomeReturnValue v = AccessController.doPrivileged(() -> {
    ...
    return theResult;
    });
  • System::getSecurityManager 返回 null
  • SecurityManager::check* 方法抛出 SecurityException,并且
  • 六个 AccessController::doPrivileged 方法会立即执行给定的操作。

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

极少数库使用安全管理器 API 的高级部分来实现自定义执行环境。例如,某个库可能会调用 AccessController::checkPermission 来强制实施自己的权限模型,或者调用 Policy::setPolicy 使自定义安全管理器将某些资源视为禁区。这些方法在 JDK 24 中是不可用的,以便提供一个默认情况下禁止访问所有资源的执行环境。我们将在未来的 JDK 版本中移除这些方法。

未来工作

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

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

    • java.lang.ClassLoader::defineClass 在定义的类名以 "java." 开头时抛出 SecurityException

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

    • java.util.java.JarInputStream 当签名的 JAR 条目签名不正确时抛出该异常。

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

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

  • 在未来的版本中,我们将弃用 PrivilegedActionPrivilegedExceptionActionPrivilegedActionException。我们在 Java 17 中没有弃用这些类,因为它们出现在与安全管理员无关的 javax.security.auth.Subject 类的方法签名中。在 Java 18 中,我们在 javax.security.auth.Subject 中添加了不使用 Privileged* 类的替代方法。最终我们会移除旧的方法和 Privileged* 类。

  • JMX Management Applets(“m-lets”)在 Java 5 中引入,允许在启用安全管理员的情况下动态加载和执行远程 MBeans。M-lets 几乎没有使用。我们在 Java 20 中弃用了 m-let API,并在 Java 23 中移除了它

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

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

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

测试

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

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

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

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

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

替代方案

Security Manager 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(参见 JDK-8328643),我们假设框架已经意识到该弃用,并正在努力采用替代方法,例如 HADOOP-19212

附录

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

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

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

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

public class BlockSystemExitAgent {
/*
* Before the application starts, register a transformer of class files.
*/
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);
}

/*
* Rewrite every invokestatic of System::exit(int) to an athrow of RuntimeException.
*/
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 选项指定它:

# Compile the agent into the agentclasses directory, enabling preview features for JDK 23
$ javac --enable-preview --release 23 -d agentclasses BlockSystemExitAgent.java

# Create JAR file manifest in agent.mf
$ cat > agent.mf << EOF
Premain-Class: BlockSystemExitAgent
Can-Retransform-Classes: true
EOF

# Create the agent JAR (Note there is a period after -C agentclasses)
$ jar --create --file=BlockSystemExitAgent.jar --manifest=agent.mf -C agentclasses .

# Run application with the agent JAR, enabling preview features for JDK 23
$ java --enable-preview -javaagent:BlockSystemExitAgent.jar -jar app.jar