数据库事务隔离级别引发的应用安全竞态条件漏洞分析

竞速到底 - 数据库事务如何破坏你的应用安全

引言

数据库是现代应用的核心组件。与任何外部依赖一样,它们为开发人员带来了额外的复杂性。然而在现实中,数据库通常被当作提供存储功能的黑盒来考虑和使用。

本文旨在揭示数据库引入的一个常被开发者忽视的复杂性方面:并发控制。最好的方法是从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
}

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

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

注意到了什么?乍一看,实现似乎是正确的:执行了充分的输入验证、边界和余额检查,没有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方式运行transfer
		go transfer()
	}
	time.Sleep(time.Second * 2)
	fmt.Printf("done.\n")
}

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

我们成功透支了账户,有效地凭空创造了钱!此时任何人都会问"为什么?“和"怎么做?”。要回答这些问题,我们首先需要绕个弯谈谈数据库。

数据库事务和隔离级别

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

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

为什么需要隔离?

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

脏读(Dirty Reads)

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

-- tx1
BEGIN;
SELECT age FROM users WHERE id = 1; -- age = 20 
-- tx2
BEGIN;
UPDATE users SET age = 21 WHERE id = 1;
-- tx1
SELECT age FROM users WHERE id = 1; -- age = 21 
-- tx2
ROLLBACK; -- tx1的第二次读取被回滚
不可重复读(Non-Repeatable Reads)

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

-- tx1
BEGIN;
SELECT age FROM users WHERE id = 1; -- age = 20 
-- tx2
UPDATE users SET age = 21 WHERE id = 1;
COMMIT;
-- tx2
SELECT age FROM users WHERE id = 1; -- age = 21
幻读(Phantom Reads)

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

-- 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]

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

丢失更新(Lost Updates)

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

-- 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; -- name设置为'alice'
-- tx1
UPDATE users SET name = 'bob' WHERE id = 1;
COMMIT; -- name设置为'bob'

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

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

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)
);
读偏斜(Read Skews)

如果我们假设有以下执行序列:

-- 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。

写偏斜(Write Skews)

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

-- 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;

然而,tx2在tx1完成之前执行了其更改。这导致tx1基于在执行期间更改的状态执行更新。

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

读未提交(Read Uncommitted)

读未提交(RU)是提供的最低隔离级别。在此级别,可以观察到上述所有现象,包括读取未提交的数据,正如其名称所示。虽然在高度并发环境中使用此隔离级别的事务可以实现更高的吞吐量,但这确实意味着并发事务可能会操作不一致的数据。从安全角度来看,这不是任何业务关键操作的理想属性。

幸运的是,这不是任何数据库引擎的默认设置,需要开发人员在创建新事务时显式设置。

读已提交(Read Committed)

读已提交(RC)建立在上一级别保证的基础上,完全防止脏读。但是,它允许其他事务在运行事务的各个操作之间修改、插入或删除数据,这可能导致不可重复读和幻读。

读已提交是大多数数据库引擎的默认隔离级别。MySQL在这方面是个例外。

可重复读(Repeatable Read)

以类似的方式,可重复读(RR)改进了先前的隔离级别,同时增加了防止不可重复读的保证。事务将仅查看在事务开始时提交的数据。在此级别仍然可以观察到幻读。

可序列化(Serializable)

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

数据竞争和竞态条件

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

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

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

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

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

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

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

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

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:

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操作中使用新计算的值时,就会使用陈旧值。我们可以对初始示例进行简单的重构,将"新值"计算移动到应用程序层。

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容器中, alongside另一个运行所选数据库的容器。

测试集中在三个数据库上:Postgres、MySQL和MariaDB。

应用程序逻辑使用两种编程语言实现:Go和Node。选择这些语言是为了让我们能够看到它们不同的并发模型(Go的goroutines与Node的事件循环)如何影响可利用性。

最后,我们指定了三种攻击应用程序的技术:

  1. 简单的多线程循环
  2. HTTP/1.1的最后字节同步
  3. HTTP/2.0的单包攻击

所有这些都使用BurpSuite的扩展执行:"Intruder"用于(1),"Turbo Intruder"用于(2)和(3)。

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

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

结果和观察

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

如果攻击者能够在同一时刻向服务器发送大量请求,有效地创建本地访问条件,接受的请求数会显著增加。因此,为了最大化命中竞争窗口的可能性,测试人员应首选最后字节同步或单包攻击等方法。

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

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

缓解措施

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

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

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

BEGIN TRANSACTION SET TRANSACTION ISOLATION LEVEL SERIALIZABLE

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

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

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

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

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

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

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

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

实现这一点的最简单方法是在表中引入版本列:

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
);

版本列的值必须在执行任何写/更新操作到数据库时始终验证。如果值更改,操作将失败,导致整个事务失败。

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

检测

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

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

  1. 原始SQL事务
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")
  1. 缺少pgx事务创建选项
rules:
  - id: pgx-tx-missing-options
    message: "Postgres transaction options not set"
    languages:
      - go
    severity: WARNING
    patterns:
      - pattern: $CONN.BeginTx($CTX)
  1. pgq事务创建选项中缺少隔离级别
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联系我们,了解更多关于我们如何帮助您的组织"构建安全"的信息。

附录 - 测试结果

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

隔离级别MySQLPostgresMariaDB
RUYYY
RCYYY
RRYNY
SNNN
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
公众号二维码
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值