安全问题是木桶效应,整个系统的安全等级取决于安全性最薄弱的那个模块。在写业务代码的时候,要从我做起,建立最基本的安全意识,从源头杜绝低级安全问题。
1 数据源头的安全处理
对于 HTTP 请求,我们要在脑子里有一个根深蒂固的概念,那就是任何客户端传过来的数据都是不能直接信任的。客户端传给服务端的数据只是信息收集,数据需要经过有效性验证、权限验证等后才能使用,并且这些数据只能认为是用户操作的意图,不能直接代表数据当前的状态。
1.1 客户端的计算不可信
案例:之前有个页面小游戏,用户参加后,可以获得飞行石,因为业务中是第一次开发H5小游戏,前后端都没有经验,所以把游戏完成和计算都放在了前端,给后端传输的是最终的计算结果,即用户获得了多少飞行石,结果是上线后一晚上被盗刷了十几亿的飞行石,造成了十几万的损失。
通过这个案例我们可以看到,在处理客户端提交过来的数据时,服务端需要明确区分,哪些数据是需要客户端提供的,哪些数据是客户端从服务端获取后在客户端计算的。其中,前者可以信任;而后者不可信任,服务端需要重新计算,如果客户端和服务端计算结果不一致的话,可以给予友好提示。
虽然目前很多项目的前端都是富前端,会做大量的逻辑计算,无需访问服务端接口就可以顺畅完成各种功能,但来自客户端的计算结果不能直接信任。最终在进行业务操作时,客户端只能扮演信息收集的角色,虽然可以将诸如价格等信息传给服务端,但只能用于校对比较,最终要以服务端的计算结果为准。
1.2 客户端提交的参数需要校验
对于客户端的数据,我们还容易忽略的一点是,误以为客户端的数据来源是服务端,客户端就不可能提交异常数据,比如客户端展示的下拉框列表,但实际上虽然用户通过网页界面的操作无法修改这些数据,但这些数据对于 HTTP 请求来说就是普通数据,完全可以随时修改为任意值。所以,服务端在使用这些数据的时候,也同样要特别小心。
即使我们知道用户是在一个下拉列表选择数据,即使我们知道用户通过网页正常操作不可能提交不合法的值,服务端也应该进行参数校验,防止非法用户绕过浏览器 UI 页面通过工具直接向服务端提交参数。
1.3 不能信任请求头里的任何内容
上面说了不能直接信任客户端的传参,也就是通过 GET 或 POST 方法传过来的数据,此外请求头的内容也不能信任。
一个比较常见的需求是,为了防刷,我们需要判断用户的唯一性。比如,针对未注册的新用户发送一些小奖品,我们不希望相同用户多次获得奖品。考虑到未注册的用户因为没有登录过所以没有用户标识,我们可能会想到根据请求的 IP 地址,来判断用户是否已经领过奖品。
IP 地址的获取方式是:优先通过 X-Forwarded-For 请求头来获取,如果没有的话再通过 HttpServletRequest 的 getRemoteAddr 方法来获取。之所以这么做是因为,通常我们的应用之前都部署了反向代理或负载均衡器,remoteAddr 获得的只能是代理的 IP 地址,而不是访问用户实际的 IP。这不符合我们的需求,因为反向代理在转发请求时,通常会把用户真实 IP 放入 X-Forwarded-For 这个请求头中。这种过于依赖 X-Forwarded-For 请求头来判断用户唯一性的实现方式,是有问题的:完全可以通过 cURL 类似的工具来模拟请求,随意篡改头的内容:
curl http://localhost:45678/trustclientip/test -H "X-Forwarded-For:183.84.18.71, 10.253.15.1"
因此,IP 地址或者说请求头里的任何信息,包括 Cookie 中的信息、Referer,只能用作参考,不能用作重要逻辑判断的依据。而对于类似这个案例唯一性的判断需求,更好的做法是,让用户进行登录或三方授权登录(比如微信),拿到用户标识来做唯一性判断。
除了请求 Body 中的信息,请求头里的任何信息同样不能信任。我们要知道,来自请求头的 IP、Referer 和 Cookie 都有被篡改的可能性,相关数据只能用来参考和记录,不能用作重要业务逻辑。
2 业务兜底的安全
2.1 短信验证码防刷
对于短信验证码,有如下 4 种可行的方式来防刷。
第一种:只有固定的请求头才能发送验证码。
也就是说,我们通过请求头中网页或 App 客户端传给服务端的一些额外参数,来判断请求是不是 App 发起的。其实,这种方式“防君子不防小人”。
比如,判断是否存在浏览器或手机型号、设备分辨率请求头。对于那些使用爬虫来抓取短信接口地址的程序来说,往往只能抓取到 URL,而难以分析出请求发送短信还需要的额外请求头,可以看作第一道基本防御。
第二种:只有先到过注册页面才能发送验证码。
对于普通用户来说,不管是通过 App 注册还是 H5 页面注册,一定是先进入注册页面才能看到发送验证码按钮,再点击发送。我们可以在页面或界面打开时请求固定的前置接口,为这个设备开启允许发送验证码的窗口,之后的请求发送验证码才是有效请求。
这种方式可以防御直接绕开固定流程,通过接口直接调用的发送验证码请求,并不会干扰普通用户。
第三种方式,控制相同手机号的发送次数和发送频次。
除非是短信无法收到,否则用户不太会请求了验证码后不完成注册流程,再重新请求。因此,我们可以限制同一手机号每天的最大请求次数。验证码的到达需要时间,太短的发送间隔没有意义,所以我们还可以控制发送的最短间隔。比如,我们可以控制相同手机号一天只能发送 10 次验证码,最短发送间隔 1 分钟。
第四种方式,增加前置图形验证码。
短信轰炸平台一般会收集很多免费短信接口,一个接口只会给一个用户发一次短信,所以控制相同手机号发送次数和间隔的方式不够有效。这时,我们可以考虑对用户体验稍微有影响,但也是最有效的方式作为保底,即将弹出图形验证码作为前置。
除了图形验证码,我们还可以使用其他更友好的人机验证手段(比如滑动、点击验证码等),甚至是引入比较新潮的无感知验证码方案(比如,通过判断用户输入手机号的打字节奏,来判断是用户还是机器),来改善用户体验。
此外,我们也可以考虑在监测到异常的情况下再弹出人机检测。比如,短时间内大量相同远端 IP 发送验证码的时候,才会触发人机检测。
除了短信验证码,对于开放的、面向用户的平台资源要考虑防刷,主要包括正常使用流程识别、人机识别、单人限量和全局限量等手段。
总之,我们要确保,只有正常用户经过正常的流程才能使用开放平台资源,并且资源的用量在业务需求合理范围内。此外,还需要考虑实时监控,遇到发送量/调用量激增要及时报警。
2.2 虚拟资产限量
虚拟资产虽然是平台方自己生产和控制,但如果生产出来可以立即使用就有立即变现的可能性。比如,因为平台 Bug 有大量用户领取高额优惠券,并立即下单使用。
在商家看来,这很可能只是一个用户支付的订单,并不会感知到用户使用平台方优惠券的情况;同时,因为平台和商家是事后结算的,所以会马上安排发货。而发货后基本就不可逆了,一夜之间造成了大量资金损失。
同样,京东、拼多多都曾出现过一夜之间被刷了大量 100 元无门槛优惠券的事情,基本原因就是对优惠券没做限量。
更合适的做法是,把优惠券看作一种资源,其生产不是凭空的,而是需要事先申请,理由是:
虚拟资产如果最终可以对应到真实金钱上的优惠,那么,能发多少取决于运营和财务的核算,应该是有计划、有上限的。引言提到的无门槛优惠券,需要特别小心。有门槛优惠券的大量使用至少会带来大量真实的消费,而使用无门槛优惠券下的订单,可能用户一分钱都没有支付。
即使虚拟资产不值钱,大量不合常规的虚拟资产流入市场,也会冲垮虚拟资产的经济体系,造成虚拟货币的极速贬值。有量的控制才有价值。
资产的申请需要理由,甚至需要走流程,这样才可以追溯是什么活动需要、谁提出的申请,程序依据申请批次来发放。
但是就算是走了流程,审批通过了,但还是会出现问题,
之前有个案例,一个活动上线,配置了一万个礼包,每个礼包一张券,运营配置错了,配置成一万个礼包,每个礼包1万张券,结果在活动开始后,触发了券发送突增告警,虽然因为监控及时发现,大部分券都还没用,但是还是给公司造成了几十万的损失。
这种解决办法就是增加限制,限制每个人的领取数量。
3.3 资金操作实现幂等防重
涉及钱的进出,需要做好以下两点。
第一,任何资金操作都需要在平台侧生成业务属性的订单,可以是优惠券发放订单,可以是返现订单,也可以是借款订单,一定是先有订单再去做资金操作。同时,订单的产生需要有业务属性。业务属性是指,订单不是凭空产生的,否则就没有控制的意义。比如,返现发放订单必须关联到原先的商品订单产生;再比如,借款订单必须关联到同一个借款合同产生。
第二,一定要做好防重,也就是实现幂等处理,并且幂等处理必须是全链路的。这里的全链路是指,从前到后都需要有相同的业务订单号来贯穿,实现最终的支付防重。
对于支付操作,我们一定是调用三方支付公司的接口或银行接口进行处理的。一般而言,这些接口都会有商户订单号的概念,对于相同的商户订单号,无法进行重复的资金处理,所以三方公司的接口可以实现唯一订单号的幂等处理。
但是,业务系统在实现资金操作时容易犯的错是,没有自始至终地使用一个订单号作为商户订单号,透传给三方支付接口。出现这个问题的原因是,比较大的互联网公司一般会把支付独立一个部门。支付部门可能会针对支付做聚合操作,内部会维护一个支付订单号,然后使用支付订单号和三方支付接口交互。最终虽然商品订单是一个,但支付订单是多个,相同的商品订单因为产生多个支付订单导致多次支付。
如果说,支付出现了重复扣款,我们可以给用户进行退款操作,但给用户付款的操作一旦出现重复付款,就很难把钱追回来了,所以更要小心。这就是全链路的意义,从一开始就需要先有业务订单产生,然后使用相同的业务订单号一直贯穿到最后的资金通路,才能真正避免重复资金操作。
如果程序逻辑涉及有价值的资源或是真实的钱,我们必须有敬畏之心。程序上线后,人是有休息时间的,但程序是一直运行着的,如果产生安全漏洞,就很可能在一夜之间爆发,被大量人利用导致大量的金钱损失。
3 敏感数据保存
3.1 用户密码保存
用户密码不能加密保存,更不能明文保存,需要使用全球唯一的、具有一定长度的、随机的盐,配合单向散列算法保存。使用 BCrypt 算法,是一个比较好的实践。
MD5算法:MD5 其实不是真正的加密算法。所谓加密算法,是可以使用密钥把明文加密为密文,随后还可以使用密钥解密出明文,是双向的。而 MD5 是散列、哈希算法或者摘要算法。不管多长的数据,使用 MD5 运算后得到的都是固定长度的摘要信息或指纹信息,无法再解密为原始数据。所以,MD5 是单向的。最重要的是,仅仅使用 MD5 对密码进行摘要,并不安全。
MD5虽然可以加盐,但是加盐如果不当,还是非常不安全,比较重要的有两点
-
第一:不能在代码中写死盐:固定的盐可以很容易就破解。比如可以先设置简单密码123456,然后再去破解网站试一下这个 MD5,就可以得到原始密码是 salt;
-
第二:盐最好是随机的值,并且是全球唯一的,意味着全球不可能有现成的彩虹表给你用。正确的做法是,使用全球唯一的、和用户无关的、足够长的随机值作为盐。比如,可以使用 UUID 作为盐,把盐一起保存到数据库中:
更好的做法是,不要使用像 MD5 这样快速的摘要算法,而是使用慢一点的算法。比如 Spring Security 已经废弃了 MessageDigestPasswordEncoder,推荐使用 BCryptPasswordEncoder,也就是BCrypt来进行密码哈希。BCrypt 是为保存密码设计的算法,相比 MD5 要慢很多。
3.3 身份证保存
诸如姓名和身份证这种需要可逆解密查询的敏感信息,需要使用对称加密算法保存。建议是,把脱敏数据和密文保存在业务数据库,独立使用加密服务来做数据加解密;对称加密需要用到的密钥和初始化向量,可以和业务数据库分开保存。我们目前业务中手机号加解密就是这么处理的,利用公司统一提供的加密家处理。
-
对称加密算法,是使用相同的密钥进行加密和解密。使用对称加密算法来加密双方的通信的话,双方需要先约定一个密钥,加密方才能加密,接收方才能解密。如果密钥在发送的时候被窃取,那么加密就是白忙一场。因此,这种加密方式的特点是,加密速度比较快,但是密钥传输分发有泄露风险。
-
非对称加密算法,或者叫公钥密码算法。公钥密码是由一对密钥对构成的,使用公钥或者说加密密钥来加密,使用私钥或者说解密密钥来解密,公钥可以任意公开,私钥不能公开。使用非对称加密的话,通信双方可以仅分享公钥用于加密,加密后的数据没有私钥无法解密。因此,这种加密方式的特点是,加密速度比较慢,但是解决了密钥的配送分发安全问题。
但是,对于保存敏感信息的场景来说,加密和解密都是我们的服务端程序,不太需要考虑密钥的分发安全性,也就是说使用非对称加密算法没有太大的意义。在这里,我们使用对称加密算法来加密数据。
对称加密常用的加密算法,有 DES、3DES 和 AES。
-
DES:虽然现在仍有许多老项目使用了 DES 算法,但我不推荐使用。在 1999 年的 DES 挑战赛 3 中,DES 密码破解耗时不到一天,而现在 DES 密码破解更快,使用 DES 来加密数据非常不安全。因此,在业务代码中要避免使用 DES 加密。
-
3DES :是使用不同的密钥进行三次 DES 串联调用,虽然解决了 DES 不够安全的问题,但是比 AES 慢,也不太推荐。
-
AES :是当前公认的比较安全,兼顾性能的对称加密算法。不过严格来说,AES 并不是实际的算法名称,而是算法标准。2000 年,NIST 选拔出 Rijndael 算法作为 AES 的标准。https就使用了RSA 来保证安全性
更多加解密相关参考 Java中常用加密类型
4 漏洞
4.1 SQL 注入
第一,从注入的难易度上来说,修改 URL 上的 QueryString 和修改 Post 请求体中的数据,没有任何区别,因为黑客是通过工具来注入的,而不是通过修改浏览器上的 URL 来注入的。甚至 Cookie 都可以用来 SQL 注入,任何提供数据的地方都可能成为注入点。
第二,黑客完全可以利用 SQL 语句构造出一些不正确的 SQL,导致执行出错。如果服务端直接显示了错误信息,那黑客需要的数据就有可能被带出来,从而达到查询数据的目的。甚至是,即使没有详细的出错信息,黑客也可以通过所谓盲注的方式进行攻击。我后面再具体解释。
第三,SQL 注入完全可以实现拖库,也就是下载整个数据库的内容,SQL 注入的危害不仅仅是突破后台登录。
根据木桶原理,整个站点的安全性受限于安全级别最低的那块短板。因此,对于安全问题,站点的所有模块必须一视同仁,并不是只加强防范所谓的重点模块。
在 MyBatis 中,“#{}”是参数化的方式,“${}”只是占位符替换。
使用“#{}”会把输入当做一个字符串来对待,这样就会直接把外部传入的内容替换到 IN 内部,同样会有注入漏洞;
所以对于 MyBatis 来说,需要使用参数化的方式来写 SQL 语句。
4.2 XSS
XSS 问题的根源在于,原本是让用户传入或输入正常数据的地方,被黑客替换为了 JavaScript 脚本,页面没有经过转义直接显示了这个数据,然后脚本就被执行了。更严重的是,脚本没有经过转义就保存到了数据库中,随后页面加载数据的时候,数据中混入的脚本又当做代码执行了。黑客可以利用这个漏洞来盗取敏感数据,诱骗用户访问钓鱼网站等。
解决方法:将前端请求(HttpServletRequest)的所有数据,先进行转义后再存入DB中
Hutools其实已经有实现,如下:
1. 引入依赖
<!-- 数据转义,防止xss攻击-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.2</version>
</dependency>
2. 配置XssHttpServletRequestWrapper
* @description 对HttpServletRequest 请求的数据进行转义,防止xss攻击
* URL: home.html?mothod=space&pid=335511
*/
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {
public XssHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
}
/**
* 重写getParameter方法,用HtmlUtil转义后再返回
*/
@Override
public String getParameter(String name) {
String value= super.getParameter(name);
if(!StrUtil.hasEmpty(value)){
value=HtmlUtil.filter(value);
}
return value;
}
/**
* 重写getParameterValues方法,
* 遍历每一个值,用HtmlUtil转义后再返回
*/
@Override
public String[] getParameterValues(String name) {
String[] values= super.getParameterValues(name);
if(values!=null){
for (int i=0;i<values.length;i++){
String value=values[i];
if(!StrUtil.hasEmpty(value)){
value=HtmlUtil.filter(value);
}
values[i]=value;
}
}
return values;
}
/**
* 重写getParameterMap方法,
* 拿到所有的k-v键值对,用LinkedHashMap接收,
* key不变,value用HtmlUtil转义后再返回
*/
@Override
public Map<String, String[]> getParameterMap() {
Map<String, String[]> parameters = super.getParameterMap();
LinkedHashMap<String, String[]> map=new LinkedHashMap();
if(parameters!=null){
for (String key:parameters.keySet()){
String[] values=parameters.get(key);
for (int i = 0; i < values.length; i++) {
String value = values[i];
if (!StrUtil.hasEmpty(value)) {
value = HtmlUtil.filter(value);
}
values[i] = value;
}
map.put(key,values);
}
}
return map;
}
/**
* 重写getHeader方法,用HtmlUtil转义后再返回
*/
@Override
public String getHeader(String name) {
String value= super.getHeader(name);
if (!StrUtil.hasEmpty(value)) {
value = HtmlUtil.filter(value);
}
return value;
}
@Override
public ServletInputStream getInputStream() throws IOException {
/**
* 拿到数据流,通过StringBuffer拼接,
* 读取到line上,用StringBuffer是因为会有多个线程同时请求,要保证线程的安全
*/
InputStream in= super.getInputStream();
InputStreamReader reader=new InputStreamReader(in, Charset.forName("UTF-8"));
BufferedReader buffer=new BufferedReader(reader);
StringBuffer body=new StringBuffer();
String line=buffer.readLine();
while(line!=null){
body.append(line);
line=buffer.readLine();
}
buffer.close();
reader.close();
in.close();
/**
* 将拿到的map,转移后存到另一个map中
*/
Map<String,Object> map=JSONUtil.parseObj(body.toString());
Map<String,Object> result=new LinkedHashMap<>();
for(String key:map.keySet()){
Object val=map.get(key);
if(val instanceof String){
if(!StrUtil.hasEmpty(val.toString())){
result.put(key,HtmlUtil.filter(val.toString()));
}
}else {
result.put(key,val);
}
}
String json=JSONUtil.toJsonStr(result);
ByteArrayInputStream bain=new ByteArrayInputStream(json.getBytes());
//匿名内部类,只需要重写read方法,把转义后的值,创建成ServletInputStream对象
return new ServletInputStream() {
@Override
public int read() throws IOException {
return bain.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
};
}
}
3. 配置XssFilter
/**
* 拦截所有的请求,对所有请求转义
*/
@WebFilter(urlPatterns = "/*")
public class XssFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
/**
* 将获取到的数据,进行转义后再放行
* home.php?mod=space&uid=952169 servletRequest 请求
* @param servletResponse 响应
* @param filterChain 拦截链
* @throws IOException
* @throws ServletException
*/
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request= (HttpServletRequest) servletRequest;
XssHttpServletRequestWrapper wrapper=new XssHttpServletRequestWrapper(request);
filterChain.doFilter(wrapper,servletResponse);
}
@Override
public void destroy() {
}
}
网友评论