Web applications can handle multiple requests simultaneously. Because of this, it's important to consider what happens when your code has <>multiple> users at the same time - aka concurrency. In the case of databases, this is a huge deal. You can query a database to verify information. However, this data can quickly become stale and make the security check irrelevant if you're not careful. This is because of the concurrency of these applications.
Transactions define a logical unit of work within the database context. These consist of multiple database operations that must be executed simultaneously and successfully for the transaction to succeed. Isolation defines the level at which concurrent transactions will be isolated from each other. These are to used to prevent dirty reads, phantom reads, and non-repeatable reads because of modification. All of these can cause havoc on an application.
At the level of Read Uncommitted, all data, including uncommitted data, can be read. At Read Committed, it only reads fully committed data, preventing dirty reads from the last step. This is the default setting for all DBs besides MySQL.
The next level is Repeatable Read. In the previous setting, transactions that are finalized can affect information within an ongoing transaction. In this setting, all transaction changes that occur during the transaction are effectively ignored during its execution for individual rows being operated on. The final setting is Serializable. This prevents Phantom reads, which are when different data is read between queries. This requires locking an entire index.
Most vulnerabilities that occur from improper database locking settings appear as Time of Check vs. Time of Use (TOCTOU) issues. They use a bank transfer as an example. The first destructive pattern they call out is Calculations Using Current Database State. This is where a query is made to the DB and validation is performed. However, the information in the query doesn't consider the other transactions being executed. In the case of a bank transfer, this could allow two transfers of $100, even though your account only has $100 total. The first update puts it to zero, while the second puts it to -$100.
The next pattern is Calculations Using Stale Values. This happens when the code reads the current state of an entry, performs calculations, and then calls UPDATE based upon this. In the case of a bank transfer, this leads to multiple operations appearing as a single one. The value subtracted is $100 that should be done twice on the value. Instead, it's done a single time because of the values that the update has access to.
Given the complexity of current applications, they were uncertain about the viability of this attack. So, they set up an application in AWS Fargate with a chosen database in either Golang or Node. After running the attack described above on a bank-transfer-like endpoint, they were able to hit the attack on all settings except when the Serializable level was used. Pretty neat!
How do we mitigate this? Conceptually, critical sections should be put at the beginning of a transaction to ensure database entry isolation. In practice, the easiest thing to do is add Serializable transaction isolation level to these transactions. This would have a large impact on the application though. Another option is to add a MUTEX via FOR SHARE or FOR UPDATE on SELECT operations. This will instruct the database to wait until the transaction is complete, allowing for the reading/editing of these fields. A final way is to add a version row to each column. By comparing the version on the read vs. the write, it will prevent race conditions.
Overall, a great post on exploiting TOCTOU issues pertaining to databases. I particularly enjoyed the mitigations section of it, as this is a tough issue to fix.