Java 安全 SDK,提供安全的、常见的 Java 安全编码规范和方法,最大限度避免开发中出现常见的漏洞。
源码完善后上传
通过将恶意的 SQL 语句插入到应用的输入参数中,然后在后台 SQL 数据库上解析执行进行的攻击。 其实现的关键条件为:
- 用户输入可控
- 后台执行的 SQL 语句拼接了用户构造的数据,进行改变了原有的语义
- 采用预编译的方式,是防御 SQL 注入的最佳方式
- 使用白名单来规范化输入验证方法
- 黑名单过滤,过滤特殊的 SQL 字符,比如
' "等 - 转义所有的输入,对于用户的输入一律不可信
- 规范输出,不将报错信息展示在前端,使用统一的错误页面
- 最小权限原则
在开发中,一般使用预编译的方式进行防御 SQL 注入。当使用者需要自己编写 SQL 语句时,需要注意使用框架提供的方式或者函数实现预编译。并不是使用 ORM 框架后,就不会用 SQL 注入问题。
使用#{}语法时,MyBatis 底层会使用PreparedStatement方法进行参数变量绑定, 可有效防止 SQL 注入
<select id="getById" resultType="org.example.User"> SELECT * FROM user WHERE id = #{id} </select>
使用${}语法时,MyBatis 底层会直接注入原始的字符串,即相当于拼接字符串,因此会导致 SQL 注入
<select id="getByName" resultType="org.example.User"> SELECT * FROM user WHERE name = ${name} limit 1 </select> 相当于 "select * from user Where name = " + name + "limit 1";
-
MyBatis 不支持 else, 需要默认值的情况,可以使用 choose(when, otherwise)
<select id="getUserListSortBy" resultType="org.example.User"> SELECT * FROM user <if test="sortBy == 'name' or sortBy == 'email'"> order by ${sortBy} </if> </select> <!-- 安全的写法 - 使用参数化查询 --> <select id="getUsers" resultType="User"> SELECT * FROM users ORDER BY <choose> <when test="orderBy == 'name'">name</when> <when test="orderBy == 'age'">age</when> <when test="orderBy == 'email'">email</when> <otherwise>id</otherwise> </choose> <choose> <when test="direction == 'desc'">DESC</when> <otherwise>ASC</otherwise> </choose> </select>
-
like 语句: 使用 bind 标签来构造新参数,然后再使用 #{}。另外需要过滤通配符等特殊字符,避免 DOS。
<select id="getUserListLike" resultType="org.example.User"> <bind name="pattern" value="'%' + name + '%'" /> SELECT * FROM user WHERE name LIKE #{pattern} </select>
-
IN 条件,使用
<foreach>和#{}<select id="selectUserIn" resultType="com.example.User"> SELECT * FROM user WHERE name in <foreach item="name" collection="nameList" open="(" separator="," close=")"> #{name} </foreach> </select>
参数有多个时,一种可以使用@Param("xxx")进行参数绑定,另一种可以通过Map来传参数。
@Param("xxx")方式
List<User> selectByIdSet(@Param("name")String name, @Param("ids")String[] idList); <select id="selectByIdSet" resultMap="BaseResultMap"> SELECT <include refid="Base_Column_List" /> from t_user WHERE name=#{name,jdbcType=VARCHAR} and id IN <foreach collection="ids" item="id" index="index" open="(" close=")" separator=","> #{id} </foreach> </select>
Map方式
Map<String, Object> params = new HashMap<String, Object>(2); params.put("name", name); params.put("idList", ids); mapper.selectByIdSet(params); <select id="selectByIdSet" resultMap="BaseResultMap"> select <include refid="Base_Column_List" /> from t_user where name = #{name} and ID in <foreach item="item" index="index" collection="idList" open="(" separator="," close=")"> #{item} </foreach> </select>
Hibernate 支持 HQL (Hibernate Query Language) 和 native SQL 查询,前者存在 HQL 注入,后者和 JDBC 存在相同的注入问题
Query<User> query = session.createQuery("from User where name = '" + name + "'", User.class); User user = query.getSingleResult();
Query<User> query = session.createQuery("from User where name = ?", User.class); query.setParameter(0, name);
Query<User> query = session.createQuery("from User where name = :name", User.class); query.setParameter("name", name); //list Query<User> query = session.createQuery("from User where name in (:nameList)", User.class); query.setParameterList("nameList", Arrays.asList("lisi", "zhaowu")); //Javabean User user = new User(); user.setName("zhaowu"); Query<User> query = session.createQuery("from User where name = :name", User.class); // User 类需要有 getName() 方法 query.setProperties(user);
String sql = "select * from user where name = '" + name + "'";
Query query = session.createNativeQuery(sql); // Query query = session.createSQLQuery(sql); <deprecated>
String sql = "select * from user where name = :name";
Query query = session.createNativeQuery(sql); // Query query = session.createSQLQuery(sql); <deprecated>
query.setParameter("name", name);
和SQL注入原理一样
- 【推荐】参数绑定
拼接用户的查询权限条件
String title = request.getParamenter("name"); MongoCollection<Document> col = mongoClient.getDatabase("MyDB").getCollection("emails"); BasicDBObject query = new BasicDBObject(); query.put("$where", "this.title==\""+ title+"\""); FindIterable<Document> find = col.find(query);
String title = request.getParamenter("name"); MongoCollection<Document> col = mongoClient.getDatabase("MyDB").getCollection("emails"); BasicDBObject query = new BasicDBObject(); query.put("$where", new BasicDBObject("$eq", title)); FindIterable<Document> find = col.find(query);
因为前端和服务端没有正确的检验上传的文件内容、类型以及路径是否合法导致
- 白名单方式,只允许上传白名单里的后缀文件 【优先】
- 黑名单方式(容易绕过)
- 上传文件后随机命名(利用时间戳+随机数字组合等)【优先】
- 检测文件内容以及文件Content-Type【Content-Type方式容易绕过】
- 限定只能上传的文件到指定的目录,不允许目录穿越 【优先】
使用File对象的getCanonicalPath方法获取上传文件的实际文件名,若检测到文件名的后缀不是允许的类型(0x00截断,小于JDK1.8),或出现java.io.IOException异常(0x00截断,JDK1.8),或包含冒号(Windows环境中需处理),则说明需要拒绝本次文件上传。
// base/WhiteAndBlackChecker.java private List<String> arrayBlackList = new ArrayList<String>(); public List<String> getWhiteList() { return arrayWhiteList; } // fileopreate/UploadFileFilter.java public boolean isValidByWhiteList(File file) throws IOException { String fileName = file.getCanonicalFile().getName(); int index = fileName.lastIndexOf("."); String suffix = fileName.substring(index + 1); return super.getWhiteList().contains(suffix.toLowerCase()); }
使用File对象的getCanonicalPath方法获取上传文件的实际路径,和指定目录进行对比,避免使用../ 实现目录穿越
public boolean isValidByAllowedDirectory(File file, String allowedDirectory) throws IOException { String canonicalPath = file.getCanonicalFile().getPath(); if (System.getProperty("os.name").contains("Window")){ return canonicalPath.toLowerCase().contains(allowedDirectory.toLowerCase()); }else{ return canonicalPath.startsWith(allowedDirectory); } }
public String generateUniqueFileName(String extName){ long currentTime = System.currentTimeMillis(); int num = (int)(new SecureRandom().nextDouble()*10000); return currentTime + "" + num + extName; }
服务器没有对下载的文件名和文件路径进行过滤,然而下载的文件名和路径用户是可控的,导致此漏洞存在。
- 在处理下载的代码中对HTTP请求中的待下载文件参数进行过滤,防止出现..等特殊字符,但可能需要处理多种编码方式。
- 生成File对象后,使用getCanonicalPath获取当前文件的真实路径,判断文件是否在允许下载的目录中,若发现文件不在允许下载的目录中,则拒绝下载。【推荐】
public boolean isValidByAllowedDirectory(File file, String allowedDirectory) throws IOException { String canonicalPath = file.getCanonicalFile().getPath(); if (System.getProperty("os.name").contains("Window")){ return canonicalPath.toLowerCase().contains(allowedDirectory.toLowerCase()); }else{ return canonicalPath.startsWith(allowedDirectory); } }
攻击者可以通过漏洞遍历出服务器操作系统中的任意目录文件名,从而导致服务器敏感信息泄漏,某些场景下(如遍历出网站日志、备份文件、管理后台等)甚至可能会导致服务器被非法入侵。
- 同级目录遍历
./ - 越级目录遍历
../../../ - 绝对路径遍历
- 限制读取的文件和目录
使用File对象的getCanonicalPath方法获得读取文件的实际路径,和指定目录进行对比,避免使用../ 实现目录穿越
/** * 方式一:检查文件路径是否在允许的目录下 * @param file 文件的对象 * @param allowedDirectory 允许的目录 * @return boolean true为合法,false不合法 * @throws IOException 异常 */ public boolean isValidByAllowedDirectory(File file, String allowedDirectory) throws IOException { String canonicalPath = file.getCanonicalFile().getPath(); if (System.getProperty("os.name").contains("Window")){ return canonicalPath.toLowerCase().contains(allowedDirectory.toLowerCase()); }else{ return canonicalPath.startsWith(allowedDirectory); } }
示例
/** * 检查文件名中是否包含了空字节,禁止出现%00字符截断 * * @param file 访问文件 * @return 是否包含空字节 */ private static boolean nullByteValid(File file) { return file.getName().indexOf('\u0000') < 1; }
禁止写入如下类型的动态脚本文件:
jsp,jspx,jspa,jspf,asp,asa,cer,aspx,php
WEB-INF/web.xml、/etc/passwd、../../../../../../../etc/passwd
攻击者伪造服务器获取资源的请求,通过服务器来攻击内部系统。比如端口扫描,读取默认文件判断服务架构,或者配合SQL注入等其他漏洞攻击内网的主机
SSRF常出现在URL中,比如分享,翻译,图片加载,文章收藏等功能
- 禁用不需要的协议,只允许http和https请求,防止类似于file:///,gopher://,ftp://等引起的问题
- 将内网IP加入黑名单,请求的地址不能是内网IP
- 限制请求的端口,比如80,443,8080等,防止端口探测
- 限制错误信息回显,统一回显错误信息,避免用户根据回显获取信息
- 视业务而定,采用白名单方式设置允许访问的Host
白名单为域名
/** * 通过白名单检查主机名是否可信 * @param url url * @return true/false */ public boolean isValidHostByWhiteList(String url){ URL urlAddress = null; try { urlAddress = new URL(url); } catch (MalformedURLException e) { logger.warn("非法的URL" + e); return false; } //获取主域名 String topDomain = UrlUtils.getTopDomain(url); if (topDomain !=null){ for (String s: super.getWhiteList()){ if (topDomain.equals(s)){ return true; } } } return false; } public static String getTopDomain(String url){ try{ String host = new URL(url).getHost().toLowerCase(); //查找倒数第二个.的位置 int index = host.lastIndexOf(".", host.lastIndexOf(".") - 1); return host.substring(index + 1); }catch(MalformedURLException e){ logger.warn("非法的URL" + e); } return null; }
白名单为允许请求的协议
/** * 白名单检查请求的协议是否合法 * @param url url * @return true/false */ public boolean isValidProtocolByWhiteList(String url){ try { URL urlAddress = new URL(url); if (super.getWhiteList().contains(urlAddress.getProtocol())){ return true; } } catch (MalformedURLException e) { logger.warn("非法的URL" + e); } return false; }
黑名单为禁止使用的协议
/** * 黑名单检查请求的协议是否合法 * @param url url * @return true/false */ public boolean isValidProtocolByBlackList(String url){ try { URL urlAddress = new URL(url); if (!super.getBlackList().contains(urlAddress.getProtocol())){ return true; } } catch (MalformedURLException e) { logger.warn("非法的URL" + e); } return false; }
协议白名单结合黑名单内网IP并判断302跳转
/** * 推荐的检测组合,白名单结合内网IP并判断302跳转 * @param url 需要检测的url * @return boolean */ public boolean checkUrl(String url){ HttpURLConnection httpURLConnection; String finalUrl = url; try { do{ //判断协议和是否是内网IP if (!isValidProtocolByWhiteList(url) && !isInnerIp(url)){ return false; } //处理302跳转 httpURLConnection = (HttpURLConnection) new URL(finalUrl).openConnection(); httpURLConnection.setInstanceFollowRedirects(false);//不跟随跳转 httpURLConnection.setUseCaches(false);//不使用缓存 httpURLConnection.setConnectTimeout(5*1000);//设置超时时间 httpURLConnection.connect();//发送dns请求 int statusCode = httpURLConnection.getResponseCode(); //获取状态码 if(statusCode>=300 && statusCode<=307 && statusCode!=304 && statusCode!=306){ String redirectedURL = httpURLConnection.getHeaderField("Location"); if(null==redirectedURL){ break; } finalUrl = redirectedURL;//获取跳转之后的url,再次进行判断 }else{ break; } }while (httpURLConnection.getResponseCode()!=HttpURLConnection.HTTP_OK);//如果没有返回200,则继续对跳转后的链接进行检查 httpURLConnection.disconnect(); } catch (IOException e) { e.printStackTrace(); } return true; } /** * 判断是否为内网IP * @param url 请求的url * @return boolean */ private static boolean isInnerIp(String url){ try { URI uri = new URI(url); String host = uri.getHost(); InetAddress inetAddress = InetAddress.getByName(host); //获取IP String ip = inetAddress.getHostAddress(); //内网IP段 String[] blackSubnetList = {"10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8"}; for (String subnet: blackSubnetList){ SubnetUtils subnetUtils = new SubnetUtils(subnet); if (subnetUtils.getInfo().isInRange(ip)){ return true; //如果IP段在内网中,返回 } } } catch (URISyntaxException | UnknownHostException e) { logger.warn("解析错误uri " + e); } return false; }
后台服务器在告知浏览器跳转时,未对客户端传入的重定向地址进行合法性校验,导致用户浏览器跳转到钓鱼页面的一种漏洞。
- 如果只希望在当前的域跳转,可做白名单限制,非白名单内的URL禁止跳转;
- 如果业务需要,可对于白名单内的地址,用户可无感知跳转,不在白名单内的地址给用户风险提示,用户选择是否跳转
- 如果某个业务已经确定将要跳转的网站,最稳妥的方式是将其编码在源代码中,通过URL中传入的参数来映射跳转网址。
/** * 检查跳转的URL是否在白名单上 * @param url 检测的URL * @return boolean */ public boolean isWhiteList(String url){ //只允许http, https String u = url.toLowerCase().trim(); if (!Pattern.matches("^https?.*$", u)){ return false; } URI uri = null; try { uri = new URI(u); String host = uri.getHost(); //获取主域名 String topDomain = UrlUtils.getTopDomain(u); List<String> whiteList = super.getWhiteList(); //如果域名在白名单或者主域名在白名单 if (whiteList.contains(host) || whiteList.contains(topDomain)){ return true; } } catch (URISyntaxException e) { logger.warn("解析url错误:" + e ); } return false; }
通过统一的跳转风险提示页面,让用户选择是否跳转。
命令执行漏洞是指应用有时需要调用一些执行系统命令的函数,如果系统命令代码未对用户可控参数进行过滤,则当用户能控制这些函数的参数时,就可以将恶意系统命令拼接到正常命令中,从而造成命令执行工具。
危害:
- 集成web服务程序的权限去执行系统命令或读/写文件
- 反弹shell
- 控制整个网站甚至服务器进行进一步的内网渗透
- 非必要不要拼接用户的输入作为命令进行执行
- 如果业务需要,使用白名单
- 精确匹配和限制用户提交的数据(前端后端都增加限制)
public void test(HttpServletRequest request){ String ip = request.getParameter("ip"); //不加过滤直接拼接到执行的命令中 String exec = "ping "+ip; ProcessBuilder p = null; BufferedReader reader = null; try { //调用shell进行执行 p = new ProcessBuilder("bash","-c",exec); p.start(); String line; reader = new BufferedReader(new InputStreamReader(p.start().getInputStream(),"GBK")); while((line=reader.readLine())!=null){ //System.out.println(line); } } catch (IOException e) { e.printStackTrace(); }finally { try { reader.close(); } catch (IOException e) { e.printStackTrace(); } } }
通过精确匹配用户输入的数据,不符合一律不执行,其次可以通过白名单方式匹配允许执行的命令。具体实现见源码exec/ExecCmdFilter.java
public void setRegex(String regex) { this.regex = regex; } public String getRegx() { return regex; } /** * 判断用户提供的命令是否合法 * @param cmd 需要检测的命令 * @return boolean 是否合法 */ private String regex; public boolean isValidCMD(String cmd){ String processedCmd = cmd.trim(); //精确匹配拼接用户输入的数据 //正则表达式为自定义 Boolean isMatch = Pattern.matches(regex, processedCmd); Boolean isWhite = super.getWhiteList().contains(processedCmd); return isMatch || isWhite; } //测试代码 public class ExecCmdFilterTest { @Test public void testIsValidCMD(){ ExecCmdFilter cmdFilter = ExecCmdFilter.getInstance(); //匹配IP的正则表达式 String regex = "((?:(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d))"; cmdFilter.setRegex(regex); String cmd = "127.0.0.1;ls"; if (cmdFilter.isValidCMD(cmd)){ System.out.println("执行的命令合法"); }else{ System.out.println("执行的命令不合法"); } } }
Cross-Site Scripting(跨站脚本攻击)简称 XSS,是一种代码注入攻击。攻击者通过在目标网站上注入恶意脚本,使之在用户的浏览器上运行。利用这些恶意脚本,攻击者可获取用户的敏感信息如 Cookie、SessionID 等,进而危害数据安全。
注入的方法
- 在 HTML 中内嵌的文本中,恶意内容以 script 标签形成注入。
- 在内联的 JavaScript 中,拼接的数据突破了原本的限制(字符串,变量,方法名等)。
- 在标签属性中,恶意内容包含引号,从而突破属性值的限制,注入其他属性或者标签。
- 在标签的 href、src 等属性中,包含
javascript:等可执行代码。 - 在 onload、onerror、onclick 等事件中,注入不受控制代码。
- 在 style 属性和标签中,包含类似
background-image:url("javascript:...");的代码(新版本浏览器已经可以防范)。 - 在 style 属性和标签中,包含类似
expression(...)的 CSS 表达式代码(新版本浏览器已经可以防范)。
目前主流最新版浏览器对内置了预防XSS的措施。防御XSS的核心就是对不可信数据进行正确的编码。所以只有在正确的地方使用正确的编码才能消除XSS漏洞。
- 预防存储型和反射型 XSS通常有两种做法
- 改成纯前端渲染,把代码和数据分隔开。要避免DOM型XSS
- 对 HTML 做充分转义
- 使用HttpOnly,禁止页面通过JavaScript访问cookie
- 输入检查
- 输入检查基本先在用户浏览器中进行。例如, 用户注册时的用户名,当要求只能为字母、数字的组合时,就需要进行严格的过滤。其他的,比如电话、邮件、生日等等,都要有一定 的格式规范。对特殊字符进行编码或者过滤。在服务端代码也需要进行输入规范的逻辑检查。
- 客户端使用JavaScript检查可以阻挡大部分正常用户的误操作,减小服务端再次验证的资源浪费。
- 输出检查
- 预防DOM 型 XSS 攻击,在使用
.innerHTML、.outerHTML、document.write()时要特别小心,不要把不可信的数据作为 HTML 插到页面上,而应尽量使用.textContent、.setAttribute()等。 - Content Security Policy
java工程中,常用的转义库为 org.owasp.encoder
//插入不可信数据到HTML标签之间时,进行HTML Entity编码 String encodedContent = ESAPI.encoder().encodeForHTML(request.getParameter("input")); //插入不可信数据到HTML属性里时,进行HTML属性编码 String encodedContent = ESAPI.encoder().encodeForHTMLAttribute(request.getParameter("input")); //插入不可信数据到SCRIPT里时,进行JavaScript编码 String encodedContent = ESAPI.encoder().encodeForJavaScript(request.getParameter("input")); //插入不可信数据到Style属性里时,进行CSS编码 String encodedContent = ESAPI.encoder().encodeForCSS(request.getParameter("input")); //插入不可信数据到HTML URL里时,进行URL编码 //当需要往HTML页面中的URL里插入不可信数据的时候,需要对其进行URL编码,如下: //<a href="http://www.abcd.com?param=...插入不可信数据前,进行URL编码..."> Link Content </a> String encodedContent = ESAPI.encoder().encodeForURL(request.getParameter("input"));
产生SpEL表达式注入漏洞的大前提是存在SpEL的相关库。产生SpEL表达式注入漏洞主要原因是,很大一部分开发人员未对用户输入进行处理就直接通过解析引擎对SpEL继续解析。一旦用户能够控制解析的SpEL语句,便可以通过反射的方式构造执行的命令,从而达到RCE的目的。
- 使用
SimpleEvaluationContext替换StandardEvaluationContext,该类抛弃了Java类型引用、构造函数及bean引用
示例:
String spel = "T(java.lang.Runtime).getRuntime().exec(\"calc\")"; ExpressionParser parser = new SpelExpressionParser(); Student student = new Student(); EvaluationContext context =SimpleEvaluationContext.forReadOnlyDataBinding().withRootObject(student).build(); Expression expression = parser.parseExpression(spel); System.out.println(expression.getValue(context));
在web.xml中定义error-page,防止当出现错误时暴露服务器信息。