You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

34 KiB

29 | 数据和代码:数据就是数据,代码就是代码

你好,我是朱晔。今天,我来和你聊聊数据和代码的问题。

正如这一讲标题“数据就是数据代码就是代码”所说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来测试下SQL注入。

首先在程序启动的时候使用JdbcTemplate创建一个userdata表表中只有ID、用户名、密码三列并初始化两条用户信息。然后创建一个不返回任何数据的Http Post接口。在实现上我们通过SQL拼接的方式把传入的用户名入参拼接到LIKE子句中实现模糊查询。

//程序启动时进行表结构和数据初始化
@PostConstruct
public void init() {
    //删除表
    jdbcTemplate.execute("drop table IF EXISTS `userdata`;");
    //创建表不包含自增ID、用户名、密码三列
    jdbcTemplate.execute("create TABLE `userdata` (\n" +
            "  `id` bigint(20) NOT NULL AUTO_INCREMENT,\n" +
            "  `name` varchar(255) NOT NULL,\n" +
            "  `password` varchar(255) NOT NULL,\n" +
            "  PRIMARY KEY (`id`)\n" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
    //插入两条测试数据
    jdbcTemplate.execute("INSERT INTO `userdata` (name,password) VALUES ('test1','haha1'),('test2','haha2')");
}
@Autowired
private JdbcTemplate jdbcTemplate;

//用户模糊搜索接口
@PostMapping("jdbcwrong")
public void jdbcwrong(@RequestParam("name") String name) {
    //采用拼接SQL的方式把姓名参数拼到LIKE子句中
    log.info("{}", jdbcTemplate.queryForList("SELECT id,name FROM userdata WHERE name LIKE '%" + name + "%'"));
}

使用sqlmap来探索这个接口

python sqlmap.py -u  http://localhost:45678/sqlinject/jdbcwrong --data name=test

一段时间后sqlmap给出了如下结果

可以看到这个接口的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 "common_mistakes"

可以看到其中有一个敏感表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 "common_mistakes" -T "userdata" --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 '<group_key>'; nested exception is java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'qbjzqhaha2qppbq1' for key '<group_key>'] with root cause
java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'qbjzqhaha2qppbq1' for key '<group_key>'

既然是这样我们就实现一个ExceptionHandler来屏蔽异常看看能否解决注入问题

@ExceptionHandler
public void handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
    log.warn(String.format("访问 %s -> %s 出现异常!", req.getRequestURI(), method.toString()), ex);
}

重启程序后重新运行刚才的sqlmap命令可以看到报错注入是没戏了但使用时间盲注还是可以查询整个表的数据

所谓盲注指的是注入后并不能从服务器得到任何执行结果甚至是错误信息只能寄希望服务器对于SQL中的真假条件表现出不同的状态。比如对于布尔盲注来说可能是“真”可以得到200状态码“假”可以得到500错误状态码或者“真”可以得到内容输出“假”得不到任何输出。总之对于不同的SQL注入可以得到不同的输出即可。

在这个案例中因为接口没有输出也彻底屏蔽了错误布尔盲注这招儿行不通了。那么退而求其次的方式就是时间盲注。也就是说通过在真假条件中加入SLEEP来实现通过判断接口的响应时间知道条件的结果是真还是假。

不管是什么盲注,都是通过真假两种状态来完成的。你可能会好奇,通过真假两种状态如何实现数据导出?

其实你可以想一下我们虽然不能直接查询出password字段的值但可以按字符逐一来查判断第一个字符是否是a、是否是b……查询到h时发现响应变慢了自然知道这就是真的得出第一位就是h。以此类推可以查询出整个值。

所以sqlmap在返回数据的时候也是一个字符一个字符跳出结果的并且时间盲注的整个过程会比报错注入慢许多。

你可以引入p6spy工具打印出所有执行的SQL观察sqlmap构造的一些SQL来分析其中原理

<dependency>
    <groupId>com.github.gavlyukovskiy</groupId>
    <artifactId>p6spy-spring-boot-starter</artifactId>
    <version>1.6.1</version>
</dependency>

所以说即使屏蔽错误信息错误码也不能彻底防止SQL注入。真正的解决方式还是使用参数化查询让任何外部输入值只可能作为数据来处理。

