数据库事务的陷阱:并发控制如何影响应用安全

本文深入探讨了数据库事务隔离级别在应用安全中的关键作用,通过实际代码示例揭示了常见的并发控制漏洞(如账户透支),并分析了不同隔离级别(RC、RR、S)对数据竞争和TOCTOU漏洞的影响,最后提供了检测与缓解方案。

一场逐底竞争——数据库事务如何破坏你的应用安全

引言

数据库是现代应用的关键组成部分。与任何外部依赖项一样,它们为构建应用的开发者引入了额外的复杂性。然而在现实世界中,它们通常被当作提供存储功能的黑盒来考虑和使用。

本文旨在阐明数据库引入的一个常被开发者忽视的复杂性方面,即并发控制。最好的方法是先看一个我们在日常工作中相当常见的代码模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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端点发出请求来进行转账,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
POST /transfer HTTP/1.1
Host: localhost:9009
Content-Type: application/json
Content-Length: 31

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

我们指定源账户和目标账户的ID,以及它们之间要转账的金额。本研究的完整源代码和其他示例应用程序可以在我们的playground仓库中找到。

在继续阅读之前,花一分钟时间审查一下代码,看看是否能发现任何问题。注意到什么了吗?乍一看,这个实现似乎是正确的。执行了充分的输入验证、边界和余额检查,没有SQL注入的可能性等等。我们还可以通过运行应用程序并发出一些请求来验证这一点。我们会看到转账被接受,直到源账户余额达到零,此时应用程序将开始对所有后续请求返回错误。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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方式运行transfer
        go transfer()
    }
    time.Sleep(time.Second * 2)
    fmt.Printf("done.\n")
}

然而,运行脚本后我们看到了一些不同的情况。我们看到几乎所有的请求都被应用程序服务器接受并成功处理了。通过/dump端点查看两个账户的余额,会发现源账户出现了负余额

我们成功透支了账户,实际上是从无中生有地变出了钱!此时,任何人都会问"为什么?“和"怎么办?"。要回答这些问题,我们首先需要绕个弯,谈谈数据库。

数据库事务与隔离级别

事务是在数据库上下文中定义逻辑工作单元的一种方式。事务由多个需要成功执行的数据库操作组成,该单元才被视为完成。任何失败都会导致事务回滚,此时开发者需要决定是接受失败还是重试操作。事务是确保数据库操作ACID属性的一种方式。虽然所有属性对于确保数据正确性和安全性都很重要,但在本文中,我们只对"I"即**隔离性(Isolation)**感兴趣。

简而言之,隔离性定义了并发事务之间相互隔离的程度。这确保了它们始终在正确的数据上运行,并且不会使数据库处于不一致的状态。隔离性是开发者可以直接控制的属性。ANSI SQL-92标准定义了四个隔离级别,稍后我们将更详细地讨论它们,但首先我们需要理解为什么需要它们。

为什么需要隔离?

引入隔离级别是为了消除在数据集上执行并发事务时可以观察到的**读现象(read phenomena)**或意外行为。理解它们的最好方法是通过一个简短的例子,这个例子摘自维基百科。

脏读(Dirty Reads)

脏读允许事务读取并发事务未提交的更改。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- tx1
BEGIN;
SELECT age FROM users WHERE id = 1; -- 年龄 = 20
-- tx2
BEGIN;
UPDATE users SET age = 21 WHERE id = 1;
-- tx1
SELECT age FROM users WHERE id = 1; -- 年龄 = 21
-- tx2
ROLLBACK; -- tx1的第二次读取被回滚

不可重复读(Non-Repeatable Reads)

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

1
2
3
4
5
6
7
8
-- tx1
BEGIN;
SELECT age FROM users WHERE id = 1; -- 年龄 = 20
-- tx2
UPDATE users SET age = 21 WHERE id = 1;
COMMIT;
-- tx1
SELECT age FROM users WHERE id = 1; -- 年龄 = 21

幻读(Phantom Reads)

幻读允许对一组条目进行连续的SELECT操作,由于并发事务进行的修改而返回不同的结果。

1
2
3
4
5
6
7
8
9
-- tx1
BEGIN;
SELECT name FROM users WHERE age > 17; -- 返回 [Alice, Bob]
-- tx2
BEGIN;
INSERT INTO users VALUES (3, 'Eve', 26);
COMMIT;
-- tx1
SELECT name FROM users WHERE age > 17; -- 返回 [Alice, Bob, Eve]

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

丢失更新(Lost Update)

