数据库事务隔离级别引发的竞态条件漏洞剖析

本文深入分析数据库事务隔离级别在并发场景下的安全隐患,通过实际代码演示竞态条件漏洞的成因、利用方式及修复方案,涵盖Postgres、MySQL等数据库的测试结果与防护建议。

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

引言

数据库是现代应用的核心组件。与任何外部依赖一样,它们为开发者带来了额外的复杂性。然而在实际应用中,数据库往往被当作提供存储功能的黑盒使用。本文旨在揭示数据库引入的一个常被开发者忽视的复杂性维度:并发控制。

让我们从一个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
}

代码执行流程:

  1. 建立新数据库事务
  2. 读取源账户余额
  3. 验证转账金额是否符合业务规则
  4. 更新源账户和目标账户余额
  5. 提交数据库事务

并发测试暴露问题

通过并发测试脚本发起10个并发转账请求,预期只有2个请求成功(初始余额100,每次转账50),但实际测试发现几乎所有请求都被成功处理,导致源账户出现负余额。

数据库事务与隔离级别

事务是数据库中定义逻辑工作单元的方式,需要确保ACID属性。本文重点关注隔离性(I)属性。

ANSI SQL-92标准定义了四个隔离级别:

读未提交(Read Uncommitted)

  • 允许所有读现象(脏读、不可重复读、幻读)
  • 非默认级别,需要显式设置

读已提交(Read Committed)

  • 防止脏读
  • 允许不可重复读和幻读
  • 多数数据库的默认级别

可重复读(Repeatable Read)

  • 防止脏读和不可重复读
  • 允许幻读

可串行化(Serializable)

  • 防止所有读现象
  • 最高隔离级别

数据竞争与竞态条件

示例应用使用Postgres默认的读已提交隔离级别,SELECT操作不会加锁,导致并发访问共享资源时出现TOCTOU(检查时间与使用时间)问题。

数据库通过锁机制实现并发控制:

  • 共享锁(读锁):多个事务可同时持有
  • 排他锁(写锁):单个事务独占

根本原因分析

缺乏SELECT操作的锁机制允许并发访问共享资源,导致竞态条件漏洞。即使应用代码本身没有明显问题,数据库日志中能清晰看到操作交错执行。

实践模式分析

模式一:基于当前数据库状态计算

UPDATE操作在数据库服务器端执行计算,使用执行时的当前值。第一个执行UPDATE的事务进入临界区,其他事务等待锁释放。当第二个事务执行时,基于已更新的值进行计算,导致状态不一致。

模式二:使用陈旧值计算

将计算移到应用层,多个并发请求读取相同数据库值,基于相同状态进行计算,导致业务逻辑被绕过。

真实世界利用

通过三种攻击技术测试:

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

测试结果显示,除可串行化级别外,其他隔离级别都存在可利用的竞态条件。

修复方案

方案一:设置可串行化隔离级别

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

方案二:悲观锁

使用FOR UPDATEFOR SHARE手动加锁:

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

方案三:乐观锁

通过版本号检测冲突:

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

检测方法

使用Semgrep等工具检测未设置隔离级别的事务:

  1. 原始SQL事务检测规则
  2. 缺少pgx事务选项检测
  3. 缺少隔离级别设置检测

结论

这不是数据库引擎的bug,而是隔离级别设计的固有特性。事务和隔离级别旨在保护并发操作不相互干扰,但缓解数据竞争和竞态条件并非其主要用途。在业务关键代码中出现这种不安全模式时,被利用的可能性很高。

资源

  • 研究内容在2024年OWASP全球AppSec会议上展示
  • 演示视频和幻灯片可在线获取
  • 测试代码位于Doyensec的GitHub仓库

附录:测试结果

下表显示各数据库隔离级别的竞态条件可利用性:

隔离级别 MySQL Postgres MariaDB
RU Y Y Y
RC Y Y Y
RR Y N Y
S N N N
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计