比如,对于之前那个接口,**在SQL语句中使用“?”作为参数占位符,然后提供参数值。**这样修改后sqlmap也就无能为力了

@PostMapping("jdbcright")
public void jdbcright(@RequestParam("name") String name) {
    log.info("{}", jdbcTemplate.queryForList("SELECT id,name FROM userdata WHERE name LIKE ?", "%" + name + "%"));
}

对于MyBatis来说同样需要使用参数化的方式来写SQL语句。在MyBatis中“#{}”是参数化的方式,“${}”只是占位符替换。

比如LIKE语句。因为使用“#{}”会为参数带上单引号导致LIKE语法错误所以一些同学会退而求其次选择“${}”的方式,比如:

@Select("SELECT id,name FROM `userdata` WHERE name LIKE '%${name}%'")
List<UserData> findByNameWrong(@Param("name") String name);

你可以尝试一下使用sqlmap同样可以实现注入。正确的做法是使用“#{}”来参数化name参数对于LIKE操作可以使用CONCAT函数来拼接%符号:

@Select("SELECT id,name FROM `userdata` WHERE name LIKE CONCAT('%',#{name},'%')")
List<UserData> findByNameRight(@Param("name") String name);

又比如IN子句。因为涉及多个元素的拼接一些同学不知道如何处理也可能会选择使用“${}”。因为使用“#{}”会把输入当做一个字符串来对待:

<select id="findByNamesWrong" resultType="org.geekbang.time.commonmistakes.codeanddata.sqlinject.UserData">
    SELECT id,name FROM `userdata` WHERE name in (${names})
</select>

但是这样直接把外部传入的内容替换到IN内部同样会有注入漏洞

@PostMapping("mybatiswrong2")
public List mybatiswrong2(@RequestParam("names") String names) {
    return userDataMapper.findByNamesWrong(names);
}

你可以使用下面这条命令测试下:

python sqlmap.py -u  http://localhost:45678/sqlinject/mybatiswrong2 --data names="'test1','test2'"

最后可以发现有4种可行的注入方式分别是布尔盲注、报错注入、时间盲注和联合查询注入

修改方式是给MyBatis传入一个List然后使用其foreach标签来拼接出IN中的内容并确保IN中的每一项都是使用“#{}”来注入参数:

@PostMapping("mybatisright2")
public List mybatisright2(@RequestParam("names") List<String> names) {
    return userDataMapper.findByNamesRight(names);
}

<select id="findByNamesRight" resultType="org.geekbang.time.commonmistakes.codeanddata.sqlinject.UserData">
    SELECT id,name FROM `userdata` WHERE name in
    <foreach collection="names" item="item" open="(" separator="," close=")">
        #{item}
    </foreach>
</select>

修改后这个接口就不会被注入了,你可以自行测试一下。

小心动态执行代码时代码注入漏洞

总结下我们刚刚看到的SQL注入漏洞的原因是黑客把SQL攻击代码通过传参混入SQL语句中执行。同样对于任何解释执行的其他语言代码也可以产生类似的注入漏洞。我们看一个动态执行JavaScript代码导致注入漏洞的案例。

现在我们要对用户名实现动态的规则判断通过ScriptEngineManager获得一个JavaScript脚本引擎使用Java代码来动态执行JavaScript代码实现当外部传入的用户名为admin的时候返回1否则返回0

private ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
//获得JavaScript脚本引擎
private ScriptEngine jsEngine = scriptEngineManager.getEngineByName("js");

@GetMapping("wrong")
public Object wrong(@RequestParam("name") String name) {
    try {
        //通过eval动态执行JavaScript脚本这里name参数通过字符串拼接方式混入JavaScript代码
        return jsEngine.eval(String.format("var name='%s'; name=='admin'?1:0;", name));
    } catch (ScriptException e) {
        e.printStackTrace();
    }
    return null;
}

这个功能本身没什么问题:

但是,如果我们把传入的用户名修改为这样:

haha';java.lang.System.exit(0);'

就可以达到关闭整个程序的目的。原因是我们直接把代码和数据拼接在了一起。外部如果构造了一个特殊的用户名先闭合字符串的单引号再执行一条System.exit命令的话就可以满足脚本不出错命令被执行。

解决这个问题有两种方式。

