Java 安全编码审计,第 1 部分:序列化,输入验证和数据净化

Comments

前言

本文关注 Java 语言的审计。Java 广泛应用于企业级实施和 Internet 网站电商。能工作的 Java 代码不等于好代码。“好”代码的指标很多,包括可读性、可维护性、可移植性和可靠性等。开发者要遵循编码规范编写合规好代码。Java 语言编程规范的提供组织很多:计算机安全应急响应组 CERT(Computer Emergency Response Team)是专门处理计算机网络安全问题的组织,由美国联邦政府资助;此外,某些行业和企业也提供了各自独立的一些指导规范。本文参考国际标准 CERT,结合常见 Web 漏洞,讨论审计 Java 代码的工具和方法,并提供整改建议。

审计 Java 代码软件产品和企业级应用,是为了发现安全问题,并针对安全问题跟踪整改,以确保软件产品和企业级应用实施的安全。一般来说,编译器虽然能约束程序员遵循一些编码标准。但是,更多复杂的标准和规则,需要更高级的自动代码扫描、静态分析工具。现有的分析工具并不健全,不同工具都具有不重复的功能,因此需要使用多种工具配合使用以获得更大的检验覆盖程度。同时,这些分析工具们产生的结果有可能包含误判或者漏判。这时还需要人工审计。

审计 Java 代码软件产品,除了从 CERT 安全编码标准出发,靶向排查,逐条审计由基本编程错误引起的软件漏洞;还可以从漏洞严重性出发,针对高危漏洞重点审计;某些行业可以考虑公司特需的指导规范。审计 Java 代码,特别是 Web 工程中的 Java 代码,要结合前后端模块进行,既要关注后端 Java 又要兼顾前端 JSP 等,既要关注静态代码,又要兼顾服务器设置和应用部署配置。

本文主要使用源代码静态分析技术审计 Java 代码,业界有多种工具可供选择,Foritfy, Coverity, Findbugs 等。Fortify 是成熟商用工具,Findbugs 是开源小工具。二者都可以扩展和定制特定于应用程序的规则。二者覆盖面和侧重点不同,漏洞汇报结果不同,因此可以考虑结合使用。

本文面向的读者是安全审计专家,作为他们的桌面参考。程序员也可以在软件开发代码过程中参考本文档,争取把软件安全问题扼杀在开发阶段萌芽状态。

基于 CERT 安全编码标准审计 Java 代码

安全的编码标准是软件安全的基石。 从编码规范的角度审计软件,可以从源头上规避漏洞,避免引发后续问题。标准分为规则和建议。规则是严重性高的,强烈推荐的标准。许多国际组织, 行业协会和国际知名公司都提供了一些应当遵循的通用编码原则。选用 CERT 因为其相对成熟且影响力大。

序列化

把对象转换为字节序列的过程称为对象的序列化。把字节序列恢复为对象的过程称为对象的反序列化。Java 对象的序列化主要有两种用途:一种是把对象的字节序列永久地保存到硬盘文件中;另外一种是在网络上传送对象的字节序列。后者被广泛应用于 Internet Web 工程。

Java 环境允许同一个程序中,处在不同受信域的两个组件进行数据通信,跨受信域的数据通信可以通过序列化进行。 序列化安全漏洞的表现为绕过 Java 安全管理器和 Java 系统安全策略,泄露敏感信息。

序列化漏洞可以出现在基础软件平台中,也可以出现在企业应用代码中。基础平台序列化漏洞,危害深重影响大,但是漏洞利用执行成本高,对攻击者要求高。

比如序列化漏洞 ( CVE-2008-5353) 针对 JDK 的在特权上下文中对非受信输入,基于 Java 系统类 sun.util.Calendar.ZoneInfo 进行反序列化,会导致执行任意的代码。

比如序列化漏洞 (CVE-2015-3825) 针对 Andoid 平台,影响了半数以上 Android 手机。 攻击者可以利用这个任意代码执行漏洞给一款没有权限的恶意应用授权,提升其权限,黑客就可以借此操控手机。