当并发事务对同一条目执行更新时,会发生丢失更新。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- tx1
BEGIN;
SELECT * FROM users WHERE id = 1;
-- tx2
BEGIN;
SELECT * FROM users WHERE id = 1;
UPDATE users SET name = 'alice' WHERE id = 1;
COMMIT; -- 名称设置为 'alice'
-- tx1
UPDATE users SET name = 'bob' WHERE id = 1;
COMMIT; -- 名称设置为 'bob'

这个执行流程导致tx2所做的更改被tx1覆盖。

读偏斜和写偏斜(Read and Write Skew)

当操作在两个或多个具有外键关系的条目上执行时,通常会出现读偏斜和写偏斜。下面的例子假设数据库包含两个表:一个users表,存储特定用户的信息;一个change_log表,存储对目标用户name列进行最后一次更改的用户信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
CREATE TABLE users(
  id INT PRIMARY KEY NOT NULL,
  name TEXT NOT NULL
);
CREATE TABLE change_log(
  id INT PRIMARY KEY NOT NULL,
  updated_by VARCHAR NOT NULL,
  user_id INT NOT NULL,
  CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES users(id)
);

读偏斜示例: 如果我们假设有以下执行顺序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- tx1
BEGIN;
SELECT * FROM users WHERE id = 1; -- 返回 'old_name'
-- tx2
BEGIN;
UPDATE users SET name = 'new_name' WHERE id = 1;
UPDATE change_logs SET updated_by = 'Bob' WHERE user_id = 1;
COMMIT;
-- tx1
SELECT * FROM change_logs WHERE user_id = 1; -- 返回 Bob

tx1事务的视角是用户Bob最后更改了ID为1的用户,将其名称设置为old_name

写偏斜示例: 在下面显示的操作序列中,tx1将在假设用户名为Alice且名称没有先前更改的情况下执行其更新。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- tx1
BEGIN;
SELECT * FROM users WHERE id = 1; -- 返回 Alice
SELECT * FROM change_logs WHERE user_id = 1; -- 返回空集
-- tx2
BEGIN;
SELECT * FROM users WHERE id = 1;
UPDATE users SET name = 'Bob' WHERE id = 1; -- 设置新名称
COMMIT;
-- tx1
UPDATE users SET name = 'Eve' WHERE id = 1; -- 设置新名称
COMMIT;

但是,tx2tx1能够完成之前执行了更改。这导致tx1基于在其执行期间发生更改的状态执行更新。

隔离级别旨在防止这些读现象中的零个或多个。让我们更详细地看看它们。

读未提交(Read Uncommitted)

读未提交(RU)是提供的最低隔离级别。在此级别,可以观察到上述所有现象,包括读取未提交的数据,正如其名称所示。虽然在高度并发环境中使用此隔离级别的事务可能具有更高的吞吐量,但这确实意味着并发事务可能会在不一致的数据上运行。从安全角度来看,这不是任何业务关键操作所需具备的属性。 幸运的是,这不是任何数据库引擎的默认设置,需要开发者在创建新事务时显式设置。

读已提交(Read Committed)

读已提交(RC)建立在前一级别保证的基础上,完全防止了脏读。然而,它确实允许其他事务在正在运行的事务的各个操作之间修改、插入或删除数据,这可能导致不可重复读和幻读。 读已提交是大多数数据库引擎的默认隔离级别。MySQL在这方面是一个例外。

可重复读(Repeatable Read)

类似地,可重复读(RR)改进了前一个隔离级别,同时增加了防止不可重复读的保证。事务只会看到事务开始时已提交的数据。在此级别仍可以观察到幻读。

可序列化(Serializable)

最后,我们拥有可序列化(S)隔离级别。最高级别旨在防止所有读现象。使用可序列化隔离级别并发执行多个事务的结果将等同于它们按顺序执行。

数据竞争与竞态条件

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

我们的示例容易受到攻击的主要原因归结为并发事务执行和并发控制不足。我们可以启用数据库日志记录,以轻松查看在利用我们的示例应用程序时数据库级别正在执行什么操作。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 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)“实现的。数据库定义了一组工作线程,其工作是执行通常称为"调度器(scheduler)“的组件分配给它们的所有事务。工作线程彼此独立,在概念上可以将其视为应用程序线程。与应用程序线程类似,它们会受到上下文切换的影响,这意味着它们可能会在执行过程中被中断,从而允许其他工作线程执行其工作。因此,我们可能最终得到部分事务执行,从而导致我们在上面的日志输出中看到的交织操作。与多线程应用程序代码一样,如果没有适当的并发控制,我们就有可能遇到数据竞争和竞态条件。