第一种方式和解决SQL注入一样需要把外部传入的条件数据仅仅当做数据来对待。我们可以通过SimpleBindings来绑定参数初始化name变量,而不是直接拼接代码:

@GetMapping("right")
public Object right(@RequestParam("name") String name) {
    try {
        //外部传入的参数
        Map<String, Object> parm = new HashMap<>();
        parm.put("name", name);
        //name参数作为绑定传给eval方法而不是拼接JavaScript代码
        return jsEngine.eval("name=='admin'?1:0;", new SimpleBindings(parm));
    } catch (ScriptException e) {
        e.printStackTrace();
    }
    return null;
}

这样就避免了注入问题:

第二种解决方法是使用SecurityManager配合AccessControlContext来构建一个脚本运行的沙箱环境。脚本能执行的所有操作权限是通过setPermissions方法精细化设置的

@Slf4j
public class ScriptingSandbox {
    private ScriptEngine scriptEngine;
    private AccessControlContext accessControlContext;

    private SecurityManager securityManager;
    private static ThreadLocal<Boolean> needCheck = ThreadLocal.withInitial(() -> false);

    public ScriptingSandbox(ScriptEngine scriptEngine) throws InstantiationException {
        this.scriptEngine = scriptEngine;
        securityManager = new SecurityManager(){
            //仅在需要的时候检查权限
            @Override
            public void checkPermission(Permission perm) {
                if (needCheck.get() && accessControlContext != null) {
                    super.checkPermission(perm, accessControlContext);
                }
            }
        };
        //设置执行脚本需要的权限
        setPermissions(Arrays.asList(
                new RuntimePermission("getProtectionDomain"),
                new PropertyPermission("jdk.internal.lambda.dumpProxyClasses","read"),
                new FilePermission(Shell.class.getProtectionDomain().getPermissions().elements().nextElement().getName(),"read"),
                new RuntimePermission("createClassLoader"),
                new RuntimePermission("accessClassInPackage.jdk.internal.org.objectweb.*"),
                new RuntimePermission("accessClassInPackage.jdk.nashorn.internal.*"),
                new RuntimePermission("accessDeclaredMembers"),
                new ReflectPermission("suppressAccessChecks")
        ));
    }
    //设置执行上下文的权限
    public void setPermissions(List<Permission> 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<Object>) () -> {
                try {
                    return scriptEngine.eval(code);
                } catch (ScriptException e) {
                    e.printStackTrace();
                }
                return null;
            }, accessControlContext);

        } catch (Exception ex) {
            log.error("抱歉,无法执行脚本 {}", code, ex);
        } finally {
            needCheck.set(false);
            System.setSecurityManager(oldSecurityManager);
        }
        return null;
    }

写一段测试代码使用刚才定义的ScriptingSandbox沙箱工具类来执行脚本

@GetMapping("right2")
public Object right2(@RequestParam("name") String name) throws InstantiationException {
    //使用沙箱执行脚本
    ScriptingSandbox scriptingSandbox = new ScriptingSandbox(jsEngine);
    return scriptingSandbox.eval(String.format("var name='%s'; name=='admin'?1:0;", 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 ("java.lang.RuntimePermission" "exitVM.0")
	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("xss")
@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("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页面后在文本框中输入点击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));
            }
        });
    }
}

的确针对这个场景这种做法是可行的。数据库中保存了转义后的数据因此数据会被当做HTML显示在页面上而不是当做脚本执行

但是,这种处理方式犯了一个严重的错误,那就是没有从根儿上来处理安全问题。因为@InitBinder是Spring Web层面的处理逻辑如果有代码不通过@RequestParam来获取数据而是直接从HTTP请求获取数据的话这种方式就不会奏效。比如这样

user.setName(request.getParameter("username"));

更合理的解决方式是定义一个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)? "" : HtmlUtils.htmlEscape(value);
    }
}    

这样我们就可以实现所有请求参数的HTML转义了。不过这种方式还是不够彻底原因是无法处理通过@RequestBody注解提交的JSON数据。比如有这样一个PUT接口直接保存了客户端传入的JSON User对象

@PutMapping
public void put(@RequestBody User user) {
    userRepository.save(user);
}

通过Postman请求这个接口保存到数据库中的数据还是没有转义