企业应用代码,应该增强鲁棒性,防止对不可信数据的反序列化。审计企业级代码的序列化漏洞非常必要。审计后的整改方案一般涉及到数据加密。也可以依赖应用服务器 API 编程,只允许应用服务对其期望的类的对象进行解序列化,达到不使用加密与签章技术,防止对不可信数据输入的解序列化的目的。DevelopWorks有技术文章谈服务器编程缓解序列化漏洞。

表 1. Java 序列化编程标准审计方法
标准审计复杂度严重性整改代价审计方法
SER00J 工具
SER01-J 人工审计
SER02-J N/A
SER03,04 N/A
SER05-J 人工审计
SERother 中低 N/A

审计重点 SER00-J 维护序列化的兼容性

每个序列化类都要有一行 serialVersionUID 定义。

问题不严重,编译器会 warning,但是程序员容易忽略。

Fortify 等商用工具无法检测。

审计方法:

搜索所有 Serializable 的类,确保有一行 Private static final long serialVersionUID =${123456}; 字样, 这是 JVM 要求的,也是 Java 开发普遍做法。

或者有 serialPersistentFields 字样。

如果二者 serialVersionUID 和 serialPersistentFields 皆无,则提示系统风险。JVM 自动赋予类 serialVersionUID。

对类进行代码重构的时候,类定义会改变,比如增加方法。这涉及到对象 API 发布和多版本维护。 旧版本类产生的字节流会和新版本类对象定义不兼容。JVM 会拒绝将一个类定义和它对象序列化联系起来。

程序如果需要在类版本演化过程中维护序列化的兼容性,要提供一的统一标识符。

为了提高 serialVersionUID 的独立性和确定性,强烈建议显示的定义 serialVersionUID,为它赋予明确的值。

整改方法:

加一行 private static final long serialVersionUID =${123456}; 即可。

审计重点 SER01-J 遵循权限最小化原则声明序列化方法

这是严重,容易发生,且修复代价低的漏洞。

Serializable 的类的固有序列化方法包括 readObject,writeObject。

Serializable 的类的固有序列化方法,还包括 readResolve,writeReplace。

它们是为了单例 (singleton) 类而专门设计的。

根据权限最小化原则,一般情况下这些方法必须被声明为 private void。否则如果 Serializable 的类开放 writeObject 函数为 public 的话,给非受信调用者过高权限,潜在有风险。有些情况下, 比如 Serializable 的类是 Extendable, 被子类继承了,为了确保子类也能访问方法,那么这些方法必须被声明为 protected,而不是 private。

编译器,Foritfy 和 Findbugs 工具都不能检测。需要人工审计。

审计方法:

人工搜索文本

public * writeObject

public * readObject

public * readResolve

public * writeReplace

整改方法:

视情况而定,比如修改为

private void writeObject

private void readObject

protected Object readResolve

protected Object writeReplace

审计重点 SER02-J 先签名封装敏感数据,再向受信域之外发送

问题中等严重,发生可能性大,修复代价中等。

审计方法:

审计难度大,不建议审计。需要结合实际应用场景,确定封签的必要性。

首先需要开发者配合确定被封装的对象是否跨信任域。

其次数据签名要求网络秘钥系统配置。要么通信双方配置有或者协商出 pair wise key,要么通信系统提供 PKI (public key infrastracture),现实应用往往妥协无法提供。

再次封签消耗资源。封签的一个潜在副作用是 DDoS 攻击,耗尽有限资源。

审计重点 SER03-04 不要泄露敏感信息

在 Java 环境中,允许处于不同受信域的组件进行数据通信,从而出现跨受信边界的数据传输。

SER03 不要序列化未加密的敏感数据

SER04 不要允许序列化绕过安全管理器

审计难度大,要根据实际业务场景定义敏感数据。

整改难度中等,要根据实际情况修复。 一般情况下,一旦定位,整改方法是将相关敏感数据声明为 transient,这样程序保证敏感数据从序列化格式中忽略的。特殊情况下,正确加密了的敏感数据可以被序列化。

审计重点 SER05-J 只有静态内部类才能序列化,其他内部类不能序列化

这是中等严重,容易发生,且修复代价中等的漏洞。

对非静态内部类的序列化依赖编译器,且随着平台的不同而不同,容易产生错误。

