36 继承有什么安全缺陷? 有时候,为了解决一个问题,我们需要一个解决办法。可是,这个办法本身还会带来更多的问题。新问题的解决带来更新的问题,就这样周而复始,绵延不绝。

比如上一篇文章,我们说到的敏感信息通过异常信息泄露的问题,就是面向对象设计和实现给我们带来的小困扰。再比如前面还有一个案例,说到了共享内存或者缓存技术带来的潜在危害和挑战,这些都是成熟技术发展背后需要做出的小妥协。只是有时候,这些小小的妥协如果没有被安排好和处理好,可能就会带来不成比例的代价。

评审案例

我们一起来看一段节选的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关键字,特别是对于公开的接口来说,将来想要加上就可能是一件非常困难的事。

上面的例子是子类通过改变父类的规范和行为带来的潜在问题。那么父类是不是也可以改变子类的行为呢? 这听起来有点怪异,但是父类对子类行为的影响,有时候也的确是一个让人非常头疼的问题。

麻烦的继承

我先总结一下,父类对子类行为的影响大致有三种:

  • 改变未继承方法的实现,或者子类调用的方法的实现(super);
  • 变更父类或者父类方法的规范;
  • 为父类添加新方法。

第一种和第三种相对比较容易理解,第二种稍微复杂一点。我们还是通过一个例子来看看其中的问题。

Hashtable是一个古老的,被广泛使用的类,它最先出现在JDK 1.0中。其中,put()和remove()是两个关键的方法。在JDK 1.2中,又有更多的方法被添加进来,比如entrySet()方法。 public class Hashtable<K,V> … { // 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<Map.Entry<K,V» entrySet() { // snipped } // snipped }

这就引入了一个难以察觉的潜在的安全漏洞。 你可能会问,添加一个方法不是很常见吗?这能有什么问题呢?

问题在于继承Hashtable的子类。假设有一个子类,它的Hashtable里要存放敏感数据,数据的添加和删除都需要授权,在JDK 1.2之前,这个子类可以重写put()和remove()方法,加载权限检查的代码。在JDK 1.2中,这个子类可能意识不到Hashtable添加了entrySet()这个新方法,从而也没有意识到要重写覆盖entrySet()方法,然而,通过对entrySet()返回值的直接操作,就可以执行数据的添加和删除的操作,成功地绕过了授权。 public class MySensitiveData extends Hashtable<Object, Object> { // 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<Map.Entry<Object, Object» 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<Object, Object>; 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语言的设计和实现,也存在类似的问题。

小结

通过对这个案例的讨论,我想和你分享下面两点个人看法。

  • 一个可扩展的类,子类和父类可能会相互影响,从而导致不可预知的行为。
  • 涉及敏感信息的类,增加可扩展性不一定是个优先选项,要尽量避免父类或者子类的影响。

学会处理和保护敏感信息,是一个优秀工程师必须迈过的门槛。

一起来动手

了解语言和各种固定模式的缺陷,是我们打怪升级的一个很好的办法。有时候,我们偏重于学习语言或者设计经验的优点,忽视了它们背后做出小小的妥协,或者缺陷。如果能利用好优点,处理好缺陷,我们就可以更好地掌握这些经验总结。毕竟世上哪有什么完美的东西呢?不完美的东西,用好了,就是好东西。

我们利用讨论区,来聊聊设计模式这个老掉牙的、备受争议的话题。说起“老掉牙”,科技的进步真是快,设计模式十多年前还是一个时髦的话题,如今已经不太受待见了,虽然我们或多或少,或直接或间接地都受益于设计模式的思想。如果你了解过设计模式,你能够分享某个设计模式的优点和缺陷吗? 使用设计模式有没有给你带来实际的困扰呢?

上面的例子中,我们提到了使用代理模式来降低父类对子类的影响。那么你知道代理模式的缺陷吗?

欢迎你把自己的经验和看法写在留言区,我们一起来学习、思考、精进!

如果你觉得这篇文章有所帮助,欢迎点击“请朋友读”,把它分享给你的朋友或者同事。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e4%bb%a3%e7%a0%81%e7%b2%be%e8%bf%9b%e4%b9%8b%e8%b7%af/36%20%e7%bb%a7%e6%89%bf%e6%9c%89%e4%bb%80%e4%b9%88%e5%ae%89%e5%85%a8%e7%bc%ba%e9%99%b7%ef%bc%9f.md