Description
The mint price of any given NFT will be paid by the buyer using the RAACNFT::mint
function, claiming a certain NFT with an ID and a Price. While the contract is pulling the price and keeps it within, it lacks though functionality to either approve or forward those funds. Therefore locking them in the contract.
Vulnerable Code
RAACNFT::mint
:
function mint(uint256 _tokenId, uint256 _amount) public override {
uint256 price = raac_hp.tokenToHousePrice(_tokenId);
if(price == 0) { revert RAACNFT__HousePrice(); }
if(price > _amount) { revert RAACNFT__InsufficientFundsMint(); }
token.safeTransferFrom(msg.sender, address(this), _amount);
_safeMint(msg.sender, _tokenId);
if (_amount > price) {
uint256 refundAmount = _amount - price;
token.safeTransfer(msg.sender, refundAmount);
}
emit NFTMinted(msg.sender, _tokenId, price);
}
As you can clearly see the token (presumably crvUSD) is getting pulled from the sender into the contract. Now looking at the interface of IRAACNFT
:
interface IRAACNFT is IERC721, IERC721Enumerable {
function mint(uint256 _tokenId, uint256 _amount) external;
function getHousePrice(uint256 _tokenId) external view returns (uint256);
function addNewBatch(uint256 _batchSize) external;
function setBaseUri(string memory _uri) external;
function currentBatchSize() external view returns (uint256);
event NFTMinted(address indexed minter, uint256 tokenId, uint256 price);
event BaseURIUpdated(string uri);
error RAACNFT__BatchSize();
error RAACNFT__HousePrice();
error RAACNFT__InsufficientFundsMint();
error RAACNFT__InvalidAddress();
}
We can see that this contract does not have a transfer
or approve
function and therefore locks those ERC-20 tokens inside the contract.
PoC
Since the PoC is a foundry test I have added a Makefile at the end of this report to simplify installation for your convenience. Otherwise if console commands would be prefered:
First run: npm install --save-dev @nomicfoundation/hardhat-foundry
Second add: require("@nomicfoundation/hardhat-foundry");
on top of the Hardhat.Config
file in the projects root directory.
Third run: npx hardhat init-foundry
And lastly, you will encounter one of the mock contracts throwing an error during compilation, this error can be circumvented by commenting out the code in entirety (ReserveLibraryMocks.sol
).
And the test should be good to go:
After following above steps copy & paste the following code into ./test/invariant/PoC.t.sol
and run forge test --mt test_PocLockedFundsInNftContract -vv
pragma solidity ^0.8.0;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {CrvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACHousePrices} from "../../contracts/core/oracles/RAACHousePriceOracle.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
contract PoC is Test {
StabilityPool public stabilityPool;
LendingPool public lendingPool;
CrvUSDToken public crvusd;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
RToken public rToken;
DebtToken public debtToken;
DEToken public deToken;
RAACToken public raacToken;
RAACMinter public raacMinter;
address owner;
address oracle;
address user1;
address user2;
address user3;
uint256 constant STARTING_TIME = 1641070800;
uint256 public currentBlockTimestamp;
uint256 constant WAD = 1e18;
uint256 constant RAY = 1e27;
function setUp() public {
vm.warp(STARTING_TIME);
currentBlockTimestamp = block.timestamp;
owner = address(this);
oracle = makeAddr("oracle");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
uint256 initialPrimeRate = 0.1e27;
raacHousePrices = new RAACHousePrices(owner);
vm.prank(owner);
raacHousePrices.setOracle(oracle);
crvusd = new CrvUSDToken(owner);
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
vm.prank(owner);
crvusd.setMinter(owner);
vm.prank(owner);
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
initialPrimeRate
);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool = new StabilityPool(address(owner));
deToken.setStabilityPool(address(stabilityPool));
raacToken = new RAACToken(owner, 0, 0);
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
stabilityPool.initialize(address(rToken), address(deToken), address(raacToken), address(raacMinter), address(crvusd), address(lendingPool));
vm.prank(owner);
raacToken.setMinter(address(raacMinter));
attacker = new Attacker(address(raacNFT));
crvusd.mint(address(attacker), type(uint128).max);
crvusd.mint(user2, type(uint128).max);
crvusd.mint(user3, type(uint128).max);
}
function test_PocLockedFundsInNftContract() public {
vm.startPrank(oracle);
raacHousePrices.setHousePrice(2, 5e18);
vm.stopPrank();
crvusd.mint(user1, 7e18);
vm.startPrank(user1);
crvusd.approve(address(raacNFT), 7e18);
raacNFT.mint(2, 7e18);
vm.stopPrank();
assertEq(crvusd.balanceOf(user1), 2e18);
console.log("Locked crvUSD balance: ", crvusd.balanceOf(address(raacNFT)));
}
}
Running the above test results in:
Ran 1 test for test/invariant/PoC.t.sol:PoC
[PASS] test_PocLockedFundsInNftContract() (gas: 273042)
Logs:
Locked crvUSD balance: 5000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.10ms (1.08ms CPU time)
Ran 1 test suite in 27.33ms (9.10ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Showcasing that the funds, indeed, are remaining in the contract.
Impact
Since this behavior occurs during normal operation of the contract the likelihood is high.
Since the NFTs to be minted represent real estate in the real world, we are presumably talking about a couple of hundred thousand USD locked within. Therefore I rate the impact as high too.
As conclusion the severity results to high.
Tools Used
Foundry & Manual Review
Recommendations
One possibility would be to directly send the funds to another contract/wallet in which those funds would be supposed to be:
+ address public raacReceiver;
- function mint(uint256 _tokenId, uint256 _amount) public override {
+ function mint(uint256 _tokenId) public override {
uint256 price = raac_hp.tokenToHousePrice(_tokenId);
if(price == 0) { revert RAACNFT__HousePrice(); }
- if(price > _amount) { revert RAACNFT__InsufficientFundsMint(); }
- token.safeTransferFrom(msg.sender, address(this), _amount);
+ token.safeTransferFrom(msg.sender, raacReceiver, price);
_safeMint(msg.sender, _tokenId);
- if (_amount > price) {0
- uint256 refundAmount = _amount - price;
- token.safeTransfer(msg.sender, refundAmount);
- }
emit NFTMinted(msg.sender, _tokenId, price);
}
if this solution should not be possible an alternative would be to implement a simple approve function so approved contract can pull those funds:
+ function approveERC20(address _target, uint256 _amount) external onlyOwner {
+ token.approve(_target, 0); // Reset allowance in case we deal with USDT or so
+ token.approve(_target, _amount);
+ }
Appendix
Copy the following import into your Hardhat.Config
file in the projects root dir:
require("@nomicfoundation/hardhat-foundry");
Paste the following into a new file "Makefile" into the projects root directory:
.PHONY: install-foundry init-foundry all
install-foundry:
npm install --save-dev @nomicfoundation/hardhat-foundry
init-foundry: install-foundry
npx hardhat init-foundry
# Default target that runs everything in sequence
all: install-foundry init-foundry
And run make all