<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注入的例子，是通过构造’or’1’='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的Cookie，writeCookie接口写入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中很常用的模板引擎，你知道如何处理转义吗？

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