您的浏览器过于古老 & 陈旧。为了更好的访问体验, 请 升级你的浏览器
Ready 发布于2019年12月14日 01:57 最近更新于 2019年12月17日 00:23

原创 面向开发者的 Web 应用安全入门指南( 3 ):SQL注入攻击

3215 次浏览 读完需要≈ 13 分钟 Java

内容目录

SQL注入攻击 是 Web 应用所遇到的最常见的攻击方式之一。许多著名的开源程序(例如:Discuz、Joomla、WordPress、DedeCMS)也都被多次曝光过发现SQL注入攻击漏洞。

正确预防 SQL注入攻击 是一个开发人员必备的基本功。

什么是SQL注入?

我们假设有这样一个业务场景:我们允许用户修改自己的昵称,在某用户修改昵称并提交之后,我们就需要将新的昵称更新到数据库。

要完成上述功能,对应的后台核心伪代码大致如下:

public void updateNickname(String newNickname, Integer memberId) throws SQLException {
	Connection conn = DriverManager.getConnection(
		"jdbc:mysql:///test?useUnicode=true&characterEncoding=UTF-8",
		"username",
		"password");
	Statement stmt = conn.createStatement();

	// "UPDATE member SET nickname = '$newNickname' WHERE id = $memberId"
	stmt.executeUpdate("UPDATE member SET nickname = '" + newNickname + "' WHERE id = " + memberId);
}

我们再假设该用户在数据表member中的 ID 为 3,输入的新昵称为「张三丰」,最终拼接出来的SQL语句如下:

UPDATE member SET nickname = '张三丰' WHERE id = 3

这当然没有问题。

不过,如果用户输入的昵称不是张三丰,而是张三丰'; -- (最后有个空格)呢?那么,实际拼接出的SQL语句为:

UPDATE member SET nickname = '张三丰'; -- ' WHERE id = 3

这会出现什么后果呢?一旦执行该SQL语句,你会发现:数据表member所有数据的nickname都被改为「张三丰」了!

这还不够严重,我们再假设:

  1. 如果用户输入的新昵称是张三丰', status = 0; -- (最后有个空格)呢?
  2. 如果用户输入的新昵称是张三丰'; DROP TABLE member; -- (最后有个空格)呢?

那么,我们实际上执行的分别是以下两条SQL语句:

UPDATE member SET nickname = '张三丰', status = 0; -- ' WHERE id = 3

-- 下面这个例子要求数据库必须开启一条SQL命令可执行多个查询的功能,一般默认均未开启,也不建议开启
-- 在 MySQL 中,要开启该功能,需要在 JDBC 连接字符串中添加参数:allowMultiQueries=true
UPDATE member SET nickname = '张三丰'; DROP TABLE member; -- ' WHERE id = 3

你会发现,第一个 SQL 语句还会更新所有status字段值(同理,也可以更新其它任何字段);第二个 SQL 语句甚至还能够删除整个表(同理,也可以把整个数据库都删除掉)!!!

如何预防SQL注入?

之所以会出现 SQL注入,就是因为外部传入的请求数据包含恶意内容,这些恶意内容参与了SQL语句的构造,导致外部恶意用户有权构造并执行任何符合SQL语法的SQL语句。

既然 SQL注入攻击 这么可怕,那么我们该如何预防呢?

要解决这个问题,我们就必须确保外部传入的内容只能作为纯粹的字段值,而不能影响SQL语句的语法解析和执行。

实际上,几乎所有的编程语言都已经为我们准备好了解决SQL注入攻击的工具,那就是 PreparedStatement(在很多语言中都叫类似的名字)。

现在,我们用 PreparedStatement 来改造上面修改昵称的方法:

public void updateNickname(String newNickname, Integer memberId) throws SQLException {
	Connection conn = DriverManager.getConnection(
		"jdbc:mysql:///test?useUnicode=true&characterEncoding=UTF-8",
		"username",
		"password");

	// 准备 SQL 语句,不要让传入的参数值去参与SQL语句的拼接,用占位符 "?" 代替
	PreparedStatement pstmt = conn.prepareStatement("UPDATE member SET nickname = ? WHERE id = ?");
	
	// 为 SQL 语句中的多个占位符依次设置对应的参数值
	pstmt.setString(1, newNickname);
	pstmt.setInt(2, memberId);

	pstmt.executeUpdate();
}

从上面的代码,我们很容易看出来:PreparedStatement 就是不让外部传入的参数直接参与 SQL语句 的构造,而是以特定的方式进行参数值绑定。

切记:在上述代码中,如果直接将参数值拼凑到SQL语句,而不用占位符"?"的方式,也是可以执行的。但是,这样做就存在被SQL注入攻击的安全隐患!因此,我们千万不要将外部参数直接在SQL语句中进行拼凑(尤其是字符串类型的参数),而应该严格使用 "?" 占位符的形式。

