内在安全机制
Java语言本身的安全机制是要保护内存资源——保证内存完整性,核心的安全特性要确保程序不能非法解析或修改驻留在内存中的机密信息。从语言本身的设计角度考虑,就是要设计一组规则,在所构建的运行环境中,程序对象对内存的操作是经过定义的而不是任意的。
Java的强制约束
- 必须严格遵循访问方法的要求。必须依照程序员制定的访问级别进行相关方法的操作。如果不遵守则会产生异常。
- 不能访问任意的内存地址。 Java没有指针的概念,因此不会像C++一样拿到一个指针强制转换成内存指针,再利用查找内存的方法得到本不该被获取的信息。
- 不能对final实体再做改动。
- 变量在初始化前不能使用。如果能读取未初始化的变量,就等同于可以读取任意内存地址。可以通过声明一个大的对象而盗取主机内存中的信息。
- 对于所有数组访问进行越界检查。越界检查除了可以减少程序错误意外,另一大贡献就是安全保障。如果整数数组后紧接着存放一个字符数组,那么通过整数数组的越界写,可以改变字符数组的内容。
- 对象不能任意强制转换为其他类型的对象。这种类型转换的限制不仅在编译器层面,在JVM里也做了强限制,在绕过编译器的转换(比如把被转换的对象标记为Object),JVM在运行时检查也会抛出
ClassCastException
。
Java语言通过上面几条约束,从语言层面保护了内存,不会允许程序在没有获得正确的访问权限时读取到本不该访问的内存。
序列化怎么保障安全
这个题目不太好,因为序列化无法保证安全。Java允许通过实现java.io.Serializable
接口来使内存对象序列化为一组字节码。这组字节码通过网络或者文件等方式被其他地方的代码读取并重建一个相同结构和内容的内存对象。这是Java的序列化和反序列化过程。作为一组字节码存储在磁盘文件或者数据流里,原则上是允许被修改的。那说白了,序列化无法保障安全。但是考虑到Java语言要设计支持一组规则,至于序列化的安全,就交给使用者自己保障。
序列化的安全设计规则:
- 可序列化的对象必须实现
java.io.Serializable
接口,这相当于给用户打预防针,告知其要考虑这个对象的安全问题。 - 序列化对象声明
transient
变量,那么该变量不被序列化。这相当于提供了保护数据的机制。
单单从这两个层面看,已经是Java语言能做到的最大化问题了。再多,则影响到了序列化本身要实现的功能。那么具体怎么做呢?就像我们常规传输数据一样——加密,你可以选择对要序列化的变量和属性加密,在反序列化时解密来增强安全。
安全规则实施
当然这里讲到的实施其实不仅仅针对安全,但是这些实施阶段确实增强了安全性。换个角度,我们其实是看Java程序的运行过程如何对应这些语言设计规则。
编译
编译阶段,可以避免“约束”中提到的前4条规则。数组越界是运行时问题,而类型转换,在编译阶段只能做到无关类型的转换,比如下例:
static class Foo {
int x;
}
static class Bar {
int x;
}
public static void main(String[] args) {
Foo foo = new Foo();
Bar bar = (Bar) foo;
}
这时编译器会提示“Cannot cast from Foo to Bar”。但是如果稍作修改,将Foo替换为Object,则编译器无能为力。
static class Foo {
int x;
}
static class Bar {
int x;
}
public static void main(String[] args) {
Object foo = new Foo();
Bar bar = (Bar) foo;
}
链接
我们都知道,编译只是做Class文件,JVM的介入是要从load class开始的。而加载完class文件后的第一件事就是链接。链接包括验证、准备和解析这几个步骤,而验证阶段,就是安全规则介入的一个阶段。验证阶段就引入了JVM的字节码校验器。
这个阶段主要是来防御恶意编译器的攻击,或者是一些无意的程序错误。比如一个类FooBar设计如下:
public class FooBar {
public String val = "abcd";
}
类FooBarTest引用了这个类并更改了val变量:
public class FooBarTest {
public static void main(String[] args) {
FooBar foobar = new FooBar();
foobar.val = "abcde";
System.out.println(foobar.val);
}
}
这时我们编译并运行FooBarTest,会打印abcde。而现在如果去修复FooBar,将public改为private,然后只编译FooBar,则不会发生错误。然而本来这时也编译FooBarTest会导致编译错的。但是因为疏忽导致没有这么做,那么如果没有链接校验,FooBarTest就是错误的执行了。然而因为字节码校验器的存在,运行FooBarTest会抛出
Exception in thread "main" java.lang.IllegalAccessError: tried to access field FooBar.val from class FooBarTest
at FooBarTest.main(FooBarTest.java:5)
优雅的解决了这个问题。
字节码校验器通过两部分来实现这种校验。首先,其作为一个微型的定理证明机,会证明class满足下列条件(只做检查):
- 类文件格式正确;
- 不会基于final派生子类,也不会覆盖final方法;
- 只有一个父类;
- 没有对primitive类型数据进行非法转型(int->Object);
- 对象之间没有进行类型转换;
- 操作数栈不会出现溢出。
接下来,在代码真正执行前进行校验(称为延迟校验)。比如刚才举例中提到的异常,就是校验器在校验字段的访问合法性时抛出的。
运行
上面两个阶段检查不了的规则,放到运行时检查:数组越界和类型转换。运行时抛出ArrayIndexOutOfBoundException
和ClassCastException
。
结语
Java语言层面的安全设计,本身也可以看出设计思路是弥补原有C和C++的部分短板而设计的。主要目标还是防止非法内存访问。但是加入了这些限制也带来了性能的缺失,这本身就是一个trade off。