This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,387 @@
<audio id="audio" title="27 | 数据源头:任何客户端的东西都不可信任" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/84/11/849f7bc622c3b9cd6ba604906d16de11.mp3"></audio>
你好,我是朱晔。
从今天开始,我要和你讨论几个有关安全的话题。首先声明,我不是安全专家,但我发现有这么一个问题,那就是许多做业务开发的同学往往一点点安全意识都没有。如果有些公司没有安全部门或专家的话,安全问题就会非常严重。
如果只是用一些所谓的渗透服务浅层次地做一下扫描和渗透,而不在代码和逻辑层面做进一步分析的话,能够发现的安全问题非常有限。要做好安全,还是要靠一线程序员和产品经理点点滴滴的意识。
所以接下来的几篇文章,我会从业务开发的角度,和你说说我们应该最应该具备的安全意识。
对于HTTP请求我们要在脑子里有一个根深蒂固的概念那就是**任何客户端传过来的数据都是不能直接信任的**。客户端传给服务端的数据只是信息收集,数据需要经过有效性验证、权限验证等后才能使用,并且这些数据只能认为是用户操作的意图,不能直接代表数据当前的状态。
举一个简单的例子,我们打游戏的时候,客户端发给服务端的只是用户的操作,比如移动了多少位置,由服务端根据用户当前的状态来设置新的位置再返回给客户端。为了防止作弊,不可能由客户端直接告诉服务端用户当前的位置。
因此客户端发给服务端的指令代表的只是操作指令并不能直接决定用户的状态对于状态改变的计算在服务端。而网络不好时我们往往会遇到走了10步又被服务端拉回来的现象就是因为有指令丢失客户端使用服务端计算的实际位置修正了客户端玩家的位置。
今天,我通过四个案例来和你说说,为什么“任何客户端的东西都不可信任”。
## 客户端的计算不可信
我们先看一个电商下单操作的案例。
在这个场景下,可能会暴露这么一个/order的POST接口给客户端让客户端直接把组装后的订单信息Order传给服务端
```
@PostMapping(&quot;/order&quot;)
public void wrong(@RequestBody Order order) {
this.createOrder(order);
}
```
订单信息Order可能包括商品ID、商品价格、数量、商品总价
```
@Data
public class Order {
private long itemId; //商品ID
private BigDecimal itemPrice; //商品价格
private int quantity; //商品数量
private BigDecimal itemTotalPrice; //商品总价
}
```
虽然用户下单时客户端肯定有商品的价格等信息也会计算出订单的总价给用户确认但是这些信息只能用于呈现和核对。即使客户端传给服务端的POJO中包含了这些信息服务端也一定要重新从数据库来初始化商品的价格重新计算最终的订单价格。**如果不这么做的话,很可能会被黑客利用,商品总价被恶意修改为比较低的价格。**
因此我们真正直接使用的、可信赖的只是客户端传过来的商品ID和数量服务端会根据这些信息重新计算最终的总价。如果服务端计算出来的商品价格和客户端传过来的价格不匹配的话可以给客户端友好提示让用户重新下单。修改后的代码如下
```
@PostMapping(&quot;/orderRight&quot;)
public void right(@RequestBody Order order) {
//根据ID重新查询商品
Item item = Db.getItem(order.getItemId());
//客户端传入的和服务端查询到的商品单价不匹配的时候,给予友好提示
if (!order.getItemPrice().equals(item.getItemPrice())) {
throw new RuntimeException(&quot;您选购的商品价格有变化,请重新下单&quot;);
}
//重新设置商品单价
order.setItemPrice(item.getItemPrice());
//重新计算商品总价
BigDecimal totalPrice = item.getItemPrice().multiply(BigDecimal.valueOf(order.getQuantity()));
//客户端传入的和服务端查询到的商品总价不匹配的时候,给予友好提示
if (order.getItemTotalPrice().compareTo(totalPrice)!=0) {
throw new RuntimeException(&quot;您选购的商品总价有变化,请重新下单&quot;);
}
//重新设置商品总价
order.setItemTotalPrice(totalPrice);
createOrder(order);
}
```
还有一种可行的做法是让客户端仅传入需要的数据给服务端像这样重新定义一个POJO CreateOrderRequest作为接口入参比直接使用领域模型Order更合理。在设计接口时我们会思考哪些数据需要客户端提供而不是把一个大而全的对象作为参数提供给服务端以避免因为忘记在服务端重置客户端数据而导致的安全问题。
下单成功后,服务端处理完成后会返回诸如商品单价、总价等信息给客户端。此时,客户端可以进行一次判断,如果和之前客户端的数据不一致的话,给予用户提示,用户确认没问题后再进入支付阶段:
```
@Data
public class CreateOrderRequest {
private long itemId; //商品ID
private int quantity; //商品数量
}
@PostMapping(&quot;orderRight2&quot;)
public Order right2(@RequestBody CreateOrderRequest createOrderRequest) {
//商品ID和商品数量是可信的没问题其他数据需要由服务端计算
Item item = Db.getItem(createOrderRequest.getItemId());
Order order = new Order();
order.setItemPrice(item.getItemPrice());
order.setItemTotalPrice(item.getItemPrice().multiply(BigDecimal.valueOf(order.getQuantity())));
createOrder(order);
return order;
}
```
通过这个案例我们可以看到,在处理客户端提交过来的数据时,服务端需要明确区分,哪些数据是需要客户端提供的,哪些数据是客户端从服务端获取后在客户端计算的。其中,前者可以信任;而后者不可信任,服务端需要重新计算,如果客户端和服务端计算结果不一致的话,可以给予友好提示。
## 客户端提交的参数需要校验
对于客户端的数据,我们还容易忽略的一点是,**误以为客户端的数据来源是服务端,客户端就不可能提交异常数据**。我们看一个案例。
有一个用户注册页面要让用户选择所在国家我们会把服务端支持的国家列表返回给页面供用户选择。如下代码所示我们的注册只支持中国、美国和英国三个国家并不对其他国家开放因此从数据库中筛选了id&lt;4的国家返回给页面进行填充
```
@Slf4j
@RequestMapping(&quot;trustclientdata&quot;)
@Controller
public class TrustClientDataController {
//所有支持的国家
private HashMap&lt;Integer, Country&gt; allCountries = new HashMap&lt;&gt;();
public TrustClientDataController() {
allCountries.put(1, new Country(1, &quot;China&quot;));
allCountries.put(2, new Country(2, &quot;US&quot;));
allCountries.put(3, new Country(3, &quot;UK&quot;));
allCountries.put(4, new Country(4, &quot;Japan&quot;));
}
@GetMapping(&quot;/&quot;)
public String index(ModelMap modelMap) {
List&lt;Country&gt; countries = new ArrayList&lt;&gt;();
//从数据库查出ID&lt;4的三个国家作为白名单在页面显示
countries.addAll(allCountries.values().stream().filter(country -&gt; country.getId()&lt;4).collect(Collectors.toList()));
modelMap.addAttribute(&quot;countries&quot;, countries);
return &quot;index&quot;;
}
}
```
我们通过服务端返回的数据来渲染模板:
```
...
&lt;form id=&quot;myForm&quot; method=&quot;post&quot; th:action=&quot;@{/trustclientdata/wrong}&quot;&gt;
&lt;select id=&quot;countryId&quot; name=&quot;countryId&quot;&gt;
&lt;option value=&quot;0&quot;&gt;Select country&lt;/option&gt;
&lt;option th:each=&quot;country : ${countries}&quot; th:text=&quot;${country.name}&quot; th:value=&quot;${country.id}&quot;&gt;&lt;/option&gt;
&lt;/select&gt;
&lt;button th:text=&quot;Register&quot; type=&quot;submit&quot;/&gt;
&lt;/form&gt;
...
```
在页面上,的确也只有这三个国家的可选项:<br>
<img src="https://static001.geekbang.org/resource/image/cc/eb/cc68781b3806c45cbd8aeb3c62bdb8eb.png" alt="">
但我们要知道的是页面是给普通用户使用的而黑客不会在乎页面显示什么完全有可能尝试给服务端返回页面上没显示的其他国家ID。如果像这样直接信任客户端传来的国家ID的话很可能会把用户注册功能开放给其他国家的人
```
@PostMapping(&quot;/wrong&quot;)
@ResponseBody
public String wrong(@RequestParam(&quot;countryId&quot;) int countryId) {
return allCountries.get(countryId).getName();
}
```
即使我们知道参数的范围来自下拉框,而下拉框的内容也来自服务端,也需要对参数进行校验。因为接口不一定要通过浏览器请求,只要知道接口定义完全可以通过其他工具提交:
```
curl http://localhost:45678/trustclientdata/wrong\?countryId=4 -X POST
```
修改方式是,在使用客户端传过来的参数之前,对参数进行有效性校验:
```
@PostMapping(&quot;/right&quot;)
@ResponseBody
public String right(@RequestParam(&quot;countryId&quot;) int countryId) {
if (countryId &lt; 1 || countryId &gt; 3)
throw new RuntimeException(&quot;非法参数&quot;);
return allCountries.get(countryId).getName();
}
```
或者是使用Spring Validation采用注解的方式进行参数校验更优雅
```
@Validated
public class TrustClientParameterController {
@PostMapping(&quot;/better&quot;)
@ResponseBody
public String better(
@RequestParam(&quot;countryId&quot;)
@Min(value = 1, message = &quot;非法参数&quot;)
@Max(value = 3, message = &quot;非法参数&quot;) int countryId) {
return allCountries.get(countryId).getName();
}
}
```
客户端提交的参数需要校验的问题可以引申出一个更容易忽略的点是我们可能会把一些服务端的数据暂存在网页的隐藏域中这样下次页面提交的时候可以把相关数据再传给服务端。虽然用户通过网页界面的操作无法修改这些数据但这些数据对于HTTP请求来说就是普通数据完全可以随时修改为任意值。所以服务端在使用这些数据的时候也同样要特别小心。
## 不能信任请求头里的任何内容
刚才我们介绍了不能直接信任客户端的传参也就是通过GET或POST方法传过来的数据此外请求头的内容也不能信任。
一个比较常见的需求是为了防刷我们需要判断用户的唯一性。比如针对未注册的新用户发送一些小奖品我们不希望相同用户多次获得奖品。考虑到未注册的用户因为没有登录过所以没有用户标识我们可能会想到根据请求的IP地址来判断用户是否已经领过奖品。
比如下面的这段测试代码。我们通过一个HashSet模拟已发放过奖品的IP名单每次领取奖品后把IP地址加入这个名单中。IP地址的获取方式是优先通过X-Forwarded-For请求头来获取如果没有的话再通过HttpServletRequest的getRemoteAddr方法来获取。
```
@Slf4j
@RequestMapping(&quot;trustclientip&quot;)
@RestController
public class TrustClientIpController {
HashSet&lt;String&gt; activityLimit = new HashSet&lt;&gt;();
@GetMapping(&quot;test&quot;)
public String test(HttpServletRequest request) {
String ip = getClientIp(request);
if (activityLimit.contains(ip)) {
return &quot;您已经领取过奖品&quot;;
} else {
activityLimit.add(ip);
return &quot;奖品领取成功&quot;;
}
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader(&quot;X-Forwarded-For&quot;);
if (xff == null) {
return request.getRemoteAddr();
} else {
return xff.contains(&quot;,&quot;) ? xff.split(&quot;,&quot;)[0] : xff;
}
}
}
```
之所以这么做是因为通常我们的应用之前都部署了反向代理或负载均衡器remoteAddr获得的只能是代理的IP地址而不是访问用户实际的IP。这不符合我们的需求因为反向代理在转发请求时通常会把用户真实IP放入X-Forwarded-For这个请求头中。
**这种过于依赖X-Forwarded-For请求头来判断用户唯一性的实现方式是有问题的**
- 完全可以通过cURL类似的工具来模拟请求随意篡改头的内容
```
curl http://localhost:45678/trustclientip/test -H &quot;X-Forwarded-For:183.84.18.71, 10.253.15.1&quot;
```
- 网吧、学校等机构的出口IP往往是同一个在这个场景下可能只有最先打开这个页面的用户才能领取到奖品而其他用户会被阻拦。
因此IP地址或者说请求头里的任何信息包括Cookie中的信息、Referer只能用作参考不能用作重要逻辑判断的依据。而对于类似这个案例唯一性的判断需求更好的做法是让用户进行登录或三方授权登录比如微信拿到用户标识来做唯一性判断。
## 用户标识不能从客户端获取
聊到用户登录业务代码非常容易犯错的一个地方是使用了客户端传给服务端的用户ID类似这样
```
@GetMapping(&quot;wrong&quot;)
public String wrong(@RequestParam(&quot;userId&quot;) Long userId) {
return &quot;当前用户Id&quot; + userId;
}
```
你可能觉得没人会这么干,但我就真实遇到过:**一个大项目因为服务端直接使用了客户端传过来的用户标识,导致了安全问题**。
犯类似低级错误的原因,有三个:
- 开发同学没有正确认识接口或服务面向的用户。如果接口面向内部服务由服务调用方传入用户ID没什么不合理但是这样的接口不能直接开放给客户端或H5使用。
- 在测试阶段为了方便测试调试,我们通常会实现一些无需登录即可使用的接口,直接使用客户端传过来的用户标识,却在上线之前忘记删除类似的超级接口。
- 一个大型网站前端可能由不同的模块构成不一定是一个系统而用户登录状态可能也没有打通。有些时候我们图简单可能会在URL中直接传用户ID以实现通过前端传值来打通用户登录状态。
如果你的接口直面用户比如给客户端或H5页面调用那么一定需要用户先登录才能使用。登录后用户标识保存在服务端接口需要从服务端比如Session中获取。这里有段代码演示了一个最简单的登录操作登录后在Session中设置了当前用户的标识
```
@GetMapping(&quot;login&quot;)
public long login(@RequestParam(&quot;username&quot;) String username, @RequestParam(&quot;password&quot;) String password, HttpSession session) {
if (username.equals(&quot;admin&quot;) &amp;&amp; password.equals(&quot;admin&quot;)) {
session.setAttribute(&quot;currentUser&quot;, 1L);
return 1L;
}
return 0L;
}
```
这里我再分享一个Spring Web的小技巧。
如果希望每一个需要登录的方法都从Session中获得当前用户标识并进行一些后续处理的话我们没有必要在每一个方法内都复制粘贴相同的获取用户身份的逻辑可以定义一个自定义注解@LoginRequired到userId参数上然后通过HandlerMethodArgumentResolver自动实现参数的组装
```
@GetMapping(&quot;right&quot;)
public String right(@LoginRequired Long userId) {
return &quot;当前用户Id&quot; + userId;
}
```
@LoginRequired本身并无特殊,只是一个自定义注解:
```
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@Documented
public @interface LoginRequired {
String sessionKey() default &quot;currentUser&quot;;
}
```
魔法来自HandlerMethodArgumentResolver。我们自定义了一个实现类LoginRequiredArgumentResolver实现了HandlerMethodArgumentResolver接口的2个方法
- supportsParameter方法判断当参数上有@LoginRequired注解时,再做自定义参数解析的处理;
- resolveArgument方法用来实现解析逻辑本身。在这里我们尝试从Session中获取当前用户的标识如果无法获取到的话提示非法调用的错误如果获取到则返回userId。这样一来Controller中的userId参数就可以自动赋值了。
```
@Slf4j
public class LoginRequiredArgumentResolver implements HandlerMethodArgumentResolver {
//解析哪些参数
@Override
public boolean supportsParameter(MethodParameter methodParameter) {
//匹配参数上具有@LoginRequired注解的参数
return methodParameter.hasParameterAnnotation(LoginRequired.class);
}
@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
//从参数上获得注解
LoginRequired loginRequired = methodParameter.getParameterAnnotation(LoginRequired.class);
//根据注解中的Session Key从Session中查询用户信息
Object object = nativeWebRequest.getAttribute(loginRequired.sessionKey(), NativeWebRequest.SCOPE_SESSION);
if (object == null) {
log.error(&quot;接口 {} 非法调用!&quot;, methodParameter.getMethod().toString());
throw new RuntimeException(&quot;请先登录!&quot;);
}
return object;
}
}
```
当然我们要实现WebMvcConfigurer接口的addArgumentResolvers方法来增加这个自定义的处理器LoginRequiredArgumentResolver
```
SpringBootApplication
public class CommonMistakesApplication implements WebMvcConfigurer {
...
@Override
public void addArgumentResolvers(List&lt;HandlerMethodArgumentResolver&gt; resolvers) {
resolvers.add(new LoginRequiredArgumentResolver());
}
}
```
测试发现,经过这样的实现,登录后所有需要登录的方法都可以一键通过加@LoginRequired注解来拿到用户标识,方便且安全:<br>
<img src="https://static001.geekbang.org/resource/image/44/1e/444b314beb2be68c6574e12d65463b1e.png" alt="">
## 重点回顾
今天,我就“任何客户端的东西都不可信任”这个结论,和你讲解了一些有代表性的错误。
第一,客户端的计算不可信。虽然目前很多项目的前端都是富前端,会做大量的逻辑计算,无需访问服务端接口就可以顺畅完成各种功能,但来自客户端的计算结果不能直接信任。最终在进行业务操作时,客户端只能扮演信息收集的角色,虽然可以将诸如价格等信息传给服务端,但只能用于校对比较,最终要以服务端的计算结果为准。
第二所有来自客户端的参数都需要校验判断合法性。即使我们知道用户是在一个下拉列表选择数据即使我们知道用户通过网页正常操作不可能提交不合法的值服务端也应该进行参数校验防止非法用户绕过浏览器UI页面通过工具直接向服务端提交参数。
第三除了请求Body中的信息请求头里的任何信息同样不能信任。我们要知道来自请求头的IP、Referer和Cookie都有被篡改的可能性相关数据只能用来参考和记录不能用作重要业务逻辑。
第四,如果接口面向外部用户,那么一定不能出现用户标识这样的参数,当前用户的标识一定来自服务端,只有经过身份认证后的用户才会在服务端留下标识。如果你的接口现在面向内部其他服务,那么也要千万小心这样的接口只能内部使用,还可能需要进一步考虑服务端调用方的授权问题。
安全问题是木桶效应,整个系统的安全等级取决于安全性最薄弱的那个模块。在写业务代码的时候,要从我做起,建立最基本的安全意识,从源头杜绝低级安全问题。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在讲述用户标识不能从客户端获取这个要点的时候我提到开发同学可能会因为用户信息未打通而通过前端来传用户ID。那我们有什么好办法来打通不同的系统甚至不同网站的用户标识吗
1. 还有一类和客户端数据相关的漏洞非常重要那就是URL地址中的数据。在把匿名用户重定向到登录页面的时候我们一般会带上redirectUrl这样用户登录后可以快速返回之前的页面。黑客可能会伪造一个活动链接由真实的网站+钓鱼的redirectUrl构成发邮件诱导用户进行登录。用户登录时访问的其实是真的网站所以不容易察觉到redirectUrl是钓鱼网站登录后却来到了钓鱼网站用户可能会不知不觉就把重要信息泄露了。这种安全问题我们叫做开放重定向问题。你觉得从代码层面应该怎么预防开放重定向问题呢
你还遇到过因为信任HTTP请求中客户端传给服务端的信息导致的安全问题吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把今天的内容分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,248 @@
<audio id="audio" title="28 | 安全兜底:涉及钱时,必须考虑防刷、限量和防重" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5d/e0/5dd2bb30f551cde52711cf7486a92be0.mp3"></audio>
你好,我是朱晔。今天,我要和你分享的主题是,任何涉及钱的代码必须要考虑防刷、限量和防重,要做好安全兜底。
涉及钱的代码,主要有以下三类。
第一,代码本身涉及有偿使用的三方服务。如果因为代码本身缺少授权、用量控制而被利用导致大量调用,势必会消耗大量的钱,给公司造成损失。有些三方服务可能采用后付款方式的结算,出现问题后如果没及时发现,下个月结算时就会收到一笔数额巨大的账单。
第二,代码涉及虚拟资产的发放,比如积分、优惠券等。虽然说虚拟资产不直接对应货币,但一般可以在平台兑换具有真实价值的资产。比如,优惠券可以在下单时使用,积分可以兑换积分商城的商品。所以从某种意义上说,虚拟资产就是具有一定价值的钱,但因为不直接涉及钱和外部资金通道,所以容易产生随意性发放而导致漏洞。
第三代码涉及真实钱的进出。比如对用户扣款如果出现非正常的多次重复扣款小则用户投诉、用户流失大则被相关管理机构要求停业整改影响业务。又比如给用户发放返现的付款功能如果出现漏洞造成重复付款涉及B端的可能还好但涉及C端用户的重复付款可能永远无法追回。
前段时间拼多多一夜之间被刷了大量100元无门槛优惠券的事情就是限量和防刷出了问题。
今天,我们就通过三个例子,和你说明如何在代码层面做好安全兜底。
## 开放平台资源的使用需要考虑防刷
我以真实遇到的短信服务被刷案例,和你说说防刷。
有次短信账单月结时发现,之前每个月是几千元的短信费用,这个月突然变为了几万元。查数据库记录发现,之前是每天发送几千条短信验证码,从某天开始突然变为了每天几万条,但注册用户数并没有激增。显然,这是短信接口被刷了。
我们知道,短信验证码服务属于开放性服务,由用户侧触发,且因为是注册验证码所以不需要登录就可以使用。如果我们的发短信接口像这样没有任何防刷的防护,直接调用三方短信通道,就相当于“裸奔”,很容易被短信轰炸平台利用:
```
@GetMapping(&quot;wrong&quot;)
public void wrong() {
sendSMSCaptcha(&quot;13600000000&quot;);
}
private void sendSMSCaptcha(String mobile) {
//调用短信通道
}
```
对于短信验证码这种开放接口程序逻辑内需要有防刷逻辑。好的防刷逻辑是对正常使用的用户毫无影响只有疑似异常使用的用户才会感受到。对于短信验证码有如下4种可行的方式来防刷。
第一种方式,**只有固定的请求头才能发送验证码。**
也就是说我们通过请求头中网页或App客户端传给服务端的一些额外参数来判断请求是不是App发起的。其实这种方式“防君子不防小人”。
比如判断是否存在浏览器或手机型号、设备分辨率请求头。对于那些使用爬虫来抓取短信接口地址的程序来说往往只能抓取到URL而难以分析出请求发送短信还需要的额外请求头可以看作第一道基本防御。
第二种方式,**只有先到过注册页面才能发送验证码。**
对于普通用户来说不管是通过App注册还是H5页面注册一定是先进入注册页面才能看到发送验证码按钮再点击发送。我们可以在页面或界面打开时请求固定的前置接口为这个设备开启允许发送验证码的窗口之后的请求发送验证码才是有效请求。
这种方式可以防御直接绕开固定流程,通过接口直接调用的发送验证码请求,并不会干扰普通用户。
第三种方式,**控制相同手机号的发送次数和发送频次。**
除非是短信无法收到否则用户不太会请求了验证码后不完成注册流程再重新请求。因此我们可以限制同一手机号每天的最大请求次数。验证码的到达需要时间太短的发送间隔没有意义所以我们还可以控制发送的最短间隔。比如我们可以控制相同手机号一天只能发送10次验证码最短发送间隔1分钟。
第四种方式,**增加前置图形验证码。**
短信轰炸平台一般会收集很多免费短信接口,一个接口只会给一个用户发一次短信,所以控制相同手机号发送次数和间隔的方式不够有效。这时,我们可以考虑对用户体验稍微有影响,但也是最有效的方式作为保底,即将弹出图形验证码作为前置。
除了图形验证码,我们还可以使用其他更友好的人机验证手段(比如滑动、点击验证码等),甚至是引入比较新潮的无感知验证码方案(比如,通过判断用户输入手机号的打字节奏,来判断是用户还是机器),来改善用户体验。
此外我们也可以考虑在监测到异常的情况下再弹出人机检测。比如短时间内大量相同远端IP发送验证码的时候才会触发人机检测。
总之,我们要确保,只有正常用户经过正常的流程才能使用开放平台资源,并且资源的用量在业务需求合理范围内。此外,还需要考虑做好短信发送量的实时监控,遇到发送量激增要及时报警。
接下来,我们一起看看限量的问题。
## 虚拟资产并不能凭空产生无限使用
虚拟资产虽然是平台方自己生产和控制但如果生产出来可以立即使用就有立即变现的可能性。比如因为平台Bug有大量用户领取高额优惠券并立即下单使用。
在商家看来,这很可能只是一个用户支付的订单,并不会感知到用户使用平台方优惠券的情况;同时,因为平台和商家是事后结算的,所以会马上安排发货。而发货后基本就不可逆了,一夜之间造成了大量资金损失。
我们从代码层面模拟一个优惠券被刷的例子。
假设有一个CouponCenter类负责优惠券的产生和发放。如下是错误做法只要调用方需要就可以凭空产生无限的优惠券
```
@Slf4j
public class CouponCenter {
//用于统计发了多少优惠券
AtomicInteger totalSent = new AtomicInteger(0);
public void sendCoupon(Coupon coupon) {
if (coupon != null)
totalSent.incrementAndGet();
}
public int getTotalSentCoupon() {
return totalSent.get();
}
//没有任何限制,来多少请求生成多少优惠券
public Coupon generateCouponWrong(long userId, BigDecimal amount) {
return new Coupon(userId, amount);
}
}
```
这样一来使用CouponCenter的generateCouponWrong方法想发多少优惠券就可以发多少
```
@GetMapping(&quot;wrong&quot;)
public int wrong() {
CouponCenter couponCenter = new CouponCenter();
//发送10000个优惠券
IntStream.rangeClosed(1, 10000).forEach(i -&gt; {
Coupon coupon = couponCenter.generateCouponWrong(1L, new BigDecimal(&quot;100&quot;));
couponCenter.sendCoupon(coupon);
});
return couponCenter.getTotalSentCoupon();
}
```
**更合适的做法是,把优惠券看作一种资源,其生产不是凭空的,而是需要事先申请**,理由是:
- 虚拟资产如果最终可以对应到真实金钱上的优惠,那么,能发多少取决于运营和财务的核算,应该是有计划、有上限的。引言提到的无门槛优惠券,需要特别小心。有门槛优惠券的大量使用至少会带来大量真实的消费,而使用无门槛优惠券下的订单,可能用户一分钱都没有支付。
- 即使虚拟资产不值钱,大量不合常规的虚拟资产流入市场,也会冲垮虚拟资产的经济体系,造成虚拟货币的极速贬值。有量的控制才有价值。
- 资产的申请需要理由,甚至需要走流程,这样才可以追溯是什么活动需要、谁提出的申请,程序依据申请批次来发放。
接下来,我们按照这个思路改进一下程序。
首先定义一个CouponBatch类要产生优惠券必须先向运营申请优惠券批次批次中包含了固定张数的优惠券、申请原因等信息
```
//优惠券批次
@Data
public class CouponBatch {
private long id;
private AtomicInteger totalCount;
private AtomicInteger remainCount;
private BigDecimal amount;
private String reason;
}
```
在业务需要发放优惠券的时候,先申请批次,然后再通过批次发放优惠券:
```
@GetMapping(&quot;right&quot;)
public int right() {
CouponCenter couponCenter = new CouponCenter();
//申请批次
CouponBatch couponBatch = couponCenter.generateCouponBatch();
IntStream.rangeClosed(1, 10000).forEach(i -&gt; {
Coupon coupon = couponCenter.generateCouponRight(1L, couponBatch);
//发放优惠券
couponCenter.sendCoupon(coupon);
});
return couponCenter.getTotalSentCoupon();
}
```
可以看到generateCouponBatch方法申请批次时设定了这个批次包含100张优惠券。在通过generateCouponRight方法发放优惠券时每发一次都会从批次中扣除一张优惠券发完了就没有了
```
public Coupon generateCouponRight(long userId, CouponBatch couponBatch) {
if (couponBatch.getRemainCount().decrementAndGet() &gt;= 0) {
return new Coupon(userId, couponBatch.getAmount());
} else {
log.info(&quot;优惠券批次 {} 剩余优惠券不足&quot;, couponBatch.getId());
return null;
}
}
public CouponBatch generateCouponBatch() {
CouponBatch couponBatch = new CouponBatch();
couponBatch.setAmount(new BigDecimal(&quot;100&quot;));
couponBatch.setId(1L);
couponBatch.setTotalCount(new AtomicInteger(100));
couponBatch.setRemainCount(couponBatch.getTotalCount());
couponBatch.setReason(&quot;XXX活动&quot;);
return couponBatch;
}
```
这样改进后的程序一个批次最多只能发放100张优惠券<br>
<img src="https://static001.geekbang.org/resource/image/c9/cb/c971894532afd5f5150a6ab2fc0833cb.png" alt="">
因为是Demo所以我们只是凭空new出来一个Coupon。在真实的生产级代码中一定是根据CouponBatch在数据库中插入一定量的Coupon记录每一个优惠券都有唯一的ID可跟踪、可注销。
最后,我们再看看防重。
## 钱的进出一定要和订单挂钩并且实现幂等
涉及钱的进出,需要做好以下两点。
第一,**任何资金操作都需要在平台侧生成业务属性的订单,可以是优惠券发放订单,可以是返现订单,也可以是借款订单,一定是先有订单再去做资金操作**。同时,订单的产生需要有业务属性。业务属性是指,订单不是凭空产生的,否则就没有控制的意义。比如,返现发放订单必须关联到原先的商品订单产生;再比如,借款订单必须关联到同一个借款合同产生。
第二,**一定要做好防重,也就是实现幂等处理,并且幂等处理必须是全链路的**。这里的全链路是指,从前到后都需要有相同的业务订单号来贯穿,实现最终的支付防重。
关于这两点,你可以参考下面的代码示例:
```
//错误每次使用UUID作为订单号
@GetMapping(&quot;wrong&quot;)
public void wrong(@RequestParam(&quot;orderId&quot;) String orderId) {
PayChannel.pay(UUID.randomUUID().toString(), &quot;123&quot;, new BigDecimal(&quot;100&quot;));
}
//正确:使用相同的业务订单号
@GetMapping(&quot;right&quot;)
public void right(@RequestParam(&quot;orderId&quot;) String orderId) {
PayChannel.pay(orderId, &quot;123&quot;, new BigDecimal(&quot;100&quot;));
}
//三方支付通道
public class PayChannel {
public static void pay(String orderId, String account, BigDecimal amount) {
...
}
}
```
对于支付操作,我们一定是调用三方支付公司的接口或银行接口进行处理的。一般而言,这些接口都会有商户订单号的概念,对于相同的商户订单号,无法进行重复的资金处理,所以三方公司的接口可以实现唯一订单号的幂等处理。
但是,业务系统在实现资金操作时容易犯的错是,没有自始至终地使用一个订单号作为商户订单号,透传给三方支付接口。出现这个问题的原因是,比较大的互联网公司一般会把支付独立一个部门。支付部门可能会针对支付做聚合操作,内部会维护一个支付订单号,然后使用支付订单号和三方支付接口交互。最终虽然商品订单是一个,但支付订单是多个,相同的商品订单因为产生多个支付订单导致多次支付。
如果说,支付出现了重复扣款,我们可以给用户进行退款操作,但给用户付款的操作一旦出现重复付款,就很难把钱追回来了,所以更要小心。
这,就是全链路的意义,从一开始就需要先有业务订单产生,然后使用相同的业务订单号一直贯穿到最后的资金通路,才能真正避免重复资金操作。
## 重点回顾
今天,我从安全兜底聊起,和你分享了涉及钱的业务最需要做的三方面工作,防刷、限量和防重。
第一,使用开放的、面向用户的平台资源要考虑防刷,主要包括正常使用流程识别、人机识别、单人限量和全局限量等手段。
第二,虚拟资产不能凭空产生,一定是先有发放计划、申请批次,然后通过批次来生产资产。这样才能达到限量、有审计、能追溯的目的。
第三,真实钱的进出操作要额外小心,做好防重处理。不能凭空去操作用户的账户,每次操作以真实的订单作为依据,通过业务订单号实现全链路的幂等控制。
如果程序逻辑涉及有价值的资源或是真实的钱,我们必须有敬畏之心。程序上线后,人是有休息时间的,但程序是一直运行着的,如果产生安全漏洞,就很可能在一夜之间爆发,被大量人利用导致大量的金钱损失。
除了在流程上做好防刷、限量和防重控制之外,我们还需要做好三方平台调用量、虚拟资产使用量、交易量、交易金额等重要数据的监控报警,这样即使出现问题也能第一时间发现。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 防重、防刷都是事前手段,如果我们的系统正在被攻击或利用,你有什么办法及时发现问题吗?
1. 任何三方资源的使用一般都会定期对账,如果在对账中发现我们系统记录的调用量低于对方系统记录的使用量,你觉得一般是什么问题引起的呢?
有关安全兜底,你还有什么心得吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,743 @@
<audio id="audio" title="29 | 数据和代码:数据就是数据,代码就是代码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4a/6c/4a906366dfc1d7d0a4063d51dbb93d6c.mp3"></audio>
你好,我是朱晔。今天,我来和你聊聊数据和代码的问题。
正如这一讲标题“数据就是数据代码就是代码”所说Web安全方面的很多漏洞都是源自把数据当成了代码来执行也就是注入类问题比如
- 客户端提供给服务端的查询值是一个数据会成为SQL查询的一部分。黑客通过修改这个值注入一些SQL来达到在服务端运行SQL的目的相当于把查询条件的数据变为了查询代码。这种攻击方式叫做SQL注入。
- 对于规则引擎我们可能会用动态语言做一些计算和SQL注入一样外部传入的数据只能当做数据使用如果被黑客利用传入了代码那么代码可能就会被动态执行。这种攻击方式叫做代码注入。
- 对于用户注册、留言评论等功能服务端会从客户端收集一些信息本来用户名、邮箱这类信息是纯文本信息但是黑客把信息替换为了JavaScript代码。那么这些信息在页面呈现时可能就相当于执行了JavaScript代码。甚至是服务端可能把这样的代码当作普通信息保存到了数据库。黑客通过构建JavaScript代码来实现修改页面呈现、盗取信息甚至蠕虫攻击的方式叫做XSS跨站脚本攻击。
今天,我们就通过案例来看一下这三个问题,并了解下应对方式。
## SQL注入能干的事情比你想象的更多
我们应该都听说过SQL注入也可能知道最经典的SQL注入的例子是通过构造or1='1作为密码实现登录。这种简单的攻击方式在十几年前可以突破很多后台的登录但现在很难奏效了。
最近几年我们的安全意识增强了都知道使用参数化查询来避免SQL注入问题。其中的原理是使用参数化查询的话参数只能作为普通数据不可能作为SQL的一部分以此有效避免SQL注入问题。
虽然我们已经开始关注SQL注入的问题但还是有一些认知上的误区主要表现在以下三个方面
第一,**认为SQL注入问题只可能发生于Http Get请求也就是通过URL传入的参数才可能产生注入点**。这是很危险的想法。从注入的难易度上来说修改URL上的QueryString和修改Post请求体中的数据没有任何区别因为黑客是通过工具来注入的而不是通过修改浏览器上的URL来注入的。甚至Cookie都可以用来SQL注入任何提供数据的地方都可能成为注入点。
第二,**认为不返回数据的接口,不可能存在注入问题**。其实黑客完全可以利用SQL语句构造出一些不正确的SQL导致执行出错。如果服务端直接显示了错误信息那黑客需要的数据就有可能被带出来从而达到查询数据的目的。甚至是即使没有详细的出错信息黑客也可以通过所谓盲注的方式进行攻击。我后面再具体解释。
第三,**认为SQL注入的影响范围只是通过短路实现突破登录只需要登录操作加强防范即可**。首先SQL注入完全可以实现拖库也就是下载整个数据库的内容之后我们会演示SQL注入的危害不仅仅是突破后台登录。其次根据木桶原理整个站点的安全性受限于安全级别最低的那块短板。因此对于安全问题站点的所有模块必须一视同仁并不是只加强防范所谓的重点模块。
在日常开发中虽然我们是使用框架来进行数据访问的但还可能会因为疏漏而导致注入问题。接下来我就用一个实际的例子配合专业的SQL注入工具[sqlmap](https://github.com/sqlmapproject/sqlmap)来测试下SQL注入。
首先在程序启动的时候使用JdbcTemplate创建一个userdata表表中只有ID、用户名、密码三列并初始化两条用户信息。然后创建一个不返回任何数据的Http Post接口。在实现上我们通过SQL拼接的方式把传入的用户名入参拼接到LIKE子句中实现模糊查询。
```
//程序启动时进行表结构和数据初始化
@PostConstruct
public void init() {
//删除表
jdbcTemplate.execute(&quot;drop table IF EXISTS `userdata`;&quot;);
//创建表不包含自增ID、用户名、密码三列
jdbcTemplate.execute(&quot;create TABLE `userdata` (\n&quot; +
&quot; `id` bigint(20) NOT NULL AUTO_INCREMENT,\n&quot; +
&quot; `name` varchar(255) NOT NULL,\n&quot; +
&quot; `password` varchar(255) NOT NULL,\n&quot; +
&quot; PRIMARY KEY (`id`)\n&quot; +
&quot;) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;&quot;);
//插入两条测试数据
jdbcTemplate.execute(&quot;INSERT INTO `userdata` (name,password) VALUES ('test1','haha1'),('test2','haha2')&quot;);
}
@Autowired
private JdbcTemplate jdbcTemplate;
//用户模糊搜索接口
@PostMapping(&quot;jdbcwrong&quot;)
public void jdbcwrong(@RequestParam(&quot;name&quot;) String name) {
//采用拼接SQL的方式把姓名参数拼到LIKE子句中
log.info(&quot;{}&quot;, jdbcTemplate.queryForList(&quot;SELECT id,name FROM userdata WHERE name LIKE '%&quot; + name + &quot;%'&quot;));
}
```
使用sqlmap来探索这个接口
```
python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test
```
一段时间后sqlmap给出了如下结果
<img src="https://static001.geekbang.org/resource/image/2f/59/2f8e8530dd0f76778c45333adfad5259.png" alt="">
可以看到这个接口的name参数有两种可能的注入方式一种是报错注入一种是基于时间的盲注。
接下来,**仅需简单的三步,就可以直接导出整个用户表的内容了**。
第一步,查询当前数据库:
```
python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test --current-db
```
可以得到当前数据库是common_mistakes
```
current database: 'common_mistakes'
```
第二步,查询数据库下的表:
```
python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test --tables -D &quot;common_mistakes&quot;
```
可以看到其中有一个敏感表userdata
```
Database: common_mistakes
[7 tables]
+--------------------+
| user |
| common_store |
| hibernate_sequence |
| m |
| news |
| r |
| userdata |
+--------------------+
```
第三步查询userdata的数据
```
python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test -D &quot;common_mistakes&quot; -T &quot;userdata&quot; --dump
```
你看,**用户密码信息一览无遗。当然,你也可以继续查看其他表的数据**
```
Database: common_mistakes
Table: userdata
[2 entries]
+----+-------+----------+
| id | name | password |
+----+-------+----------+
| 1 | test1 | haha1 |
| 2 | test2 | haha2 |
+----+-------+----------+
```
在日志中可以看到sqlmap实现拖库的方式是让SQL执行后的出错信息包含字段内容。注意看下错误日志的第二行错误信息中包含ID为2的用户的密码字段的值“haha2”。这就是报错注入的基本原理
```
[13:22:27.375] [http-nio-45678-exec-10] [ERROR] [o.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.DuplicateKeyException: StatementCallback; SQL [SELECT id,name FROM userdata WHERE name LIKE '%test'||(SELECT 0x694a6e64 WHERE 3941=3941 AND (SELECT 9927 FROM(SELECT COUNT(*),CONCAT(0x71626a7a71,(SELECT MID((IFNULL(CAST(password AS NCHAR),0x20)),1,54) FROM common_mistakes.userdata ORDER BY id LIMIT 1,1),0x7170706271,FLOOR(RAND(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a))||'%']; Duplicate entry 'qbjzqhaha2qppbq1' for key '&lt;group_key&gt;'; nested exception is java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'qbjzqhaha2qppbq1' for key '&lt;group_key&gt;'] with root cause
java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'qbjzqhaha2qppbq1' for key '&lt;group_key&gt;'
```
既然是这样我们就实现一个ExceptionHandler来屏蔽异常看看能否解决注入问题
```
@ExceptionHandler
public void handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
log.warn(String.format(&quot;访问 %s -&gt; %s 出现异常!&quot;, req.getRequestURI(), method.toString()), ex);
}
```
重启程序后重新运行刚才的sqlmap命令可以看到报错注入是没戏了但使用时间盲注还是可以查询整个表的数据
<img src="https://static001.geekbang.org/resource/image/76/c4/76ec4c2217cc5ac190b578e7236dc9c4.png" alt="">
所谓盲注指的是注入后并不能从服务器得到任何执行结果甚至是错误信息只能寄希望服务器对于SQL中的真假条件表现出不同的状态。比如对于布尔盲注来说可能是“真”可以得到200状态码“假”可以得到500错误状态码或者“真”可以得到内容输出“假”得不到任何输出。总之对于不同的SQL注入可以得到不同的输出即可。
在这个案例中因为接口没有输出也彻底屏蔽了错误布尔盲注这招儿行不通了。那么退而求其次的方式就是时间盲注。也就是说通过在真假条件中加入SLEEP来实现通过判断接口的响应时间知道条件的结果是真还是假。
不管是什么盲注,都是通过真假两种状态来完成的。你可能会好奇,通过真假两种状态如何实现数据导出?
其实你可以想一下我们虽然不能直接查询出password字段的值但可以按字符逐一来查判断第一个字符是否是a、是否是b……查询到h时发现响应变慢了自然知道这就是真的得出第一位就是h。以此类推可以查询出整个值。
所以sqlmap在返回数据的时候也是一个字符一个字符跳出结果的并且时间盲注的整个过程会比报错注入慢许多。
你可以引入[p6spy](https://github.com/p6spy/p6spy)工具打印出所有执行的SQL观察sqlmap构造的一些SQL来分析其中原理
```
&lt;dependency&gt;
&lt;groupId&gt;com.github.gavlyukovskiy&lt;/groupId&gt;
&lt;artifactId&gt;p6spy-spring-boot-starter&lt;/artifactId&gt;
&lt;version&gt;1.6.1&lt;/version&gt;
&lt;/dependency&gt;
```
<img src="https://static001.geekbang.org/resource/image/5d/0d/5d9a582025bb06adf863ae21ccb9280d.png" alt="">
所以说即使屏蔽错误信息错误码也不能彻底防止SQL注入。真正的解决方式还是使用参数化查询让任何外部输入值只可能作为数据来处理。
比如,对于之前那个接口,**在SQL语句中使用“?”作为参数占位符,然后提供参数值。**这样修改后sqlmap也就无能为力了
```
@PostMapping(&quot;jdbcright&quot;)
public void jdbcright(@RequestParam(&quot;name&quot;) String name) {
log.info(&quot;{}&quot;, jdbcTemplate.queryForList(&quot;SELECT id,name FROM userdata WHERE name LIKE ?&quot;, &quot;%&quot; + name + &quot;%&quot;));
}
```
**对于MyBatis来说同样需要使用参数化的方式来写SQL语句。在MyBatis中“#{}”是参数化的方式,“${}”只是占位符替换。**
比如LIKE语句。因为使用“#{}”会为参数带上单引号导致LIKE语法错误所以一些同学会退而求其次选择“${}”的方式,比如:
```
@Select(&quot;SELECT id,name FROM `userdata` WHERE name LIKE '%${name}%'&quot;)
List&lt;UserData&gt; findByNameWrong(@Param(&quot;name&quot;) String name);
```
你可以尝试一下使用sqlmap同样可以实现注入。正确的做法是使用“#{}”来参数化name参数对于LIKE操作可以使用CONCAT函数来拼接%符号:
```
@Select(&quot;SELECT id,name FROM `userdata` WHERE name LIKE CONCAT('%',#{name},'%')&quot;)
List&lt;UserData&gt; findByNameRight(@Param(&quot;name&quot;) String name);
```
又比如IN子句。因为涉及多个元素的拼接一些同学不知道如何处理也可能会选择使用“${}”。因为使用“#{}”会把输入当做一个字符串来对待:
```
&lt;select id=&quot;findByNamesWrong&quot; resultType=&quot;org.geekbang.time.commonmistakes.codeanddata.sqlinject.UserData&quot;&gt;
SELECT id,name FROM `userdata` WHERE name in (${names})
&lt;/select&gt;
```
但是这样直接把外部传入的内容替换到IN内部同样会有注入漏洞
```
@PostMapping(&quot;mybatiswrong2&quot;)
public List mybatiswrong2(@RequestParam(&quot;names&quot;) String names) {
return userDataMapper.findByNamesWrong(names);
}
```
你可以使用下面这条命令测试下:
```
python sqlmap.py -u http://localhost:45678/sqlinject/mybatiswrong2 --data names=&quot;'test1','test2'&quot;
```
最后可以发现有4种可行的注入方式分别是布尔盲注、报错注入、时间盲注和联合查询注入
<img src="https://static001.geekbang.org/resource/image/bd/d3/bdc7a7bcb34b59396f4a99d62425d6d3.png" alt="">
修改方式是给MyBatis传入一个List然后使用其foreach标签来拼接出IN中的内容并确保IN中的每一项都是使用“#{}”来注入参数:
```
@PostMapping(&quot;mybatisright2&quot;)
public List mybatisright2(@RequestParam(&quot;names&quot;) List&lt;String&gt; names) {
return userDataMapper.findByNamesRight(names);
}
&lt;select id=&quot;findByNamesRight&quot; resultType=&quot;org.geekbang.time.commonmistakes.codeanddata.sqlinject.UserData&quot;&gt;
SELECT id,name FROM `userdata` WHERE name in
&lt;foreach collection=&quot;names&quot; item=&quot;item&quot; open=&quot;(&quot; separator=&quot;,&quot; close=&quot;)&quot;&gt;
#{item}
&lt;/foreach&gt;
&lt;/select&gt;
```
修改后这个接口就不会被注入了,你可以自行测试一下。
## 小心动态执行代码时代码注入漏洞
总结下我们刚刚看到的SQL注入漏洞的原因是黑客把SQL攻击代码通过传参混入SQL语句中执行。同样对于任何解释执行的其他语言代码也可以产生类似的注入漏洞。我们看一个动态执行JavaScript代码导致注入漏洞的案例。
现在我们要对用户名实现动态的规则判断通过ScriptEngineManager获得一个JavaScript脚本引擎使用Java代码来动态执行JavaScript代码实现当外部传入的用户名为admin的时候返回1否则返回0
```
private ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
//获得JavaScript脚本引擎
private ScriptEngine jsEngine = scriptEngineManager.getEngineByName(&quot;js&quot;);
@GetMapping(&quot;wrong&quot;)
public Object wrong(@RequestParam(&quot;name&quot;) String name) {
try {
//通过eval动态执行JavaScript脚本这里name参数通过字符串拼接方式混入JavaScript代码
return jsEngine.eval(String.format(&quot;var name='%s'; name=='admin'?1:0;&quot;, name));
} catch (ScriptException e) {
e.printStackTrace();
}
return null;
}
```
这个功能本身没什么问题:
<img src="https://static001.geekbang.org/resource/image/a5/08/a5c253d78b6b40f6e2aa8283732f0408.png" alt="">
但是,如果我们把传入的用户名修改为这样:
```
haha';java.lang.System.exit(0);'
```
就可以达到关闭整个程序的目的。原因是我们直接把代码和数据拼接在了一起。外部如果构造了一个特殊的用户名先闭合字符串的单引号再执行一条System.exit命令的话就可以满足脚本不出错命令被执行。
解决这个问题有两种方式。
第一种方式和解决SQL注入一样需要**把外部传入的条件数据仅仅当做数据来对待。我们可以通过SimpleBindings来绑定参数初始化name变量**,而不是直接拼接代码:
```
@GetMapping(&quot;right&quot;)
public Object right(@RequestParam(&quot;name&quot;) String name) {
try {
//外部传入的参数
Map&lt;String, Object&gt; parm = new HashMap&lt;&gt;();
parm.put(&quot;name&quot;, name);
//name参数作为绑定传给eval方法而不是拼接JavaScript代码
return jsEngine.eval(&quot;name=='admin'?1:0;&quot;, new SimpleBindings(parm));
} catch (ScriptException e) {
e.printStackTrace();
}
return null;
}
```
这样就避免了注入问题:
<img src="https://static001.geekbang.org/resource/image/a0/49/a032842a5e551db18bd45dacf7794a49.png" alt="">
第二种解决方法是使用SecurityManager配合AccessControlContext来构建一个脚本运行的沙箱环境。脚本能执行的所有操作权限是通过setPermissions方法精细化设置的
```
@Slf4j
public class ScriptingSandbox {
private ScriptEngine scriptEngine;
private AccessControlContext accessControlContext;
private SecurityManager securityManager;
private static ThreadLocal&lt;Boolean&gt; needCheck = ThreadLocal.withInitial(() -&gt; false);
public ScriptingSandbox(ScriptEngine scriptEngine) throws InstantiationException {
this.scriptEngine = scriptEngine;
securityManager = new SecurityManager(){
//仅在需要的时候检查权限
@Override
public void checkPermission(Permission perm) {
if (needCheck.get() &amp;&amp; accessControlContext != null) {
super.checkPermission(perm, accessControlContext);
}
}
};
//设置执行脚本需要的权限
setPermissions(Arrays.asList(
new RuntimePermission(&quot;getProtectionDomain&quot;),
new PropertyPermission(&quot;jdk.internal.lambda.dumpProxyClasses&quot;,&quot;read&quot;),
new FilePermission(Shell.class.getProtectionDomain().getPermissions().elements().nextElement().getName(),&quot;read&quot;),
new RuntimePermission(&quot;createClassLoader&quot;),
new RuntimePermission(&quot;accessClassInPackage.jdk.internal.org.objectweb.*&quot;),
new RuntimePermission(&quot;accessClassInPackage.jdk.nashorn.internal.*&quot;),
new RuntimePermission(&quot;accessDeclaredMembers&quot;),
new ReflectPermission(&quot;suppressAccessChecks&quot;)
));
}
//设置执行上下文的权限
public void setPermissions(List&lt;Permission&gt; permissionCollection) {
Permissions perms = new Permissions();
if (permissionCollection != null) {
for (Permission p : permissionCollection) {
perms.add(p);
}
}
ProtectionDomain domain = new ProtectionDomain(new CodeSource(null, (CodeSigner[]) null), perms);
accessControlContext = new AccessControlContext(new ProtectionDomain[]{domain});
}
public Object eval(final String code) {
SecurityManager oldSecurityManager = System.getSecurityManager();
System.setSecurityManager(securityManager);
needCheck.set(true);
try {
//在AccessController的保护下执行脚本
return AccessController.doPrivileged((PrivilegedAction&lt;Object&gt;) () -&gt; {
try {
return scriptEngine.eval(code);
} catch (ScriptException e) {
e.printStackTrace();
}
return null;
}, accessControlContext);
} catch (Exception ex) {
log.error(&quot;抱歉,无法执行脚本 {}&quot;, code, ex);
} finally {
needCheck.set(false);
System.setSecurityManager(oldSecurityManager);
}
return null;
}
```
写一段测试代码使用刚才定义的ScriptingSandbox沙箱工具类来执行脚本
```
@GetMapping(&quot;right2&quot;)
public Object right2(@RequestParam(&quot;name&quot;) String name) throws InstantiationException {
//使用沙箱执行脚本
ScriptingSandbox scriptingSandbox = new ScriptingSandbox(jsEngine);
return scriptingSandbox.eval(String.format(&quot;var name='%s'; name=='admin'?1:0;&quot;, name));
}
```
这次,我们再使用之前的注入脚本调用这个接口:
```
http://localhost:45678/codeinject/right2?name=haha%27;java.lang.System.exit(0);%27
```
可以看到结果中抛出了AccessControlException异常注入攻击失效了
```
[13:09:36.080] [http-nio-45678-exec-1] [ERROR] [o.g.t.c.c.codeinject.ScriptingSandbox:77 ] - 抱歉,无法执行脚本 var name='haha';java.lang.System.exit(0);''; name=='admin'?1:0;
java.security.AccessControlException: access denied (&quot;java.lang.RuntimePermission&quot; &quot;exitVM.0&quot;)
at java.security.AccessControlContext.checkPermission(AccessControlContext.java:472)
at java.lang.SecurityManager.checkPermission(SecurityManager.java:585)
at org.geekbang.time.commonmistakes.codeanddata.codeinject.ScriptingSandbox$1.checkPermission(ScriptingSandbox.java:30)
at java.lang.SecurityManager.checkExit(SecurityManager.java:761)
at java.lang.Runtime.exit(Runtime.java:107)
```
在实际应用中,我们可以考虑同时使用这两种方法,确保代码执行的安全性。
## XSS必须全方位严防死堵
对于业务开发来说XSS的问题同样要引起关注。
XSS问题的根源在于原本是让用户传入或输入正常数据的地方被黑客替换为了JavaScript脚本页面没有经过转义直接显示了这个数据然后脚本就被执行了。更严重的是脚本没有经过转义就保存到了数据库中随后页面加载数据的时候数据中混入的脚本又当做代码执行了。黑客可以利用这个漏洞来盗取敏感数据诱骗用户访问钓鱼网站等。
我们写一段代码测试下。首先服务端定义两个接口其中index接口查询用户名信息返回给xss页面save接口使用@RequestParam注解接收用户名并创建用户保存到数据库然后重定向浏览器到index接口
```
@RequestMapping(&quot;xss&quot;)
@Slf4j
@Controller
public class XssController {
@Autowired
private UserRepository userRepository;
//显示xss页面
@GetMapping
public String index(ModelMap modelMap) {
//查数据库
User user = userRepository.findById(1L).orElse(new User());
//给View提供Model
modelMap.addAttribute(&quot;username&quot;, user.getName());
return &quot;xss&quot;;
}
//保存用户信息
@PostMapping
public String save(@RequestParam(&quot;username&quot;) String username, HttpServletRequest request) {
User user = new User();
user.setId(1L);
user.setName(username);
userRepository.save(user);
//保存完成后重定向到首页
return &quot;redirect:/xss/&quot;;
}
}
//用户类同时作为DTO和Entity
@Entity
@Data
public class User {
@Id
private Long id;
private String name;
}
```
我们使用Thymeleaf模板引擎来渲染页面。模板代码比较简单页面加载的时候会在标签显示用户名用户输入用户名提交后调用save接口创建用户
```
&lt;div style=&quot;font-size: 14px&quot;&gt;
&lt;form id=&quot;myForm&quot; method=&quot;post&quot; th:action=&quot;@{/xss/}&quot;&gt;
&lt;label th:utext=&quot;${username}&quot;/&gt;
&lt;input id=&quot;username&quot; name=&quot;username&quot; size=&quot;100&quot; type=&quot;text&quot;/&gt;
&lt;button th:text=&quot;Register&quot; type=&quot;submit&quot;/&gt;
&lt;/form&gt;
&lt;/div&gt;
```
打开xss页面后在文本框中输入&lt;script&gt;alert(test)&lt;/script&gt;点击Register按钮提交页面会弹出alert对话框
<img src="https://static001.geekbang.org/resource/image/cc/7f/cc50a56d83b3687859a396081346a47f.png" alt="">
<img src="https://static001.geekbang.org/resource/image/c4/71/c4633bc6edc93c98e1d27969f6518571.png" alt="">
并且,脚本被保存到了数据库:
<img src="https://static001.geekbang.org/resource/image/7e/bc/7ed8a0a92059149ed32bae43458307bc.png" alt="">
你可能想到了解决方式就是HTML转码。既然是通过@RequestParam来获取请求参数,那我们定义一个@InitBinder实现数据绑定的时候,对字符串进行转码即可:
```
@ControllerAdvice
public class SecurityAdvice {
@InitBinder
protected void initBinder(WebDataBinder binder) {
//注册自定义的绑定器
binder.registerCustomEditor(String.class, new PropertyEditorSupport() {
@Override
public String getAsText() {
Object value = getValue();
return value != null ? value.toString() : &quot;&quot;;
}
@Override
public void setAsText(String text) {
//赋值时进行HTML转义
setValue(text == null ? null : HtmlUtils.htmlEscape(text));
}
});
}
}
```
的确针对这个场景这种做法是可行的。数据库中保存了转义后的数据因此数据会被当做HTML显示在页面上而不是当做脚本执行
<img src="https://static001.geekbang.org/resource/image/5f/ca/5ff4c92a1571da41ccb804c4232171ca.png" alt="">
<img src="https://static001.geekbang.org/resource/image/88/01/88cedbd1557690157e52010280386801.png" alt="">
但是,这种处理方式犯了一个严重的错误,那就是没有从根儿上来处理安全问题。因为@InitBinder是Spring Web层面的处理逻辑如果有代码不通过@RequestParam来获取数据而是直接从HTTP请求获取数据的话这种方式就不会奏效。比如这样
```
user.setName(request.getParameter(&quot;username&quot;));
```
更合理的解决方式是定义一个servlet Filter通过HttpServletRequestWrapper实现servlet层面的统一参数替换
```
//自定义过滤器
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class XssFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
chain.doFilter(new XssRequestWrapper((HttpServletRequest) request), response);
}
}
public class XssRequestWrapper extends HttpServletRequestWrapper {
public XssRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public String[] getParameterValues(String parameter) {
//获取多个参数值的时候对所有参数值应用clean方法逐一清洁
return Arrays.stream(super.getParameterValues(parameter)).map(this::clean).toArray(String[]::new);
}
@Override
public String getHeader(String name) {
//同样清洁请求头
return clean(super.getHeader(name));
}
@Override
public String getParameter(String parameter) {
//获取参数单一值也要处理
return clean(super.getParameter(parameter));
}
//clean方法就是对值进行HTML转义
private String clean(String value) {
return StringUtils.isEmpty(value)? &quot;&quot; : HtmlUtils.htmlEscape(value);
}
}
```
这样我们就可以实现所有请求参数的HTML转义了。不过这种方式还是不够彻底原因是无法处理通过@RequestBody注解提交的JSON数据。比如有这样一个PUT接口直接保存了客户端传入的JSON User对象
```
@PutMapping
public void put(@RequestBody User user) {
userRepository.save(user);
}
```
通过Postman请求这个接口保存到数据库中的数据还是没有转义
<img src="https://static001.geekbang.org/resource/image/6d/4f/6d8e2b3b68e8a623d039d9d73999a64f.png" alt="">
我们需要自定义一个Jackson反列化器来实现反序列化时的字符串的HTML转义
```
//注册自定义的Jackson反序列器
@Bean
public Module xssModule() {
SimpleModule module = new SimpleModule();
module.module.addDeserializer(String.class, new XssJsonDeserializer());
return module;
}
public class XssJsonDeserializer extends JsonDeserializer&lt;String&gt; {
@Override
public String deserialize(JsonParser jsonParser, DeserializationContext ctxt) throws IOException, JsonProcessingException {
String value = jsonParser.getValueAsString();
if (value != null) {
//对于值进行HTML转义
return HtmlUtils.htmlEscape(value);
}
return value;
}
@Override
public Class&lt;String&gt; handledType() {
return String.class;
}
}
```
这样就实现了既能转义Get/Post通过请求参数提交的数据又能转义请求体中直接提交的JSON数据。
你可能觉得做到这里我们的防范已经很全面了但其实不是。这种只能堵新漏确保新数据进入数据库之前转义。如果因为之前的漏洞数据库中已经保存了一些JavaScript代码那么读取的时候同样可能出问题。因此我们还要实现数据读取的时候也转义。
接下来,我们看一下具体的实现方式。
首先之前我们处理了JSON反序列化问题那么就需要同样处理序列化实现数据从数据库中读取的时候转义否则读出来的JSON可能包含JavaScript代码。
比如我们定义这样一个GET接口以JSON来返回用户信息
```
@GetMapping(&quot;user&quot;)
@ResponseBody
public User query() {
return userRepository.findById(1L).orElse(new User());
}
```
<img src="https://static001.geekbang.org/resource/image/b2/f8/b2f919307e42e79ce78622b305d455f8.png" alt="">
修改之前的SimpleModule加入自定义序列化器并且实现序列化时处理字符串转义
```
//注册自定义的Jackson序列器
@Bean
public Module xssModule() {
SimpleModule module = new SimpleModule();
module.addDeserializer(String.class, new XssJsonDeserializer());
module.addSerializer(String.class, new XssJsonSerializer());
return module;
}
public class XssJsonSerializer extends JsonSerializer&lt;String&gt; {
@Override
public Class&lt;String&gt; handledType() {
return String.class;
}
@Override
public void serialize(String value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
if (value != null) {
//对字符串进行HTML转义
jsonGenerator.writeString(HtmlUtils.htmlEscape(value));
}
}
}
```
可以看到这次读到的JSON也转义了
<img src="https://static001.geekbang.org/resource/image/31/fc/315f67193d1f9efe4b09db85361c53fc.png" alt="">
其次我们还需要处理HTML模板。对于Thymeleaf模板引擎需要注意的是使用th:utext来显示数据是不会进行转义的需要使用th:text
```
&lt;label th:text=&quot;${username}&quot;/&gt;
```
经过修改后即使数据库中已经保存了JavaScript代码呈现的时候也只能作为HTML显示了。现在对于进和出两个方向我们都实现了补漏。
所谓百密总有一疏。为了避免疏漏进一步控制XSS可能带来的危害我们还要考虑一种情况如果需要在Cookie中写入敏感信息的话我们可以开启HttpOnly属性。这样JavaScript代码就无法读取Cookie了即便页面被XSS注入了攻击代码也无法获得我们的Cookie。
写段代码测试一下。定义两个接口其中readCookie接口读取Key为test的CookiewriteCookie接口写入Cookie根据参数HttpOnly确定Cookie是否开启HttpOnly
```
//服务端读取Cookie
@GetMapping(&quot;readCookie&quot;)
@ResponseBody
public String readCookie(@CookieValue(&quot;test&quot;) String cookieValue) {
return cookieValue;
}
//服务端写入Cookie
@GetMapping(&quot;writeCookie&quot;)
@ResponseBody
public void writeCookie(@RequestParam(&quot;httpOnly&quot;) boolean httpOnly, HttpServletResponse response) {
Cookie cookie = new Cookie(&quot;test&quot;, &quot;zhuye&quot;);
//根据httpOnly入参决定是否开启HttpOnly属性
cookie.setHttpOnly(httpOnly);
response.addCookie(cookie);
}
```
可以看到由于test和_ga这两个Cookie不是HttpOnly的。通过document.cookie可以输出这两个Cookie的内容
<img src="https://static001.geekbang.org/resource/image/72/77/726e984d392aa1afc6d7371447700977.png" alt="">
为test这个Cookie启用了HttpOnly属性后就不能被document.cookie读取到了输出中只有_ga一项
<img src="https://static001.geekbang.org/resource/image/1b/0c/1b287474f0666d5a2fde8e9442ae2e0c.png" alt="">
但是服务端可以读取到这个cookie
<img src="https://static001.geekbang.org/resource/image/b2/bd/b25da8d4aa5778798652f9685a93f6bd.png" alt="">
## 重点回顾
今天我通过案例和你具体分析了SQL注入和XSS攻击这两类注入类安全问题。
在学习SQL注入的时候我们通过sqlmap工具看到了几种常用注入方式这可能改变了我们对SQL注入威力的认知对于POST请求、请求没有任何返回数据、请求不会出错的情况下仍然可以完成注入并可以导出数据库的所有数据。
对于SQL注入来说使用参数化的查询是最好的堵漏方式对于JdbcTemplate来说我们可以使用“?”作为参数占位符对于MyBatis来说我们需要使用“#{}”进行参数化处理。
和SQL注入类似的是脚本引擎动态执行代码需要确保外部传入的数据只能作为数据来处理不能和代码拼接在一起只能作为参数来处理。代码和数据之间需要划出清晰的界限否则可能产生代码注入问题。同时我们可以通过设置一个代码的执行沙箱来细化代码的权限这样即便产生了注入问题因为权限受限注入攻击也很难发挥威力。
**随后通过学习XSS案例我们认识到处理安全问题需要确保三点。**
- 第一,要从根本上、从最底层进行堵漏,尽量不要在高层框架层面做,否则堵漏可能不彻底。
- 第二,堵漏要同时考虑进和出,不仅要确保数据存入数据库的时候进行了转义或过滤,还要在取出数据呈现的时候再次转义,确保万无一失。
- 第三除了直接堵漏外我们还可以通过一些额外的手段限制漏洞的威力。比如为Cookie设置HttpOnly属性来防止数据被脚本读取又比如尽可能限制字段的最大保存长度即使出现漏洞也会因为长度问题限制黑客构造复杂攻击脚本的能力。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 在讨论SQL注入案例时最后那次测试我们看到sqlmap返回了4种注入方式。其中布尔盲注、时间盲注和报错注入我都介绍过了。你知道联合查询注入是什么吗
1. 在讨论XSS的时候对于Thymeleaf模板引擎我们知道如何让文本进行HTML转义显示。FreeMarker也是Java中很常用的模板引擎你知道如何处理转义吗
你还遇到过其他类型的注入问题吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,621 @@
<audio id="audio" title="30 | 如何正确保存和传输敏感数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/d6/63978f5797af79797ed9fbbe6b1596d6.mp3"></audio>
你好,我是朱晔。
今天我们从安全角度来聊聊用户名、密码、身份证等敏感信息应该怎么保存和传输。同时你还可以进一步复习加密算法中的散列、对称加密和非对称加密算法以及HTTPS等相关知识。
## 应该怎样保存用户密码?
最敏感的数据恐怕就是用户的密码了。黑客一旦窃取了用户密码,或许就可以登录进用户的账号,消耗其资产、发布不良信息等;更可怕的是,有些用户至始至终都是使用一套密码,密码一旦泄露,就可以被黑客用来登录全网。
为了防止密码泄露,最重要的原则是不要保存用户密码。你可能会觉得很好笑,不保存用户密码,之后用户登录的时候怎么验证?其实,我指的是**不保存原始密码,这样即使拖库也不会泄露用户密码。**
我经常会听到大家说不要明文保存用户密码应该把密码通过MD5加密后保存。这的确是一个正确的方向但这个说法并不准确。
首先MD5其实不是真正的加密算法。所谓加密算法是可以使用密钥把明文加密为密文随后还可以使用密钥解密出明文是双向的。
而MD5是散列、哈希算法或者摘要算法。不管多长的数据使用MD5运算后得到的都是固定长度的摘要信息或指纹信息无法再解密为原始数据。所以MD5是单向的。**最重要的是仅仅使用MD5对密码进行摘要并不安全**。
比如使用如下代码在保持用户信息时对密码进行了MD5计算
```
UserData userData = new UserData();
userData.setId(1L);
userData.setName(name);
//密码字段使用MD5哈希后保存
userData.setPassword(DigestUtils.md5Hex(password));
return userRepository.save(userData);
```
通过输出可以看到密码是32位的MD5
```
&quot;password&quot;: &quot;325a2cc052914ceeb8c19016c091d2ac&quot;
```
到某MD5破解网站上输入这个MD5不到1秒就得到了原始密码
<img src="https://static001.geekbang.org/resource/image/e1/de/e1b3638dea64636494c3dcb0bb9b8ade.png" alt="">
其实你可以想一下虽然MD5不可解密但是我们可以构建一个超大的数据库把所有20位以内的数字和字母组合的密码全部计算一遍MD5存进去需要解密的时候搜索一下MD5就可以得到原始值了。这就是字典表。
目前有些MD5解密网站使用的是彩虹表是一种使用时间空间平衡的技术即可以使用更大的空间来降低破解时间也可以使用更长的破解时间来换取更小的空间。
**此外你可能会觉得多次MD5比较安全其实并不是这样**。比如如下代码使用两次MD5进行摘要
```
userData.setPassword(DigestUtils.md5Hex(DigestUtils.md5Hex( password)));
```
得到下面的MD5
```
&quot;password&quot;: &quot;ebbca84993fe002bac3a54e90d677d09&quot;
```
也可以破解出密码并且破解网站还告知我们这是两次MD5算法
<img src="https://static001.geekbang.org/resource/image/ce/b1/ce87f65a3289e50d4e29754073b7eab1.png" alt="">
所以直接保存MD5后的密码是不安全的。一些同学可能会说还需要加盐。是的但是加盐如果不当还是非常不安全比较重要的有两点。
第一,**不能在代码中写死盐,且盐需要有一定的长度**,比如这样:
```
userData.setPassword(DigestUtils.md5Hex(&quot;salt&quot; + password));
```
得到了如下MD5
```
&quot;password&quot;: &quot;58b1d63ed8492f609993895d6ba6b93a&quot;
```
对于这样一串MD5虽然破解网站上找不到原始密码但是黑客可以自己注册一个账号使用一个简单的密码比如1
```
&quot;password&quot;: &quot;55f312f84e7785aa1efa552acbf251db&quot;
```
然后再去破解网站试一下这个MD5就可以得到原始密码是salt也就知道了盐值是salt
<img src="https://static001.geekbang.org/resource/image/32/ca/321dfe5822da9fe186b17f283bda1fca.png" alt="">
其实,知道盐是什么没什么关系,关键的是我们是在代码里写死了盐,并且盐很短、所有用户都是这个盐。这么做有三个问题:
- 因为盐太短、太简单了如果用户原始密码也很简单那么整个拼起来的密码也很短这样一般的MD5破解网站都可以直接解密这个MD5除去盐就知道原始密码了。
- 相同的盐意味着使用相同密码的用户MD5值是一样的知道了一个用户的密码就可能知道了多个。
- 我们也可以使用这个盐来构建一张彩虹表,虽然会花不少代价,但是一旦构建完成,所有人的密码都可以被破解。
**所以最好是每一个密码都有独立的盐并且盐要长一点比如超过20位**
第二,**虽然说每个人的盐最好不同,但我也不建议将一部分用户数据作为盐。**比如,使用用户名作为盐:
```
userData.setPassword(DigestUtils.md5Hex(name + password));
```
如果世界上所有的系统都是按照这个方案来保存密码那么root、admin这样的用户使用再复杂的密码也总有一天会被破解因为黑客们完全可以针对这些常用用户名来做彩虹表。**所以,盐最好是随机的值,并且是全球唯一的,意味着全球不可能有现成的彩虹表给你用。**
正确的做法是使用全球唯一的、和用户无关的、足够长的随机值作为盐。比如可以使用UUID作为盐把盐一起保存到数据库中
```
userData.setSalt(UUID.randomUUID().toString());
userData.setPassword(DigestUtils.md5Hex(userData.getSalt() + password));
```
并且每次用户修改密码的时候都重新计算盐,重新保存新的密码。你可能会问,盐保存在数据库中,那被拖库了不是就可以看到了吗?难道不应该加密保存吗?
在我看来,盐没有必要加密保存。盐的作用是,防止通过彩虹表快速实现密码“解密”,如果用户的盐都是唯一的,那么生成一次彩虹表只可能拿到一个用户的密码,这样黑客的动力会小很多。
**更好的做法是不要使用像MD5这样快速的摘要算法而是使用慢一点的算法**。比如Spring Security已经废弃了MessageDigestPasswordEncoder推荐使用BCryptPasswordEncoder也就是[BCrypt](https://en.wikipedia.org/wiki/Bcrypt)来进行密码哈希。BCrypt是为保存密码设计的算法相比MD5要慢很多。
写段代码来测试一下MD5以及使用不同代价因子的BCrypt看看哈希一次密码的耗时。
```
private static BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@GetMapping(&quot;performance&quot;)
public void performance() {
StopWatch stopWatch = new StopWatch();
String password = &quot;Abcd1234&quot;;
stopWatch.start(&quot;MD5&quot;);
//MD5
DigestUtils.md5Hex(password);
stopWatch.stop();
stopWatch.start(&quot;BCrypt(10)&quot;);
//代价因子为10的BCrypt
String hash1 = BCrypt.gensalt(10);
BCrypt.hashpw(password, hash1);
System.out.println(hash1);
stopWatch.stop();
stopWatch.start(&quot;BCrypt(12)&quot;);
//代价因子为12的BCrypt
String hash2 = BCrypt.gensalt(12);
BCrypt.hashpw(password, hash2);
System.out.println(hash2);
stopWatch.stop();
stopWatch.start(&quot;BCrypt(14)&quot;);
//代价因子为14的BCrypt
String hash3 = BCrypt.gensalt(14);
BCrypt.hashpw(password, hash3);
System.out.println(hash3);
stopWatch.stop();
log.info(&quot;{}&quot;, stopWatch.prettyPrint());
}
```
可以看到MD5只需要0.8毫秒而三次BCrypt哈希代价因子分别设置为10、12和14耗时分别是82毫秒、312毫秒和1.2秒:
<img src="https://static001.geekbang.org/resource/image/13/46/13241938861dd3ca9ba984776cc90846.png" alt="">
也就是说如果制作8位密码长度的MD5彩虹表需要5个月那么对于BCrypt来说可能就需要几十年大部分黑客应该都没有这个耐心。
我们写一段代码观察下BCryptPasswordEncoder生成的密码哈希的规律
```
@GetMapping(&quot;better&quot;)
public UserData better(@RequestParam(value = &quot;name&quot;, defaultValue = &quot;zhuye&quot;) String name, @RequestParam(value = &quot;password&quot;, defaultValue = &quot;Abcd1234&quot;) String password) {
UserData userData = new UserData();
userData.setId(1L);
userData.setName(name);
//保存哈希后的密码
userData.setPassword(passwordEncoder.encode(password));
userRepository.save(userData);
//判断密码是否匹配
log.info(&quot;match ? {}&quot;, passwordEncoder.matches(password, userData.getPassword()));
return userData;
}
```
我们可以发现三点规律。
第一我们调用encode、matches方法进行哈希、做密码比对的时候不需要传入盐。**BCrypt把盐作为了算法的一部分强制我们遵循安全保存密码的最佳实践。**
第二,生成的盐和哈希后的密码拼在了一起:`$`是字段分隔符,其中第一个`$`后的2a代表算法版本第二个`$`后的10是代价因子默认是10代表2的10次方次哈希第三个`$`后的22个字符是盐再后面是摘要。所以说我们不需要使用单独的数据库字段来保存盐。
```
&quot;password&quot;: &quot;$2a$10$wPWdQwfQO2lMxqSIb6iCROXv7lKnQq5XdMO96iCYCj7boK9pk6QPC&quot;
//格式为:$&lt;ver&gt;$&lt;cost&gt;$&lt;salt&gt;&lt;digest&gt;
```
第三代价因子的值越大BCrypt哈希的耗时越久。因此对于代价因子的值更建议的实践是根据用户的忍耐程度和硬件设置一个尽可能大的值。
最后,我们需要注意的是,虽然黑客已经很难通过彩虹表来破解密码了,但是仍然有可能暴力破解密码,也就是对于同一个用户名使用常见的密码逐一尝试登录。因此,除了做好密码哈希保存的工作外,我们还要建设一套完善的安全防御机制,在感知到暴力破解危害的时候,开启短信验证、图形验证码、账号暂时锁定等防御机制来抵御暴力破解。
## 应该怎么保存姓名和身份证?
我们把姓名和身份证,叫做二要素。
现在互联网非常发达,很多服务都可以在网上办理,很多网站仅仅依靠二要素来确认你是谁。所以,二要素是比较敏感的数据,如果在数据库中明文保存,那么数据库被攻破后,黑客就可能拿到大量的二要素信息。如果这些二要素被用来申请贷款等,后果不堪设想。
之前我们提到的单向散列算法,显然不适合用来加密保存二要素,因为数据无法解密。这个时候,我们需要选择真正的加密算法。可供选择的算法,包括对称加密和非对称加密算法两类。
对称加密算法,是使用相同的密钥进行加密和解密。使用对称加密算法来加密双方的通信的话,双方需要先约定一个密钥,加密方才能加密,接收方才能解密。如果密钥在发送的时候被窃取,那么加密就是白忙一场。因此,这种加密方式的特点是,加密速度比较快,但是密钥传输分发有泄露风险。
非对称加密算法,或者叫公钥密码算法。公钥密码是由一对密钥对构成的,使用公钥或者说加密密钥来加密,使用私钥或者说解密密钥来解密,公钥可以任意公开,私钥不能公开。使用非对称加密的话,通信双方可以仅分享公钥用于加密,加密后的数据没有私钥无法解密。因此,这种加密方式的特点是,加密速度比较慢,但是解决了密钥的配送分发安全问题。
但是,对于保存敏感信息的场景来说,加密和解密都是我们的服务端程序,不太需要考虑密钥的分发安全性,也就是说使用非对称加密算法没有太大的意义。在这里,我们使用对称加密算法来加密数据。
接下来我就重点与你说说对称加密算法。对称加密常用的加密算法有DES、3DES和AES。
虽然现在仍有许多老项目使用了DES算法但我不推荐使用。在1999年的DES挑战赛3中DES密码破解耗时不到一天而现在DES密码破解更快使用DES来加密数据非常不安全。因此**在业务代码中要避免使用DES加密**。
而3DES算法是使用不同的密钥进行三次DES串联调用虽然解决了DES不够安全的问题但是比AES慢也不太推荐。
AES是当前公认的比较安全兼顾性能的对称加密算法。不过严格来说AES并不是实际的算法名称而是算法标准。2000年NIST选拔出Rijndael算法作为AES的标准。
AES有一个重要的特点就是分组加密体制一次只能处理128位的明文然后生成128位的密文。如果要加密很长的明文那么就需要迭代处理而迭代方式就叫做模式。网上很多使用AES来加密的代码使用的是最简单的ECB模式也叫电子密码本模式其基本结构如下
<img src="https://static001.geekbang.org/resource/image/27/8b/27c2534caeefcac4a5dd1a2814957d8b.png" alt="">
可以看到,这种结构有两个风险:明文和密文是一一对应的,如果明文中有重复的分组,那么密文中可以观察到重复,掌握密文的规律;因为每一个分组是独立加密和解密的 ,如果密文分组的顺序,也可以反过来操纵明文,那么就可以实现不解密密文的情况下,来修改明文。
我们写一段代码来测试下。在下面的代码中我们使用ECB模式测试
- 加密一段包含16个字符的字符串得到密文A然后把这段字符串复制一份成为一个32个字符的字符串再进行加密得到密文B。我们验证下密文B是不是重复了一遍的密文A。
- 模拟银行转账的场景,假设整个数据由发送方账号、接收方账号、金额三个字段构成。我们尝试改变密文中数据的顺序来操纵明文。
```
private static final String KEY = &quot;secretkey1234567&quot;; //密钥
//测试ECB模式
@GetMapping(&quot;ecb&quot;)
public void ecb() throws Exception {
Cipher cipher = Cipher.getInstance(&quot;AES/ECB/NoPadding&quot;);
test(cipher, null);
}
//获取加密秘钥帮助方法
private static SecretKeySpec setKey(String secret) {
return new SecretKeySpec(secret.getBytes(), &quot;AES&quot;);
}
//测试逻辑
private static void test(Cipher cipher, AlgorithmParameterSpec parameterSpec) throws Exception {
//初始化Cipher
cipher.init(Cipher.ENCRYPT_MODE, setKey(KEY), parameterSpec);
//加密测试文本
System.out.println(&quot;一次:&quot; + Hex.encodeHexString(cipher.doFinal(&quot;abcdefghijklmnop&quot;.getBytes())));
//加密重复一次的测试文本
System.out.println(&quot;两次:&quot; + Hex.encodeHexString(cipher.doFinal(&quot;abcdefghijklmnopabcdefghijklmnop&quot;.getBytes())));
//下面测试是否可以通过操纵密文来操纵明文
//发送方账号
byte[] sender = &quot;1000000000012345&quot;.getBytes();
//接收方账号
byte[] receiver = &quot;1000000000034567&quot;.getBytes();
//转账金额
byte[] money = &quot;0000000010000000&quot;.getBytes();
//加密发送方账号
System.out.println(&quot;发送方账号:&quot; + Hex.encodeHexString(cipher.doFinal(sender)));
//加密接收方账号
System.out.println(&quot;接收方账号:&quot; + Hex.encodeHexString(cipher.doFinal(receiver)));
//加密金额
System.out.println(&quot;金额:&quot; + Hex.encodeHexString(cipher.doFinal(money)));
//加密完整的转账信息
byte[] result = cipher.doFinal(ByteUtils.concatAll(sender, receiver, money));
System.out.println(&quot;完整数据:&quot; + Hex.encodeHexString(result));
//用于操纵密文的临时字节数组
byte[] hack = new byte[result.length];
//把密文前两段交换
System.arraycopy(result, 16, hack, 0, 16);
System.arraycopy(result, 0, hack, 16, 16);
System.arraycopy(result, 32, hack, 32, 16);
cipher.init(Cipher.DECRYPT_MODE, setKey(KEY), parameterSpec);
//尝试解密
System.out.println(&quot;原始明文:&quot; + new String(ByteUtils.concatAll(sender, receiver, money)));
System.out.println(&quot;操纵密文:&quot; + new String(cipher.doFinal(hack)));
}
```
输出如下:
<img src="https://static001.geekbang.org/resource/image/cd/59/cd506b4cf8a020d4b6077fdfa3b34959.png" alt="">
可以看到:
- 两个相同明文分组产生的密文,就是两个相同的密文分组叠在一起。
- 在不知道密钥的情况下,我们操纵密文实现了对明文数据的修改,对调了发送方账号和接收方账号。
所以说,**ECB模式虽然简单但是不安全不推荐使用**。我们再看一下另一种常用的加密模式CBC模式。
CBC模式在解密或解密之前引入了XOR运算第一个分组使用外部提供的初始化向量IV从第二个分组开始使用前一个分组的数据这样即使明文是一样的加密后的密文也是不同的并且分组的顺序不能任意调换。这就解决了ECB模式的缺陷
<img src="https://static001.geekbang.org/resource/image/79/e8/7955a199e2400adc7ac7577b3712bae8.png" alt="">
我们把之前的代码修改为CBC模式再次进行测试
```
private static final String initVector = &quot;abcdefghijklmnop&quot;; //初始化向量
@GetMapping(&quot;cbc&quot;)
public void cbc() throws Exception {
Cipher cipher = Cipher.getInstance(&quot;AES/CBC/NoPadding&quot;);
IvParameterSpec iv = new IvParameterSpec(initVector.getBytes(&quot;UTF-8&quot;));
test(cipher, iv);
}
```
可以看到,相同的明文字符串复制一遍得到的密文并不是重复两个密文分组,并且调换密文分组的顺序无法操纵明文:
<img src="https://static001.geekbang.org/resource/image/8b/08/8b79074d6533a84c32e48eab3daef808.png" alt="">
其实除了ECB模式和CBC模式外AES算法还有CFB、OFB、CTR模式你可以参考[这里](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation)了解它们的区别。《实用密码学》一书比较推荐的是CBC和CTR模式。还需要注意的是ECB和CBC模式还需要设置合适的填充模式才能处理超过一个分组的数据。
对于敏感数据保存除了选择AES+合适模式进行加密外,我还推荐以下几个实践:
- 不要在代码中写死一个固定的密钥和初始化向量,最好和之前提到的盐一样,是唯一、独立并且每次都变化的。
- 推荐使用独立的加密服务来管控密钥、做加密操作,千万不要把密钥和密文存在一个数据库,加密服务需要设置非常高的管控标准。
- 数据库中不能保存明文的敏感信息,但可以保存脱敏的信息。普通查询的时候,直接查脱敏信息即可。
接下来,我们按照这个策略完成相关代码实现。
第一步对于用户姓名和身份证我们分别保存三个信息脱敏后的明文、密文和加密ID。加密服务加密后返回密文和加密ID随后使用加密ID来请求加密服务进行解密
```
@Data
@Entity
public class UserData {
@Id
private Long id;
private String idcard;//脱敏的身份证
private Long idcardCipherId;//身份证加密ID
private String idcardCipherText;//身份证密文
private String name;//脱敏的姓名
private Long nameCipherId;//姓名加密ID
private String nameCipherText;//姓名密文
}
```
第二步加密服务数据表保存加密ID、初始化向量和密钥。加密服务表中没有密文实现了密文和密钥分离保存
```
@Data
@Entity
public class CipherData {
@Id
@GeneratedValue(strategy = AUTO)
private Long id;
private String iv;//初始化向量
private String secureKey;//密钥
}
```
第三步加密服务使用GCM模式 Galois/Counter Mode的AES-256对称加密算法也就是AES-256-GCM。
这是一种[AEAD](https://tools.ietf.org/html/rfc5116)Authenticated Encryption with Associated Data认证加密算法除了能实现普通加密算法提供的保密性之外还能实现可认证性和密文完整性是目前最推荐的AES模式。
使用类似GCM的AEAD算法进行加解密除了需要提供初始化向量和密钥之外还可以提供一个AAD附加认证数据additional authenticated data用于验证未包含在明文中的附加信息解密时不使用加密时的AAD将解密失败。其实GCM模式的内部使用的就是CTR模式只不过还使用了GMAC签名算法对密文进行签名实现完整性校验。
接下来我们实现基于AES-256-GCM的加密服务包含下面的主要逻辑
- 加密时允许外部传入一个AAD用于认证加密服务每次都会使用新生成的随机值作为密钥和初始化向量。
- 在加密后加密服务密钥和初始化向量保存到数据库中返回加密ID作为本次加密的标识。
- 应用解密时需要提供加密ID、密文和加密时的AAD来解密。加密服务使用加密ID从数据库查询出密钥和初始化向量。
这段逻辑的实现代码比较长,我加了详细注释方便你仔细阅读:
```
@Service
public class CipherService {
//密钥长度
public static final int AES_KEY_SIZE = 256;
//初始化向量长度
public static final int GCM_IV_LENGTH = 12;
//GCM身份认证Tag长度
public static final int GCM_TAG_LENGTH = 16;
@Autowired
private CipherRepository cipherRepository;
//内部加密方法
public static byte[] doEncrypt(byte[] plaintext, SecretKey key, byte[] iv, byte[] aad) throws Exception {
//加密算法
Cipher cipher = Cipher.getInstance(&quot;AES/GCM/NoPadding&quot;);
//Key规范
SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), &quot;AES&quot;);
//GCM参数规范
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
//加密模式
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmParameterSpec);
//设置aad
if (aad != null)
cipher.updateAAD(aad);
//加密
byte[] cipherText = cipher.doFinal(plaintext);
return cipherText;
}
//内部解密方法
public static String doDecrypt(byte[] cipherText, SecretKey key, byte[] iv, byte[] aad) throws Exception {
//加密算法
Cipher cipher = Cipher.getInstance(&quot;AES/GCM/NoPadding&quot;);
//Key规范
SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), &quot;AES&quot;);
//GCM参数规范
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
//解密模式
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmParameterSpec);
//设置aad
if (aad != null)
cipher.updateAAD(aad);
//解密
byte[] decryptedText = cipher.doFinal(cipherText);
return new String(decryptedText);
}
//加密入口
public CipherResult encrypt(String data, String aad) throws Exception {
//加密结果
CipherResult encryptResult = new CipherResult();
//密钥生成器
KeyGenerator keyGenerator = KeyGenerator.getInstance(&quot;AES&quot;);
//生成密钥
keyGenerator.init(AES_KEY_SIZE);
SecretKey key = keyGenerator.generateKey();
//IV数据
byte[] iv = new byte[GCM_IV_LENGTH];
//随机生成IV
SecureRandom random = new SecureRandom();
random.nextBytes(iv);
//处理aad
byte[] aaddata = null;
if (!StringUtils.isEmpty(aad))
aaddata = aad.getBytes();
//获得密文
encryptResult.setCipherText(Base64.getEncoder().encodeToString(doEncrypt(data.getBytes(), key, iv, aaddata)));
//加密上下文数据
CipherData cipherData = new CipherData();
//保存IV
cipherData.setIv(Base64.getEncoder().encodeToString(iv));
//保存密钥
cipherData.setSecureKey(Base64.getEncoder().encodeToString(key.getEncoded()));
cipherRepository.save(cipherData);
//返回本地加密ID
encryptResult.setId(cipherData.getId());
return encryptResult;
}
//解密入口
public String decrypt(long cipherId, String cipherText, String aad) throws Exception {
//使用加密ID找到加密上下文数据
CipherData cipherData = cipherRepository.findById(cipherId).orElseThrow(() -&gt; new IllegalArgumentException(&quot;invlaid cipherId&quot;));
//加载密钥
byte[] decodedKey = Base64.getDecoder().decode(cipherData.getSecureKey());
//初始化密钥
SecretKey originalKey = new SecretKeySpec(decodedKey, 0, decodedKey.length, &quot;AES&quot;);
//加载IV
byte[] decodedIv = Base64.getDecoder().decode(cipherData.getIv());
//处理aad
byte[] aaddata = null;
if (!StringUtils.isEmpty(aad))
aaddata = aad.getBytes();
//解密
return doDecrypt(Base64.getDecoder().decode(cipherText.getBytes()), originalKey, decodedIv, aaddata);
}
}
```
第四步,分别实现加密和解密接口用于测试。
我们可以让用户选择如果需要保护二要素的话就自己输入一个查询密码作为AAD。系统需要读取用户敏感信息的时候还需要用户提供这个密码否则无法解密。这样一来即使黑客拿到了用户数据库的密文、加密服务的密钥和IV也会因为缺少AAD无法解密
```
@Autowired
private CipherService cipherService;
//加密
@GetMapping(&quot;right&quot;)
public UserData right(@RequestParam(value = &quot;name&quot;, defaultValue = &quot;朱晔&quot;) String name,
@RequestParam(value = &quot;idcard&quot;, defaultValue = &quot;300000000000001234&quot;) String idCard,
@RequestParam(value = &quot;aad&quot;, required = false)String aad) throws Exception {
UserData userData = new UserData();
userData.setId(1L);
//脱敏姓名
userData.setName(chineseName(name));
//脱敏身份证
userData.setIdcard(idCard(idCard));
//加密姓名
CipherResult cipherResultName = cipherService.encrypt(name,aad);
userData.setNameCipherId(cipherResultName.getId());
userData.setNameCipherText(cipherResultName.getCipherText());
//加密身份证
CipherResult cipherResultIdCard = cipherService.encrypt(idCard,aad);
userData.setIdcardCipherId(cipherResultIdCard.getId());
userData.setIdcardCipherText(cipherResultIdCard.getCipherText());
return userRepository.save(userData);
}
//解密
@GetMapping(&quot;read&quot;)
public void read(@RequestParam(value = &quot;aad&quot;, required = false)String aad) throws Exception {
//查询用户信息
UserData userData = userRepository.findById(1L).get();
//使用AAD来解密姓名和身份证
log.info(&quot;name : {} idcard : {}&quot;,
cipherService.decrypt(userData.getNameCipherId(), userData.getNameCipherText(),aad),
cipherService.decrypt(userData.getIdcardCipherId(), userData.getIdcardCipherText(),aad));
}
//脱敏身份证
private static String idCard(String idCard) {
String num = StringUtils.right(idCard, 4);
return StringUtils.leftPad(num, StringUtils.length(idCard), &quot;*&quot;);
}
//脱敏姓名
public static String chineseName(String chineseName) {
String name = StringUtils.left(chineseName, 1);
return StringUtils.rightPad(name, StringUtils.length(chineseName), &quot;*&quot;);
```
访问加密接口获得如下结果,可以看到数据库表中只有脱敏数据和密文:
```
{&quot;id&quot;:1,&quot;name&quot;:&quot;朱*&quot;,&quot;idcard&quot;:&quot;**************1234&quot;,&quot;idcardCipherId&quot;:26346,&quot;idcardCipherText&quot;:&quot;t/wIh1XTj00wJP1Lt3aGzSvn9GcqQWEwthN58KKU4KZ4Tw==&quot;,&quot;nameCipherId&quot;:26347,&quot;nameCipherText&quot;:&quot;+gHrk1mWmveBMVUo+CYon8Zjj9QAtw==&quot;}
```
访问解密接口,可以看到解密成功了:
```
[21:46:00.079] [http-nio-45678-exec-6] [INFO ] [o.g.t.c.s.s.StoreIdCardController:102 ] - name : 朱晔 idcard : 300000000000001234
```
如果AAD输入不对会得到如下异常
```
javax.crypto.AEADBadTagException: Tag mismatch!
at com.sun.crypto.provider.GaloisCounterMode.decryptFinal(GaloisCounterMode.java:578)
at com.sun.crypto.provider.CipherCore.finalNoPadding(CipherCore.java:1116)
at com.sun.crypto.provider.CipherCore.fillOutputBuffer(CipherCore.java:1053)
at com.sun.crypto.provider.CipherCore.doFinal(CipherCore.java:853)
at com.sun.crypto.provider.AESCipher.engineDoFinal(AESCipher.java:446)
at javax.crypto.Cipher.doFinal(Cipher.java:2164)
```
经过这样的设计二要素就比较安全了。黑客要查询用户二要素的话需要同时拿到密文、IV+密钥、AAD。而这三者可能由三方掌管要全部拿到比较困难。
## 用一张图说清楚HTTPS
我们知道HTTP协议传输数据使用的是明文。那在传输敏感信息的场景下如果客户端和服务端中间有一个黑客作为中间人拦截请求就可以窃听到这些数据还可以修改客户端传过来的数据。这就是很大的安全隐患。
为解决这个安全隐患有了HTTPS协议。HTTPS=SSL/TLS+HTTP通过使用一系列加密算法来确保信息安全传输以实现数据传输的机密性、完整性和权威性。
- 机密性:使用非对称加密来加密密钥,然后使用密钥来加密数据,既安全又解决了非对称加密大量数据慢的问题。你可以做一个实验来测试两者的差距。
- 完整性:使用散列算法对信息进行摘要,确保信息完整无法被中间人篡改。
- 权威性:使用数字证书,来确保我们是在和合法的服务端通信。
可以看出理解HTTPS的流程将有助于我们理解各种加密算法的区别以及证书的意义。此外SSL/TLS还是混合加密系统的一个典范如果你需要自己开发应用层数据加密系统也可以参考它的流程。
那么我们就来看看HTTPS TLS 1.2连接RSA握手的整个过程吧。
<img src="https://static001.geekbang.org/resource/image/98/7c/982510795a50e4b18808eed81dac647c.png" alt="">
作为准备工作网站管理员需要申请并安装CA证书到服务端。CA证书中包含非对称加密的公钥、网站域名等信息密钥是服务端自己保存的不会在任何地方公开。
建立HTTPS连接的过程首先是TCP握手然后是TLS握手的一系列工作包括
1. 客户端告知服务端自己支持的密码套件比如TLS_RSA_WITH_AES_256_GCM_SHA384其中RSA是密钥交换的方式AES_256_GCM是加密算法SHA384是消息验证摘要算法提供客户端随机数。
1. 服务端应答选择的密码套件,提供服务端随机数。
1. 服务端发送CA证书给客户端客户端验证CA证书后面详细说明
1. 客户端生成PreMasterKey并使用非对称加密+公钥加密PreMasterKey。
1. 客户端把加密后的PreMasterKey传给服务端。
1. 服务端使用非对称加密+私钥解密得到PreMasterKey并使用PreMasterKey+两个随机数生成MasterKey。
1. 客户端也使用PreMasterKey+两个随机数生成MasterKey。
1. 客户端告知服务端之后将进行加密传输。
1. 客户端使用MasterKey配合对称加密算法进行对称加密测试。
1. 服务端也使用MasterKey配合对称加密算法进行对称加密测试。
接下来客户端和服务端的所有通信都是加密通信并且数据通过签名确保无法篡改。你可能会问客户端怎么验证CA证书呢
其实CA证书是一个证书链你可以看一下上图的左边部分
- 从服务端拿到的CA证书是用户证书我们需要通过证书中的签发人信息找到上级中间证书再网上找到根证书。
- 根证书只有为数不多的权威机构才能生成一般预置在OS中根本无法伪造。
- 找到根证书后,提取其公钥来验证中间证书的签名,判断其权威性。
- 最后再拿到中间证书的公钥,验证用户证书的签名。
这,就验证了用户证书的合法性,然后再校验其有效期、域名等信息进一步验证有效性。
总结一下TLS通过巧妙的流程和算法搭配解决了传输安全问题使用对称加密加密数据使用非对称加密算法确保密钥无法被中间人解密使用CA证书链认证确保中间人无法伪造自己的证书和公钥。
如果网站涉及敏感数据的传输必须使用HTTPS协议。作为用户如果你看到网站不是HTTPS的或者看到无效证书警告也不应该继续使用这个网站以免敏感信息被泄露。
## 重点回顾
今天,我们一起学习了如何保存和传输敏感数据。我来带你回顾一下重点内容。
对于数据保存,你需要记住两点:
- 用户密码不能加密保存更不能明文保存需要使用全球唯一的、具有一定长度的、随机的盐配合单向散列算法保存。使用BCrypt算法是一个比较好的实践。
- 诸如姓名和身份证这种需要可逆解密查询的敏感信息,需要使用对称加密算法保存。我的建议是,把脱敏数据和密文保存在业务数据库,独立使用加密服务来做数据加解密;对称加密需要用到的密钥和初始化向量,可以和业务数据库分开保存。
对于数据传输则务必通过SSL/TLS进行传输。对于用于客户端到服务端传输数据的HTTP我们需要使用基于SSL/TLS的HTTPS。对于一些走TCP的RPC服务同样可以使用SSL/TLS来确保传输安全。
最后,我要提醒你的是,如果不确定应该如何实现加解密方案或流程,可以咨询公司内部的安全专家,或是参考业界各大云厂商的方案,切勿自己想当然地去设计流程,甚至创造加密算法。
今天用到的代码我都放在了GitHub上你可以点击[这个链接](https://github.com/JosephZhu1983/java-common-mistakes)查看。
## 思考与讨论
1. 虽然我们把用户名和密码脱敏加密保存在数据库中,但日志中可能还存在明文的敏感数据。你有什么思路在框架或中间件层面,对日志进行脱敏吗?
1. 你知道HTTPS双向认证的目的是什么吗流程上又有什么区别呢
关于各种加密算法,你还遇到过什么坑吗?你又是如何保存敏感数据的呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,137 @@
<audio id="audio" title="答疑篇:安全篇思考题答案合集" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/d9/304913fee45ec6b5390af039b20fccd9.mp3"></audio>
你好,我是朱晔。
今天我们继续一起分析这门课“安全篇”模块的第27~30讲的课后思考题。这些题目涉及了数据源头、安全兜底、数据和代码、敏感数据相关的4大知识点。
接下来,我们就一一具体分析吧。
### [27 | 数据源头:任何客户端的东西都不可信任](https://time.geekbang.org/column/article/235700)
**问题1**在讲述用户标识不能从客户端获取这个要点的时候我提到开发同学可能会因为用户信息未打通而通过前端来传用户ID。那我们有什么好办法来打通不同的系统甚至不同网站的用户标识吗
答:打通用户在不同系统之间的登录,大致有以下三种方案。
第一种把用户身份放在统一的服务端每一个系统都需要到这个服务端来做登录状态的确认确认后在自己网站的Cookie中保存会话这就是单点登录的做法。这种方案要求所有关联系统都对接一套中央认证服务器中央保存用户会话在未登录的时候跳转到中央认证服务器进行登录或登录状态确认。因此这种方案适合一个公司内部的不同域名下的网站。
第二种把用户身份信息直接放在Token中在客户端任意传递Token由服务端进行校验如果共享密钥话甚至不需要同一个服务端进行校验无需采用中央认证服务器相对比较松耦合典型的标准是JWT。这种方案适合异构系统的跨系统用户认证打通而且相比单点登录的方案用户体验会更好一些。
第三种如果需要打通不同公司系统的用户登录状态那么一般都会采用OAuth 2.0的标准中的授权码模式,基本流程如下:
1. 第三方网站客户端转到授权服务器上送ClientID、重定向地址RedirectUri等信息。
1. 用户在授权服务器进行登录并且进行授权批准(授权批准这步可以配置为自动完成)。
1. 授权完成后,重定向回到之前客户端提供的重定向地址,附上授权码。
1. 第三方网站服务端通过授权码+ClientID+ClientSecret去授权服务器换取Token。这里的Token包含访问Token和刷新Token访问Token过期后用刷新Token去获得新的访问Token。
因为我们不会对外暴露ClientSecret也不会对外暴露访问Token同时使用授权码换取Token的过程是服务端进行的客户端拿到的只是一次性的授权码所以这种模式比较安全。
**问题2**还有一类和客户端数据相关的漏洞非常重要那就是URL地址中的数据。在把匿名用户重定向到登录页面的时候我们一般会带上redirectUrl这样用户登录后可以快速返回之前的页面。黑客可能会伪造一个活动链接由真实的网站+钓鱼的redirectUrl构成发邮件诱导用户进行登录。用户登录时访问的其实是真的网站所以不容易察觉到redirectUrl是钓鱼网站登录后却来到了钓鱼网站用户可能会不知不觉就把重要信息泄露了。这种安全问题我们叫做开放重定向问题。你觉得从代码层面应该怎么预防开放重定向问题呢
答:要从代码层面预防开放重定向问题,有以下三种做法可供参考:
- 第一种固定重定向的目标URL。
- 第二种可采用编号方式指定重定向的目标URL也就是重定向的目标URL只能是在我们的白名单内的。
- 第三种,用合理充分的校验方式来校验跳转的目标地址,如果是非己方地址,就告知用户跳转有风险,小心钓鱼网站的威胁。
### [28 | 安全兜底:涉及钱时,必须考虑防刷、限量和防重](https://time.geekbang.org/column/article/237060)
**问题1**防重、防刷都是事前手段,如果我们的系统正在被攻击或利用,你有什么办法及时发现问题吗?
答:对于及时发现系统正在被攻击或利用,监控是较好的手段,关键点在于报警阈值怎么设置。我觉得可以对比昨天同时、上周同时的量,发现差异达到一定百分比报警,而且报警需要有升级机制。此外,有的时候大盘很大的话,活动给整个大盘带来的变化不明显,如果进行整体监控可能出了问题也无法及时发现,因此可以考虑对于活动做独立的监控报警。
**问题2**任何三方资源的使用一般都会定期对账,如果在对账中发现我们系统记录的调用量低于对方系统记录的使用量,你觉得一般是什么问题引起的呢?
答:我之前遇到的情况是,在事务内调用外部接口,调用超时后本地事务回滚本地就没有留下数据。更合适的做法是:
1. 请求发出之前先记录请求数据提交事务,记录状态为未知。
1. 发布调用外部接口的请求,如果可以拿到明确的结果,则更新数据库中记录的状态为成功或失败。如果出现超时或未知异常,不能假设第三方接口调用失败,需要通过查询接口查询明确的结果。
1. 写一个定时任务补偿数据库中所有未知状态的记录,从第三方接口同步结果。
值得注意的是对账的时候一定要对两边不管哪方数据缺失都可能是因为程序逻辑有bug需要重视。此外任何涉及第三方系统的交互都建议在数据库中保持明细的请求/响应报文方便在出问题的时候定位Bug根因。
### [29 | 数据和代码:数据就是数据,代码就是代码](https://time.geekbang.org/column/article/237139)
**问题1**在讨论SQL注入案例时最后那次测试我们看到sqlmap返回了4种注入方式。其中布尔盲注、时间盲注和报错注入我都介绍过了。你知道联合查询注入是什么吗
联合查询注入也就是通过UNION来实现我们需要的信息露出一般属于回显的注入方式。我们知道UNION可以用于合并两个SELECT查询的结果集因此可以把注入脚本来UNION到原始的SELECT后面。这样就可以查询我们需要的数据库元数据以及表数据了。
注入的关键点在于:
- 第一UNION的两个SELECT语句的列数和字段类型需要一致。
- 第二需要探查UNION后的结果和页面回显呈现数据的对应关系。
**问题2**在讨论XSS的时候对于Thymeleaf模板引擎我们知道如何让文本进行HTML转义显示。FreeMarker也是Java中很常用的模板引擎你知道如何处理转义吗
其实现在大多数的模板引擎都使用了黑名单机制而不是白名单机制来做HTML转义这样更能有效防止XSS漏洞。也就是默认开启HTML转义如果某些情况你不需要转义可以临时关闭。
比如,[FreeMarker](https://freemarker.apache.org/docs/dgui_misc_autoescaping.html)2.3.24以上版本默认对HTML、XHTML、XML等文件类型输出格式设置了各种转义规则你可以使用?no_esc
```
&lt;#-- 假设默认是HTML输出 --&gt;
${'&lt;b&gt;test&lt;/b&gt;'} &lt;#-- 输出: &amp;lt;b&amp;gt;test&amp;lt;/b&amp;gt; --&gt;
${'&lt;b&gt;test&lt;/b&gt;'?no_esc} &lt;#-- 输出: &lt;b&gt;test&lt;/b&gt; --&gt;
```
或noautoesc指示器
```
${'&amp;'} &lt;#-- 输出: &amp;amp; --&gt;
&lt;#noautoesc&gt;
${'&amp;'} &lt;#-- 输出: &amp; --&gt;
...
${'&amp;'} &lt;#-- 输出: &amp; --&gt;
&lt;/#noautoesc&gt;
${'&amp;'} &lt;#-- 输出: &amp;amp; --&gt;
```
来临时关闭转义。又比如,对于模板引擎[Mustache](https://mustache.github.io/mustache.5.html),可以使用三个花括号而不是两个花括号,来取消变量自动转义:
```
模板:
* {{name}}
* {{company}}
* {{{company}}}
数据:
{
&quot;name&quot;: &quot;Chris&quot;,
&quot;company&quot;: &quot;&lt;b&gt;GitHub&lt;/b&gt;&quot;
}
输出:
* Chris
*
* &amp;lt;b&amp;gt;GitHub&amp;lt;/b&amp;gt;
* &lt;b&gt;GitHub&lt;/b&gt;
```
### [30 | 如何正确保存和传输敏感数据?](https://time.geekbang.org/column/article/239150)
**问题1**虽然我们把用户名和密码脱敏加密保存在数据库中,但日志中可能还存在明文的敏感数据。你有什么思路在框架或中间件层面,对日志进行脱敏吗?
如果我们希望在日志的源头进行脱敏那么可以在日志框架层面做。比如对于logback日志框架我们可以自定义MessageConverter通过正则表达式匹配敏感信息脱敏。
需要注意的是,这种方式有两个缺点。
第一,正则表达式匹配敏感信息的格式不一定精确,会出现误杀漏杀的现象。一般来说,这个问题不会很严重。要实现精确脱敏的话,就只能提供各种脱敏工具类,然后让业务应用在日志中记录敏感信息的时候,先手动调用工具类进行脱敏。
第二如果数据量比较大的话脱敏操作可能会增加业务应用的CPU和内存使用甚至会导致应用不堪负荷出现不可用。考虑到目前大部分公司都引入了ELK来集中收集日志并且一般而言都不允许上服务器直接看文件日志因此我们可以考虑在日志收集中间件中比如logstash写过滤器进行脱敏。这样可以把脱敏的消耗转义到ELK体系中不过这种方式同样有第一点提到的字段不精确匹配导致的漏杀误杀的缺点。
**问题2**你知道HTTPS双向认证的目的是什么吗流程上又有什么区别呢
单向认证一般用于Web网站浏览器只需要验证服务端的身份。对于移动端App如果我们希望有更高的安全性可以引入HTTPS双向认证也就是除了客户端验证服务端身份之外服务端也验证客户端的身份。
单向认证和双向认证的流程区别,主要包括以下三个方面。
第一不仅仅服务端需要有CA证书客户端也需要有CA证书。
第二双向认证的流程中客户端校验服务端CA证书之后客户端会把自己的CA证书发给服务端然后服务端需要校验客户端CA证书的真实性。
第三客户端给服务端的消息会使用自己的私钥签名服务端可以使用客户端CA证书中的公钥验签。
这里还想补充一点对于移动应用程序考虑到更强的安全性我们一般也会把服务端的公钥配置在客户端中这种方式的叫做SSL Pinning。也就是说由客户端直接校验服务端证书的合法性而不是通过证书信任链来校验。采用SSL Pinning由于客户端绑定了服务端公钥因此我们无法通过在移动设备上信用根证书实现抓包。不过这种方式的缺点是需要小心服务端CA证书过期后续证书注意不要修改公钥。
好了以上就是咱们整个《Java 业务开发常见错误100例》这门课的30讲正文的思考题答案或者解题思路了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。