LIKE 查询的陷阱

正确使用 PreparedStatement 就能够很轻松地避免 SQL注入攻击,但是这样做完全就够了吗?

我们来看这样的一个场景:我们需要根据输入的昵称来进行右侧模糊匹配查询,并返回符合查询条件的用户数据集合。

核心逻辑的伪代码大致如下:

public void queryByNickname(String nickname) throws SQLException {
	// Connection conn = ... // omitted for brevity

	// 准备 SQL 语句,传入的参数值用占位符 "?" 代替
	PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM member WHERE nickname LIKE ?");

	String nicknameForLike = nickname + '%';
	// 为 SQL 语句中的多个占位符设置对应的参数值
	pstmt.setString(1, nicknameForLike); // 昵称右侧进行模糊匹配

	// "SELECT * FROM member WHERE nickname LIKE '$nickname%'"
	ResultSet rs = pstmt.executeQuery();
	// TODO 处理查询到的结果集
}

在上述代码中,为了实现对输入昵称的右侧模糊查询,我们需要先在传入的nickname右侧加上%,然后再进行参数绑定设置。

如果你足够细心的话,你会发现 外部传入的参数 和 SQL语法 又提前混淆在一起了。

思考这样的一个问题:如果传入的参数nickname中也包含有%呢?比如:%张张%三丰

你会发现,哪怕你只想进行右侧模糊查询,但实际上nickname参数中的%也被当作了 SQL 语法中的模糊匹配特殊符号,从而干扰查询结果。

因此,为了确保业务逻辑完全符合预期,我们需要对外部参数中可能存在的模糊匹配特殊符号进行预转义处理,以确保其以普通文本的身份参与模糊查询。

public void queryByNickname(String nickname) throws SQLException {
	// 对外部传入的 nickname 进行预转义
	// SQL语法规定:"%" 和 "_" 都是 LIKE 匹配的特殊字符,因此都需要转义
	nickname = nickname.replace("%", "\\%")
					.replace("_", "\\_");

	// 下面的代码省略,与上一个示例中的代码保持一致即可
}

只有这样,我们才能够保证进行 LIKE 查询的时候,查询的结果也完全符合我们的预期。

备注

  • 部分语言的 API 还支持以命名参数的形式(WHERE nickname = :nickname)来进行参数绑定,例如 PHP 的 PDOStatement
  • 实际上,我个人认为「LIKE查询的陷阱」完全是 API 的一个设计缺陷,应该让模糊查询的特殊字符包含在SQL语句中,而不是混淆在绑定的参数值中。
    比如,在本文的示例中,应该这样: SELECT * FROM member WHERE nickname LIKE '?%'
    然后直接将传入的参数进行绑定,而无需做任何预处理: pstmt.setString(1, nickname);

  • CodePlayer技术交流群1
  • CodePlayer技术交流群2

1 条评论

二周 · 3年前

MyBatis中的LIKE查询

Like查询在很多成熟的框架中均未做标准化的处理,比如这里的MyBatis

@Test
	void listDocument() 
		QueryWrapper<Document> wrapper = Wrappers.query();
		wrapper.select("title").likeRight("title", "QQ");
		System.out.println(documentService.list(wrapper).stream().map(Document::getTitle).collect(Collectors.joining(",")));
		// Preparing: SELECT title FROM document WHERE (title LIKE ?) 
		// Parameters: QQ%(String)
		// Result: QQ,QQ浏览器,QQ阅读,QQ相册
		
		wrapper = Wrappers.query();
		wrapper.select("title").likeRight("title", "%QQ");
		System.out.println(documentService.list(wrapper).stream().map(Document::getTitle).collect(Collectors.joining(",")));
		// Preparing: SELECT title FROM document WHERE (title LIKE ?) 
		// Parameters: %QQ%(String)
		// Result: QQ,QQ浏览器,QQ阅读,QQ相册,测试QQ,测试QQ浏览器
	
		wrapper = Wrappers.query();
		wrapper.select("title").likeRight("title", "\\%QQ");
		System.out.println(documentService.list(wrapper).stream().map(Document::getTitle).collect(Collectors.joining(",")));
		// Preparing: SELECT title FROM document WHERE (title LIKE ?) 
		// Parameters: \%QQ%(String)
		// Result: 无结果
	}

第二个查询我把参数设置为%QQ ,其结果就和 LIKE '%QQ%'一样了。
第三个查询,我把参数设置为\\%QQ,就无结果了,这个就是符合要求的查询。

0 0 0

撰写评论

打开导航菜单