我们需要自定义一个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<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代码。

比如我们定义这样一个GET接口以JSON来返回用户信息

@GetMapping("user")
@ResponseBody
public User query() {
    return userRepository.findById(1L).orElse(new User());
}

修改之前的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<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));
        }
    }
}

可以看到这次读到的JSON也转义了

其次我们还需要处理HTML模板。对于Thymeleaf模板引擎需要注意的是使用th:utext来显示数据是不会进行转义的需要使用th:text

<label th:text="${username}"/>

经过修改后即使数据库中已经保存了JavaScript代码呈现的时候也只能作为HTML显示了。现在对于进和出两个方向我们都实现了补漏。

所谓百密总有一疏。为了避免疏漏进一步控制XSS可能带来的危害我们还要考虑一种情况如果需要在Cookie中写入敏感信息的话我们可以开启HttpOnly属性。这样JavaScript代码就无法读取Cookie了即便页面被XSS注入了攻击代码也无法获得我们的Cookie。

写段代码测试一下。定义两个接口其中readCookie接口读取Key为test的CookiewriteCookie接口写入Cookie根据参数HttpOnly确定Cookie是否开启HttpOnly

//服务端读取Cookie
@GetMapping("readCookie")
@ResponseBody
public String readCookie(@CookieValue("test") String cookieValue) {
    return cookieValue;
}
//服务端写入Cookie
@GetMapping("writeCookie")
@ResponseBody
public void writeCookie(@RequestParam("httpOnly") boolean httpOnly, HttpServletResponse response) {
    Cookie cookie = new Cookie("test", "zhuye");
    //根据httpOnly入参决定是否开启HttpOnly属性
    cookie.setHttpOnly(httpOnly);
    response.addCookie(cookie);
}

可以看到由于test和_ga这两个Cookie不是HttpOnly的。通过document.cookie可以输出这两个Cookie的内容

为test这个Cookie启用了HttpOnly属性后就不能被document.cookie读取到了输出中只有_ga一项

但是服务端可以读取到这个cookie

重点回顾

今天我通过案例和你具体分析了SQL注入和XSS攻击这两类注入类安全问题。

在学习SQL注入的时候我们通过sqlmap工具看到了几种常用注入方式这可能改变了我们对SQL注入威力的认知对于POST请求、请求没有任何返回数据、请求不会出错的情况下仍然可以完成注入并可以导出数据库的所有数据。

对于SQL注入来说使用参数化的查询是最好的堵漏方式对于JdbcTemplate来说我们可以使用“?”作为参数占位符对于MyBatis来说我们需要使用“#{}”进行参数化处理。

和SQL注入类似的是脚本引擎动态执行代码需要确保外部传入的数据只能作为数据来处理不能和代码拼接在一起只能作为参数来处理。代码和数据之间需要划出清晰的界限否则可能产生代码注入问题。同时我们可以通过设置一个代码的执行沙箱来细化代码的权限这样即便产生了注入问题因为权限受限注入攻击也很难发挥威力。

随后通过学习XSS案例我们认识到处理安全问题需要确保三点。

  • 第一,要从根本上、从最底层进行堵漏,尽量不要在高层框架层面做,否则堵漏可能不彻底。
  • 第二,堵漏要同时考虑进和出,不仅要确保数据存入数据库的时候进行了转义或过滤,还要在取出数据呈现的时候再次转义,确保万无一失。
  • 第三除了直接堵漏外我们还可以通过一些额外的手段限制漏洞的威力。比如为Cookie设置HttpOnly属性来防止数据被脚本读取又比如尽可能限制字段的最大保存长度即使出现漏洞也会因为长度问题限制黑客构造复杂攻击脚本的能力。

今天用到的代码我都放在了GitHub上你可以点击这个链接查看。

思考与讨论

  1. 在讨论SQL注入案例时最后那次测试我们看到sqlmap返回了4种注入方式。其中布尔盲注、时间盲注和报错注入我都介绍过了。你知道联合查询注入是什么吗
  2. 在讨论XSS的时候对于Thymeleaf模板引擎我们知道如何让文本进行HTML转义显示。FreeMarker也是Java中很常用的模板引擎你知道如何处理转义吗

你还遇到过其他类型的注入问题吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。