数据库事务与并发控制:应用安全中的竞态条件漏洞剖析

0 阅读14分钟

竞逐到底——数据库事务正在破坏你的应用安全

引言

数据库是现代应用中至关重要的一部分。与任何外部依赖一样,它为开发人员构建应用带来了额外的复杂性。然而在现实世界中,数据库通常被视为提供存储功能的黑盒并被这样使用。

本文旨在揭示数据库引入的一个经常被开发人员忽视的复杂性方面:并发控制。最好的切入点是看看我们在 Doyensec 日常工作中经常遇到的一个相当常见的代码模式:

func (db *Db) Transfer(source int, destination int, amount int) error {
  ctx := context.Background()

  conn, err := pgx.Connect(ctx, db.databaseUrl)
  defer conn.Close(ctx)

  // (1)
  tx, err := conn.BeginTx(ctx)

  var user User
  // (2)
  err = conn.
    QueryRow(ctx, "SELECT id, name, balance FROM users WHERE id = $1", source).
    Scan(&user.Id, &user.Name, &user.Balance)

  // (3)
  if amount <= 0 || amount > user.Balance {
    tx.Rollback(ctx)
    return fmt.Errorf("invalid transfer")
  }

  // (4)
  _, err = conn.Exec(ctx, "UPDATE users SET balance = balance - $2 WHERE id = $1", source, amount)
  _, err = conn.Exec(ctx, "UPDATE users SET balance = balance + $2 WHERE id = $1", destination, amount)

  // (5)
  err = tx.Commit(ctx)
  return nil
}

注意:为清晰起见,已删除所有错误检查。

对于不熟悉 Go 的读者,这里简要总结一下代码的作用。假设应用会先对传入的 HTTP 请求执行身份验证和授权。当所有必要的检查通过后,将调用处理数据库逻辑的 db.Transfer 函数。此时应用将:

  1. 开启一个新的数据库事务
  2. 读取源账户的余额
  3. 验证转账金额相对于源账户余额和应用业务规则是否有效
  4. 相应地更新源账户和目标账户的余额
  5. 提交数据库事务

可以通过向 /transfer 端点发送请求来进行转账,如下所示:

POST /transfer HTTP/1.1
Host: localhost:9009
Content-Type: application/json
Content-Length: 31

{
    "source":1,
    "destination":2,
    "amount":50
}

在继续阅读之前,花点时间检查一下代码,看看是否能发现任何问题。

注意到什么了吗?乍一看,实现似乎是正确的。进行了充分的输入验证、余额边界检查,没有 SQL 注入的可能性等。我们也可以通过运行应用并发送几个请求来验证这一点。我们会看到,在源账户余额变为零之前,转账请求一直被接受,之后应用将开始为所有后续请求返回错误。

这看起来没问题。现在,让我们尝试一些更动态的测试。使用下面的 Go 脚本,我们尝试向 /transfer 端点发送 10 个并发请求。我们预期两个请求会被接受(初始余额为 100,进行两笔 50 的转账),其余的被拒绝。

func transfer() {
	client := &http.Client{}

	body := transferReq{
		From:   1,
		To:     2,
		Amount: 50,
	}
	bodyBuffer := new(bytes.Buffer)
	json.NewEncoder(bodyBuffer).Encode(body)

	req, err := http.NewRequest("POST", "http://localhost:9009/transfer", bodyBuffer)
	if err != nil {
		panic(err)
	}
	req.Header.Add("Content-Type", `application/json`)
	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	} else if _, err := io.Copy(os.Stdout, resp.Body); err != nil {
		panic(err)
	}
	fmt.Printf(" / status code => %v\n", resp.StatusCode)
}

func main() {
	for i := 0; i < 10; i++ {
		// 作为 goroutine 运行转账
		go transfer()
	}
	time.Sleep(time.Second * 2)
	fmt.Printf("done.\n")
}

然而,运行脚本时我们看到了不同的结果。我们发现几乎所有(如果不是全部)请求都被应用服务器接受并成功处理。使用 /dump 端点查看两个账户的余额会发现源账户出现了负余额。

