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) {
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;
emit SwapTaxRateUpdated(swapTaxRate);
if (initialBurnTaxRate > MAX_TAX_RATE) revert BurnTaxRateExceedsLimit();
burnTaxRate = initialBurnTaxRate == 0 ? 50 : initialBurnTaxRate;
emit BurnTaxRateUpdated(burnTaxRate);
}
RAACToken::setMinter
function setMinter(address _minter) external onlyOwner {
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:
Installing hardhat-foundry: npm i --save-dev @nomicfoundation/hardhat-foundry
Add require("@nomicfoundation/hardhat-foundry"); to the top of your hardhat.config.js file.
Run cp .env.example .env for environment variables used
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
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
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
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
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