对内部类的序列化会导致外部类的实例也被序列化。这样有可能泄露敏感数据。

审计方法:

人工文本搜索,工具无法执行。

人工查找 implements Serializable 的所有内部类

e.g. 文本编辑器 UltraEdit 搜索关键字:

%class * implements Serializable{

[^t^b]class * implements Serializable{

整改方法有两种:

  1. class ${InnerSer} {}

    去除内部类的序列化。

  2. static class ${InnerSer} implements Serializable {}

    把内部类声明为静态从而防止被序列化。

输入验证和数据净化

输入验证是防止各种注入攻击,SQL 注入,XML 注入,LDAP 注入,以及 XSS 跨站攻击的有效手段。

工具审计 Fortify 和 Findbugs 均可。

尽可能先使用自动化分析工具,当一个规则不能由工具审计的时候,或者怀疑工具漏报误报的时候,必须采用人工审计的方式。

整改原则:

当存在公用 Java 代码库和解析器的数据净化和验证方法时,应该优先考虑使用它们。

表 2. Java 输入验证和数据净化编程标准审计方法
标准审计复杂度严重性整改代价审计方法
IDS00-J 工具为主,人工为辅
IDS01-J 工具
IDS02-J N/A
IDS03-J 人工
IDS04-J 人工
IDS05-J 人工
IDS06-J 人工
IDS07-J 人工
IDS08,09,11 N/A
IDS012-J 人工
IDSother 中低 N/A

审计重点 IDS00-J 净化穿越受信边界的非受信数据

程序接受未经过验证的用户数据。问题严重,包括 SQL 注入攻击和 XML 注入攻击。发生可能性大,修复代价中等。

审计方法:

工具检测 SQL 注入

Fortify 可以检测 SQL injection(由 Juliet Test Case SWE89 验证支持)

人工审计 XML 注入有难度

Fortify 和 findbugs 都无法检测 XML injection。

整改方法:

SQL 命令行解析器和 XML 解析器都提供了自己的数据净化和验证的方法。当存在这样的方法的时候,应当优先考虑它们,因为自定义的方法会忽略一些特殊情况,会忽略解析器自身所隐含的复杂性。

  1. SQL 注入相关攻击,用 PreparedStatement 代替 Statement, 通过使用 PreparedStatement 类的 set*() 方法,可以进行强制类型检查,会自动转义双引号内的输入数据,减少 SQL 注入漏洞。
    1. 设计精巧的注入文本还是有可能规避 PreparedStatement,更进一步的整改方法,可以是根据数据库,代替使用$符号的地方。比如对于 DB2,可以使用'%'||'#param#'||'%'或者 CONCAT('%', #param#, '%') 避免。
  2. XML 注入相关攻击,
    1. 一个方法是,写程序时候使用白模板,Pattern.matches, 使用何种 pattern 和业务逻辑相关,比如年龄字段只能是数字且不能大于 150
    2. 另外一个通用方法是,写程序时候使用 Java 类库类 SchemaFactory 以及 setEntityResolver 方法,通过定义 schema 来规范并验证 xml 输入。比如姓名字段只能 (0,1] 代表无名或者一个名字, 而不允许多个名字。

审计重点 IDS01-J 先标准化,然后再检验字符串

问题严重,可以包庇 XSS 跨站攻击,使得代码无法识别跨站攻击使用的<script>字符。发生可能性高,重点审计。

因为不同版本 unicode 编码集的不同,所以同一个字符没有唯一的二进制表达,存在二义性。 比如尖括号<>,在一种 Unicode 版本中可能表达为“\uFE64”“\\uFE65”, 在另外 Unicode 版本可能表达为另外形式。

通常用 NFKC 格式对任意编码的字符串进行标准化,消除二义性(当然还有 NFKD 标准化形式,总要选一种)。

审计方法:

工具审计,Fortify 和 findbugs 均可。

整改方法:

先 Normalize, 再 Validate。

比如:

String s =“\uFE64”+“script”+“uFE65”;

s = Normalizer.normalize(s, Form.NFKC);

pattern.matcher(s)

以上是针对服务器端的 string validation, 多说一句,对输出到客户端的字符串进行编码可以使系统更加安全。例如,把“<”编码为“&lt;”,“"”编码为“&quot;”等。

审计重点 IDS02-J 先标准化,然后再检验路径名

首先通过一个例子了解路径名的多样性。黑客可以改用包含 ../序列的参数来指定位于特定目录之外的文件,从而违反程序安全策略,引发路径遍历漏洞,攻击者可能可以向任意目录上传文件。

整改方法是采用 getCanonicalPath() 方法,并后续校验用 getCanonicalPath() 得到的路径名。

Java 一般路径 getPath(), 绝对路径 getAbsolutePath() 和规范路径 getCanonicalPath() 不同。

举例在 workspace 中新建 myTestPathPrj 工程,运行如下代码

 public static void testPath() throws Exception{
 File file = new File("..\\src\\ testPath.txt");
 System.out.println(file.getAbsolutePath());
 System.out.println(file.getCanonicalPath());
 }

得到的结果形如:

E:\workspace\myTestPathPrj\..\src\ testPath.txt
E:\ workspace\src\ testPath.txt

有些操作系统,例如 Windows 和 Macintosh,File.getAbsolutePath() 也能解析符号链接,别名等。尽管如此, Java 语言不能保证所有平台都奏效,或者未来实现中也能这样。

规范路径名是绝对路径名,并且是惟一的。所以还是尽量使用规范路径。

规范路径名的准确定义与系统有关。如有必要,此方法首先将路径名转换成绝对路径名,这与调用 getAbsolutePath() 方法的效果一样。然后用与系统相关的方式将它映射到其惟一路径名。这通常涉及到从路径名中移除多余的名称(比如 "." 和 "..")、分析符号连接(对于 UNIX 平台),以及将驱动器名转换成标准大小写形式(对于 Microsoft Windows 平台)。

审计方法:

人工审计,难度大,Fortify 和 Findbugs 工具不支持。

首先文本查找 getPath, getAbsolutePath。

再排查程序的安全策略配置文件,搜索 permission Java.io.FilePermission 字样和 grant 字样,防止误报。换句话说,如果 IO 方案中已经做出防御。只为程序的绝对路径赋予读写权限,其他目录不赋予读写权限。那么目录系统还是安全的。

整改方法:

尽量使用 getCanonicalPath()。

或者使用安全管理器,或者使用安全配置策略文件。如何配置安全策略文件,和具体使用的 web server 相关。

审计重点 IDS03-J 不 log 记录未净化的用户输入

问题严重,引发日志注入攻击,通过错误的日志误导系统维护工程师。

审计方法:

人工审计,工具默认不支持。

抽查文本形似:

logger.IDSver*uIDSname

整改方案:

先净化用户输入再记录。比如 pattern.match(“[A-Za-z0-9_]+”, uIDSname) 只是整改,减小日志注入攻击可能性。

审计重点 IDS04-J 限制上传文件的大小

审计方法:

人工方式,文本搜索 new FileOutputStream,上下文搜索 getSize。

工具 Fortify 和 Findbugs 无法检测。

整改方案:

用 ZipEntry.getSize() 方法得到文件大小,如果解压文件过大,抛出异常。

更进一步,JavaWeb 中的文件上传, 一般选择采用 apache 的开源工具 common-fileupload,因为直接使用 Servlet 解析其请求参数的原始方法比较麻烦。Fileupload 的库函数中包括形如,upload.setFileSizeMax() 和 upload.setSizeMax() 的校验函数,可上下文搜索这两个库函数,以策安全。

除了文件大小,特别注意文件上传的很多小细节问题,需要在 JavaWeb 程序中实现。否则会引起资源耗尽,拒绝服务攻击 DDoS 等。

  1. 上传文件推荐放在外界无法直接访问的目录下,比如放于 WEB-INF 目录
  2. 上传文件要有唯一的文件名,防止文件覆盖
  3. 上传文件不要扎堆放置在同一个目录下,可以根据文件名,依靠 hash 算法,新建目录,分别存放
  4. 上传文件的类型要限制,简单方法是用后缀名判断

在允许上传文件的网站和 web 应用中,通过客户端代码也可以验证上传的文件。但是攻击者使用抓包修改报文并重放的方式,可以绕过客户端验证。所以有必要在服务器端再验证。业界有一些推荐的解决方案。

审计重点 IDS05-J 使用合法的文件名和路径名

如果文件名和路径名中包含了特殊字符,就会有问题。比如以破折号开头的文件名,比如空格。

审计方法:

人工审计,工具 Fortify 不能发现。

先文本搜索 new File() 字样,再上下文搜索是否提供白名单支持,形如 pattern.match,更进一步排查 validation 是否足够力度。

整改方案:

推荐的一种安全文件名的匹配模式可以是 Pattern.compile(“[^A-Za-z0-9%&+,.:=_]”)。

审计重点 IDS06-J 从格式化字符串中排除用户输入

举例来说 System.out.printf(“%s”+args[0]) 安全可行,但是直接 System.out.printf(args[0]) 危险,用户可以在输入中用特殊字符串比如 %l$tm 诱骗系统打印出敏感信息。

审计方法:

工具 Fortify (Juliet TestCase CWE134_Uncontrolled_Format_String 支持)。

整改方案:

从格式化字符串中排除用户输入。

审计重点 IDS07-J 不用 Runtime.exec() 传递未净化数据

问题严重,可以引起参数注入攻击。

审计方法:

工具检查 Fortify 可检测(Juliet Test Case CWE78_OS_Command_Injection 支持)。

整改方案:

白名单 e.g. Pattern.matches(“[0-9A-Za-z@.]+”, dir),或者不使用 Runtime.exec, 直接用替代的功能函数。

审计重点 IDS08-J 净化数据后传递给正则表达式

中等严重性问题,可以引发正则注入攻击,用户输入作为正则表达式来源。审计难度大。整改方案可以使用白名单。

审计重点 IDS09-J 如果没有指定适当 locale, 不要使用 locale 相关方法

问题严重性中等。

原书建议显式设置 Locale。

实际上考虑 I18N 问题,程序会首先依据程序体内 Locale,然后参考 JVM 和 OS 的 locale。 这些应该是灵活的,不建议显式的程序代码设置 Locale。

审计重点 IDS10-J 不拆分两种数据结构中的字符串

不严重问题

成熟的软件产品代码,读取字符字节都是 copy 的成熟用例,出问题可能性不大。

审计重点 IDS11-J 在验证前去掉非字符码点

严重问题。审计难度大。

审计重点 IDS12-J 在不同字符编码之间无损转换字符串数据

不严重,整改方式灵活。

审计方法:

人工审计。工具不支持

人工文本搜索 new String(*, charset)。

整改方案:

使用 CharsetEncoder 和 CharsetDecoder 两个类来处理。

审计重点 IDS13-J 在文件或者网络 I/O 两端使用兼容的编码方式

问题不严重,人工审计,需要开发者配合理解代码逻辑和业务场景。

需要了解对于 web 应用程序,客户端和服务器端和编码有关的设置函数

  1. Java class 编译时可以指定编码
  2. JSP 编译: 在 JSP 文件开头设置,一般设为 charset=utf-8。
  3. JSP 输出:在 JSP 文件头可以指定,比如 response.setCharacterEncoding("GBK")。这指定文件输出到浏览器时使用的编码。
  4. META 设置:针对静态网页。因为静态网页无法使用上述两个 JSP 设置。JSP 设置优先级高于 META 设置,当它们共存时。
  5. FORM 设置:例如 URLEncoder.encode(key, "utf-8"))。浏览器专门针对表单,而不是网页使用特点 charset。

结束语

本文是 Java 安全编码审计的第一部分,主要针对序列化,输入验证和数据净化等内容,谈论是否适合审计,如何审计并提供整改建议。Java 安全编码审计第二部分,将介绍针对 Java 平台安全、表达式、数值类型运算、面向对象、输入输出、异常处理、运行环境等内容的审计,欢迎阅读。


相关主题

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=1014477
ArticleTitle=Java 安全编码审计,第 1 部分:序列化,输入验证和数据净化
publish-date=09062015