Java源码安全审查
最近业务需要出一份Java Web应用源码安全审查报告, 对比了市面上数种工具及其分析结果, 基于结果总结了一份规则库. 本文目录结构如下:
检测工具
FindSecurityBugs
基于class文件分析, 他是大名鼎鼎的findbugs的插件, 安装比较简单. 在findbugs官网下载安装包, 插件jar, 把jar放到findbugs-3.0.1\plugin目录.
打开bin路径下的findbugs.bat启动软件. 在菜单栏 - 编辑 - 选项可以查看插件是否启用成功.
新建项目, 输入名称, 选择需要分析的class路径, 引用的第三方包地址, 源码路径, 点击Analyze即可.
最终生成的结果可以转为html报告, 也可以导出xml文件, 在findbugs分析查看. 本文主要关注Security一栏.
代码卫士
360出品, 名声似乎不太好, 误报比较多, 不过结果也有一定的参考价值. 如果代码在码云的话, 点服务一栏, 可以在线扫描码云库里的源码, 本地代码要去官网申请试用.
扫描结果
Fortify
HP出品的老牌扫描工具, 网上有破解的. 安装过程一路next即可. 最后启动AuditWorkBench, 选择scan java project, 耐心等待.
结果跟findbugs类似.
CodePecker
啄木鸟源码分析, 国内的一款也是基于字节码分析的工具, 提供了一个收费的在线审计平台. 知乎有不少软文, 没找到免费试用的地方, 放弃了.
结果分析
代码注入
命令注入
命令注入是指应用程序执行命令的字符串或字符串的一部分来源于不可信赖的数据源,程序没有对这些不可信赖的数据进行验证、过滤,导致程序执行恶意命令的一种攻击方式。
String dir = request.getParameter("dir"); Process proc = Runtime.getRuntime().exec("cmd.exe /c dir" + dir);
如果攻击者传递了一个dir形式为"dummy && del c:\\dbms\\*.*"的字符串,那么该段代码将会在执行其他指定命令的同时执行这条删除命令。
修复方式
(1)程序对非受信的用户输入数据进行净化,删除不安全的字符。
(2)限定输入类型, 创建一份安全字符串列表,限制用户只能输入该列表中的数据。
1 // 方式1 2 if (!Pattern.matches("[0-9A-Za-z@.]+", dir)) { 3 // Handle error 4 } 5 6 // 方式2 7 int number = Integer.parseInt(request.getParameter("dir")); 8 switch (number) { 9 case 1: 10 btype = "tables" 11 break; // Option 1 12 case 2: 13 btype = "users" 14 break; // Option 2 15 ......
HTTP响应截断
程序从一个不可信赖的数据源获取数据,未进行验证就置于HTTP头文件中发给用户,可能会导致HTTP响应截断攻击。
String author = request.getParameter(AUTHOR_PARAM); ... Cookie cookie = new Cookie("author", author); cookie.setMaxAge(cookieExpiration); response.addCookie(cookie);
那么如果攻击者提交的是一个恶意字符串,比如“Wiley Hacker\r\nHTTP/1.1 200 OK\r\n...”,那么HTTP响应就会被分割成以下形式的两个响应:
HTTP/1.1 200 OK
...
Set-Cookie: author=Wiley Hacker
HTTP/1.1 200 OK
...
这样第二个响应已完全由攻击者控制,攻击者可以用所需的头文件和正文内容构建该响应实施攻击。
修复方式
防止HTTP响应截断攻击的最安全的方法是创建一份安全字符白名单,只接受完全由这些受认可的字符组成的输入出现在HTTP响应头文件中。
String author = request.getParameter(AUTHOR_PARAM); if (Pattern.matches("[0-9A-Za-z]+", author)) { ... Cookie cookie = new Cookie("author", author); cookie.setMaxAge(cookieExpiration); response.addCookie(cookie); }
SQL注入
SQL注入是一种数据库攻击手段。攻击者通过向应用程序提交恶意代码来改变原SQL语句的含义,进而执行任意SQL命令,达到入侵数据库乃至操作系统的目的。
String sqlString = "SELECT * FROM db_user WHERE username = '" + username + "' AND password = '" + pwd + "'"; Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sqlString);
攻击者能够替代username和password中的任意字符串,它们可以使用下面的关于password的字符串进行SQL注入。
SELECT * FROM db_user WHERE username='' AND password='' OR '1'='1'
修复方式
造成SQL注入攻击的根本原因在于攻击者可以改变SQL查询的上下文,使程序员原本要作为数据解析的数值,被篡改为命令了。防止SQL注入的方法如下:
(1)正确使用参数化API进行SQL查询。
(2)如果构造SQL指令时需要动态加入约束条件,可以通过创建一份合法字符串列表,使其对应于可能要加入到SQL指令中的不同元素,来避免SQL注入攻击。
String sqlString = "select * from db_user where username=? and password=?"; PreparedStatement stmt = connection.prepareStatement(sqlString); stmt.setString(1, username); stmt.setString(2, pwd); ResultSet rs = stmt.executeQuery();
正则表达式注入
数据被传递至应用程序并作为正则表达式使用。可能导致线程过度使用 CPU 资源,从而导致拒绝服务攻击。
下述代码java中字符串的split, replaceAll均支持正则的方式, 导致CPU挂起.
1 final String input = "0000000000000000000000000000000000000000000000"; 2 long startTime = System.currentTimeMillis(); 3 System.out.println(input.split("(0*)*A")); 4 System.out.println("耗时:" + (System.currentTimeMillis() - startTime) + "ms");
-
该正则的意思是说匹配器在输入的末尾并没有检测到”A”。现在外侧的限定符后退一次,内存的则前进一次,如此重复,无法得到结果。
-
因此,匹配器逐步回退,并尝试所有的组合以找出匹配符号。它最终将返回(没有匹配的结果),但是该过程的复杂性是指数型的(输入中添加一个字符加倍了运行时间)
修复方式
使用线程池 + Future, 限定执行时间, 并捕获异常.
1 ExecutorService service = Executors.newFixedThreadPool(1); 2 Future result = service.submit(new Callable<Object>() { 3 @Override 4 public Object call() { 5 final String input = "0000000000000000000000000000000000000000000000"; 6 return input.split("(0*)*A"); 7 } 8 }); 9 service.shutdown(); 10 System.out.println(result.get(5, TimeUnit.SECONDS));
LDAP注入
LDAP注入是指客户端发送查询请求时,输入的字符串中含有一些特殊字符,导致修改了LDAP本来的查询结构,从而使得可以访问更多的未授权数据的一种攻击方式。
以下代码动态构造一个 LDAP 查询,并对其加以执行,该查询可以检索所有报告给指定经理的雇员记录。该经理的名字是从 HTTP 请求中读取的,因此不可信任。
1 DirContext ctx = new InitialDirContext(env); 2 String managerName = request.getParameter("managerName"); 3 //retrieve all of the employees who report to a manager 4 String filter = "(manager=" + managerName + ")"; 5 NamingEnumeration employees = ctx.search("ou=People,dc=example,dc=com",filter);
如果攻击者为 managerName 输入字符串 Hacker, Wiley)(|(objectclass=*),则该查询会变成:
(manager=Hacker, Wiley)(|(objectclass=*))
根据执行查询的权限,增加 |(objectclass=*) 条件会导致筛选器与目录中的所有输入都匹配,而且会使攻击者检索到有关用户输入池的信息。
如果攻击者能够控制查询的命令结构,那么这样的攻击至少会影响执行 LDAP 查询的用户可以访问的所有记录。
修复方式
用白名单的方法,确保LDAP查询中由用户控制的数值完全来自于预定的字符集合,应不包含任何LDAP元字符。
比如使用Spring框架中EqualsFilter类来构造一个编码得当的筛选器字符串.
DirContext ctx = new InitialDirContext(env); String managerName = request.getParameter("managerName"); //retrieve all of the employees who report to a manager EqualsFilter filter = new EqualsFilter("manager", managerName); NamingEnumeration employees = ctx.search("ou=People,dc=example,dc=com",filter.toString());
输入验证
拒绝服务
拒绝服务是攻击者通过极度消耗应用资源,以致程序崩溃或其他合法用户无法进行使用的一种攻击方式。
例如解压文件前,未检查文件大小,攻击者可以通过提供一个超大文件,实施DOS攻击。
1 FileOutputStream fos = new FileOutputStream(entry.getName()); 2 dest = new BufferedOutputStream(fos, BUFFER); 3 while ((count = zis.read(data, 0, BUFFER)) != -1) { 4 dest.write(data, 0, count); 5 }
修复方式
对涉及到系统资源的外部数据应该进行严格校验,防止无限制的输入。对于用户上传的文件, 要在后台进行大小校验.
比如对解压文件进行验证,超过100M,将抛出异常。
1 if (entry.getSize() > TOOBIG) { 2 throw new IllegalStateException("File to be unzipped is huge."); 3 }
重定向参数
应用程序允许未验证的用户输入控制重定向中的URL,可能会导致攻击者发动钓鱼攻击。
String url = request.getParameter("url"); response.sendRedirect(url);
修复方式
创建一份合法URL列表,用户只能从中进行选择,进行重定向操作。
XML实体注入
简称XXE攻击, XML解析器中默认会解析xml中的ENTITY来支持全局变量以及外部文件读取.
如果从web请求中获取xml内容, 并在服务器端解析, 则可能导致xxe攻击.
1 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); 2 DocumentBuilder db = dbf.newDocumentBuilder(); 3 Document doc = db.parse(xmlFile); 4 NodeList list = doc.getElementsByTagName("active");
修复方式
(1)关闭XML实体解析
(2)使用JSON来替代XML做数据传输
1 DocumentBuilderFactory dbf =DocumentBuilderFactory.newInstance(); 2 dbf.setExpandEntityReferences(false);
资源注入
使用用户输入控制资源标识符,借此攻击者可以访问或修改其他受保护的系统资源。当满足以下两个条件时,就会发生资源注入:
(1)攻击者可以指定已使用的标识符来访问系统资源。例如,攻击者可能可以指定用来连接到网络资源的端口号。
(2)攻击者可以通过指定特定资源来获取某种权限,而这种权限在一般情况下是不可能获得的。例如,程序可能会允许攻击者把敏感信息传输到第三方服务器。
1 URL url = new URL(request.getParameter("remoteURL")); 2 URLConnection connection = url.openConnection(); 3 ... 4 String remotePort = request.getParameter("remotePort"); 5 ServerSocket srvr = new ServerSocket(remotePort); 6 Socket skt = srvr.accept();
修复方式
使用白名单策略, 限制资源文件读取和访问.
日志伪造
允许日志记录未经验证的用户输入,会导致日志伪造攻击。攻击者可能通过破坏文件格式或注入意外的字符,从而使文件无法使用。
更阴险的攻击可能会导致日志文件中的统计信息发生偏差, 掩护攻击者的跟踪轨迹.
1 if (loginSuccessful) { 2 logger.severe("User login succeeded for: " + username); 3 } else { 4 logger.severe("User login failed for: " + username); 5 }
攻击者可以将username替换为一个多行字符串,如下所示:
jack
2013-7-30 java.util.logging.LogManager log
Server: User login succeeded for: Tom
修复方式
对不可信赖的数据进行校验。
另外日志中不应该出现敏感数据, 例如密码, 手机号, 邮箱这些信息.
1 if (!Pattern.matches("[A-Za-z0-9_]+", username)) { 2 // Unsanitized username 3 logger.severe("User login failed for unauthorized user"); 4 } else if (loginSuccessful) { 5 logger.severe("User login succeeded for: " + username); 6 }
文件校验
对于用户上传的文件, 需要在前后台双重校验, 校验后缀, 文件大小, 二进制头等等.
其他可输入项, 也要做前后台双重校验, 防止中间人修改数据.
修复方式
对文件二进制头进行校验
1 static { 2 MAGIC_NUMBER.put("jpg", new String[]{"FFD8"}); 3 MAGIC_NUMBER.put("gif", new String[]{"47494638"}); 4 MAGIC_NUMBER.put("png", new String[]{"89504E470D0A1A0A"}); 5 MAGIC_NUMBER.put("pdf", new String[]{"25504446"}); 6 MAGIC_NUMBER.put("doc", new String[]{"D0CF11E0A1B11AE1", "7B5C72746631"}); 7 MAGIC_NUMBER.put("xls", new String[]{"D0CF11E0A1B11AE1"}); 8 MAGIC_NUMBER.put("ppt", new String[]{"D0CF11E0A1B11AE1"}); 9 MAGIC_NUMBER.put("docx", new String[]{"504B0304"}); 10 MAGIC_NUMBER.put("xlsx", new String[]{"504B0304"}); 11 MAGIC_NUMBER.put("pptx", new String[]{"504B0304"}); 12 } 13 14 /** 15 * 在检验范围内(MAGIC_NUMBER.keySet(): jpg, gif, png, pdf, xls, ppt, doc, xlsx, pptx, docx) 16 * 且文件后缀和文件二进制头不一致。返回false 17 */ 18 public static boolean checkFileType(byte[] content, String suffix) { 19 if (!MAGIC_NUMBER.keySet().contains(suffix)) { 20 return true; 21 } 22 23 byte[] bytes = Arrays.copyOfRange(content, 0, Math.min(content.length, MAGIC_HEADER_LENGTH)); 24 String fileCode = getFileHeader(bytes); 25 for (String magicNumber : MAGIC_NUMBER.get(suffix)) { 26 if (fileCode.toUpperCase().startsWith(magicNumber)) { 27 return true; 28 } 29 } 30 return false; 31 }
密码管理
硬编码密码
程序中采用硬编码方式处理密码,一方面会降低系统安全性,另一方面不易于程序维护。
1 private String rootManagerPassword = DEFAULTADMINPASSWORD; 2 ...... 3 if (password == null) { 4 password = "123456"; 5 }
修复方式
程序中所需密码应从配置文件中获取经过加密的密码值。
弱加密
在安全性要求较高的系统中,使用不安全的加密算法(如DES、RC4、RC5等),将无法保证敏感数据的保密性。
1 Cipher des = Cipher.getInstance("DES"); 2 SecretKey key = KeyGenerator.getInstance("DES").generateKey();
修复方式
使用安全的加密算法(如AES、3DES、RSA)对敏感数据进行加密。
1 Cipher aes = Cipher.getInstance("AES"); 2 KeyGenerator kg = KeyGenerator.getInstance("AES"); 3 kg.init(128); 4 SecretKey key = kg.generateKey();
不安全的Hash
在安全性要求较高的系统中,不应使用被业界公认的不安全的哈希算法(如MD2、MD4、MD5、SHA、SHA1等)来保证数据的完整性。
1 MessageDigest messageDigest = MessageDigest.getInstance("MD5"); 2 messageDigest.update(stringID.getBytes());
修复方式
采用散列值>=224比特的SHA系列算法(如SHA-224、SHA-256、SHA-384和SHA-512)来保证敏感数据的完整性。
1 md = MessageDigest.getInstance("SHA-256"); 2 md.update(bt); 3 strDes = bytes2Hex(md.digest()); // to HexString
不安全的随机数
Java API中提供了java.util.Random类实现PRNG(),该PRNG是可移植和可重复的,如果两个java.util.Random类的实例使用相同的种子,会在所有Java实现中生成相同的数值序列。
1 // Random对象r和s设置了相同的种子,因此 i == j 以及数组b[]和c[]的相应值是相等的。 2 Random r = new Random(12345); 3 int i = r.nextInt(); 4 byte[] b = new byte[4]; 5 r.nextBytes(b); 6 7 Random s = new Random(12345); 8 int j = s.nextInt(); 9 byte[] c = new byte[4]; 10 s.nextBytes(c);
修复方式
使用更安全的随机数生成器,如java.security.SecureRandom类。
1 SecureRandom number = SecureRandom.getInstance("SHA1PRNG"); 2 System.out.println(number.nextInt() + " " + number.nextInt());
跨站脚本
XSS
应用程序从数据库或其它后端数据存储获取不可信赖的数据,在未检验数据是否存在恶意代码的情况下,便将其传送给了Web用户,应用程序将易于受到存储型XSS攻击。
1 PrintWriter writer = WebUtils.createPrintWriter(res); 2 writer.print(str); 3 writer.flush(); 4 writer.close();
修复方式
对输出的字符串内容进行html转义编码.
public static String replaceScript4Xss(String message) { if (StringUtils.isEmpty(message)) { return StringUtils.EMPTY; } StringBuffer builder = new StringBuffer(message.length() * 2); CharacterIterator it = new StringCharacterIterator(message); for (char ch = it.first(); ch != CharacterIterator.DONE; ch = it.next()) { if ((((ch > '`') && (ch < '{')) || ((ch > '@') && (ch < '['))) || (((ch == ' ') || ((ch > '/') && (ch < ':'))) || (((ch == '.') || (ch == ',')) || ((ch == '-') || (ch == '_'))))) { builder.append(ch); } else { builder.append("&#" + (int) ch + ";"); } } return builder.toString(); }
CSRF跨站
跨站请求伪造(CSRF)是伪造客户端请求的一种攻击。应用程序允许用户提交不包含任何保密信息的请求,将可能导致CSRF攻击。
如以下代码片段用于银行转账功能,若对于该重要敏感的操作没有进行相应防护,将易于导致跨站请求伪造攻击。
1 <form method="GET" action="/transferFunds " > 2 cash: <input type="text" name="cash"> 3 to: <input type=" text " name=“to"> 4 <input type="submit" name="action" value="TransferFunds"> 5 </form>
修复方式
(1)二次验证,进行重要敏感操作时,要求用户进行二次验证。
(2)验证码,进行重要敏感操作时,加入验证码。
(3)在重要敏感操作的表单中加入隐藏的Token, 服务器端程序响应用户请求前先验证Token,判断请求的合法性。
Cookie属性
Cookie未设置httponly以及secure属性.
1 Cookie cookie = new Cookie("userName",userName); 2 response.addCookie(cookie);
修复方式
1 Cookie cookie = new Cookie("userName",userName); 2 cookie.setSecure(true); // Secure flag 3 cookie.setHttpOnly(true);
Cookie生命周期
Cookie生命周期不应该超过一年.
1 Cookie cookie = new Cookie("email", email); 2 cookie.setMaxAge(60*60*24*365);
jsessionid
登录前后改变jsessionid标识, 修改配置容器, 增强jsessionid算法逻辑.
1 HttpSession oldSession = req.getSession(false); 2 if (oldSession != null) { 3 //废弃旧的session, 否则每次退出后再登录, jsessionid不会变化. 4 oldSession.invalidate(); 5 } 6 HttpSession session = req.getSession(true);
SecurityHeaders
配置更安全的HTTP Header
1 res.addHeader("X-Content-Type-Options", "nosniff"); 2 res.addHeader("X-XSS-Protection", "1; mode=block"); 3 res.addHeader("X-Frame-Options", "SAMEORIGIN"); 4 res.addHeader("Content-Security-Policy", "object-src 'self'"); 5 6 res.addHeader("Cache-Control", "no-cache"); 7 res.addHeader("Pragma", "no-cache"); 8 res.addDateHeader("Expires", 0);
资源管理
日期格式化
SimpleDateFormat 非线程安全的,parse()和format()方法包含一个可导致用户看到其他用户数据的race condition。
1 private static SimpleDateFormat dateFormat;
修复方式
使用ThreadLocal放置SimpleDateFormat或者同步锁的方式.
访问权限
程序未进行恰当的访问权限控制,执行了一个包含用户控制主键的SQL语句,可能会导致攻击者访问未经授权的记录。
如下面代码片段中的SQL语句用于查询与指定标识符相匹配的清单。
1 id = Integer.decode(request.getParameter("invoiceID")); 2 String query = "SELECT * FROM invoices WHERE id = ?"; 3 PreparedStatement stmt = conn.prepareStatement(query); 4 stmt.setInt(1, id); 5 ResultSet results = stmt.execute();
修复方式
先判断当前用户权限是否可以增删数据, 可以通过把当前被授权的用户名作为查询语句的一部分来实现。
1 userName = ctx.getAuthenticatedUserName(); 2 id = Integer.decode(request.getParameter("invoiceID")); 3 String query = 4 "SELECT * FROM invoices WHERE id = ? AND user = ?"; 5 PreparedStatement stmt = conn.prepareStatement(query); 6 stmt.setString(1, id); 7 stmt.setString(2, userName); 8 ResultSet results = stmt.execute();
API限流
对于开放的API进行限流操作, 防止资源耗尽. 例如获取IP城市 或者 天气等等, 限制每个IP每小时最多调用1000次之类的.
简单实现可以用计数器限流, 另外Guava提供了RateLimiter可以实现令牌桶算法限流.
1 RateLimiter limiter = caches.get(ip); 2 3 if (limiter.tryAcquire()) { 4 System.out.println(i + " success " + new SimpleDateFormat("HH:mm:ss.sss").format(new Date())); 5 } else { 6 System.out.println(i + " failed " + new SimpleDateFormat("HH:mm:ss.sss").format(new Date())); 7 }
资源释放
对于一些资源文件, 使用完毕后要在finally语句中进行释放, 例如connection, 文件句柄, socket等等.
1 try { 2 DatabaseMetaData e = connection.getMetaData(); 3 ResultSet rs1 = e.getTableTypes(); 4 ...... 5 } catch (SQLException e) { 6 return StringUtils.EMPTY; 7 } finally { 8 DBUtils.close(connection); 9 }
路径遍历
用程序对用户可控制的输入未经合理校验,就传送给一个文件API。攻击者可能会使用一些特殊的字符(如“..”和“/”)摆脱受保护的限制,访问一些受保护的文件或目录。
1 String path = getInputPath(); 2 if (path.startsWith("/safe_dir/")){ 3 File f = new File(path); 4 f.delete() 5 }
攻击者可能提供类似下面的输入:/safe_dir/../important.dat
修复方式
使用白名单策略, 限制资源文件读取和访问.
路径输出
禁止输出服务器绝对路径到前端.
1 PrintWriter writer = WebUtils.createPrintWriter(res); 2 writer.write(file.getAbsolutePath());
修复方式
使用相对路径
数据跨越信任边界
数据从一个不可信赖域存储到一个可信赖域导致程序错误信赖未验证的数据。
1 String name = req.getParameter("userName"); 2 HttpSession sess = req.getSession(); 3 sess.setAttribute("user", name);
修复方式
数据跨越信任边界时需要进行合理的验证,保证信赖域中数据是安全的。
配置管理
Session失效配置
将Session的失效时间设置为30分钟或更少,既能使用户在一段时间内与应用程序互动,又提供了一个限制窗口攻击的合理范围。
修复方式
<session-config> <session-timeout>30</session-timeout> </session-config>
错误页面
Web应用程序的默认错误页面不应显示程序的敏感信息。Web应用程序应该为4xx(如404)错误、5xx(如503)错误、java.lang.Throwable异常定义一个错误页面,防止攻击者挖掘应用程序容器内置错误响应信息。报错页面中不应该包含类名, 方法名, 执行堆栈等信息.
修复方式
应用程序应该在web.xml中配置默认的错误页面。
<error-page> <error-code>403</error-code> <location>/common/403.jsp</location> </error-page> <error-page> <error-code>404</error-code> <location>/common/404.jsp</location> </error-page> <error-page> <error-code>500</error-code> <location>/common/500.jsp</location> </error-page> <error-page> <exception-type>java.lang.Throwable</exception-type> <location>/common/error.jsp</location> </error-page>
不安全的SSLContext
1 SSLContext.getInstance("SSL");
修复方式
配置web容器使用更安全的TLSv1.2协议.
1 SSLContext.getInstance("TLS");
未加密的Socket
1 ServerSocket soc = new ServerSocket(1234); 2 ...... 3 Socket soc = new Socket("www.google.com",80); 4 ......
修复方式
1 ServerSocket soc = SSLServerSocketFactory.getDefault().createServerSocket(1234); 2 ...... 3 Socket soc = SSLSocketFactory.getDefault().createSocket("www.google.com", 443); 4 ......
不安全的FTP协议
代码中使用SFTP替代FTP
1 Channel channel = session.openChannel("sftp"); 2 channel.connect();