竞速至底端 - 数据库事务如何破坏你的应用安全
引言
数据库是现代应用的核心组件。与任何外部依赖一样,它们为开发者带来了额外的复杂性。然而在实际应用中,数据库往往被当作提供存储功能的黑盒使用。本文旨在揭示数据库引入的一个常被开发者忽视的复杂性维度:并发控制。
让我们从一个Doyensec在日常工作中常见的代码模式开始:
|
|
代码执行流程:
- 建立新数据库事务
- 读取源账户余额
- 验证转账金额是否符合业务规则
- 更新源账户和目标账户余额
- 提交数据库事务
并发测试暴露问题
通过并发测试脚本发起10个并发转账请求,预期只有2个请求成功(初始余额100,每次转账50),但实际测试发现几乎所有请求都被成功处理,导致源账户出现负余额。
数据库事务与隔离级别
事务是数据库中定义逻辑工作单元的方式,需要确保ACID属性。本文重点关注隔离性(I)属性。
ANSI SQL-92标准定义了四个隔离级别:
读未提交(Read Uncommitted)
- 允许所有读现象(脏读、不可重复读、幻读)
- 非默认级别,需要显式设置
读已提交(Read Committed)
- 防止脏读
- 允许不可重复读和幻读
- 多数数据库的默认级别
可重复读(Repeatable Read)
- 防止脏读和不可重复读
- 允许幻读
可串行化(Serializable)
- 防止所有读现象
- 最高隔离级别
数据竞争与竞态条件
示例应用使用Postgres默认的读已提交隔离级别,SELECT操作不会加锁,导致并发访问共享资源时出现TOCTOU(检查时间与使用时间)问题。
数据库通过锁机制实现并发控制:
- 共享锁(读锁):多个事务可同时持有
- 排他锁(写锁):单个事务独占
根本原因分析
缺乏SELECT操作的锁机制允许并发访问共享资源,导致竞态条件漏洞。即使应用代码本身没有明显问题,数据库日志中能清晰看到操作交错执行。
实践模式分析
模式一:基于当前数据库状态计算
UPDATE操作在数据库服务器端执行计算,使用执行时的当前值。第一个执行UPDATE的事务进入临界区,其他事务等待锁释放。当第二个事务执行时,基于已更新的值进行计算,导致状态不一致。
模式二:使用陈旧值计算
将计算移到应用层,多个并发请求读取相同数据库值,基于相同状态进行计算,导致业务逻辑被绕过。
真实世界利用
通过三种攻击技术测试:
- 简单多线程循环
- HTTP/1.1最后字节同步
- HTTP/2.0单包攻击
测试结果显示,除可串行化级别外,其他隔离级别都存在可利用的竞态条件。
修复方案
方案一:设置可串行化隔离级别
|
|
方案二:悲观锁
使用FOR UPDATE
或FOR SHARE
手动加锁:
|
|
方案三:乐观锁
通过版本号检测冲突:
|
|
检测方法
使用Semgrep等工具检测未设置隔离级别的事务:
- 原始SQL事务检测规则
- 缺少pgx事务选项检测
- 缺少隔离级别设置检测
结论
这不是数据库引擎的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 |