我们已经成功透支了账户,凭空生成了钱!此时,每个人都会问“为什么?”和“怎么会?”。为了回答这些问题,我们需要先绕个弯,谈谈数据库。

数据库事务与隔离级别

事务是在数据库上下文中定义一个逻辑工作单元的方式。事务由多个数据库操作组成,这些操作必须成功执行,该工作单元才算完成。任何失败都将导致事务回滚,此时开发人员需要决定是接受失败还是重试操作。事务是确保数据库操作 ACID 属性的一种方式。虽然所有属性对于保证数据正确性和安全性都很重要,但对于本文,我们只关心“I”,即隔离性(Isolation)。

简而言之,隔离性定义了并发事务彼此隔离的程度。这确保了它们始终操作正确的数据,并且不会使数据库处于不一致的状态。隔离性是一个开发人员可以直接控制的属性。ANSI SQL-92 标准定义了四种隔离级别,稍后我们将更详细地研究它们,但首先我们需要了解为什么需要它们。

为什么需要隔离性?

引入隔离级别是为了消除读现象或意外行为,这些现象或行为可能在对数据集执行并发事务时观察到。理解它们的最佳方式是通过一个简短的例子。

脏读(Dirty Reads)
脏读允许事务读取并发事务所做的未提交更改。

不可重复读(Non-Repeatable Reads)
不可重复读允许连续的 SELECT 操作由于并发事务修改同一表条目而返回不同的结果。

幻读(Phantom Reads)
幻读允许对一组条目连续的 SELECT 操作由于并发事务所做的修改而返回不同的结果。

除了标准中定义的现象外,在现实世界中还可以观察到诸如“读偏斜”(Read Skews)、“写偏斜”(Write Skews)和“丢失更新”(Lost Updates)等行为。

丢失更新(Lost Updates)
当并发事务对同一个条目执行更新时,就会发生丢失更新,导致后提交的事务覆盖先提交事务的更改。

读偏斜(Read Skews)写偏斜(Write Skews) 通常在操作在两个或更多具有外键关系的条目上执行时出现。

隔离级别详解

隔离级别旨在防止其中部分或全部读现象。

  • 读未提交(Read Uncommitted, RU):最低的隔离级别。所有上述现象都可能发生,包括读取未提交的数据。这不是任何业务关键型操作的理想属性。
  • 读已提交(Read Committed, RC):在上一级别的基础上,完全防止脏读。但允许其他事务在运行事务的各个操作之间修改、插入或删除数据,这可能导致不可重复读和幻读。这是大多数数据库引擎的默认隔离级别。
  • 可重复读(Repeatable Read, RR):改进了上一级别,增加了防止不可重复读的保证。事务将只查看在事务开始时已提交的数据。在此级别仍可能观察到幻读。
  • 可串行化(Serializable, S):最高隔离级别,旨在防止所有读现象。并发执行多个具有可串行化隔离级别的事务的结果,等同于它们按串行顺序执行。

数据竞争与竞态条件

现在我们已经了解了这些基础知识,让我们回到最初的例子。如果我们假设该示例使用的是 Postgres,并且没有显式设置隔离级别,那么我们将使用 Postgres 的默认级别:读已提交(Read Committed)。此设置将保护我们免受脏读的影响,并且由于我们没有在事务中执行多次读取,因此幻读或不可重复读也不是问题。

我们的示例存在漏洞的主要原因归结为并发事务执行不充分的并发控制。我们可以启用数据库日志来轻松查看示例应用被利用时在数据库级别执行了什么。

