Re-entracy attacks in solidity
2024 Apr 26
See all posts
Re-entracy attacks in solidity
Re-entrancy attacks typically occur when a contract calls an
untrusted external contract, which then calls back into the original
contract before the first execution completed. This can leads to
unwanted effects like draining of funds.
Consider this:
function transfer(address to, uint amount) external {
if (balances[msg.sender] >= amount) {
balances[to] += amount;
balances[msg.sender] -= amount;
}
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
}
The withdraw function calls the attacker's fallback function, which
makes a call to transfer, allowing to transfer amount to a attacker
owned address (to
) despite withdraw having made the
transfer out. This happens because the state is not updated
(balances
) and a call to external contract is made, which
can then re-enter the original contract, wherein some state invariant is
broken.
Most re-entrancy attacks involve send
,
transfer
and call
functions. send
and transfer
are considered safe because they are limited
to 2300 gas, which prevents them from calling other smart contracts
(though gas limit can be incremented when making a send/transfer). The
call
function is more vulnerable, which forwards all the
gas (63/64th of the gas) and can therefore call other contracts.
There are various ways to prevent this. One way which is typically
used is to block SC from calling certain functions in your contract
(msg.sender == tx.origin
). This means only EOAs can make
calls to your contract. Although, this invariant is broken in EIPs based
around account abstraction (like 3074). So, apart from being limiting,
this is also not a future proof startegy.
Another way to avoid is to use checks-effects-interactions pattern in
your smart contracts. First, put in all the assertions, then make the
state changes your contract needs (at the end of which invariants are
maintained) and then interact with other smart contracts (if needed).
This can also help fix the example above, where zero-ing out the balance
in withdraw
should be done before making the external
contract call.
The most comprehensive way seems to be to use a contract storage
based "lock" or mutex. The lock turns on when it enters a function
(checking that it is off before), and then turns off when function
execution is done. This ensures that the function (or the group of
functions where the same lock is used) is not re-entered. In fact,
OpenZeppelin has a ReentrancyGuard
which implements
this lock, by providing a nonReentrant
modifier. If the
chain implements EIP-1553 (transient storage), one can also used
ReentrancyGuardTransient
which stores the lock values in
transient storage (thereby being less expensive).
There's also a "pull payment" pattern, wherein the idea is that the
function needed to do the transfer puts it in an escrow account, from
which the funds need to be "pulled" out in a separate tx by the user.
This is implemented by openzeppelin's PullPayment.
bibliography
Re-entracy attacks in solidity
2024 Apr 26 See all postsRe-entrancy attacks typically occur when a contract calls an untrusted external contract, which then calls back into the original contract before the first execution completed. This can leads to unwanted effects like draining of funds.
Consider this:
The withdraw function calls the attacker's fallback function, which makes a call to transfer, allowing to transfer amount to a attacker owned address (
to
) despite withdraw having made the transfer out. This happens because the state is not updated (balances
) and a call to external contract is made, which can then re-enter the original contract, wherein some state invariant is broken.Most re-entrancy attacks involve
send
,transfer
andcall
functions.send
andtransfer
are considered safe because they are limited to 2300 gas, which prevents them from calling other smart contracts (though gas limit can be incremented when making a send/transfer). Thecall
function is more vulnerable, which forwards all the gas (63/64th of the gas) and can therefore call other contracts.There are various ways to prevent this. One way which is typically used is to block SC from calling certain functions in your contract (
msg.sender == tx.origin
). This means only EOAs can make calls to your contract. Although, this invariant is broken in EIPs based around account abstraction (like 3074). So, apart from being limiting, this is also not a future proof startegy.Another way to avoid is to use checks-effects-interactions pattern in your smart contracts. First, put in all the assertions, then make the state changes your contract needs (at the end of which invariants are maintained) and then interact with other smart contracts (if needed). This can also help fix the example above, where zero-ing out the balance in
withdraw
should be done before making the external contract call.The most comprehensive way seems to be to use a contract storage based "lock" or mutex. The lock turns on when it enters a function (checking that it is off before), and then turns off when function execution is done. This ensures that the function (or the group of functions where the same lock is used) is not re-entered. In fact, OpenZeppelin has a
ReentrancyGuard
which implements this lock, by providing anonReentrant
modifier. If the chain implements EIP-1553 (transient storage), one can also usedReentrancyGuardTransient
which stores the lock values in transient storage (thereby being less expensive).There's also a "pull payment" pattern, wherein the idea is that the function needed to do the transfer puts it in an escrow account, from which the funds need to be "pulled" out in a separate tx by the user. This is implemented by openzeppelin's PullPayment.
bibliography