Web 安全方面的很多漏洞,都是源自把数据当成了代码来执行,也就是注入类问题,比如:
客户端提供给服务端的查询值,是一个数据,会成为 SQL 查询的一部分。攻击者通过修改这个值注入一些 SQL,来达到在服务端运行 SQL 的目的,相当于把查询条件的数据变为了查询代码。这个叫做SQL 注入。
对于规则引擎,我们可能会用动态语言做一些计算,和 SQL 注入一样外部传入的数据只能当做数据使用,如果被攻击者利用传入了代码,那么代码可能就会被动态执行。这个叫代码注入。
对于用户注册、留言评论这些功能,服务端会从客户端收集一些信息,本来用户名、邮箱这类信息是纯文本信息,但是攻击者把信息替换为了 JavaScript 代码。这些信息在页面呈现时,可能就相当于执行了 JavaScript 代码。而服务端可能把这样的代码,当作普通信息保存到了数据库。攻击者通过构建 JavaScript 代码来实现修改页面呈现、盗取信息,甚至蠕虫攻击的方式,叫做 XSS(跨站脚本)攻击。
一、SQL注入能做的比你想象的还要多
如果你还不知道什么是SQL注入,可以查看我的这篇文章进行了解天天听SQL注入,SQL注入到底是怎么注入的?
SQL注入最经典的例子,就是传入 or 1=1 作为密码实现登录。而这种简单的攻击方式,在十几年前可以突破很多后台系统,但现在不行了。
近几年大家的安全意识增强了,都知道使用参数化查询来避免SQL注入问题(例如mybatis中的#{}取值方式)。但还是有几点需要注意
第一、SQL注入不只是存在于get请求(不只是存在URL传参中)
从注入的复杂程度来说,修改URL和修改post请求的请求体没有区别,因为攻击者是使用工具执行请求,而不是修改浏览器上面的URL执行请求的。甚至cookie也有可能用来注入,任何提供数据的地方都可能称为注入点。
第二、不返回数据的接口同样存在注入问题
攻击者完全可以构造不正确的SQL语句,使执行出错,如果服务端直接显示了错误信息,那攻击者需要的数据很有可能被带出来。即使没有错误信息,攻击者也可以通过盲注的方式进行攻击。
对于错误信息,在开发中服务端应当使用全局错误捕获来拦截所有的异常,并封装好自定义的错误提示返回客户端。
第三、SQL注入不仅仅是可以用来突破登录
SQL注入完全可以实现下载整个数据库的内容。其次根据木桶原理,整个系统的安全性受限于安全级别最低的那块短板。因此对于安全问题,并不是只加强防范某些重点模块就行。
在mybatis中,{}”是参数化的方式,“${}”只是占位符替换。
比如 LIKE 语句。因为使用“#{}”会为参数带上单引号,导致 LIKE 语法错误,所以一些开发人员会退而求其次,选择“${}”的方式,比如
@Select("SELECT id,name FROM `userdata` WHERE name LIKE '%${name}%'")
List<UserData> selectByName(@Param("name") String name);
正确的做法是,使用“#{}”来参数化 name 参数,对于 LIKE 操作可以使用 CONCAT 函数来拼接 % 符号:
@Select("SELECT id,name FROM `userdata` WHERE name LIKE CONCAT('%',#{name},'%')")
List<UserData> selectByNameRight(@Param("name") String name);
或者使用bind标签对原字符串前后绑定百分号后再使用#{}获取绑定后的值使用
二、XSS必须严防丝堵
XSS 问题的根源是,原本是让用户传入或输入正常数据的地方,被攻击者替换为了 JavaScript 脚本,页面没有经过转义直接显示了这个数据,然后脚本就被执行了。更严重的是,脚本没有经过转义就保存到了数据库中,随后页面加载数据的时候,数据中混入的脚本又当做代码执行了。攻击者就可以利用这个漏洞来盗取敏感数据,诱骗用户访问钓鱼网站等。
写一段代码测试下。首先,服务端定义两个接口,其中 index 接口查询用户名信息返回给xss页面,save 接口使用 @RequestParam 注解接收用户名,并创建用户保存到数据库;然后,重定向浏览器到 index 接口
@Controller @RequestMapping("xss") public class XssController { @Resource private UserRepository userRepository; @GetMapping public String index(ModelMap modelMap) { //查数据库 User user = userRepository.findById(1L).orElse(new User()); //给View提供Model modelMap.addAttribute("username", user.getName()); return "xss"; } @PostMapping public String save(@RequestParam("username") String username, HttpServletRequest request) { User user = new User(); user.setId(1L); user.setName(username); userRepository.save(user); //保存完成后重定向到首页 return "redirect:/xss/"; } } //用户类,同时作为DTO和Entity @Entity @Data public class User { @Id private Long id; private String name; }
使用 Thymeleaf 模板引擎渲染页面。代码比较简单,页面加载的时候会在标签显示用户名,用户输入用户名提交后调用 save 接口创建用户
<div style="font-size: 14px"> <form id="myForm" method="post" th:action="@{/xss/}"> <label th:utext="${username}"/> <input id="username" name="username" size="100" type="text"/> <button th:text="Register" type="submit"/> </form> </div>
打开xss页面后,在文本框中输入 <script>alert('test')</script> 点击 Register 按钮提交,页面会弹出 alert 对话框,并且脚本呗保存到了数据库
大家可能想到了,解决方式就是 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() : ""; } @Override public void setAsText(String text) { //赋值时进行HTML转义 setValue(text == null ? null : HtmlUtils.htmlEscape(text)); } }); } }
针对这个场景,此做法确实是可行的,脚本没有被执行,也被转码后保存到了数据库中
但是这种方式并没有从根源解决问题,@InitBinder 是 Spring Web 层面的处理逻辑,如果有代码不通过 @RequestParam 来获取数据,而是直接从 HTTP 请求获取数据的话,这种方式就不会奏效。比如使用request.getParameter()。
更合理的解决方法是顶一个filter,实现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)? "" : HtmlUtils.htmlEscape(value); } }
不过,这种方式还是不够彻底,原因是无法处理通过 @RequestBody 注解提交的 JSON 数据。处理JSON数据,需要自定义一个 Jackson 反列化器,来实现反序列化时的字符串的 HTML 转义
@Bean public Module xssModule() { SimpleModule module = new SimpleModule(); module.module.addDeserializer(String.class, new XssJsonDeserializer()); return module; } public class XssJsonDeserializer extends JsonDeserializer<String> { @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<String> handledType() { return String.class; } }
这样就实现了既能转义 Get/Post 通过请求参数提交的数据,又能转义请求体中直接提交的 JSON 数据。
你可能觉得做到这里,我们的防范已经很全面了,但其实不是。这种只能堵新漏,确保新数据进入数据库之前转义。如果因为之前的漏洞,数据库中已经保存了一些 JavaScript 代码,那么读取的时候同样可能出问题。因此,我们还要实现数据读取的时候也转义。
之前我们处理了 JSON 反序列化问题,那么就需要同样处理序列化,实现数据从数据库中读取的时候转义,否则读出来的 JSON 可能包含 JavaScript 代码。
修改之前的 SimpleModule 加入自定义序列化器,并且实现序列化时处理字符串转义
@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<String> { @Override public Class<String> 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)); } } }
经过修改后,即使数据库中已经保存了 JavaScript 代码,呈现的时候也只能作为 HTML 显示了。现在,对于进和出两个方向,都实现了补漏。