拉取示例日志,我们可以看到类似以下内容:

 1. [TX1] LOG:  BEGIN ISOLATION LEVEL READ COMMITTED
 2. [TX2] LOG:  BEGIN ISOLATION LEVEL READ COMMITTED
 3. [TX1] LOG:  SELECT id, name, balance FROM users WHERE id = 2
 4. [TX2] LOG:  SELECT id, name, balance FROM users WHERE id = 2
 5. [TX1] LOG:  UPDATE users SET balance = balance - 50 WHERE id = 2
 6. [TX2] LOG:  UPDATE users SET balance = balance - 50 WHERE id = 2
 7. [TX1] LOG:  UPDATE users SET balance = balance + 50 WHERE id = 1
 8. [TX1] LOG:  COMMIT
 9. [TX2] LOG:  UPDATE users SET balance = balance + 50 WHERE id = 1
10. [TX2] LOG:  COMMIT

我们最初注意到的是,单个事务的各个操作并不是作为一个单元执行的。它们的各个操作是交织在一起的,这与最初的事务定义(即单个执行单元)相矛盾。这种交织是事务并发执行的结果。

并发事务执行

数据库被设计为并发处理传入的工作负载,从而提高吞吐量和系统性能。并发执行是通过“工作线程”(workers)实现的。工作线程相互独立,可以概念性地视为应用线程。像应用线程一样,它们会受到上下文切换的影响,这意味着它们可以在执行过程中被中断,从而允许其他工作线程执行其工作。因此,最终可能会出现部分事务执行的情况,导致我们在上面的日志输出中看到的操作交织。

回到数据库日志,我们还可以看到两个事务都试图先后对同一个条目执行更新(第 5 行和第 6 行)。数据库会通过在修改的条目上设置锁来防止这种并发修改,保护更改直到做出更改的事务完成或失败。锁主要分为两种类型:共享锁(Shared Locks,读锁)和排他锁(Exclusive Locks,写锁)。读锁不是互斥的,而写锁是互斥的。

根本原因

我们示例中使用的隔离级别(读已提交)在从数据库读取数据时不会放置任何锁。这意味着只有写操作会在被修改的条目上放置锁。可视化这一点后,我们的问题变得清晰:

SELECT 操作缺乏锁,允许对共享资源的并发访问。这引入了 TOCTOU(检查时间-使用时间)问题,导致了可利用的竞态条件。尽管问题在应用代码本身中不可见,但在数据库日志中却变得显而易见。

理论应用于实践

不同的代码模式可能导致不同的利用场景。对于我们的特定示例,主要区别在于新的应用状态是如何计算的,或者更具体地说,计算中使用了哪些值。

模式 #1 - 使用当前数据库状态进行计算
在原始示例中,新的余额计算发生在数据库服务器上。使用数据库默认的隔离级别,SELECT 操作将在任何锁创建之前执行,同一条目被返回给应用代码。第一个执行 UPDATE 的事务将进入临界区,并被允许执行其余操作并提交。在此期间,所有其他事务将挂起并等待锁释放。当第一个事务提交其更改时,它改变了数据库的状态,从而打破了等待事务启动时所依据的假设。当第二个事务执行其 UPDATE 时,计算将在更新后的值上执行,使应用处于不正确状态。

模式 #2 - 使用过期值进行计算
当应用代码读取数据库条目的当前状态,在应用层执行所需的计算,然后在 UPDATE 操作中使用新计算的值时,就会使用过期值。如果两个或多个并发请求同时调用 db.Transfer 函数,初始的 SELECT 很可能在任何锁创建之前执行。所有函数调用将从数据库读取相同的值。金额验证将成功通过,并计算出新的余额。

乍一看,数据库状态没有显示任何不一致。这是因为两个事务都基于相同的状态执行了金额计算,并且都使用相同的金额执行了 UPDATE 操作。尽管数据库状态未被破坏,但值得牢记的是,我们能够执行业务逻辑本应允许的更多次数的事务。

此模式也可能被滥用,从而破坏数据库状态。例如,我们可以执行从源账户到不同目标账户的多笔转账。

现实世界中的利用

如果你在本地运行示例应用,并且数据库在同一台机器上运行,你可能会看到向 /transfer 端点发出的几乎所有(如果不是全部)请求都会被应用服务器接受。客户端、应用服务器和数据库服务器之间的低延迟使所有请求都能进入竞争窗口并成功提交。