回到数据库日志,我们还可以看到两个事务都试图在同一条目上进行更新,一个接一个(第5行和第6行)。数据库将通过在被修改的条目上设置锁来防止这种并发修改,以保护更改,直到进行更改的事务完成或失败。数据库供应商可以自由实现任意数量的不同类型的锁,但其中大多数可以简化为两种类型:共享锁(shared locks)排他锁(exclusive locks)

共享锁(或读锁) 是在从数据库读取的表条目上获取的。它们不是互斥的,这意味着多个事务可以在同一条目上持有共享锁。

排他锁(或写锁),顾名思义是排他的。在执行写/更新操作时获取,每个表条目只能有一个此类型的锁处于活动状态。这有助于防止对同一条目进行并发更改。

数据库供应商提供了一种简单的方法,可以在事务执行的任何时间查询活动锁,前提是您可以暂停它或手动执行它。例如,在Postgres中,以下查询将显示活动锁:

1
SELECT locktype, relation::regclass, mode, transactionid AS tid, virtualtransaction AS vtid, pid, granted, waitstart FROM pg_catalog.pg_locks l LEFT JOIN pg_catalog.pg_database db ON db.oid = l.database WHERE (db.datname = '<db_name>' OR db.datname IS NULL) AND NOT pid = pg_backend_pid() ORDER BY pid;

对于MySQL,可以使用类似的查询:

1
SELECT thread_id, lock_data, lock_type, lock_mode, lock_status FROM performance_schema.data_locks WHERE object_name = '<db_name>';

对于其他数据库供应商,请参阅相应的文档。

根本原因

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

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

将理论应用于实践

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

模式 #1 - 使用当前数据库状态进行计算

在最初的示例中,我们可以看到新的余额计算将发生在数据库服务器上。这是由于UPDATE操作的结构方式。它包含一个简单的加法/减法操作,数据库将在执行时使用balance列的当前值进行计算。将所有内容放在一起,我们最终得到如下所示的执行流程:

使用数据库的默认隔离级别,SELECT操作将在创建任何锁之前执行,并且相同的条目将返回给应用程序代码。首先执行其UPDATE的事务将进入临界区,并将被允许执行其剩余操作并提交。在此期间,所有其他事务将挂起并等待锁释放。通过提交其更改,第一个事务将更改数据库的状态,有效地打破了等待事务启动时所依据的假设。当第二个事务执行其UPDATE时,计算将在更新后的值上进行,使应用程序处于不正确的状态。

模式 #2 - 使用陈旧值进行计算

当应用程序代码读取数据库条目的当前状态,在应用程序层执行所需的计算,并在UPDATE操作中使用新计算的值时,就会发生使用陈旧值的情况。我们可以对我们的初始示例进行简单的重构,将"新值"计算移动到应用程序层。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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)
  tx, err := conn.BeginTx(ctx)
  var userSrc User
  err = conn.
    QueryRow(ctx, "SELECT id, name, balance FROM users WHERE id = $1", source).
    Scan(&userSrc.Id, &userSrc.Name, &userSrc.Balance)
  var userDest User
  err = conn.
    QueryRow(ctx, "SELECT id, name, balance FROM users WHERE id = $1", destination).
    Scan(&userDest.Id, &userDest.Name, &userDest.Balance)
  if amount <= 0 || amount > userSrc.Balance {
    tx.Rollback(ctx)
    return fmt.Errorf("invalid transfer")
  }
  // 注意:余额计算已移至应用程序层
  newSrcBalance := userSrc.Balance - amount
  newDestBalance := userDest.Balance + amount
  _, err = conn.Exec(ctx, "UPDATE users SET balance = $2 WHERE id = $1", source, newSrcBalance)
  _, err = conn.Exec(ctx, "UPDATE users SET balance = $2 WHERE id = $1", destination, newDestBalance)
  err = tx.Commit(ctx)
  return nil
}

如果两个或多个并发请求同时调用db.Transfer函数,则初始的SELECT很可能在创建任何锁之前执行。所有函数调用都将从数据库读取相同的值。金额验证将成功通过,并将计算新的余额。如果我们运行之前的测试用例,看看这个场景如何影响我们的数据库状态:

乍一看,数据库状态没有显示任何不一致之处。这是因为两个事务都基于相同的状态执行了金额计算,并且都使用相同的金额执行了UPDATE操作。尽管数据库状态没有被破坏,但值得注意的是,我们能够执行比业务逻辑允许的更多次事务。例如,使用微服务架构构建的应用程序可能实现如下业务逻辑:

如果服务T假设来自主应用程序的所有传入请求都是有效的,并且自身不执行任何额外的验证,那么它将愉快地处理任何传入的请求。之前描述的竞态条件允许我们利用这种行为并多次调用下游服务T,有效地执行比业务需求允许的更多次转账。

这种模式也可以被(滥)用来破坏数据库状态。也就是说,我们可以执行从源账户到不同目标账户的多笔转账。

利用这个漏洞,两个并发事务最初都会看到源余额为100,这将通过金额验证。

现实世界中的利用

如果在本地运行示例应用程序,并且数据库在同一台机器上运行,你很可能会看到发送到/transfer端点的大多数(如果不是全部)请求都会被应用程序服务器接受。客户端、应用程序服务器和数据库服务器之间的低延迟允许所有请求命中竞态窗口并成功提交。然而,现实世界的应用程序部署要复杂得多,运行在云环境中,使用Kubernetes集群部署,放置在反向代理后面,并受防火墙保护。

我们很好奇在现实环境中命中竞态窗口有多困难。为了测试这一点,我们设置了一个简单的应用程序,部署在AWS Fargate容器中,旁边是另一个运行所选数据库的容器。

测试主要集中在三个数据库上:Postgres、MySQL和MariaDB。 应用程序逻辑使用两种编程语言实现:Go和Node。选择这些语言是为了让我们能够了解它们不同的并发模型(Go的goroutine与Node的事件循环)如何影响可利用性。 最后,我们指定了三种攻击应用程序的技术:

  1. 简单的多线程循环
  2. 针对HTTP/1.1的最后字节同步
  3. 针对HTTP/2.0的单数据包攻击 所有这些都使用BurpSuite的扩展程序执行:“Intruder"用于(1),“Turbo Intruder"用于(2)和(3)。

使用此设置,我们通过使用10个线程/连接执行20个请求来攻击应用程序,从Bob(账户ID 2,起始余额为200)向Alice转账50。攻击完成后,我们记录了接受的请求数。对于一个不易受攻击的应用程序,接受的请求不应超过4个。

对于每个应用程序/数据库/攻击方法的组合,这执行了10次。记录了成功处理的请求数。从这些数字中,我们得出结论,特定的隔离级别是否可利用。结果可以在此处找到。

结果与观察

我们的测试表明,如果应用程序中存在这种模式,那么它很可能被利用。除了可序列化(Serializable)级别外,在所有情况下,我们都能够超出预期的接受请求数,从而透支账户。接受的请求数在不同技术之间有所不同,但我们能够超出预期(在某些情况下,超出程度很大)这一事实足以证明该问题的可利用性。

如果攻击者能够在同一时刻向服务器发送大量请求,有效地创造本地访问的条件,那么接受的请求数会大幅增加。因此,为了最大限度地提高命中竞态窗口的可能性,测试人员应优先选择诸如最后字节同步或单数据包攻击等方法。

Postgres的可重复读(Repeatable Read)级别是一个例外。它之所以不易受攻击,是因为它实现了一个称为快照隔离(Snapshot Isolation) 的隔离级别。此隔离级别提供的保证介于可重复读(Repeatable Read)和可序列化(Serializable)之间,最终提供了足够的保护,并减轻了我们示例中的竞态条件。

语言的并发模式对竞态条件的可利用性没有显著影响。

缓解措施

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

缓解措施可以通过多种方式实现。有些需要手动工作,而另一些则是开箱即用的,由所选的数据库提供。让我们从最简单且通常首选的方法开始:将事务隔离级别设置为可序列化(Serializable)

如前所述,隔离级别是数据库事务的用户/开发者控制的属性。可以通过在创建事务时简单地指定它来设置:

1
BEGIN TRANSACTION SET TRANSACTION ISOLATION LEVEL SERIALIZABLE

这可能因数据库而异,因此最好始终查阅相应的文档。通常,ORM或数据库驱动程序提供应用程序级接口来设置所需的隔离级别。Postgres的Go驱动程序pgx允许用户执行以下操作:

1
tx, err := conn.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})

值得注意的是,可序列化(Serializable)作为最高隔离级别,可能会对应用程序的性能产生影响。然而,它的使用可以仅限于业务关键事务。所有其他事务可以保持不变,并使用数据库的默认隔离级别或该特定操作的任何适当级别执行。

