learn.lianglianglee.com/专栏/Java 业务开发常见错误 100 例/29 数据和代码:数据就是数据,代码就是代码.md.html
2022-05-11 18:46:27 +08:00

2595 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<!-- saved from url=(0046)https://kaiiiz.github.io/hexo-theme-book-demo/ -->
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="/static/favicon.png">
<title>29 数据和代码:数据就是数据,代码就是代码.md</title>
<!-- Spectre.css framework -->
<link rel="stylesheet" href="/static/index.css">
<!-- theme css & js -->
<meta name="generator" content="Hexo 4.2.0">
</head>
<body>
<div class="book-container">
<div class="book-sidebar">
<div class="book-brand">
<a href="/">
<img src="/static/favicon.png">
<span>技术文章摘抄</span>
</a>
</div>
<div class="book-menu uncollapsible">
<ul class="uncollapsible">
<li><a href="/" class="current-tab">首页</a></li>
</ul>
<ul class="uncollapsible">
<li><a href="../">上一级</a></li>
</ul>
<ul class="uncollapsible">
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/00 开篇词 业务代码真的会有这么多坑?.md">00 开篇词 业务代码真的会有这么多坑?.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/01 使用了并发工具类库,线程安全就高枕无忧了吗?.md">01 使用了并发工具类库,线程安全就高枕无忧了吗?.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/02 代码加锁:不要让“锁”事成为烦心事.md">02 代码加锁:不要让“锁”事成为烦心事.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/03 线程池:业务代码最常用也最容易犯错的组件.md">03 线程池:业务代码最常用也最容易犯错的组件.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/04 连接池:别让连接池帮了倒忙.md">04 连接池:别让连接池帮了倒忙.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/05 HTTP调用你考虑到超时、重试、并发了吗.md">05 HTTP调用你考虑到超时、重试、并发了吗.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/06 2成的业务代码的Spring声明式事务可能都没处理正确.md">06 2成的业务代码的Spring声明式事务可能都没处理正确.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/07 数据库索引:索引并不是万能药.md">07 数据库索引:索引并不是万能药.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/08 判等问题:程序里如何确定你就是你?.md">08 判等问题:程序里如何确定你就是你?.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/09 数值计算:注意精度、舍入和溢出问题.md">09 数值计算:注意精度、舍入和溢出问题.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/10 集合类坑满地的List列表操作.md">10 集合类坑满地的List列表操作.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/11 空值处理分不清楚的null和恼人的空指针.md">11 空值处理分不清楚的null和恼人的空指针.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/12 异常处理:别让自己在出问题的时候变为瞎子.md">12 异常处理:别让自己在出问题的时候变为瞎子.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/13 日志:日志记录真没你想象的那么简单.md">13 日志:日志记录真没你想象的那么简单.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/14 文件IO实现高效正确的文件读写并非易事.md">14 文件IO实现高效正确的文件读写并非易事.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/15 序列化:一来一回你还是原来的你吗?.md">15 序列化:一来一回你还是原来的你吗?.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/16 用好Java 8的日期时间类少踩一些“老三样”的坑.md">16 用好Java 8的日期时间类少踩一些“老三样”的坑.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/17 别以为“自动挡”就不可能出现OOM.md">17 别以为“自动挡”就不可能出现OOM.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/18 当反射、注解和泛型遇到OOP时会有哪些坑.md">18 当反射、注解和泛型遇到OOP时会有哪些坑.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/19 Spring框架IoC和AOP是扩展的核心.md">19 Spring框架IoC和AOP是扩展的核心.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/20 Spring框架框架帮我们做了很多工作也带来了复杂度.md">20 Spring框架框架帮我们做了很多工作也带来了复杂度.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/21 代码重复:搞定代码重复的三个绝招.md">21 代码重复:搞定代码重复的三个绝招.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/22 接口设计:系统间对话的语言,一定要统一.md">22 接口设计:系统间对话的语言,一定要统一.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/23 缓存设计:缓存可以锦上添花也可以落井下石.md">23 缓存设计:缓存可以锦上添花也可以落井下石.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/24 业务代码写完,就意味着生产就绪了?.md">24 业务代码写完,就意味着生产就绪了?.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/25 异步处理好用,但非常容易用错.md">25 异步处理好用,但非常容易用错.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/26 数据存储NoSQL与RDBMS如何取长补短、相辅相成.md">26 数据存储NoSQL与RDBMS如何取长补短、相辅相成.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/27 数据源头:任何客户端的东西都不可信任.md">27 数据源头:任何客户端的东西都不可信任.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/28 安全兜底:涉及钱时,必须考虑防刷、限量和防重.md">28 安全兜底:涉及钱时,必须考虑防刷、限量和防重.md.html</a>
</li>
<li>
<a class="current-tab" href="/专栏/Java 业务开发常见错误 100 例/29 数据和代码:数据就是数据,代码就是代码.md">29 数据和代码:数据就是数据,代码就是代码.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/30 如何正确保存和传输敏感数据?.md">30 如何正确保存和传输敏感数据?.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/31 加餐1带你吃透课程中Java 8的那些重要知识点.md">31 加餐1带你吃透课程中Java 8的那些重要知识点.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/32 加餐2带你吃透课程中Java 8的那些重要知识点.md">32 加餐2带你吃透课程中Java 8的那些重要知识点.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/33 加餐3定位应用问题排错套路很重要.md">33 加餐3定位应用问题排错套路很重要.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/34 加餐4分析定位Java问题一定要用好这些工具.md">34 加餐4分析定位Java问题一定要用好这些工具.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/35 加餐5分析定位Java问题一定要用好这些工具.md">35 加餐5分析定位Java问题一定要用好这些工具.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/36 加餐6这15年来我是如何在工作中学习技术和英语的.md">36 加餐6这15年来我是如何在工作中学习技术和英语的.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/37 加餐7程序员成长28计.md">37 加餐7程序员成长28计.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/38 加餐8Java程序从虚拟机迁移到Kubernetes的一些坑.md">38 加餐8Java程序从虚拟机迁移到Kubernetes的一些坑.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:代码篇思考题集锦(一).md">答疑篇:代码篇思考题集锦(一).md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:代码篇思考题集锦(三).md">答疑篇:代码篇思考题集锦(三).md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:代码篇思考题集锦(二).md">答疑篇:代码篇思考题集锦(二).md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:加餐篇思考题答案合集.md">答疑篇:加餐篇思考题答案合集.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:安全篇思考题答案合集.md">答疑篇:安全篇思考题答案合集.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/答疑篇:设计篇思考题答案合集.md">答疑篇:设计篇思考题答案合集.md.html</a>
</li>
<li>
<a href="/专栏/Java 业务开发常见错误 100 例/结束语 写代码时,如何才能尽量避免踩坑?.md">结束语 写代码时,如何才能尽量避免踩坑?.md.html</a>
</li>
</ul>
</div>
</div>
<div class="sidebar-toggle" onclick="sidebar_toggle()" onmouseover="add_inner()" onmouseleave="remove_inner()">
<div class="sidebar-toggle-inner"></div>
</div>
<script>
function add_inner() {
let inner = document.querySelector('.sidebar-toggle-inner')
inner.classList.add('show')
}
function remove_inner() {
let inner = document.querySelector('.sidebar-toggle-inner')
inner.classList.remove('show')
}
function sidebar_toggle() {
let sidebar_toggle = document.querySelector('.sidebar-toggle')
let sidebar = document.querySelector('.book-sidebar')
let content = document.querySelector('.off-canvas-content')
if (sidebar_toggle.classList.contains('extend')) { // show
sidebar_toggle.classList.remove('extend')
sidebar.classList.remove('hide')
content.classList.remove('extend')
} else { // hide
sidebar_toggle.classList.add('extend')
sidebar.classList.add('hide')
content.classList.add('extend')
}
}
function open_sidebar() {
let sidebar = document.querySelector('.book-sidebar')
let overlay = document.querySelector('.off-canvas-overlay')
sidebar.classList.add('show')
overlay.classList.add('show')
}
function hide_canvas() {
let sidebar = document.querySelector('.book-sidebar')
let overlay = document.querySelector('.off-canvas-overlay')
sidebar.classList.remove('show')
overlay.classList.remove('show')
}
</script>
<div class="off-canvas-content">
<div class="columns">
<div class="column col-12 col-lg-12">
<div class="book-navbar">
<!-- For Responsive Layout -->
<header class="navbar">
<section class="navbar-section">
<a onclick="open_sidebar()">
<i class="icon icon-menu"></i>
</a>
</section>
</header>
</div>
<div class="book-content" style="max-width: 960px; margin: 0 auto;
overflow-x: auto;
overflow-y: hidden;">
<div class="book-post">
<p id="tip" align="center"></p>
<div><h1>29 数据和代码:数据就是数据,代码就是代码</h1>
<p>你好,我是朱晔。今天,我来和你聊聊数据和代码的问题。</p>
<p>正如这一讲标题“数据就是数据代码就是代码”所说Web 安全方面的很多漏洞,都是源自把数据当成了代码来执行,也就是注入类问题,比如:</p>
<p>客户端提供给服务端的查询值,是一个数据,会成为 SQL 查询的一部分。黑客通过修改这个值注入一些 SQL来达到在服务端运行 SQL 的目的,相当于把查询条件的数据变为了查询代码。这种攻击方式,叫做 SQL 注入。</p>
<p>对于规则引擎,我们可能会用动态语言做一些计算,和 SQL 注入一样外部传入的数据只能当做数据使用,如果被黑客利用传入了代码,那么代码可能就会被动态执行。这种攻击方式,叫做代码注入。</p>
<p>对于用户注册、留言评论等功能,服务端会从客户端收集一些信息,本来用户名、邮箱这类信息是纯文本信息,但是黑客把信息替换为了 JavaScript 代码。那么,这些信息在页面呈现时,可能就相当于执行了 JavaScript 代码。甚至是,服务端可能把这样的代码,当作普通信息保存到了数据库。黑客通过构建 JavaScript 代码来实现修改页面呈现、盗取信息,甚至蠕虫攻击的方式,叫做 XSS跨站脚本攻击。</p>
<p>今天,我们就通过案例来看一下这三个问题,并了解下应对方式。</p>
<h2>SQL 注入能干的事情比你想象的更多</h2>
<p>我们应该都听说过 SQL 注入,也可能知道最经典的 SQL 注入的例子是通过构造or1='1 作为密码实现登录。这种简单的攻击方式,在十几年前可以突破很多后台的登录,但现在很难奏效了。</p>
<p>最近几年,我们的安全意识增强了,都知道使用参数化查询来避免 SQL 注入问题。其中的原理是,使用参数化查询的话,参数只能作为普通数据,不可能作为 SQL 的一部分,以此有效避免 SQL 注入问题。</p>
<p>虽然我们已经开始关注 SQL 注入的问题,但还是有一些认知上的误区,主要表现在以下三个方面:</p>
<p>第一,认为 SQL 注入问题只可能发生于 Http Get 请求,也就是通过 URL 传入的参数才可能产生注入点。这是很危险的想法。从注入的难易度上来说,修改 URL 上的 QueryString 和修改 Post 请求体中的数据,没有任何区别,因为黑客是通过工具来注入的,而不是通过修改浏览器上的 URL 来注入的。甚至 Cookie 都可以用来 SQL 注入,任何提供数据的地方都可能成为注入点。</p>
<p>第二,认为不返回数据的接口,不可能存在注入问题。其实,黑客完全可以利用 SQL 语句构造出一些不正确的 SQL导致执行出错。如果服务端直接显示了错误信息那黑客需要的数据就有可能被带出来从而达到查询数据的目的。甚至是即使没有详细的出错信息黑客也可以通过所谓盲注的方式进行攻击。我后面再具体解释。</p>
<p>第三,认为 SQL 注入的影响范围只是通过短路实现突破登录只需要登录操作加强防范即可。首先SQL 注入完全可以实现拖库也就是下载整个数据库的内容之后我们会演示SQL 注入的危害不仅仅是突破后台登录。其次,根据木桶原理,整个站点的安全性受限于安全级别最低的那块短板。因此,对于安全问题,站点的所有模块必须一视同仁,并不是只加强防范所谓的重点模块。</p>
<p>在日常开发中,虽然我们是使用框架来进行数据访问的,但还可能会因为疏漏而导致注入问题。接下来,我就用一个实际的例子配合专业的 SQL 注入工具sqlmap来测试下 SQL 注入。</p>
<p>首先,在程序启动的时候使用 JdbcTemplate 创建一个 userdata 表(表中只有 ID、用户名、密码三列并初始化两条用户信息。然后创建一个不返回任何数据的 Http Post 接口。在实现上,我们通过 SQL 拼接的方式,把传入的用户名入参拼接到 LIKE 子句中实现模糊查询。</p>
<pre><code>//程序启动时进行表结构和数据初始化
@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;));
}
</code></pre>
<p>使用 sqlmap 来探索这个接口:</p>
<pre><code>python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test
</code></pre>
<p>一段时间后sqlmap 给出了如下结果:</p>
<p><img src="assets/2f8e8530dd0f76778c45333adfad5259.png" alt="img" /></p>
<p>可以看到,这个接口的 name 参数有两种可能的注入方式:一种是报错注入,一种是基于时间的盲注。</p>
<p>接下来,仅需简单的三步,就可以直接导出整个用户表的内容了。</p>
<p>第一步,查询当前数据库:</p>
<pre><code>python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test --current-db
</code></pre>
<p>可以得到当前数据库是 common_mistakes</p>
<pre><code>current database: 'common_mistakes'
</code></pre>
<p>第二步,查询数据库下的表:</p>
<pre><code>python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test --tables -D &quot;common_mistakes&quot;
</code></pre>
<p>可以看到其中有一个敏感表 userdata</p>
<pre><code>Database: common_mistakes
[7 tables]
+--------------------+
| user |
| common_store |
| hibernate_sequence |
| m |
| news |
| r |
| userdata |
+--------------------+
</code></pre>
<p>第三步,查询 userdata 的数据:</p>
<pre><code>python sqlmap.py -u http://localhost:45678/sqlinject/jdbcwrong --data name=test -D &quot;common_mistakes&quot; -T &quot;userdata&quot; --dump
</code></pre>
<p>你看,用户密码信息一览无遗。当然,你也可以继续查看其他表的数据:</p>
<pre><code>Database: common_mistakes
Table: userdata
[2 entries]
+----+-------+----------+
| id | name | password |
+----+-------+----------+
| 1 | test1 | haha1 |
| 2 | test2 | haha2 |
+----+-------+----------+
</code></pre>
<p>在日志中可以看到sqlmap 实现拖库的方式是,让 SQL 执行后的出错信息包含字段内容。注意看下错误日志的第二行,错误信息中包含 ID 为 2 的用户的密码字段的值“haha2”。这就是报错注入的基本原理</p>
<pre><code>[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;'
</code></pre>
<p>既然是这样,我们就实现一个 ExceptionHandler 来屏蔽异常,看看能否解决注入问题:</p>
<pre><code>@ExceptionHandler
public void handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
log.warn(String.format(&quot;访问 %s -&gt; %s 出现异常!&quot;, req.getRequestURI(), method.toString()), ex);
}
</code></pre>
<p>重启程序后重新运行刚才的 sqlmap 命令,可以看到报错注入是没戏了,但使用时间盲注还是可以查询整个表的数据:</p>
<p><img src="assets/76ec4c2217cc5ac190b578e7236dc9c4.png" alt="img" /></p>
<p>所谓盲注,指的是注入后并不能从服务器得到任何执行结果(甚至是错误信息),只能寄希望服务器对于 SQL 中的真假条件表现出不同的状态。比如,对于布尔盲注来说,可能是“真”可以得到 200 状态码,“假”可以得到 500 错误状态码;或者,“真”可以得到内容输出,“假”得不到任何输出。总之,对于不同的 SQL 注入可以得到不同的输出即可。</p>
<p>在这个案例中,因为接口没有输出,也彻底屏蔽了错误,布尔盲注这招儿行不通了。那么退而求其次的方式,就是时间盲注。也就是说,通过在真假条件中加入 SLEEP来实现通过判断接口的响应时间知道条件的结果是真还是假。</p>
<p>不管是什么盲注,都是通过真假两种状态来完成的。你可能会好奇,通过真假两种状态如何实现数据导出?</p>
<p>其实你可以想一下,我们虽然不能直接查询出 password 字段的值,但可以按字符逐一来查,判断第一个字符是否是 a、是否是 b……查询到 h 时发现响应变慢了,自然知道这就是真的,得出第一位就是 h。以此类推可以查询出整个值。</p>
<p>所以sqlmap 在返回数据的时候,也是一个字符一个字符跳出结果的,并且时间盲注的整个过程会比报错注入慢许多。</p>
<p>你可以引入p6spy工具打印出所有执行的 SQL观察 sqlmap 构造的一些 SQL来分析其中原理</p>
<pre><code>&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;
</code></pre>
<p><img src="assets/5d9a582025bb06adf863ae21ccb9280d.png" alt="img" /></p>
<p>所以说,即使屏蔽错误信息错误码,也不能彻底防止 SQL 注入。真正的解决方式,还是使用参数化查询,让任何外部输入值只可能作为数据来处理。</p>
<p>比如,对于之前那个接口,在 SQL 语句中使用“?”作为参数占位符然后提供参数值。这样修改后sqlmap 也就无能为力了:</p>
<pre><code>@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;));
}
</code></pre>
<p>对于 MyBatis 来说,同样需要使用参数化的方式来写 SQL 语句。在 MyBatis 中,“#{}”是参数化的方式,“${}”只是占位符替换。</p>
<p>比如 LIKE 语句。因为使用“#{}”会为参数带上单引号,导致 LIKE 语法错误,所以一些同学会退而求其次,选择“${}”的方式,比如:</p>
<pre><code>@Select(&quot;SELECT id,name FROM `userdata` WHERE name LIKE '%${name}%'&quot;)
List&lt;UserData&gt; findByNameWrong(@Param(&quot;name&quot;) String name);
</code></pre>
<p>你可以尝试一下,使用 sqlmap 同样可以实现注入。正确的做法是,使用“#{}”来参数化 name 参数,对于 LIKE 操作可以使用 CONCAT 函数来拼接 % 符号:</p>
<pre><code>@Select(&quot;SELECT id,name FROM `userdata` WHERE name LIKE CONCAT('%',#{name},'%')&quot;)
List&lt;UserData&gt; findByNameRight(@Param(&quot;name&quot;) String name);
</code></pre>
<p>又比如 IN 子句。因为涉及多个元素的拼接,一些同学不知道如何处理,也可能会选择使用“${}”。因为使用“#{}”会把输入当做一个字符串来对待:</p>
<pre><code>&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;
</code></pre>
<p>但是,这样直接把外部传入的内容替换到 IN 内部,同样会有注入漏洞:</p>
<pre><code>@PostMapping(&quot;mybatiswrong2&quot;)
public List mybatiswrong2(@RequestParam(&quot;names&quot;) String names) {
return userDataMapper.findByNamesWrong(names);
}
</code></pre>
<p>你可以使用下面这条命令测试下:</p>
<pre><code>python sqlmap.py -u http://localhost:45678/sqlinject/mybatiswrong2 --data names=&quot;'test1','test2'&quot;
</code></pre>
<p>最后可以发现,有 4 种可行的注入方式,分别是布尔盲注、报错注入、时间盲注和联合查询注入:</p>
<p><img src="assets/bdc7a7bcb34b59396f4a99d62425d6d3.png" alt="img" /></p>
<p>修改方式是,给 MyBatis 传入一个 List然后使用其 foreach 标签来拼接出 IN 中的内容,并确保 IN 中的每一项都是使用“#{}”来注入参数:</p>
<pre><code>@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;
</code></pre>
<p>修改后这个接口就不会被注入了,你可以自行测试一下。</p>
<h2>小心动态执行代码时代码注入漏洞</h2>
<p>总结下,我们刚刚看到的 SQL 注入漏洞的原因是,黑客把 SQL 攻击代码通过传参混入 SQL 语句中执行。同样,对于任何解释执行的其他语言代码,也可以产生类似的注入漏洞。我们看一个动态执行 JavaScript 代码导致注入漏洞的案例。</p>
<p>现在,我们要对用户名实现动态的规则判断:通过 ScriptEngineManager 获得一个 JavaScript 脚本引擎,使用 Java 代码来动态执行 JavaScript 代码,实现当外部传入的用户名为 admin 的时候返回 1否则返回 0</p>
<pre><code>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;
}
</code></pre>
<p>这个功能本身没什么问题:</p>
<p><img src="assets/a5c253d78b6b40f6e2aa8283732f0408.png" alt="img" /></p>
<p>但是,如果我们把传入的用户名修改为这样:</p>
<pre><code>haha';java.lang.System.exit(0);'
</code></pre>
<p>就可以达到关闭整个程序的目的。原因是,我们直接把代码和数据拼接在了一起。外部如果构造了一个特殊的用户名先闭合字符串的单引号,再执行一条 System.exit 命令的话,就可以满足脚本不出错,命令被执行。</p>
<p>解决这个问题有两种方式。</p>
<p>第一种方式和解决 SQL 注入一样,需要把外部传入的条件数据仅仅当做数据来对待。我们可以通过 SimpleBindings 来绑定参数初始化 name 变量,而不是直接拼接代码:</p>
<pre><code>@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;
}
</code></pre>
<p>这样就避免了注入问题:</p>
<p><img src="assets/a032842a5e551db18bd45dacf7794a49.png" alt="img" /></p>
<p>第二种解决方法是,使用 SecurityManager 配合 AccessControlContext来构建一个脚本运行的沙箱环境。脚本能执行的所有操作权限是通过 setPermissions 方法精细化设置的:</p>
<pre><code>@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;
}
</code></pre>
<p>写一段测试代码,使用刚才定义的 ScriptingSandbox 沙箱工具类来执行脚本:</p>
<pre><code>@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));
}
</code></pre>
<p>这次,我们再使用之前的注入脚本调用这个接口:</p>
<pre><code>http://localhost:45678/codeinject/right2?name=haha%27;java.lang.System.exit(0);%27
</code></pre>
<p>可以看到,结果中抛出了 AccessControlException 异常,注入攻击失效了:</p>
<pre><code>[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)
</code></pre>
<p>在实际应用中,我们可以考虑同时使用这两种方法,确保代码执行的安全性。</p>
<h2>XSS 必须全方位严防死堵</h2>
<p>对于业务开发来说XSS 的问题同样要引起关注。</p>
<p>XSS 问题的根源在于,原本是让用户传入或输入正常数据的地方,被黑客替换为了 JavaScript 脚本,页面没有经过转义直接显示了这个数据,然后脚本就被执行了。更严重的是,脚本没有经过转义就保存到了数据库中,随后页面加载数据的时候,数据中混入的脚本又当做代码执行了。黑客可以利用这个漏洞来盗取敏感数据,诱骗用户访问钓鱼网站等。</p>
<p>我们写一段代码测试下。首先,服务端定义两个接口,其中 index 接口查询用户名信息返回给 xss 页面save 接口使用 @RequestParam 注解接收用户名,并创建用户保存到数据库;然后,重定向浏览器到 index 接口:</p>
<pre><code>@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;
}
</code></pre>
<p>我们使用 Thymeleaf 模板引擎来渲染页面。模板代码比较简单,页面加载的时候会在标签显示用户名,用户输入用户名提交后调用 save 接口创建用户:</p>
<pre><code>&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;
</code></pre>
<p>打开 xss 页面后,在文本框中输入 <script>alert(test)</script> 点击 Register 按钮提交,页面会弹出 alert 对话框:</p>
<p><img src="assets/cc50a56d83b3687859a396081346a47f.png" alt="img" /></p>
<p><img src="assets/c4633bc6edc93c98e1d27969f6518571.png" alt="img" /></p>
<p>并且,脚本被保存到了数据库:</p>
<p><img src="assets/7ed8a0a92059149ed32bae43458307bc.png" alt="img" /></p>
<p>你可能想到了,解决方式就是 HTML 转码。既然是通过 @RequestParam 来获取请求参数,那我们定义一个 @InitBinder 实现数据绑定的时候,对字符串进行转码即可:</p>
<pre><code>@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));
}
});
}
}
</code></pre>
<p>的确,针对这个场景,这种做法是可行的。数据库中保存了转义后的数据,因此数据会被当做 HTML 显示在页面上,而不是当做脚本执行:</p>
<p><img src="assets/5ff4c92a1571da41ccb804c4232171ca.png" alt="img" /></p>
<p><img src="assets/88cedbd1557690157e52010280386801.png" alt="img" /></p>
<p>但是,这种处理方式犯了一个严重的错误,那就是没有从根儿上来处理安全问题。因为 @InitBinder 是 Spring Web 层面的处理逻辑,如果有代码不通过 @RequestParam 来获取数据,而是直接从 HTTP 请求获取数据的话,这种方式就不会奏效。比如这样:</p>
<pre><code>user.setName(request.getParameter(&quot;username&quot;));
</code></pre>
<p>更合理的解决方式是,定义一个 servlet Filter通过 HttpServletRequestWrapper 实现 servlet 层面的统一参数替换:</p>
<pre><code>//自定义过滤器
@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);
}
}
</code></pre>
<p>这样,我们就可以实现所有请求参数的 HTML 转义了。不过,这种方式还是不够彻底,原因是无法处理通过 @RequestBody 注解提交的 JSON 数据。比如,有这样一个 PUT 接口,直接保存了客户端传入的 JSON User 对象:</p>
<pre><code>@PutMapping
public void put(@RequestBody User user) {
userRepository.save(user);
}
</code></pre>
<p>通过 Postman 请求这个接口,保存到数据库中的数据还是没有转义:</p>
<p><img src="assets/6d8e2b3b68e8a623d039d9d73999a64f.png" alt="img" /></p>
<p>我们需要自定义一个 Jackson 反列化器,来实现反序列化时的字符串的 HTML 转义:</p>
<pre><code>//注册自定义的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;
}
}
</code></pre>
<p>这样就实现了既能转义 Get/Post 通过请求参数提交的数据,又能转义请求体中直接提交的 JSON 数据。</p>
<p>你可能觉得做到这里,我们的防范已经很全面了,但其实不是。这种只能堵新漏,确保新数据进入数据库之前转义。如果因为之前的漏洞,数据库中已经保存了一些 JavaScript 代码,那么读取的时候同样可能出问题。因此,我们还要实现数据读取的时候也转义。</p>
<p>接下来,我们看一下具体的实现方式。</p>
<p>首先,之前我们处理了 JSON 反序列化问题,那么就需要同样处理序列化,实现数据从数据库中读取的时候转义,否则读出来的 JSON 可能包含 JavaScript 代码。</p>
<p>比如,我们定义这样一个 GET 接口以 JSON 来返回用户信息:</p>
<pre><code>@GetMapping(&quot;user&quot;)
@ResponseBody
public User query() {
return userRepository.findById(1L).orElse(new User());
}
</code></pre>
<p><img src="assets/b2f919307e42e79ce78622b305d455f8.png" alt="img" /></p>
<p>修改之前的 SimpleModule 加入自定义序列化器,并且实现序列化时处理字符串转义:</p>
<pre><code>//注册自定义的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));
}
}
}
</code></pre>
<p>可以看到,这次读到的 JSON 也转义了:</p>
<p><img src="assets/315f67193d1f9efe4b09db85361c53fc.png" alt="img" /></p>
<p>其次,我们还需要处理 HTML 模板。对于 Thymeleaf 模板引擎,需要注意的是,使用 th:utext 来显示数据是不会进行转义的,需要使用 th:text</p>
<pre><code>&lt;label th:text=&quot;${username}&quot;/&gt;
</code></pre>
<p>经过修改后,即使数据库中已经保存了 JavaScript 代码,呈现的时候也只能作为 HTML 显示了。现在,对于进和出两个方向,我们都实现了补漏。</p>
<p>但,所谓百密总有一疏。为了避免疏漏,进一步控制 XSS 可能带来的危害,我们还要考虑一种情况:如果需要在 Cookie 中写入敏感信息的话,我们可以开启 HttpOnly 属性。这样 JavaScript 代码就无法读取 Cookie 了,即便页面被 XSS 注入了攻击代码,也无法获得我们的 Cookie。</p>
<p>写段代码测试一下。定义两个接口,其中 readCookie 接口读取 Key 为 test 的 CookiewriteCookie 接口写入 Cookie根据参数 HttpOnly 确定 Cookie 是否开启 HttpOnly</p>
<pre><code>//服务端读取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);
}
</code></pre>
<p>可以看到,由于 test 和 _ga 这两个 Cookie 不是 HttpOnly 的。通过 document.cookie 可以输出这两个 Cookie 的内容:</p>
<p><img src="assets/726e984d392aa1afc6d7371447700977.png" alt="img" /></p>
<p>为 test 这个 Cookie 启用了 HttpOnly 属性后,就不能被 document.cookie 读取到了,输出中只有 _ga 一项:</p>
<p><img src="assets/1b287474f0666d5a2fde8e9442ae2e0c.png" alt="img" /></p>
<p>但是服务端可以读取到这个 cookie</p>
<p><img src="assets/b25da8d4aa5778798652f9685a93f6bd.png" alt="img" /></p>
<h2>重点回顾</h2>
<p>今天,我通过案例,和你具体分析了 SQL 注入和 XSS 攻击这两类注入类安全问题。</p>
<p>在学习 SQL 注入的时候,我们通过 sqlmap 工具看到了几种常用注入方式,这可能改变了我们对 SQL 注入威力的认知:对于 POST 请求、请求没有任何返回数据、请求不会出错的情况下,仍然可以完成注入,并可以导出数据库的所有数据。</p>
<p>对于 SQL 注入来说,使用参数化的查询是最好的堵漏方式;对于 JdbcTemplate 来说,我们可以使用“?”作为参数占位符;对于 MyBatis 来说,我们需要使用“#{}”进行参数化处理。</p>
<p>和 SQL 注入类似的是,脚本引擎动态执行代码,需要确保外部传入的数据只能作为数据来处理,不能和代码拼接在一起,只能作为参数来处理。代码和数据之间需要划出清晰的界限,否则可能产生代码注入问题。同时,我们可以通过设置一个代码的执行沙箱来细化代码的权限,这样即便产生了注入问题,因为权限受限注入攻击也很难发挥威力。</p>
<p>随后通过学习 XSS 案例,我们认识到处理安全问题需要确保三点。</p>
<p>第一,要从根本上、从最底层进行堵漏,尽量不要在高层框架层面做,否则堵漏可能不彻底。</p>
<p>第二,堵漏要同时考虑进和出,不仅要确保数据存入数据库的时候进行了转义或过滤,还要在取出数据呈现的时候再次转义,确保万无一失。</p>
<p>第三,除了直接堵漏外,我们还可以通过一些额外的手段限制漏洞的威力。比如,为 Cookie 设置 HttpOnly 属性,来防止数据被脚本读取;又比如,尽可能限制字段的最大保存长度,即使出现漏洞,也会因为长度问题限制黑客构造复杂攻击脚本的能力。</p>
<p>今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。</p>
<h2>思考与讨论</h2>
<p>在讨论 SQL 注入案例时,最后那次测试我们看到 sqlmap 返回了 4 种注入方式。其中,布尔盲注、时间盲注和报错注入,我都介绍过了。你知道联合查询注入,是什么吗?</p>
<p>在讨论 XSS 的时候,对于 Thymeleaf 模板引擎,我们知道如何让文本进行 HTML 转义显示。FreeMarker 也是 Java 中很常用的模板引擎,你知道如何处理转义吗?</p>
<p>你还遇到过其他类型的注入问题吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。</p>
</div>
</div>
<div>
<div style="float: left">
<a href="/专栏/Java 业务开发常见错误 100 例/28 安全兜底:涉及钱时,必须考虑防刷、限量和防重.md">上一页</a>
</div>
<div style="float: right">
<a href="/专栏/Java 业务开发常见错误 100 例/30 如何正确保存和传输敏感数据?.md">下一页</a>
</div>
</div>
</div>
</div>
</div>
</div>
<a class="off-canvas-overlay" onclick="hide_canvas()"></a>
</div>
<script defer src="https://static.cloudflareinsights.com/beacon.min.js/v652eace1692a40cfa3763df669d7439c1639079717194" integrity="sha512-Gi7xpJR8tSkrpF7aordPZQlW2DLtzUlZcumS8dMQjwDHEnw9I7ZLyiOj/6tZStRBGtGgN6ceN6cMH8z7etPGlw==" data-cf-beacon='{"rayId":"70997056b99b3d60","version":"2021.12.0","r":1,"token":"1f5d475227ce4f0089a7cff1ab17c0f5","si":100}' crossorigin="anonymous"></script>
</body>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-NPSEEVD756"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'G-NPSEEVD756');
var path = window.location.pathname
var cookie = getCookie("lastPath");
console.log(path)
if (path.replace("/", "") === "") {
if (cookie.replace("/", "") !== "") {
console.log(cookie)
document.getElementById("tip").innerHTML = "<a href='" + cookie + "'>跳转到上次进度</a>"
}
} else {
setCookie("lastPath", path)
}
function setCookie(cname, cvalue) {
var d = new Date();
d.setTime(d.getTime() + (180 * 24 * 60 * 60 * 1000));
var expires = "expires=" + d.toGMTString();
document.cookie = cname + "=" + cvalue + "; " + expires + ";path = /";
}
function getCookie(cname) {
var name = cname + "=";
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i].trim();
if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
}
return "";
}
</script>
</html>