SecurityManager 后期安全和沙盒
Ron Pressler,2021 年 4 月 23 日免责声明:此帖仅代表作者的观点
上周,JEP 411 提议弃用 Java 的 Security Manager,并通过逐步功能降级的过程最终将其移除。应移除 Security Manager,因为维护它的高成本不再能证明其优势,而随着部署和威胁环境的不断变化,其优势已大幅下降。在这篇博文中,我想介绍一些 Security Manager 的用例并提供更好的替代方案。颇具讽刺意味的是,尽管很少有人使用 Security Manager,但移除它的提议让人们考虑了 Java 的安全性,而这种关注度的提升有望让人们更容易理解我们当前的安全重点,至少部分依赖于模块系统。
安全措施的部署是为了抵御特定的威胁环境。作为一项安全措施,Security Manager 被设计为抵御不受信任代码构成的威胁,即你认为可能是恶意的代码。这是运行网站上 JavaScript 的浏览器面临的主要威胁,Java Applet 也是如此,Security Manager 最初就是为其设计的一个沙盒。沙盒是一种限制代码可用的操作的机制,我们稍后会详细讨论,但服务器端应用程序面临的威胁环境却大不相同。服务器往往主要运行受信任代码,即被认为是良性的代码,而它面临的威胁是通过精心设计的输入导致受信任代码执行意外操作的漏洞利用。
Java 的可信代码安全机制包括一 套加密协议、安全 XML 处理、JAR 签名 和 序列化过滤器,但也包括固有的 VM 属性,如内存安全性——它可以防止数组溢出和释放后使用——并且将越来越依赖于模块系统的封装。并非所有有助于安全性的功能看起来都是专用的安全功能;即使 Loom 的虚拟线程也有助于防止因通过 ThreadLocal
泄露的秘密而造成的漏洞,而多个不相关的任务意外共享了这些秘密。安全管理器不是保护可信服务器端代码的中心组件,这也是一件好事,因为很少有系统使用它,而且它无法防御一些最常见和最危险的攻击。事实上,安全管理器已经在 公共池(以及 Loom 的虚拟线程)上被削弱了,因为设置适当的安全上下文会破坏这些构造的性能要求,并且在 CompletableFuture
或任何异步(或“响应式”)上下文中使用它要求开发人员仔细 捕获和重新建立 安全上下文,因为操作从一个线程移动到另一个线程。尽管如此,沙箱可以通过阻止攻击触发的意外操作来作为可信代码的额外有效保护层,但多年来发现安全管理器尽管具有强大的理论功能,但对于可信代码来说却是一个无效的沙箱。
为了更好地理解原因,我想介绍一下沙箱分类法。沙箱可以限制直接可供沙箱代码使用的 API 元素;我将称这种沙箱为浅沙箱,因为它在调用堆栈中靠近沙箱代码的位置执行访问检查。相比之下,深沙箱会阻止调用堆栈中更远处的操作,靠近实际执行操作的位置,例如与操作系统或硬件交互时。假设调用 foo
或 bar
可能会导致写入某个文件。浅沙箱可能会禁止调用 foo
、bar
或两者,而深沙箱将允许调用它们,但可能会阻止实际的文件写入操作。在深沙箱类别中,我们引入进一步的区分。简单深沙箱会阻止某些操作,例如写入特定文件,而不管这些操作如何执行;相比之下,路径相关(或堆栈相关)深沙箱可能会根据执行操作所采取的代码路径来阻止或允许特定操作,方法是结合授予调用堆栈不同层的不同权限。例如,如果 foo
和 bar
写入系统配置文件,则简单深沙箱将允许它们执行写入操作或阻止两者,而路径相关沙箱可能会阻止 foo
的尝试,但允许 bar
的尝试,大概是因为它信任 bar
以安全的方式执行写入操作。我们还可以设想介于简单和路径相关之间的沙箱,例如线程相关沙箱,它会根据执行受控操作的线程的身份来允许或阻止该操作。
有人建议将第三方库视为不受信任或半受信任的代码,这可以作为针对隐藏的恶意代码或无意漏洞的有效安全措施。虽然我对该提议隐含的假设持怀疑态度,即应用程序本身(可能包含数百万行代码)可以被认为更安全,但将应用程序与其依赖项一起沙箱化的概念作为防御漏洞的一种手段是有价值的。
对于使用大量复杂 API 的应用程序和库,深度沙箱可以提供更好的安全性,因为只需要针对其安全含义分析并限制特定的操作(例如与文件系统交互),而不是可能的数十万个 API 元素。但这里存在使用安全管理器来实现此目的的问题:它是一个路径相关的深度沙箱,这意味着它非常复杂,而复杂性是安全的敌人。一方面,复杂应用程序所需的权限集可能非常大,并且很难评估它是否真正提供了必要的安全措施;亚马逊使用形式化方法来分析策略文件,即使是针对其简单沙箱。另一方面,该集合取决于应用程序及其依赖项的内部实现细节,因此需要在每次更新应用程序或其任何依赖项时重新计算和重新分析,这极大地增加了维护负担。最后,安全管理器的路径相关性进一步复杂化了事情,需要谨慎使用AccessController.doPrivileged;如果库不使用doPrivileged
,则需要将权限授予调用堆栈中的所有调用者。结果是安全管理器非常复杂,以至于很少有应用程序使用它,而那些使用它的应用程序通常会错误地使用它。毫无疑问,安全管理器是现存最复杂的沙箱之一,但其理论上强大的灵活性使其在实践中无效,要么使其难以正确使用,要么一开始就阻止人们使用它;根本不使用的安全设备是不安全的,而错误使用的设备则更糟——它给人一种虚假的安全感。而且它的复杂性也使其持续维护如此昂贵。
应用程序的更高级替代方案是简单深度沙箱,例如操作系统级容器和虚拟机提供的沙箱,它们还具有限制本机代码执行的操作的附加好处。此类沙箱应与深度监控相结合,该监控可以检测和警报应用程序或其依赖项的可疑活动,这是通过将适当的 JFR 事件流式传输到监视服务来实现的。今天这并不简单(无需诉诸字节码检测),因为 JDK 尚未检测到许多有趣的 JFR 事件,例如套接字连接和接受,但它肯定会在安全管理器降级或删除时检测到。
安全管理器的另一个用例——这个用例与安全完全无关,只是将安全管理器用作 JDK 代码中的检测回调——是作为单元测试的一部分测试代码行为;例如,断言某个代码单元是否执行某些 IO 操作。同样,也可以使用 JFR 更好地实现这一点,甚至还有一个专门为此目的而制作的库JfrUnit。JFR 允许观察更多种类的有趣代码行为,包括内存分配,并且它是一个比安全管理器更适合此目的的组件。
浅层 Java 沙箱
有些人指出了另一个用例,即沙盒化服务器端插件。插件通常是受信任的(即使像 VSCode 这样的流行 IDE 也将插件视为受信任代码),但对它们进行沙盒化并不是为了抵御恶意代码,而是通过限制插件可用的 API 来保护应用程序的功能完整性,从而控制其操作。此用例过于狭窄且过于罕见,不足以证明继续维护安全管理器的高昂成本,并且在同一进程中混合受信任和不受信任的代码是一个难题,但我认为,无论如何,通过浅沙盒来更好地解决此问题,允许插件仅通过非常有限的一组 API 与其环境进行交互。
浅沙盒可以由相对简单的第三方库提供。它们可以依赖于模块系统,其强大的封装可以被认为是一个非常简单的浅沙盒。插件可以加载到 模块层 中,使用 服务加载器,但这不足以创建我们想要的限制性沙盒,因为基本模块 (java.base
) 已经授予了比我们希望插件拥有的权力大得多的权力。可以通过为该层分配一个自定义类加载器来优化此问题,该类加载器阻止插件加载某些类。然后,还可以通过让类加载器转换其加载的类(插件的类)来进一步优化它,例如使用 ASM 库,用存根替换对我们不阻止的类上危险方法的调用,这些存根将抛出一些非法访问异常或过滤方法的参数,允许某些值通过。最后,StackWalker 可用于检查存根调用者的类,作为决定是否允许调用的部分。