这种方法的一种替代方案是通过手动锁定实现悲观锁(pessimistic locking)。这种方法背后的理念是,业务关键事务将在开始时获取所有必需的锁,并且仅在事务完成或失败时释放它们。这确保没有其他并发执行的事务能够干扰。可以通过在SELECT操作中指定FOR SHAREFOR UPDATE选项来执行手动锁定:

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

这将指示数据库对所有通过读取操作返回的条目放置共享锁或排他锁,从而在锁释放之前禁止对其进行任何修改。然而,这种方法可能容易出错。总是有可能其他操作被忽略,或者新添加的操作没有FOR SHARE/FOR UPDATE选项,可能会重新引入数据竞争。此外,在较低的隔离级别,可能会出现如下所示的情况:

该图显示了一个场景,其中tx2对一个在tx1提交后变得陈旧的值进行验证,并最终覆盖了tx1执行的更新,导致了丢失更新。

最后,还可以使用乐观锁(optimistic locking) 来实现缓解。乐观锁是悲观锁的概念对立面,它期望不会出错,并且仅在事务结束时执行冲突检测。如果检测到冲突(即,底层数据被并发事务修改),事务将失败并需要重试。这种方法通常使用逻辑时钟或表列来实现,其值在事务执行期间不得更改。

实现此功能的最简单方法是在表中引入一个version列:

1
2
3
4
5
6
CREATE TABLE users(
  id INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
  name TEXT NOT NULL,
  balance INT NOT NULL,
  version INT NOT NULL AUTO_INCREMENT
);

然后,在执行任何写/更新数据库操作时,必须始终验证version列的值。如果值发生更改,操作将失败,从而导致整个事务失败。

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

检测

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

这两种方法都代表了一种可以使用Semgrep等工具搜索的模式。因此,如果我们假设我们的应用程序是使用Go构建的,并且使用pgx访问存储在Postgres数据库中的数据,我们可以使用以下Semgrep规则来检测未指定隔离级别的情况。

  1. 原始SQL事务

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    rules:
      - id: pgx-sql-tx-missing-isolation-level
        message: "SQL transaction without isolation level"
        languages:
          - go
        severity: WARNING
        patterns:
          - pattern: $CONN.Exec($CTX, $BEGIN)
          - metavariable-regex:
              metavariable: $BEGIN
              regex: ("begin transaction"|"BEGIN TRANSACTION")
    
  2. 缺少pgx事务创建选项

    1
    2
    3
    4
    5
    6
    7
    8
    
    rules:
      - id: pgx-tx-missing-options
        message: "Postgres transaction options not set"
        languages:
          - go
        severity: WARNING
        patterns:
          - pattern: $CONN.BeginTx($CTX)
    
  3. pgq事务创建选项中缺少隔离级别

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    rules:
      - id: pgx-tx-missing-options-isolation
        message: "Postgres transaction isolation level not set"
        languages:
          - go
        severity: WARNING
        patterns:
          - pattern: $CONN.BeginTx($CTX, $OPTS)
          - metavariable-pattern:
              metavariable: $OPTS
              patterns:
                - pattern-not: >
                    $PGX.TxOptions{..., IsoLevel:$LVL, ...}
    

所有这些模式都可以轻松修改以适应你的技术栈和选择的数据库。

值得注意的是,像这样的规则并不是一个完整的解决方案。盲目地将它们集成到现有的流水线中会产生很多噪音。我们建议使用它们来构建应用程序执行的所有事务的清单,并将该信息作为审查应用程序的起点,并在需要时应用强化。

结束语

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

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

资源

这项研究由Viktor Chuchurski (@viktorot)在2024年于里斯本举行的OWASP Global AppSec会议上发表。该演讲的录像可以在这里找到,演讲幻灯片可以在此处下载。

Playground代码可以在Doyensec的GitHub上找到。

更多信息: 如果你想了解更多关于我们的其他研究,请查看我们的博客,在X上关注我们(@doyensec),或随时通过info@doyensec.com联系我们,以获取更多关于我们如何帮助你的组织"安全构建"的信息。

附录 - 测试结果

下表显示了作为我们研究一部分测试的数据库中,哪些隔离级别允许竞态条件发生。

读未提交 (RU) 读已提交 (RC) 可重复读 (RR) 可序列化 (S)
MySQL Y Y Y N
Postgres Y Y N N
MariaDB Y Y Y N
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计