✅Demystifying Exploitable Bugs in Smart Contracts
7 categories of machine unauditable bugs
Intro
"Demystifying Exploitable Bugs in Smart Contracts" is a paper written by @i2huer et al. for ICSE 2023. Authors looked through many audit reports on Code4rena and Immunefi-like real-world exploits, and categoried "machine unauditable" bugs into 7 categories.
Usually we talk about "bug types" such as reentrancy, unsafe external call, delegatecall storage misalignment, etc, but we don't talk about "bug categories". In this paper authors listed 7 bug categories in smart contracts:
(C1) price oracle manipulation
(C2) erroneous accounting
(C3) ID uniqueness violations
(C4) inconsistent state updates
(C5) privilege escalation
(C6) atomicity violations
(C7) contract implementation specific bugs
These 7 categories represent a higher level of abstraction.
Repo:
This is the paper:
This is the paper's supplemental material:
This supplementary material contains a few vulnerable contract samples that the paper itself does not include. Not sure why, but I will use these samples since I enjoy learning new things by examples.
MUBs vs. MABs
Machine Unauditable Bug (MUB) means exploitable bug that cannot be detected with existing tools.
More than 80% of the exploitable bugs are MUBs.
Price oracle manipulation and privilege escalation are the most common bugs in real-world exploits.
Accounting error is the most common bug in audit contests.
In contrast, if a bug can be detected with existing tools, then it is called a Machine Auditable Bug (MAB).
Statistics
Code4rena
The most popular and lucrative categories on Code4rena are:
Lending
Lending projects facilitate the borrowing and lending of assets between users. Lenders deposit their assets into the project, earning interest, while borrowers borrow assets by providing collateral.
Dexes
Dex projects provide a platform for users to exchange assets in a decentralized manner. Uniswap, which we have discussed in detail in the main text, is one of the most well-known Dex projects.
Yield
Yield projects reward users for staking their funds. Users deposit their funds into the project, and the project invests the funds in various other opportunities. All users share in the profits of the investment, based on their staking shares.
Services
Service projects offer essential functionalities that can be utilized by other DeFi projects. For example, governance voting is a popular DeFi service, as is the tokenization of seed investors’ and founders’ funds.
Derivatives
Derivative projects, like their traditional finance counterparts, derive their value from the performance of an underlying entity. Futures and options are two common derivative products in DeFi.
Real-world exploits
"Findings"
Authors list 10 "findings" during their research. They are:
Although the DeFi community has heavily invested on protecting their products, the current supply of tools and human auditor resources have not met the demand.
Existing techniques rely on simple and general oracles or hand-coded ones that are project specific. Such oracles may not be sufficient for functional bugs in general.
A large portion of exploitable bugs in the wild (i.e., 80%) are not machine auditable.
Majority of exploitable bugs are difficult to find.
There are no obvious differences between audit difficulty distributions of MABs and MUBs.
MUBs can be classified to 7 categories, with 85% belonging to categories C1-C6 that are not project specific.
Different types of MUBs have different popularity, with accounting errors (C2) and price oracle manipulation (C1) most popular in the Code4rena bugs and the real exploits, respectively. Auditing is particularly effective in preventing certain bugs such as accounting errors.
Different types of MUBs have different auditing difficulties, with price oracle manipulation and ID uniqueness violation bugs the hardest and the easiest, respectively.
Different kinds of DeFi projects tend to be prone to different types of MUBs.
Five out of the seven MUB categories (accounting for 60% of MUBs), namely, all except (C2) accounting errors and (C7) implementation specific bugs, have general abstract models which may serve as oracles for future automated tools.
Personally I think finding 6, 9, 10 are innovative:
Finding 6 -> Most bugs have universality
Finding 9 -> Auditors should focus on different bug categories for different types of projects
Finding 10 -> Accounting errors and implementation specific bugs are the future.
MABs
MAB categories:
I am not very interested in MABs at this moment. Usually developers would run common tools before turning in the code for audit test. It means the MABs left in the code are the difficult ones. I don't think you can easily find them unless you have customized tools.
MUBs
Authors categoried MUBs into 7 categories. This is the essence of this paper, stay focused.
(C1) Price Oracle Manipulation -> most common in real-world exploits
This bug happens when price oracle does not return correct price and lend to asset loss.
AMM is oftentimes vulnerable to this bug. For example, Uniswap has an official API for querying prices but it has high gas cost. For saving gas, developers tend to implement their own queries.
Before diving into the bug, let's review Uniswap's swap()
function:
Carbon version of the code:
amount0In * 3
represents a contract fee of 0.3%. A multiplier 1000 is used here to make 0.003 * 1000 = 3. By doing so we convert floating point computation into integer computation.
In the require
statement, >=
is used because the >
case benefits the protocol. User can transfer more tokens into the contract than needed and the contract will take that.
You should be familiar with rest of the logic in this swap function.
The following vulnerable contract comes from Deus Finance. This contract suffered from price oracle manipulation that caused a loss of $3.1 millions:
Carbon version of the code:
The collateral factor of this protocol is 80%. If user's debt exceeds collateral * 80%, then the collateral will be liquidated.
Once liquidation condition is met, a user can call liquidate()
to pay for another user's debt and get his/her collateral. Inside this function, getPrice()
is called as price oracle. The developer decided to use this customized function instead the official Uniswap API.
Here is an attack scenario:
Bob (victim) deposits 100 WETH as collateral and borrows 100,000 USDC.
Assume the current price of WETH is $4,000 and the Uniswap AMM holds 100 WETH and 400,000 USDC.
Bob's position is healthy at this stage since his debt is 100,000 USDC and his collateral worths 400,000 USDC.
Alice (adversary) encapsulates the following 5 operations within a single malicious transaction:
Borrow 100 WETH flash loan. Flash loan attack works because the entire attack happens within a single transaction.
Exchange 100 WETH for 200,000 USDC through Uniswap. Now AMM has 200 WETH and 200,000 USDC, therefore the AMM is still balanced since
100 * 400000 == 200 * 200000
. Note that the real-world WETH price is $4,000 but the WETH price in the AMM becomes $1,000. Bob's collateral suddenly devalued a lot.Call
liquidate(bob)
. This is possible because Bob's debt is 100,000 USDC and his collateral worths 100,000 USDC. This ratio exceeds the 80% collateral factor, hence his collateral is liquidable. By paying 100,000 USDC, Alice gets 100 WETH that worths 400,000 USDC in the real world. She makes $300,000 profit here.Exchange 200,000 USDC for 100 WETH. This would undo operation 1 and rebalance the AMM. In this attack, Alice made $300,000 profit with 0 cost.
Repay flash loan debt.
To prevent price oracle manipulation, most on-chain DEXes provide manipulation-resistant APIs for price queries. Time-weighted average price (TWAP) is the most common solution nowadays. It is a pricing algorithm that calculates the average price of an asset over a set period. It provides great resistance against flash loans. Recall that a flash loan has to happen within a single transaction and hence the time weight of its manipulated price is 0.
(C2) Erroneous Accounting -> most common in audit contests
The following is a code snippet of the LFW ecosystem, which has been exploited and lost $0.21 millions:
Carbon version of the code:
User would call swap()
to swap token0
for token1
:
The first input amount1Out
means how much token1
user wants to swap for, the second input to
is user's receiving address.
amount0In * 22
represents a 0.22% contract fee. When calculating balance0Adj
, developer used 10000 as multiplier to avoid expensive floating point computation. Since 0.22% = 0.0022, we need 10000 as multiplier so that 0.0022 * 10000 = 22.
However, in the require
statement, the multiplier was mistakenly set to 1000. This leads to pricing error. How? Let's do the math:
If we ignore the contract fee, the formula is simplified to:
It indicates that the attacker only needs to pay 1/10 of expected token0
to swap for token1
.
(C3) ID Uniqueness Violations -> common in audit contests
This vulnerable code snippet comes from the Foundation contest on Code4rena:
Carbon version of the code:
A NFT seller would call createReserveAuction()
to start an auction on the NFT to be sold. This function takes two inputs, the first input nftContract
is the address of the NFT being sold, and the second input tokenId
is the ID of the "currency" the seller want to sell the NFT for.
Within createReserveAuction()
, _transferToEscrow()
is called. This internal function looks up the auction ID and determines if the ID is 0. If it is, super._transferToEscrow()
will be called and a new auction will be initialized.
However, note that the uniqueness of nftContract
and tokenId
is not guaranteed. That means createReserveAuction()
can be called with the same input for multiple times.
Here is an attack scenario:
Attacker calls
createReserveAuction()
twice with the samenftContract
andtokenId
.The first invocation is intended. It correctly transfers NFT and creates a new auction.
The second invocation is unintended. Although the check in
_transferToEscrow()
would fail, but it does not revert. A new auction will still be created. Note that this new auction is empty because nothing was transferred to it.At this moment there are 2 auctions for the same NFT. The 1st auction actually contains the NFT and the 2nd auction contains nothing.
Attacker cancels the 1st auction and gets the NFT back. Bidders are still bidding in the 2nd auction.
In the end a bidder becomes winner, but the NFT transfer will revert since the auction is empty. The winner's money is stuck in the protocol and there is no way to withdraw.
The root cause of this bug is the uniqueness of nftContract
-tokenId
pair is not guaranteed. To fix it, developers should add a check for duplicated auctions.
(C4) Inconsistent State Updates
This vulnerable contract comes from Sushi Trident contest phase 2 on Code4rena:
Carbon version of the code:
This is a burn function inside an AMM contract. A user would call burn()
to burn LP token and get the ERC20 token plus interest back. In this function a portion of both reserves should be transferred to the user.
It is easy to see the error:
Here the portion that is transferred to the user is not handled, only the fees are deducted.
The fix for this code snippet:
(C5) Privilege Escalation
This is a rewritten version of an anonymized real-world contract:
Carbon version of the code:
User can propose himself/herself as owner candidate by calling propose()
. A new proposal will be created. There can only be one on-going proposal.
Other users call vote()
to vote using voting tokens. The proposal lasts for 2 days.
When the voting phase ends, end()
will be called to set new owner. The voting token collected during vote()
calls must exceed 50% of the total supply.
The implementation looks legit, but there exists an unexpected call sequence together with flash loan attack that would result in privilege escalation. Here is how:
Alice proposes herself via
propose()
.When the deadline
proposal.sTime + 2 days
is approaching, Alice creates a single transaction that bundles the following operations:Flash loan a lot
votingToken
from protocol's AMM contract.Call
votingToken.transferFrom()
to send voting token to the contract directly -> this is the same as callingvote()
without actually calling it, so that therequire
statement is bypassed.Call
end()
to become owner.Get voting tokens back by calling
getLockedFunds()
.Pay off flash loan debt.
(C6) Atomicity Violations -> least common
A real-world vulnerability in PancakeSwap Lottery contract:
Carbon version of the code:
The business model contains 3 operations:
buying tickets
drawing winners
claiming prizes
Among these 3 operations, the "drawing winners" operation is vulnerable. The correct way of implementing it:
enterDrawingPhase()
-> this function should be called in a transaction to add a lockdraw()
-> this function should be called in another transaction and it should require that lock
We can see that the buy()
function is correctly implemented since it has a require
statement for the lock:
However, the contract also has a multibuy()
function acting as a batch-buy version of buy()
. This function does not require
the lock.
Here is an attack scenario:
The protocol enters drawing phase and
draw()
is called.Attacker observes the winning ticket ID in the mempool.
Attacker calls
multibuy()
to batch-buy winning tickets. This is doable sincemultibuy()
does not require thedrawingPhase
lock.
This is a typical front running attack.
(C7) Contract Implementation Specific Bugs
Here is a redacted version of Cartel contract containing an accounting bug that leads to $560,000 bounty:
Carbon version of the code:
This line is problematic:
It should be:
This bug is kind of sneaky because _allowances[from][to]
sounds like a legit syntax. However, imagine that from
, to
, and msg.sender
are three different people:
from
: Aliceto
: Bobmsg.sender
: Eve
Since _allowances[from][to]
has nothing to do with msg.sender
, abstractly, Eve is interfering with Alice and Bob's business.
Here is an attack scenario:
Alice approves Bob 10 tokens. Now
_allowances[alice][bob] = 10
.Eve calls
transferFrom(alice, bob, 0)
. That means "transfer 0 token from alice to bob".Inside
transferFrom()
,_approve(from, msg.sender, _allowances[alice][bob] - 0)
is executed.That is equivalent to
_allowances[from][msg.sender] = _allowances[alice][bob] - 0
. Here Eve is getting allowance for free.Eve now has 10 tokens allowance from Alice, so she can steal those tokens with a call
transferFrom(alice, msg.sender, 10)
.
This bug survives several rounds of audit. You can tell that it is hard to find because it is an accounting error that won't be caught by automated tools.
Last updated