# 36 | 继承有什么安全缺陷? 有时候,为了解决一个问题,我们需要一个解决办法。可是,这个办法本身还会带来更多的问题。新问题的解决带来更新的问题,就这样周而复始,绵延不绝。 比如[上一篇文章](https://time.geekbang.org/column/article/87256)[中](https://time.geekbang.org/column/article/87256),我们说到的敏感信息通过异常信息泄露的问题,就是面向对象设计和实现给我们带来的小困扰。再比如[前面还有](https://time.geekbang.org/column/article/86590)[一个](https://time.geekbang.org/column/article/86590)[案例](https://time.geekbang.org/column/article/86590),说到了共享内存或者缓存技术带来的潜在危害和挑战,这些都是成熟技术发展背后需要做出的小妥协。只是有时候,这些小小的妥协如果没有被安排好和处理好,可能就会带来不成比例的代价。 ## 评审案例 我们一起来看一段节选的java.io.FilePermission类的定义。你知道为什么FilePermission被定义为final类吗? ``` package java.io; // /** * This class represents access to a file or directory. A * FilePermission consists of a pathname and a set of actions * valid for that pathname. * */ public final class FilePermission extends Permission implements Serializable { /** * Creates a new FilePermission object with the specified actions. * path is the pathname of a file or directory, and * actions contains a comma-separated list of the desired * actions granted on the file or directory. Possible actions are * "read", "write", "execute", "delete", and "readlink". * */ public FilePermission(String path, String actions); /** * Returns the "canonical string representation" of the actions. * That is, this method always returns present actions in the * following order: read, write, execute, delete, readlink. * */ @Override public String getActions(); /** * Checks if this FilePermission object "implies" the * specified permission. * * @param p the permission to check against. * * @return true if the specified permission * is not null and is implied by this * object, false otherwise. */ @Override public boolean implies(Permission p); // } ``` FilePermission被声明为final,也就意味着该类不能被继承,不能被扩展了。我们都知道,在面向对象的设计中,是否具备可扩展性是一个衡量设计优劣的好指标。如果允许扩展的话,那么想要增加一个“link”的操作就会方便很多,只要扩展FilePermission类就可以了。 但是对于FilePermission这个类,OpenJDK为什么放弃了可扩展性? ## 案例分析 如果我们保留FilePermission的可扩展性,你来评审一下下面的代码,可以看出这段代码的问题吗? ``` package com.example; public final class MyFilePermission extends FilePermission { @Override public String getActions() { return "read"; } @Override public boolean implies(Permission p) { return true; } } ``` 如果你还没有找出这个问题,可能是因为我还遗漏了对FilePermission常见使用场景的介绍。在Java的安全管理模式下,一个用户通常可能会被授予有限的权限。 比如用户“xuelei”可以读取用户“duke”的文件,但不能更改用户“duke”的文件。 授权的策咯可能看起来像下面的描述: ``` grant Principal com.sun.security.auth.UnixPrincipal "xuelei" { permission com.example.MyFilePermission "/home/duke", "read"; }; ``` 这项策略要想起作用,上面的描述就要转换成一个MyFilePermission的实例。然后调用该实例的implies()方法类判断是否可以授权一项操作。 ``` Permission myPermission = ... // read "/home/duke" public void checkRead() { if (myPermission.implies(New FilePermission(file, "read"))) { // read is allowed. } else { // throw exception, read is not allowed. } } public void checkWrite() { if (myPermission.implies(New FilePermission(file, "write"))) { // writeis allowed. } else { // throw exception, write is not allowed. } } ``` 这里请注意,MyFilePermission.implies()总是返回“true”, 所以上述的checkRead()和checkWrite()方法总是成功的,不管用户被明确指示授予了什么权限,实际上暗地里他已经被授予了所有权限。这就成功地绕过了Java的安全管理。 能够绕过Java安全机制的主要原因,在于我们允许了FilePermission的扩展。而扩展类的实现,有可能有意或者无意地改变了FilePermission的规范和运行,从而带来不可预料的行为。 如果你关注OpenJDK安全组的代码评审邮件组,你可能会注意到,对于面向对象的可扩展性这一便利和诱惑,很多工程师能够保持住克制。 保持克制,可能会遗漏一两颗看似近在眼前的甜甜的糖果,但可以减轻你对未来长期的担忧。 一个类或者方法如果使用了final关键字,我们可以稍微放宽心。如果没有使用final关键字,我们可能需要反复揣摩好长时间,仔细权衡可扩展性可能会带来的弊端。 一个公共类或者方法如果使用了final关键字,将来如果需要扩展性,就可以去掉这个关键字。但是,如果最开始没有使用final关键字,特别是对于公开的接口来说,将来想要加上就可能是一件非常困难的事。 上面的例子是子类通过改变父类的规范和行为带来的潜在问题。那么父类是不是也可以改变子类的行为呢? 这听起来有点怪异,但是父类对子类行为的影响,有时候也的确是一个让人非常头疼的问题。 ## 麻烦的继承 我先总结一下,父类对子类行为的影响大致有三种: 1. 改变未继承方法的实现,或者子类调用的方法的实现(super); 2. 变更父类或者父类方法的规范; 3. 为父类添加新方法。 第一种和第三种相对比较容易理解,第二种稍微复杂一点。我们还是通过一个例子来看看其中的问题。 Hashtable是一个古老的,被广泛使用的类,它最先出现在JDK 1.0中。其中,put()和remove()是两个关键的方法。在JDK 1.2中,又有更多的方法被添加进来,比如entrySet()方法。 ``` public class Hashtable ... { // snipped /** * Returns a {@link Set} view of the mappings contained in ( this map. * The set is backed by the map, so changes to the map are * reflected in the set, and vice-versa. If the map is modified * while an iteration over the set is in progress (except through * the iterator's own {@code remove} operation, or through the * {@code setValue} operation on a map entry returned by the * iterator) the results of the iteration are undefined. The set * supports element removal, which removes the corresponding * mapping from the map, via the {@code Iterator.remove}, * {@code Set.remove}, {@code removeAll}, {@code retainAll} and * {@code clear} operations. It does not support the * {@code add} or {@code addAll} operations. * * @since 1.2 */ public Set> entrySet() { // snipped } // snipped } ``` 这就引入了一个难以察觉的潜在的安全漏洞。 你可能会问,添加一个方法不是很常见吗?这能有什么问题呢? 问题在于继承Hashtable的子类。假设有一个子类,它的Hashtable里要存放敏感数据,数据的添加和删除都需要授权,在JDK 1.2之前,这个子类可以重写put()和remove()方法,加载权限检查的代码。在JDK 1.2中,这个子类可能意识不到Hashtable添加了entrySet()这个新方法,从而也没有意识到要重写覆盖entrySet()方法,然而,通过对entrySet()返回值的直接操作,就可以执行数据的添加和删除的操作,成功地绕过了授权。 ``` public class MySensitiveData extends Hashtable { // snipped @Override public synchronized Object put(Object key, Object value) { // check permission and then add the key-value // snipped super.put(key, value) } @Override public synchronized Object remove(Object key) { // check permission and then remove the key-value // snipped return super.remove(key); } // snipped, no override of entrySet() } ``` ``` MySensitiveData sensitiveData = ... // get the handle of the data Set> sdSet = sensitiveData.entrySet(); sdSet.remove(...); // no permission check sdSet.add(...); // no permission check // the sensitive data get modified, unwarranted. ``` 现实中,这种问题非常容易发生。一般来说,我们的代码总是依赖一定的类库,有时候需要扩展某些类。这个类库可能是第三方的产品,也可能是一个独立的内部类库。但遗憾的是,类库并不知道我们需要拓展哪些类,也可能没办法知道我们该如何拓展。 所以,当有一个新方法添加到类库的新版本中时,这个新方法会如何影响扩展类,该类库也没有特别多的想象空间和处理办法。就像Hashtable要增加entrySet()方法时,让Hashtable的维护者意识到有一个特殊的MySensitiveData扩展,是非常困难和不现实的。然而Hashtable增加entrySet()方法,合情又合理,也没有什么值得抱怨的。 然而,当JDK 1.0/1.1升级到JDK 1.2时,Hashtable增加了entrySet()方法,上述的MySensitiveData的实现就存在严重的安全漏洞。要想修复该安全漏洞,MySensitiveData需要重写覆盖entrySet()方法,植入权限检查的代码。 可是,我们怎样可能知道MySensitiveData需要修改呢! 一般来说,如果依赖的类库进行了升级,没有影响应用的正常运营,我们就正常升级了,而不会想到检查依赖类库做了哪些具体的变更,以及评估每个变更潜在的影响。这实在不是软件升级的初衷,也远远超越了大部分组织的能力范围。 而且,如果MySensitiveData不是直接继承Hashtable,而是经过了中间环节,这个问题就会更加隐晦,更加难以察觉。 ``` public class IntermediateOne extends Hashtable; public class IntermediateTwo extends IntermediateOne; public class Intermediate extends IntermediateTwo; public class MySensitiveData extends Intermediate; ``` 糟糕的是,随着语言变得越来越高级,类库越来越丰富,发现这些潜在问题的难度也是节节攀升。我几乎已经不期待肉眼可以发现并防范这类问题了。 那么,到底有没有办法可以防范此类风险呢? 主要有两个方法。 **一方面,当我们变更一个可扩展类时,要极其谨慎小心**。一个类如果可以不变更,就尽量不要变更,能在现有框架下解决问题,就尽量不要试图创造新的轮子。有时候,我们的确难以压制想要创造出什么好东西的冲动,这是非常好的品质。只是变更公开类库时,一定要多考虑这么做的潜在影响。你是不是开始思念final关键字的好处了? **另一方面,当我们扩展一个类时,如果涉及到敏感信息的授权与保护,可以考虑使用代理的模式,而不是继承的模式**。代理模式可以有效地降低可扩展对象的新增方法带来的影响。 ``` public class MySensitiveData { private final Hashtable hashtable = ... public synchronized Object put(Object key, Object value) { // check permission and then add the key-value hashtable.put(key, value) } public synchronized Object remove(Object key) { // check permission and then remove the key-value return hashtable.remove(key); } } ``` 我们使用了Java语言来讨论继承的问题,其实**这是一个面向对象机制的普遍的问****题,**甚至它也不单单是面向对象语言的问题,比如使用C语言的设计和实现,也存在类似的问题。 ## 小结 通过对这个案例的讨论,我想和你分享下面两点个人看法。 1. **一个可扩展的类,子类和父类可能会相互影响,从而导致不可预知的行为。** 2. **涉及敏感信息的类,增加可扩展性不一定是个优先选项,要尽量避免父类或者子类的影响。** 学会处理和保护敏感信息,是一个优秀工程师必须迈过的门槛。 ## 一起来动手 了解语言和各种固定模式的缺陷,是我们打怪升级的一个很好的办法。有时候,我们偏重于学习语言或者设计经验的优点,忽视了它们背后做出小小的妥协,或者缺陷。如果能利用好优点,处理好缺陷,我们就可以更好地掌握这些经验总结。毕竟世上哪有什么完美的东西呢?不完美的东西,用好了,就是好东西。 我们利用讨论区,来聊聊设计模式这个老掉牙的、备受争议的话题。说起“老掉牙”,科技的进步真是快,设计模式十多年前还是一个时髦的话题,如今已经不太受待见了,虽然我们或多或少,或直接或间接地都受益于设计模式的思想。如果你了解过设计模式,你能够分享某个设计模式的优点和缺陷吗? 使用设计模式有没有给你带来实际的困扰呢? 上面的例子中,我们提到了使用代理模式来降低父类对子类的影响。那么你知道代理模式的缺陷吗? 欢迎你把自己的经验和看法写在留言区,我们一起来学习、思考、精进! 如果你觉得这篇文章有所帮助,欢迎点击“请朋友读”,把它分享给你的朋友或者同事。