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.