DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Having an EOA for very critical actors may lead to total protocol takeover


Summary

In RAAC, there are critical roles such as Minter, Collector, Executor, Oracle, Manager, and Deployer. These actors hold high-privilege functions that significantly contribute to the protocol's stability and security. If any of these roles are assigned to an EOA, the protocol may be at risk, as delegating crucial functions to a malicious actor due to a private key leak is a common attack vector, especially with the rise of unvetted tooling and codebases.


Vulnerability Details

Let us examine the RAACToken contract located in 2025-02-raac/contracts/core/tokens/RAACToken.sol. We will specifically focus on two major roles in this contract: RAACToken::initialOwner and RAACToken::minter. The owner has the ability to perform core functionalities within this contract, and so does the minter. This is evident from the additional modifier on the functions:

modifier onlyOwner() {
_checkOwner();
_;
}

Similarly, for the minter:

modifier onlyMinter() {
if (msg.sender != minter) revert OnlyMinterCanMint();
_;
}

Even though there is a check to ensure that the addresses of RAACToken::initialOwner and RAACToken::minter are not 0, this check alone is insufficient, especially with the increasing risk of private key leaks. It is advisable to add another verification step to ensure that the addresses provided are not EOAs but rather deployer contracts. The vulnerability lies in RAACToken::constructor and RAACToken::setMinter.

RAACToken::constructor

constructor(
address initialOwner,
uint256 initialSwapTaxRate,
uint256 initialBurnTaxRate
) ERC20("RAAC Token", "RAAC") Ownable(initialOwner) {
// @audit! Can the initialOwner be an EOA? Why not incorporate the condition `initialOwner.code.length == 0`?
// Just asking. It may be vulnerable to a private key leak.
if (initialOwner == address(0)) revert InvalidAddress();
if (initialOwner.code.length == 0) revert RAACToken__EOAOwner();
feeCollector = initialOwner;
if (initialSwapTaxRate > MAX_TAX_RATE) revert SwapTaxRateExceedsLimit();
swapTaxRate = initialSwapTaxRate == 0 ? 100 : initialSwapTaxRate; // Default to 1% if 0
emit SwapTaxRateUpdated(swapTaxRate);
if (initialBurnTaxRate > MAX_TAX_RATE) revert BurnTaxRateExceedsLimit();
burnTaxRate = initialBurnTaxRate == 0 ? 50 : initialBurnTaxRate; // Default to 0.5% if 0
emit BurnTaxRateUpdated(burnTaxRate);
}

RAACToken::setMinter

function setMinter(address _minter) external onlyOwner {
// @audit! Can the minter be an EOA? Why not incorporate the condition `initialOwner.code.length == 0`?
// Just asking. It may be vulnerable to a private key leak.
if (_minter == address(0)) revert InvalidAddress();
minter = _minter;
emit MinterSet(_minter);
}

Impact

  • If a private key leak occurs due to a malicious script, the account may be compromised.

  • The compromised account may fall into the hands of a malicious actor, and since a proxy contract was not used, recovery becomes nearly impossible.

  • The malicious actor can now execute critical functions like RAACToken::setMinter.

  • Below is proof of concept demonstrating this vulnerability:

Project setup

Add foundry setup to the project by:

  1. Installing hardhat-foundry: npm i --save-dev @nomicfoundation/hardhat-foundry

  2. Add require("@nomicfoundation/hardhat-foundry"); to the top of your hardhat.config.js file.

  3. Run cp .env.example .env for environment variables used

  4. Run npx hardhat init-foundry in your terminal. This will generate a foundry.toml file based on your Hardhat project’s existing configuration, and will install the forge-std library

  5. Run forge soldeer init for simple dependencies management, specifically forge-std. It will generate soldeer.lock, remappings.txt, and a dependencies folder with all the dependencies