然而,现实世界的应用部署要复杂得多。我们好奇在现实世界中击中竞争窗口有多难。我们设置了一个简单的应用,部署在 AWS Fargate 容器中,另一个容器运行选定的数据库(Postgres、MySQL、MariaDB)。应用逻辑使用两种编程语言实现:Go 和 Node。我们指定了三种攻击技术:

  1. 简单的多线程循环
  2. HTTP/1.1 的 last-byte 同步
  3. HTTP/2.0 的单数据包攻击

我们的测试表明,如果应用中存在这种模式,它很可能可以被利用。除了可串行化(Serializable)级别外,在所有情况下,我们都能够超过预期的接受请求数量,从而透支账户。为了最大化击中竞争窗口的可能性,测试人员应优先使用诸如 last-byte 同步或单数据包攻击等方法。

缓解措施

从概念上讲,修复只需要将临界区的开始移动到事务的开头。这将确保首先读取条目的事务获得对其的独占访问权,并且是唯一允许提交的事务。所有其他事务将等待其完成。

缓解措施可以通过多种方式实现:

  1. 设置事务隔离级别为可串行化(Serializable)
    这是最简单且通常首选的方案。

    BEGIN TRANSACTION SET TRANSACTION ISOLATION LEVEL SERIALIZABLE
    

    需要注意的是,可串行化是最高隔离级别,可能会对应用性能产生影响。但是,其使用可以仅限于业务关键型事务。

  2. 通过手动锁定实现悲观锁
    业务关键型事务在开始时获取所有所需的锁,并在事务完成或失败时才释放它们。这可以通过在 SELECT 操作中指定 FOR SHAREFOR UPDATE 选项来实现:

    SELECT id, name, balance FROM users WHERE id = 1 FOR UPDATE
    

    然而,这种方法可能容易出错,有可能忽略其他操作或新添加的操作未使用锁定选项。

  3. 使用乐观锁
    乐观锁期望不会出现问题,只在事务结束时执行冲突检测。如果检测到冲突,事务将失败并需要重试。实现此方法的最简单方法是引入一个版本列:

    UPDATE users SET balance = 100 WHERE id = 1 AND version = <last_seen_version>
    

检测

如果应用使用 ORM,设置隔离级别通常涉及调用一个 setter 函数或将其作为函数参数提供。另一方面,如果应用使用原始 SQL 语句构建数据库事务,隔离级别将作为事务 BEGIN 语句的一部分提供。

这两种方法都代表了一种可以使用 Semgrep 等工具搜索的模式。例如,如果应用使用 Go 和 pgx 访问 Postgres 数据库,可以使用 Semgrep 规则来检测未指定隔离级别的实例(规则包括:原始 SQL 事务缺少隔离级别、缺少 pgx 事务创建选项、pgx 事务创建选项中缺少隔离级别)。

需要注意的是,这类规则不是一个完整的解决方案。盲目地将它们集成到现有流程中会产生大量噪音。我们更建议使用它们来构建应用执行的所有事务的清单,并将这些信息作为审查应用的起点,并在必要时进行加固。

结语

最后,需要强调的是,这不是数据库引擎的 bug。这是隔离级别设计和实现的一部分,在 SQL 规范和每个数据库的专用文档中都有明确描述。事务和隔离级别旨在保护并发操作免于相互干扰。然而,防止数据竞争和竞态条件并不是它们的主要用例。不幸的是,我们发现这是一个常见的误解。

虽然在正常情况下使用事务有助于保护应用程序免受数据损坏,但这并不足以缓解数据竞争。当这种不安全模式被引入业务关键型代码(账户管理功能、金融交易、折扣码应用等)时,其可被利用的可能性很高。因此,请检查应用程序的业务关键型操作,并验证它们是否进行了适当的数据锁定。 fKDlTFcPcc1J0xpM4hJULkjXPtJrTsGD/6Tl23V4Bz739rx9wxMPgVnuEN0a76BAo38k4xTRCylWGS4HYezfcpv7bm55X22+M6MPYjlOw0o=