竞相触底 - 数据库事务如何破坏您的应用安全
引言
数据库是现代应用的关键组成部分。与任何外部依赖项一样,它们为开发人员构建应用引入了额外的复杂性。然而在现实世界中,数据库通常被视为提供存储功能的黑盒。
本文旨在阐明数据库引入的一个常被开发人员忽视的复杂性方面:并发控制。最好的方法是从Doyensec在日常工作中看到的相当常见的代码模式开始:
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
函数。此时应用程序将:
- 建立新的数据库事务
- 读取源账户余额
- 验证转账金额相对于源账户余额和应用程序业务规则的有效性
- 适当更新源账户和目标账户的余额
- 提交数据库事务
可以通过向/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
31
|
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"或隔离性感兴趣。
简而言之,隔离定义了并发事务彼此隔离的程度。这确保它们始终在正确的数据上运行,并且不会使数据库处于不一致的状态。隔离是开发人员可以直接控制的属性。ANSI SQL-92标准定义了四个隔离级别,我们将更详细地研究它们,但首先我们需要理解为什么需要它们。
为什么需要隔离?
引入隔离级别是为了消除读取现象或意外行为,这些行为可以在对数据集执行并发事务时观察到。理解它们的最佳方式是通过一个简短的示例,慷慨地从维基百科借用。
脏读
脏读允许事务读取并发事务未提交的更改。
1
2
3
4
5
6
7
8
9
10
|
-- 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的第二次读取被回滚
|
不可重复读
不可重复读允许连续的SELECT操作由于并发事务修改相同的表条目而返回不同的结果。
1
2
3
4
5
6
7
8
|
-- 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
|
幻读
幻读允许对一组条目的连续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]
|
除了标准中定义的现象外,在现实世界中还可以观察到"读取偏斜”、“写入偏斜"和"丢失更新"等行为。
丢失更新
当并发事务对同一条目执行更新时会发生丢失更新。
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覆盖。
读取和写入偏斜通常在对具有外键关系的两个或多个条目执行操作时出现。以下示例假设数据库包含两个表:一个存储有关特定用户信息的users表,以及一个存储有关执行目标用户name列最新更改的用户信息的change_log表:
1
2
3
4
5
6
7
8
9
10
11
|
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;
|
然而,tx2在tx1能够完成之前执行了其更改。这导致tx1基于在其执行期间更改的状态执行更新。
隔离级别旨在防止零个或多个这些读取现象。让我们更详细地看看它们。
读未提交
读未提交(RU)是提供的最低隔离级别。在此级别,可以观察到上述所有现象,包括读取未提交的数据,正如其名称所示。虽然使用此隔离级别的事务可以在高并发环境中实现更高的吞吐量,但这确实意味着并发事务可能会操作不一致的数据。从安全角度来看,这不是任何业务关键操作的理想属性。
幸运的是,这不是任何数据库引擎的默认设置,需要在创建新事务时由开发人员显式设置。
读已提交
读已提交(RC)建立在前一级别的保证之上,并完全防止脏读。但是,它允许其他事务在运行事务的各个操作之间修改、插入或删除数据,这可能导致不可重复读和幻读。
读已提交是大多数数据库引擎的默认隔离级别。MySQL在这方面是个例外。
可重复读
以类似的方式,可重复读(RR)改进了前一个隔离级别,同时增加了也将防止不可重复读的保证。事务将仅查看在事务开始时提交的数据。在此级别仍可以观察到幻读。
可序列化
最后,我们有可序列化(S)隔离级别。最高级别旨在防止所有读取现象。使用可序列化隔离并发执行多个事务的结果将等同于它们按串行顺序执行。
数据竞争和竞态条件
现在我们已经介绍了这些内容,让我们回到最初的示例。如果我们假设示例使用的是Postgres并且我们没有显式设置隔离级别,我们将使用Postgres的默认设置:读已提交。此设置将保护我们免受脏读的影响,而幻读或不可重复读不是问题,因为我们没有在事务内执行多次读取。
我们的示例易受攻击的主要原因归结为并发事务执行和不足的并发控制。我们可以启用数据库日志记录,以便在利用我们的示例应用程序时轻松查看在数据库级别执行的内容。
提取我们示例的日志,我们可以看到类似的内容:
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
|
我们最初注意到的是,单个事务的各个操作不是作为单个单元执行的。它们的各个操作是交织在一起的,这与初始事务定义描述它们的方式(即单个执行单元)相矛盾。这种交织是由于事务并发执行而发生的。
并发事务执行
数据库被设计为并发执行其传入工作负载。这导致吞吐量增加,最终形成性能更高的系统。虽然实现细节可能因不同的数据库供应商而异,但在高层次上,并发执行是使用"工作线程"实现的。数据库定义了一组工作线程,其工作是执行通常称为"调度程序"的组件分配给它们的所有事务。工作线程彼此独立,可以在概念上视为应用程序线程。与应用程序线程一样,它们会受到上下文切换的影响,这意味着它们可以在执行过程中被中断,允许其他工作线程执行其工作。因此,我们最终可能会得到部分事务执行,导致我们在上面的日志输出中看到的交织操作。与多线程应用程序代码一样,如果没有适当的并发控制,我们就有可能遇到数据竞争和竞态条件。
回到数据库日志,我们还可以看到两个事务都试图一个接一个地更新相同的条目(第5行和第6行)。这种并发修改将被数据库通过对修改的条目设置锁来防止,保护更改直到进行更改的事务完成或失败。数据库供应商可以自由实现任意数量的不同锁类型,但它们大多数可以简化为两种类型:共享锁和排他锁。
共享(或读取)锁是在从数据库读取的表条目上获取的。它们不是互斥的,意味着多个事务可以在同一条目上持有共享锁。
排他(或写入)锁,顾名思义是排他的。在执行写入/更新操作时获取,每个表条目只能有一个此类锁处于活动状态。这有助于防止对同一条目进行并发更改。
数据库供应商提供了一种简单的方法来查询事务执行任何时间的活动锁,前提是您可以暂停它或手动执行它。例如,在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操作锁定的图示]
SELECT操作缺少锁定允许对共享资源的并发访问。这引入了TOCTOU(检查时间,使用时间)问题,导致可利用的竞态条件。尽管问题在应用程序代码本身中不可见,但在数据库日志中变得明显。
理论应用于实践
不同的代码模式可以允许不同的利用场景。对于我们特定的示例,主要区别在于如何计算新的应用程序状态,或者更具体地说,在计算中使用哪些值。
模式 #1 - 使用当前数据库状态进行计算
在原始示例中,我们可以看到新的余额计算将在数据库服务器上发生。这是由于UPDATE操作的结构方式。它包含一个简单的加法/减法操作,将由数据库在执行时使用balance列的当前值进行计算。将所有内容放在一起,我们最终得到如下所示的执行流程。
![模式1执行流程图]
使用数据库的默认隔离级别,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
26
27
28
29
30
31
32
|
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很有可能在创建任何锁之前执行。所有函数调用将从数据库读取相同的值。金额验证将成功通过,并将计算新余额。让我们看看如果我们运行先前的测试用例,此场景如何影响我们的数据库状态:
![模式2执行流程图]
乍一看,