2.11 IDS10-J不要拆分两种数据结构中的字符串
在历史遗留系统中,常常假设字符串中的每一个字符使用8位(一个字节,Java中的byte)。而Java语言使用16位表示一个字符(Java中的Char类型)。遗憾的是,不管是Java的byte类型还是char类型数据,都不能表示所有的Unicode字符。许多字符串使用例如UTF-8编码的方式存储和通信,而在这种编码中,字符长度是可变的。
当Java字符串以字符数组的方式存储时,它可以用一个字节数组来表示,字符串里的一个字符可以用两个连续的或更多的byte类型或者char类型表示。如果拆分一个char类型或byte类型的数组,将会对多字节的字符产生风险。
如果忽略那些补充字符(supplementary character)多字节字符或者整合字符(修改其他字符的那些字符),攻击者可能绕过输入验证。因此,不应该拆分两种数据结构中的字符。
2.11.1 多字节字符
在一些字符集中会使用多字节字符编码,这些字符集要求用一个以上的字节来唯一标识每一个字符。比如,在日文Shift-JIS编码中,就支持多字节编码,其最大的字符长度为2个字节(一个起始字节,一个结尾字节)。
字节类型 范围
对结尾字节而言,它可能覆盖单个字节或者多字节字符的起始字节。当一个多字节字符被分拆时,特别是跨不同的缓冲区边界分拆时,它会产生不同的解释,如果没有按照正常的缓冲区边界进行分拆的话。这种差异一般由构造字符时使用的字节有二义性造成。
2.11.2 补充字符
根据Java API[API 2006]对Character类的描述(Unicode的字符表示):
Char数据类型(和Character对象封装的值)需要依赖于初始的Unicode编码定义,其中将其定义为长度为16位的字符编码。Unicode编码后来经过改动,允许使用多于16位来表示字符编码。合法的字符编码位于u0000?~u10FFFF,这就是我们所熟悉的Unicode字符编码值。
Java 2平台在char数组、String和StringBuffer?类中使用UTF-16编码表示。在这种字符表示中,补充字符会用一对char值来表示,第一个高位字符范围是uD800~uDBFF,第二个低位字符范围是uDC00~uDFFF。
一个int值可以表示所有的Unicode编码字符,包括那些补充码。int类型中的最低21位是用来表示Unicode编码的,其他的11个高位必须为0。除非特指,关于补充码和字符值有下面的规则:
那些只能接受char值的方法是不支持补充码的。替代范围内的char值会被认为是未定义的字符。比如,Character.isLetter('uD840')会返回false,即使这种特殊字符紧跟着任何表示字母的字符串的低位替代值。
接受int值的方法支持所有的Unicode字符,包括字符。比如,Character.isLetter(0x2F81A)会返回true,因为这个码点值代表一个字母(在CJK编码中)。
2.11.3 不符合规则的代码示例(读取)
这个不符合规则的代码示例会从一个套接字中读取1024个字节,并且使用这些数据创建一个字符串。它同时使用一个while循环来读取这些字节,就像在FIO10-J中推荐的那样。当检测到套接字中的数据多于1024个字节时,它就会抛出异常。这样的机制能够防止非受信的输入耗尽程序的内存。
public final int MAX_SIZE = 1024;
public String readBytes(Socket socket) throws IOException {
??InputStream in = socket.getInputStream();
??byte[] data = new byte[MAX_SIZE+1];
??int offset = 0;
??int bytesRead = 0;
??String str = new String();
??while ((bytesRead = in.read(data, offset, data.length - offset))
??????????!= -1) {
????offset += bytesRead;
????str += new String(data, offset, data.length - offset, "UTF-8");
????if (offset >= data.length) {
??????throw new IOException("Too much input");
????}
??}
??in.close();
??return str;
}
这个代码示例没有考虑到使用多字节编码的字符和循环选代边界之间的关系。如果在最后一个通过read()方法读取的数据流中存在一个对字节编码的首字节,那么其他的字节只能在循环的下一次处理。然而,可以通过在一个循环中创建一个新的字符串来解决多字节编码问题。这样的话,多字节编码就可能会被错误地解释。
2.11.4 符合规则的方案(读取)
该符合规则的方案将字符串的创建推迟到接收完所有的数据时才完成。
public final int MAX_SIZE = 1024;
public String readBytes(Socket socket) throws IOException {
??InputStream in = socket.getInputStream();
??byte[] data = new byte[MAX_SIZE+1];
??int offset = 0;
??int bytesRead = 0;
??while ((bytesRead = in.read(data, offset, data.length - offset))
??????????!= -1) {
????offset += bytesRead;
????if (offset >= data.length) {
??????throw new IOException("Too much input");
????}
??}
??String str = new String(data, "UTF-8");
??in.close();
??return str;
}
这段代码避免了将跨不同缓冲区的多字节编码字符分隔的问题,使用的方法是,直到读取完所有的数据,才开始创建字符串。
2.11.5 符合规则的方案(Reader)
这个符合规则的方案使用的是Reader而不是InputStream。这个Reader类会快速地将字节数据转换为字符数据,所以能够避免分隔多字节字符的问题。当套接字使用多于1024个字符而不是恰好使用1024字节时,这个例程会自动退出。
public final int MAX_SIZE = 1024;
public String readBytes(Socket socket) throws IOException {
??InputStream in = socket.getInputStream();
??Reader r = new InputStreamReader(in, "UTF-8");
??char[] data = new char[MAX_SIZE+1];
??int offset = 0;
??int charsRead = 0;
??String str = new String(data);
??while ((charsRead = r.read(data, offset, data.length - offset))
?????????!= -1) {
????offset += charsRead;
????str += new String(data, offset, data.length - offset);
????if (offset >= data.length) {
??????throw new IOException("Too much input");
????}
??}
??in.close();
??return str;
}
2.11.6 不符合规则的代码示例(子字符串)
这个不符合规则的代码示例想截取字符串中的首字母。它的做法不对,因为使用了Character.isLetter()方法,这个方法不能处理补充字符和合并字符[Hornig 2007]。
// Fails for supplementary or combining characters
public static String trim_bad1(String string) {
??char ch;
??int i;
??for (i = 0; i < string.length(); i += 1) {
????ch = string.charAt(i);
????if (!Character.isLetter(ch)) {
??????break;
????}
??}
??return string.substring(i);
}
2.11.7 不符合规则的代码示例(子字符串)
这个不符合规则的代码示例想要纠正使用?String.codePointAt()?的错误,这个方法使用了int类型作为输入参数。这对补充字符来说是正确的,但对于合并字符而言却是错误的[Hornig 2007]。
// Fails for combining characters
public static String trim_bad2(String string) {
??int ch;
??int i;
??for (i = 0; i < string.length(); i += Character.charCount(ch)) {
????ch = string.codePointAt(i);
????if (!Character.isLetter(ch)) {
??????break;
????}
??}
??return string.substring(i);
}
2.11.8 符合规则的方案(子字符串)
这个方案可以处理补充字符和合并字符[Hornig 2007]。根据Java API[API 2006]文档对java.text.BreakIterator的说明:
BreakIterator实现了能够在文本边界内定位的方法。BreakIterator?的对象实例维护了当前的位置信息,并且会对文本进行扫描,当遇到文本边界的时候,它会返回字符所在的位置索引。
返回的边界可能是那些补充字符、合并字符。例如,一个音节字符可以被存储为一个基础字符加上一个用来区分的符号。
public static String trim_good(String string) {
??BreakIterator iter = BreakIterator.getCharacterInstance();
??iter.setText(string);
??int i;
??for (i = iter.first(); i != BreakIterator.DONE; i = iter.next()) {
????int ch = string.codePointAt(i);
????if (!Character.isLetter(ch)) {
??????break;
????}????
??}
??// Reached first or last text boundary
??if (i == BreakIterator.DONE) {?
????// The input was either blank or had only (leading) letters
????return "";?
??} else {
????return string.substring(i);
??}
}
如果要对locale敏感的字符串比较、搜索和排序,可以使用?java.text.Collator?类。
2.11.9 风险评估
如果没有考虑到补充字符和合并字符的话,将会导致不可预计的行为。