We are testing the contract ```RAACToken`` access control. Create a folder test in the tokens folder contracts/core/tokens/ such that contracts/core/tokens/test/ and create a file inside it RAACToken.t.sol Add these contents to the file RAACToken.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import { Test } from '../../../../dependencies/forge-std-1.9.6/src/Test.sol';
import {console} from '../../../../dependencies/forge-std-1.9.6/src/console.sol';
import { RAACToken } from '../RAACToken.sol';
contract FakeDeployer {}
contract TestRAACToken is Test {
RAACToken public rAACToken;
FakeDeployer fakeDeployer;
address public owner;
function setUp() public {
fakeDeployer = new FakeDeployer();
owner = address(fakeDeployer);
rAACToken = new RAACToken(owner, 50, 50);
fakeDeployer = new FakeDeployer();
}
function test_SetMinter_WithEOA_ShouldSucceed() external {
address newMinter = address(2);
vm.prank(owner);
rAACToken.setMinter(newMinter);
address actualMinter = rAACToken.minter();
assertEq(newMinter, actualMinter);
console.log(" The length of the address code is ", newMinter.code.length);
}
function test_SetMinterV2_WithEOA_ShouldRevert() external {
address newMinter = address(2);
vm.prank(owner);
vm.expectRevert(RAACToken.RAACToken__EOAMinter.selector);
rAACToken.setMinterV2(newMinter);
address actualMinter = rAACToken.minter();
assertFalse(newMinter == actualMinter);
}
function test_SetMinterV2_WithContract_ShouldSucceed() external {
address newMinter = address(fakeDeployer);
vm.prank(owner);
rAACToken.setMinterV2(newMinter);
address actualMinter = rAACToken.minter();
assertEq(newMinter, actualMinter);
console.log(" The length of the address code is ", newMinter.code.length);
}
}

⚠️ Important: I had to change these lines of code on the file test/unit/libraries/ReserveLibraryMock.sol because they were getting on the way due to arguments errors

+ ReserveLibrary.ReserveData private reserveData;
function deposit(uint256 amount) external nonReentrant {
- reserveData.deposit(amount);
+ reserveData.deposit(rateData, amount, msg.sender);
}
function withdraw(uint256 amount) external nonReentrant {
- reserveData.withdraw(amount);
+ reserveData.withdraw(rateData, amount, msg.sender);
}
- function setPrimeRate(uint256 newPrimeRate) external {
- reserveData.primeRate = newPrimeRate;
- }
- function updateReserveInterests() external {
- reserveData.updateReserveInterests();
- }
- function calculateUtilizationRate() external view returns (uint256) {
- return reserveData.calculateUtilizationRate();
- }

The FakeDeployer contract is simply an empty contract ensuring that its code.length is non-zero, unlike an EOA

  1. Run the test through this command forge test --mt test_SetMinter_WithEOA_ShouldSucceed -vvv

⚠️ Important: The test might fail because I added another function to handle the revert test. You can simply copy the code into the RAACToken contract

// error handlers
+ error RAACToken__EOAOwner();
+ error RAACToken__EOAMinter();
+ function setMinterV2(address _minter) external onlyOwner {
+ // @audit! can the initialowner be an EOA? Why not incorporate the condition ```initialOwner.code.length == 0```?
+ // Just asking. It may be vulnerable to a private key leak
+ if (_minter == address(0)) revert InvalidAddress();
+ if (_minter.code.length == 0) revert RAACToken__EOAMinter();
+ minter = _minter;
+ emit MinterSet(_minter);
+ }

With the above changes, the test should successfully run and pass for setting a minter.

The first test confirms that the minter role (and all other critical roles) in RAAC, can be assigned to an EOA, which is not recommended and cannot be overemphasized. This introduces a single point of failure when an entire business hinges on the security of one private key. The test IRAACToken::test_SetMinter_WithEOA_ShouldSucceed passes, indicating that an EOA can become a minter. This risk extends to the owner role, posing significant threats to the protocol. With a console log, we observe the output of the code as shown below

Test Output:

(base) vik@vik:~/projects/auditing/2025-02-raac$ forge test --mt test_SetMinter_WithEOA_ShouldSucceed -vvv
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for contracts/core/tokens/test/RAACToken.t.sol:TestRAACToken
[PASS] test_SetMinter_WithEOA_ShouldSucceed() (gas: 40800)
Logs:
The length of the address code is 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.35ms (390.81µs CPU time)
Ran 1 test suite in 35.97ms (2.35ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
(base) vik@vik:~/projects/auditing/2025-02-raac$

Tools Used

  • Foundry for testing and runtime (through the hardhat-foundry npm package)

  • Soldeer for dependency management.


Recommendations

RAAC should utilize a proxy contract or multi-signature wallet for critical roles. While a dedicated development environment could help, it does not fully mitigate the risk,due to human errors and forgetfulness. Implementing a proxy pattern is recommended. Additional checks should be included in the constructor for setting the initial owner to not accept an externally owned account controlled by a private key. Below is the recommended fix:

+ error RAACToken__EOAOwner();
+ error RAACToken__EOAMinter();
constructor(
address initialOwner,
uint256 initialSwapTaxRate,
uint256 initialBurnTaxRate
) ERC20("RAAC Token", "RAAC") Ownable(initialOwner) {
if (initialOwner == address(0)) revert InvalidAddress();
// @> now there is a check to confirm that the deployer of the contract is a proxy contract
+ if (initialOwner.code.length == 0) revert RAACToken__EOAOwner();
feeCollector = initialOwner;
}
function setMinter(address _minter) external onlyOwner {
if (_minter == address(0)) revert InvalidAddress();
// @> now there is a check to confirm that the minter of the contract is a proxy contract
+ if (_minter.code.length == 0) revert RAACToken__EOAMinter();
minter = _minter;
emit MinterSet(_minter);
}

This ensures that only smart contracts can be assigned to critical roles, greatly reducing the risk of a full contract compromise.

Tests 2 and 3 prove a successful implement these changes

function test_SetMinterV2_WithEOA_ShouldRevert() external {
address newMinter = address(2);
vm.prank(owner);
vm.expectRevert(RAACToken.RAACToken__EOAMinter.selector);
rAACToken.setMinterV2(newMinter);
address actualMinter = rAACToken.minter();
assertFalse(newMinter == actualMinter);
}

the above test TestRAACToken::test_SetMinterV2_WithEOA_ShouldRevert should pass if there is a revert and it does as can be seen below

(base) vik@vik:~/projects/auditing/2025-02-raac$ forge test --mt test_SetMinterV2_WithEOA_ShouldRevert -vvv
[⠒] Compiling...
[⠃] Compiling 3 files with Solc 0.8.28
[⠊] Solc 0.8.28 finished in 3.65s
Compiler run successful!
Ran 1 test for contracts/core/tokens/test/RAACToken.t.sol:TestRAACToken
[PASS] test_SetMinterV2_WithEOA_ShouldRevert() (gas: 16687)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.90ms (307.94µs CPU time)
Ran 1 test suite in 29.99ms (1.90ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Test 3 TestRAACToken::test_SetMinterV2_WithContract_ShouldSucceed however passes because we have set a contract address as the minter, protecting against potential primary key leak

function test_SetMinterV2_WithContract_ShouldSucceed() external {
address newMinter = address(fakeDeployer);
vm.prank(owner);
rAACToken.setMinterV2(newMinter);
address actualMinter = rAACToken.minter();
assertEq(newMinter, actualMinter);
console.log(" The length of the address code is ", newMinter.code.length);
}

The test passes

(base) vik@vik:~/projects/auditing/2025-02-raac$ forge test --mt test_SetMinterV2_WithContract_ShouldSucceed -vvv
[⠢] Compiling...
No files changed, compilation skipped
Ran 1 test for contracts/core/tokens/test/RAACToken.t.sol:TestRAACToken
[PASS] test_SetMinterV2_WithContract_ShouldSucceed() (gas: 45494)
Logs:
The length of the address code is 62
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.79ms (1.09ms CPU time)
Ran 1 test suite in 57.61ms (5.79ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

In summary, these are some of the recommendations

  • Use the proxy pattern for ease of upgradeability and security (best)

  • Use dedicated development environment (not independent though of the deployer contract)

  • Add checks to ensure that an EOA has not been given critical roles


Updates

Lead Judging Commences

n0kto Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Other
Assigned finding tags:

Informational or Gas

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelihood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

n0kto Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Other
Assigned finding tags:

Informational or Gas

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelihood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

Support

FAQs

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