In this lesson, you'll learn design patterns that help mitigate Reentrancy attacks. After you've learned the theory, you'll fix a Reentrancy bug in the lab environment!
Tip
Do you already have a grasp on how to defend against Reentrancy attacks? Feel free to move to the Reentrancy Fundamentals: Defend Lab and put your knowledge to the test!
Invariants are properties of the program state that should always be true.
Think of invariants like accounting rules. For example, your bank account's records should always match the actual money in the account. If there's ever a mismatch, something is wrong.
As you write code, it's vital to document what your invariants are. Within the Vulnerable contract, the primary invariant is: the sum of all balances should equal the contract's ETH balance.
Mental Model
Invariants can break for brief moments of time. For example, the time between
Marker 1 and
Marker 2. When you spot this violation, it's a hint that you should think about various edge cases. In particular, you should ponder: "How would a financially motivated Attacker exploit this condition?" In this case, Vulnerable Contract calls into an untrusted contract (Attacker Contract) while invariants are violated. This gives the Attacker Contract the ability to steal ETH.
One way to learn about an "ideal" implementation is to learn non-ideal variants. As you experiment with this pattern, reflect on how this pattern might NOT be ideal.
OPTIONAL: If you previously opened the lab environment and made changes to Vulnerable.sol, you'll need to close Vulnerable.sol and run the revert command within the lab environment's terminal.
In the lab environment, open Vulnerable.sol.
Apply the code diff (above) to Vulnerable.sol.
Reflection
Before moving forward, try to predict how this will affect the Reentrancy vulnerability.
In the lab environment's terminal, execute cv.
Behind the scenes, the cv command will automatically invoke Attacker.attack().
While it may seem counterintuitive, often the best time to receive a quiz is before you "officially" learn the content. Even if you don't answer "correctly", this process can strengthen your knowledge retention. Try your best to answer the questions below before viewing the Answer section.
The code changes prevented the Reentrancy attack. Why did this occur?
Review the Experimental Challenge's terminal output
Tip:
This event trace was generated from the 'Code' section (above).
Notice the OUT_OF_GAS error that prevented the Reentrancy attack. To understand why this occurred, we need to understand send() in more detail.
Solidity's transfer() and send():
Transfer an amount of Ether.
Forward 2300 gas to the recipient. In practice, this means a recipient contract's fallback function would have 2300 gas to execute its logic.
The whole reason transfer() and send() were introduced was to address the cause of the infamous hack on The DAO. The idea was that 2300 gas is enough to emit a log entry but insufficient to make a reentrant call that then modifies storage.
As explained by Consensys, the idea behind the 2300 gas limit is to prevent reentrant state changes. In this case, the 2300 gas limit prevented the Reentrancy attack.
While the 2300 gas limit prevented this particular attack, there might be some edge cases to consider. You'll explore this topic in the next question.
As it relates to Reentrancy, can you think of any edge cases with the send() coding pattern?
Think of this pattern's assumptions.
transfer() and send() were previously the recommended approach for preventing Reentrancy attacks. It was assumed that Reentrancy couldn't occur with a 2300 gas limit. However, this recommendation was based on the assumption that opcode pricing would stay constant. EIP-1884 showed us that opcode pricing can change 1.
What happens if the price of an opcode decreases? In this situation, the 2300 gas could then "buy" more execution power. This additional execution power could then be leveraged for Reentrancy.
Key takeaways:
Incorrect Assumption
: Opcode pricing isn't stable. When considering a defensive pattern, ensure that your reasoning is "future proof".
Analysis
: If you update Marker 1 to transfer() or send(), you can not currently leverage Reentrancy to steal ETH. However, opcode pricing can change so you shouldn't use this pattern.
A Reentrancy guard is a piece of code that causes execution to fail when Reentrancy is detected. There is an implementation of this pattern in OpenZeppelin Contracts called ReentrancyGuard, which provides the
nonReentrant modifier. Applying this modifier to a function will render it “non-reentrant”, and attempts to re-enter this function will be rejected by reverting the call.
Vulnerable.sol that exhibits the Reentrancy guard pattern.
Tip
Green highlights: Correspond to additions.
Red highlights: Correspond to removals.
nonReentrant works like a simple lock system. When a function with the nonReentrant modifier is called, ReentrancyGuard.sol will (behind the scenes) update a _status state variable. You can think of _status as holding the lock/unlocked status.
When a
nonReentrant function starts executing, it sets a lock (changing
_status from NOT_ENTERED to ENTERED). If another function (in the call stack) tries to call ANY of the contract's nonReentrant functions, that call will be reverted. Once the function finishes execution, it removes the lock (setting _status back to NOT_ENTERED).
OPTIONAL: If you previously opened the lab environment and made changes to Vulnerable.sol, you'll need to close Vulnerable.sol and run the revert command within the lab environment's terminal.
In the lab environment, open Vulnerable.sol.
Apply the code diff (above) to Vulnerable.sol.
Reflection
Before moving forward, try to predict how this will affect the Reentrancy vulnerability
In the lab environment's terminal, execute cv.
Behind the scenes, the cv command will call Attacker.attack().
After executing cv, you'll notice that Attacker.sol wasn't able to steal any ETH from Vulnerable.sol. Although nonReentrant worked in this example, can this pattern be cumbersome to maintain?
Tip:
This event trace was generated from the 'Code' section (above).
Yes! This pattern can be cumbersome to maintain.
At Marker 1, you'll notice the Attacker received their ETH balance (via Attacker.receive()) but the Vulnerable.balances[ATTACKER_ADDRESS] state hasn't been updated. Into the future, imagine that another developer created a new method (i.e., Vulnerable.newMethod()) that used Vulnerable.balances[ATTACKER_ADDRESS].
At Marker 2, the Attacker could call any method in the contract. So instead of calling Vulnerable.withdrawAll(), imagine the Attacker called Vulnerable.newMethod(). As Vulnerable.newMethod() uses the stale balances value, it could also be vulnerable to Reentrancy. So in this case, the developer would need to add the nonReentrant modifier to Vulnerable.newMethod() to lower the probability of Reentrancy. The Vulnerable.newMethod() example is typically classified as
Cross-Function
Reentrancy. Cross-Function Reentrancy occurs when one function initiates an external call (Vulnerable.withdrawAll()) before updating the state (Vulnerable.balances) and the external contract (Attacker.sol) calls another function (Vulnerable.newMethod()) that depends on this state.
Incorrect Assumption
As you can imagine, the author of Vulnerable.newMethod() might not understand the limitations of Vulnerable.balances[ATTACKER_ADDRESS] and thereby might not remember to add the nonReentrant modifier. Ideally, the Reentrancy issue would be addressed where the invariants were originally violated (i.e., Vulnerable.withdrawAll()). We can't assume that other engineers will have the appropriate context.
Could
this code have any other Reentrancy vulnerabilities?
Think of ways that external contracts can observe another contract's state
Tip:
This event trace was generated from the 'Code' section (above).
Yes! This code could be vulnerable to Read-Only Reentrancy. You'll dive into this below.
Marker 2: At this point in the execution flow, the Attacker received their deposited balance (via Attacker.receive()) but the Vulnerable.balances[ATTACKER_ADDRESS] state hasn't been updated. In Vulnerable.sol, the balances are easily observable by other contracts due to the public modifier within mapping(address => uint256) public balances. Behind the scenes, Solidity transforms this public modifier into a view function. So in other words, after Marker 1, contracts that observe Vulnerable.balances[ATTACKER_ADDRESS] will be viewing an incorrect value. In fact, that's what
Marker 2 is showing. From the Attacker contract's perspective, it's reading the broken invariant. In this case, Vulnerable.sol can be vulnerable to Read-Only Reentrancy. The primary characteristic of Read-Only Reentrancy is that a broken invariant is read.
So, how could this be exploited?
Imagine there's another contract on the blockchain (i.e., VulnerableLender). Further, imagine that when VulnerableLender.gatherAssets() is called, it uses Vulnerable.balances to make its lending decision. At Marker 1, control of the execution is given to the Attacker contract. At this moment, the Attacker could call VulnerableLender.gatherAssets() and VulnerableLender would use the incorrect Vulnerable.balances value! This is an example of Cross-Contract Reentrancy and Read-Only Reentrancy. Cross-Contract Reentrancy occurs when multiple vulnerable contracts are involved in the Reentrancy vulnerability. Read-Only Reentrancy is a specific instance of Cross-Contract Reentrancy.
Unfortunately, the nonReentrant modifier can't be used to fix this vulnerability. First, you can't apply modifiers to public contract variables. Even if you manually created the view function (that the public variable created behind-the-scenes), nonReentrant still wouldn't work. As previously discussed, nonReentrant needs to update a state variable (i.e., _status). State changes can't occur within view functions.
In the ideal world, you would not violate invariants while calling untrusted contracts. However, if you can't avoid this situation, ReentrancyGuard is a pattern that you can explore. As mentioned, ReentrancyGuard might produce unexpected outcomes and if you implement it, you'll need to be mindful of its challenges. For simplicity, all ReentrancyGuard challenges were not outlined in this lesson.
The best way to combat Reentrancy vulnerabilities is to leverage the Checks-Effects-Interactions (CEI) pattern.
Using Checks-Effects-Interactions (CEI):
Classify all code statements as a Check, Effect or Interaction.
Checks will check a condition.
Effects will affect the contract state.
Interactions will interact with an external contract.
While coding, ensure the following order is preserved: Checks (occurs before) Effects (occurs before) Interactions.
The primary benefit of CEI (compared to other defensive patterns) is that it fixes the "core" problem. When CEI is properly implemented, attackers cannot exploit inconsistent contract states.
What are the pros and cons of this pattern?
Pros:
Simple to reason about.
Applies to all Reentrancy variants (e.g., Single-Function Reentrancy, Cross-Function Reentrancy, Cross-Contract Reentrancy, etc.)
Cons: Prone to error. All code statements need to be classified and ordered correctly. What occurs if someone forgot to add the correct ordering? Or what happens if a linter changes the order of code statements? To counteract these issues, you can use slither to help detect Reentrancy bugs as well as other security issues. You'll learn more about slither in the upcoming paragraphs.
Mental Model
Bugs happen. Always assume that a security protection will fail. At a minimum, you should have 2 security protections for every major vulnerability class. When you have multiple protections, you're applying defense-in-depth. Defense-in-depth is like protecting a medieval castle - no single defense is
perfect, so multiple layers work together. The moat might freeze over, the guards might be bribed, but an Attacker would need to overcome ALL of these independent defenses to come inside. By having multiple varied defenses, even if one layer fails (or has "bugs"), the others continue protecting your valuable assets.
In the next experimental challenge, you'll leverage
slither to detect a CEI violation. slither is a Solidity & Vyper static analysis framework maintained by Trail Of Bits. It runs a suite of vulnerability detectors, prints visual information about contract details, and provides an API to easily write custom analyses. slither enables developers to find vulnerabilities, enhance their code comprehension, and quickly prototype custom analyses.2
OPTIONAL: If you previously made changes to Vulnerable.sol, you'll need to close Vulnerable.sol and run the revert command within the lab environment's terminal.
Navigate to the Code section and focus on withdrawAll(). Where applicable, classify all code statements as a Check, Effect or Interaction. What order did you observe?
Check
Interaction
Effect
Notice how the Checks-Effects-Interactions (CEI) pattern is violated. This is why the code is vulnerable to Reentrancy.
In the lab's terminal, execute
slither $BLOCKBASH_WORKSPACE_DIR_PATH/Vulnerable.sol. Is the output expected?
Yes, this output is expected! slither correctly found the broken invariant (Vulnerable.balances) and disclosed where this invariant is used.
It's highly recommended to run slither within a CI pipeline so you can find vulnerabilities before deploying to production. If the CEI pattern is violated, slither might give insight. If your CI pipeline leverages Github Actions, you're in luck! The creators of slither, created a Github Action that you can leverage.
Reentrancy can occur in various forms (e.g., Single-Function Reentrancy, Cross-Function Reentrancy, Cross-Contract Reentrancy, Read-Only Reentrancy).
The best way to combat Reentrancy vulnerabilities is to leverage the Checks-Effects-Interactions (CEI) pattern.
Checks-Effects-Interactions can be prone to error. While no tool is perfect, you can leverage slither in your CI pipeline to notify you of potential CEI violations.
Content should be used for educational purposes only. You should not leverage this content for nefarious purposes. Blockbash's authors are not liable for misuse of this content.
Everyone makes mistakes, including the authors of Blockbash. All content and recommendations should be verified via another source. Blockbash's authors are not liable for any mistakes. If you've found an error, please create a Github Issue.