一、简介
尽管是最著名的漏洞之一,SQL 注入仍然在臭名昭著的OWASP 前 10 名列表中名列前茅 ——现在是更一般的 注入类的一部分。
在本教程中,我们将探讨Java 中导致易受攻击的应用程序的常见编码错误,以及如何使用 JVM 标准运行时库中可用的 API 来避免这些错误。我们还将介绍我们可以从 JPA、Hibernate 等 ORM 中获得哪些保护,以及我们仍然需要担心哪些盲点。
2. 应用程序如何容易受到 SQL 注入的影响?
注入攻击之所以有效,是因为对于许多应用程序而言,执行给定计算的唯一方法是动态生成代码,而这些代码又由另一个系统或组件运行。如果在生成此代码的过程中,我们使用未经适当清理的不受信任的数据,我们就会为黑客利用敞开大门。
这句话可能听起来有点抽象,所以让我们用一个教科书的例子来看看这在实践中是如何发生的:
public List<AccountDTO>
unsafeFindAccountsByCustomerId(String customerId)
throws SQLException {
// UNSAFE !!! DON'T DO THIS !!!
String sql = "select "
+ "customer_id,acc_number,branch_id,balance "
+ "from Accounts where customer_id = '"
+ customerId
+ "'";
Connection c = dataSource.getConnection();
ResultSet rs = c.createStatement().executeQuery(sql);
// ...
}
这段代码的问题很明显:我们将 customerId的值放入查询中,根本没有验证。如果我们确定这个值只会来自受信任的来源,就不会发生任何不好的事情,但我们可以吗?
让我们假设这个函数用于 帐户 资源的 REST API 实现。利用这段代码是微不足道的:我们所要做的就是发送一个值,当与查询的固定部分连接时,改变它的预期行为:
curl -X GET \
'http://localhost:8080/accounts?customerId=abc%27%20or%20%271%27=%271' \
假设 customerId参数值在到达我们的函数之前未选中,这就是我们将收到的内容:
abc' or '1' = '1
当我们将此值与固定部分连接时,我们得到将要执行的最终 SQL 语句:
select customer_id, acc_number,branch_id, balance
from Accounts where customerId = 'abc' or '1' = '1'
可能不是我们想要的……
一个聪明的开发者(我们不都是吗?)现在会想:“这太傻了!我永远 不会使用字符串连接来构建这样的查询”。
没那么快......这个典型的例子确实很愚蠢,但在某些情况下我们可能仍然需要这样做:
- 具有动态搜索条件的复杂查询:根据用户提供的条件添加 UNION 子句
- 动态分组或排序:用作 GUI 数据表后端的 REST API
2.1。我正在使用 JPA。我很安全,对吧?
这是一个常见的误解。JPA 和其他 ORM 使我们免于创建手工编码的 SQL 语句,但它们不会阻止我们编写易受攻击的代码。
让我们看看上一个示例的 JPA 版本的外观:
public List<AccountDTO> unsafeJpaFindAccountsByCustomerId(String customerId) {
String jql = "from Account where customerId = '" + customerId + "'";
TypedQuery<Account> q = em.createQuery(jql, Account.class);
return q.getResultList()
.stream()
.map(this::toAccountDTO)
.collect(Collectors.toList());
}
我们之前指出的相同问题也存在于此:我们使用未经验证的输入来创建 JPA 查询,因此我们在这里遇到了相同类型的漏洞利用。
三、预防技术
现在我们知道什么是 SQL 注入,让我们看看如何保护我们的代码免受这种攻击。在这里,我们专注于 Java 和其他 JVM 语言中可用的一些非常有效的技术,但类似的概念也适用于其他环境,例如 PHP、.Net、Ruby 等。
对于那些正在寻找可用技术的完整列表(包括特定于数据库的技术)的人,OWASP 项目维护了一个 SQL 注入预防备忘单,这是了解有关该主题的更多信息的好地方。
3.1。参数化查询
该技术包括在我们需要插入用户提供的值时在查询中使用带有问号占位符(“?”)的准备好的语句。这是非常有效的,除非 JDBC 驱动程序的实现中存在错误,否则不会受到攻击。
让我们重写我们的示例函数来使用这种技术:
public List<AccountDTO> safeFindAccountsByCustomerId(String customerId)
throws Exception {
String sql = "select "
+ "customer_id, acc_number, branch_id, balance from Accounts"
+ "where customer_id = ?";
Connection c = dataSource.getConnection();
PreparedStatement p = c.prepareStatement(sql);
p.setString(1, customerId);
ResultSet rs = p.executeQuery(sql));
// omitted - process rows and return an account list
}
在这里,我们使用 了Connection实例中 可用的prepareStatement()方法 来获取PreparedStatement。这个接口扩展了常规的 Statement 接口,使用了几种方法,这些方法允许我们在执行查询之前安全地在查询中插入用户提供的值。
对于 JPA,我们有一个类似的特性:
String jql = "from Account where customerId = :customerId";
TypedQuery<Account> q = em.createQuery(jql, Account.class)
.setParameter("customerId", customerId);
// Execute query and return mapped results (omitted)
在 Spring Boot 下运行此代码时,我们可以将属性logging.level.sql设置为 DEBUG 并查看实际构建了什么查询以执行此操作:
// Note: Output formatted to fit screen
[DEBUG][SQL] select
account0_.id as id1_0_,
account0_.acc_number as acc_numb2_0_,
account0_.balance as balance3_0_,
account0_.branch_id as branch_i4_0_,
account0_.customer_id as customer5_0_
from accounts account0_
where account0_.customer_id=?
正如预期的那样,ORM 层使用customerId参数的占位符创建了一个准备好的语句 。这与我们在普通 JDBC 案例中所做的相同——但少了一些语句,这很好。
作为奖励,这种方法通常会产生更好的查询,因为大多数数据库可以缓存与准备好的语句关联的查询计划。
请注意,这种方法仅适用于用作 值的占位符。例如,我们不能使用占位符来动态更改表的名称:
// This WILL NOT WORK !!!
PreparedStatement p = c.prepareStatement("select count(*) from ?");
p.setString(1, tableName);
在这里,JPA 也无济于事:
// This WILL NOT WORK EITHER !!!
String jql = "select count(*) from :tableName";
TypedQuery q = em.createQuery(jql,Long.class)
.setParameter("tableName", tableName);
return q.getSingleResult();
在这两种情况下,我们都会收到运行时错误。
这背后的主要原因是预准备语句的本质:数据库服务器使用它们来缓存拉取结果集所需的查询计划,这对于任何可能的值通常都是相同的。这不适用于 SQL 语言中可用的表名和其他构造,例如 order by子句中使用的列。
3.2. JPA 标准 API
由于显式 JQL 查询构建是 SQL 注入的主要来源,因此我们应该尽可能使用 JPA 的查询 API。
有关此 API 的快速入门,请参阅有关 Hibernate Criteria queries 的文章。另外值得一读的是我们关于 JPA Metamodel 的文章,它展示了如何生成元模型类,这将帮助我们摆脱用于列名的字符串常量——以及当它们发生变化时出现的运行时错误。
让我们重写 JPA 查询方法以使用 Criteria API:
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Account> cq = cb.createQuery(Account.class);
Root<Account> root = cq.from(Account.class);
cq.select(root).where(cb.equal(root.get(Account_.customerId), customerId));
TypedQuery<Account> q = em.createQuery(cq);
// Execute query and return mapped results (omitted)
在这里,我们使用了更多的代码行来获得相同的结果,但好处是现在我们不必担心 JQL 语法。
另一个重点:尽管Criteria API 很冗长,但它使创建复杂的查询服务更加直接和安全。有关说明如何在实践中执行此操作的完整示例,请查看JHipster生成的应用程序使用的方法。
3.3. 用户数据清理
数据清理是一种对用户提供的数据应用过滤器的技术,以便我们的应用程序的其他部分可以安全地使用它。过滤器的实现可能会有很大差异,但我们通常可以将它们分为两种类型:白名单和黑名单。
黑名单由试图识别无效模式的过滤器组成,在 SQL 注入预防的上下文中通常没有什么价值——但对于检测却没有!稍后再谈。
另一方面,当我们可以准确定义什么是有效输入时,白名单特别有效。
让我们增强我们的safeFindAccountsByCustomerId 方法,现在调用者还可以指定用于对结果集进行排序的列。由于我们知道可能的列的集合,我们可以使用一个简单的集合来实现一个白名单,并使用它来清理接收到的参数:
private static final Set<String> VALID_COLUMNS_FOR_ORDER_BY
= Collections.unmodifiableSet(Stream
.of("acc_number","branch_id","balance")
.collect(Collectors.toCollection(HashSet::new)));
public List<AccountDTO> safeFindAccountsByCustomerId(
String customerId,
String orderBy) throws Exception {
String sql = "select "
+ "customer_id,acc_number,branch_id,balance from Accounts"
+ "where customer_id = ? ";
if (VALID_COLUMNS_FOR_ORDER_BY.contains(orderBy)) {
sql = sql + " order by " + orderBy;
} else {
throw new IllegalArgumentException("Nice try!");
}
Connection c = dataSource.getConnection();
PreparedStatement p = c.prepareStatement(sql);
p.setString(1,customerId);
// ... result set processing omitted
}
在这里,我们将准备好的语句方法和用于清理 orderBy参数的白名单结合起来。最终结果是带有最终 SQL 语句的安全字符串。在这个简单的示例中,我们使用的是静态集,但我们也可以使用数据库元数据函数来创建它。
我们可以对 JPA 使用相同的方法,同时利用 Criteria API 和元数据来避免在我们的代码中使用字符串常量:
// Map of valid JPA columns for sorting
final Map<String,SingularAttribute<Account,?>> VALID_JPA_COLUMNS_FOR_ORDER_BY = Stream.of(
new AbstractMap.SimpleEntry<>(Account_.ACC_NUMBER, Account_.accNumber),
new AbstractMap.SimpleEntry<>(Account_.BRANCH_ID, Account_.branchId),
new AbstractMap.SimpleEntry<>(Account_.BALANCE, Account_.balance))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
SingularAttribute<Account,?> orderByAttribute = VALID_JPA_COLUMNS_FOR_ORDER_BY.get(orderBy);
if (orderByAttribute == null) {
throw new IllegalArgumentException("Nice try!");
}
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Account> cq = cb.createQuery(Account.class);
Root<Account> root = cq.from(Account.class);
cq.select(root)
.where(cb.equal(root.get(Account_.customerId), customerId))
.orderBy(cb.asc(root.get(orderByAttribute)));
TypedQuery<Account> q = em.createQuery(cq);
// Execute query and return mapped results (omitted)
此代码具有与普通 JDBC 相同的基本结构。首先,我们使用白名单来清理列名,然后继续创建CriteriaQuery以从数据库中获取记录。
3.4. 我们现在安全吗?
假设我们在任何地方都使用了参数化查询和/或白名单。我们现在可以去找我们的经理并保证我们安全吗?
嗯……没那么快。即使不考虑图灵的停止问题,我们还必须考虑其他方面:
- 存储过程:这些也容易出现 SQL 注入问题;如有可能,请对将通过准备好的语句发送到数据库的值进行清理
- 触发器:与过程调用相同的问题,但更隐蔽,因为有时我们不知道它们在那里......
- 不安全的直接对象引用:即使我们的应用程序没有 SQL 注入,仍然存在与此漏洞类别相关的风险——这里的要点与攻击者可以欺骗应用程序的不同方式有关,因此它返回他或她的记录不应该访问 - OWASP 的 GitHub 存储库中提供了关于此主题的很好的备忘单
简而言之,我们最好的选择是谨慎。如今,许多组织正是为此使用了“红队”。让他们做他们的工作,这正是发现任何剩余的漏洞。
4. 损害控制技术
作为一种良好的安全实践,我们应该始终实施多个防御层——一个被称为 深度防御的概念。主要思想是,即使我们无法在代码中找到所有可能的漏洞——这是处理遗留系统时的常见情况——我们至少应该尝试限制攻击可能造成的损害。
当然,这将是整篇文章甚至一本书的主题,但让我们举几个措施:
- 应用最小权限原则:尽可能限制用于访问数据库的帐户的权限
- 使用可用的特定于数据库的方法来添加额外的保护层;例如,H2 数据库有一个会话级选项,它禁用 SQL 查询上的所有文字值
- 使用短期凭证:使应用程序经常轮换数据库凭证; 实现这一点的一个好方法是使用Spring Cloud Vault
- 记录一切:如果应用程序存储客户数据,这是必须的; 有许多可用的解决方案可以直接集成到数据库或作为代理工作,因此在发生攻击时,我们至少可以评估损失
- 使用WAF或类似的入侵检测解决方案:这些是典型的 黑名单示例——通常,它们带有一个包含已知攻击特征的相当大的数据库,并会在检测时触发可编程操作。有些还包括可以通过应用一些工具来检测入侵的 JVM 内代理——这种方法的主要优点是最终的漏洞变得更容易修复,因为我们将获得完整的堆栈跟踪。
5. 结论
在本文中,我们介绍了 Java 应用程序中的 SQL 注入漏洞——对任何依赖数据进行业务的组织来说都是一个非常严重的威胁——以及如何使用简单的技术来防止它们。
像往常一样,本文的完整代码可以在 Github 上找到。