gitbook/代码精进之路/docs/87633.md
2022-09-03 22:05:03 +08:00

14 KiB
Raw Permalink Blame History

36 | 继承有什么安全缺陷?

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

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

评审案例

我们一起来看一段节选的java.io.FilePermission类的定义。你知道为什么FilePermission被定义为final类吗

package java.io;

// <snipped>
/**
 * This class represents access to a file or directory.  A
 * FilePermission consists of a pathname and a set of actions
 * valid for that pathname.
 * <snipped>
 */
public final class FilePermission
        extends Permission implements Serializable {
    /**
     * Creates a new FilePermission object with the specified actions.
     * <i>path</i> is the pathname of a file or directory, and
     * <i>actions</i> contains a comma-separated list of the desired
     * actions granted on the file or directory. Possible actions are
     * "read", "write", "execute", "delete", and "readlink".
     * <snipped>
     */
    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. 
     * <snipped>
     */
    @Override
    public String getActions();

    /**
     * Checks if this FilePermission object "implies" the 
     * specified permission.
     * <snipped>
     * @param p the permission to check against.
     *
     * @return <code>true</code> if the specified permission
     *         is not <code>null</code> and is implied by this
     *         object, <code>false</code> otherwise.
     */
    @Override
    public boolean implies(Permission p);

    // <snipped>
}

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<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语言的设计和实现也存在类似的问题。

小结

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

  1. 一个可扩展的类,子类和父类可能会相互影响,从而导致不可预知的行为。

  2. 涉及敏感信息的类,增加可扩展性不一定是个优先选项,要尽量避免父类或者子类的影响。

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

一起来动手

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

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

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

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

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