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>
-
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,防止当出现错误时暴露服务器信息。