On January 2, 2024, at 18:53:38 UTC, the Radiant Protocol, deployed on Arbitrum, was compromised. The attacker exploited legacy issues in the AAVE and Compound code, leading to the theft of approximately $4.5 million in assets from Radiant Protocol’s newly deployed transaction pool.
Radiant is a fork of the AAVE project, primarily functioning as a collateral-based DeFi lending platform. In protocols like AAVE and Compound, there’s an inherent problem due to precision loss in calculations.
In the compromised code, the lendingpool.withdraw
and lendingpool.deposit
functions update and check the state of the reserve corresponding to the token in question. Ultimately, these functions call the asset’s corresponding aToken to execute mint
or burn
.
function deposit(
address asset,
uint256 amount,
address onBehalfOf,
uint16 referralCode
) public override whenNotPaused {
DataTypes.ReserveData storage reserve = _reserves[asset];
ValidationLogic.validateDeposit(reserve, amount);
address aToken = reserve.aTokenAddress;
reserve.updateState();
reserve.updateInterestRates(asset, aToken, amount, 0);
IERC20(asset).safeTransferFrom(msg.sender, aToken, amount);
if (IAToken(aToken).balanceOf(onBehalfOf) == 0) {
_usersConfig[onBehalfOf].setUsingAsCollateral(reserve.id, true);
emit ReserveUsedAsCollateralEnabled(asset, onBehalfOf);
}
IAToken(aToken).mint(onBehalfOf, amount, reserve.liquidityIndex);
emit Deposit(asset, msg.sender, onBehalfOf, amount, referralCode);
}
function withdraw(address asset, uint256 amount, address to) external override whenNotPaused returns (uint256) {
DataTypes.ReserveData storage reserve = _reserves[asset];
address aToken = reserve.aTokenAddress;
uint256 userBalance = IAToken(aToken).balanceOf(msg.sender);
uint256 amountToWithdraw = amount;
if (amount == type(uint256).max) {
amountToWithdraw = userBalance;
}
ValidationLogic.validateWithdraw(
asset,
amountToWithdraw,
userBalance,
_reserves,
_usersConfig[msg.sender],
_reservesList,
_reservesCount,
_addressesProvider.getPriceOracle()
);
reserve.updateState();
reserve.updateInterestRates(asset, aToken, 0, amountToWithdraw);
if (amountToWithdraw == userBalance) {
_usersConfig[msg.sender].setUsingAsCollateral(reserve.id, false);
emit ReserveUsedAsCollateralDisabled(asset, msg.sender);
}
IAToken(aToken).burn(msg.sender, to, amountToWithdraw, reserve.liquidityIndex);
emit Withdraw(asset, msg.sender, to, amountToWithdraw);
return amountToWithdraw;
}
Within these functions, the aToken’s mint
and burn
operations include an index
parameter. This parameter is used for the normalization of token assets, accounting for interest and earnings over time. As the liquidity in the pool changes, so does this index.
The actual amount minted or burned by the aToken is the deposit amount divided by this index, effectively scaling the amount.
function mint(address user, uint256 amount, uint256 index) external override onlyLendingPool returns (bool) {
uint256 previousBalance = super.balanceOf(user);
uint256 amountScaled = amount.rayDiv(index);
require(amountScaled != 0, Errors.CT_INVALID_MINT_AMOUNT);
_mint(user, amountScaled);
emit Transfer(address(0), user, amount);
emit Mint(user, amount, index);
return previousBalance == 0;
}
function burn(
address user,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyLendingPool {
uint256 amountScaled = amount.rayDiv(index);
require(amountScaled != 0, Errors.CT_INVALID_BURN_AMOUNT);
_burn(user, amountScaled);
IERC20(_underlyingAsset).safeTransfer(receiverOfUnderlying, amount);
emit Transfer(user, address(0), amount);
emit Burn(user, receiverOfUnderlying, amount, index);
}
...
uint256 internal constant RAY = 1e27;
...
function rayDiv(uint256 a, uint256 b) internal pure returns (uint256) {
require(b != 0, Errors.MATH_DIVISION_BY_ZERO);
uint256 halfB = b / 2;
require(a <= (type(uint256).max - halfB) / RAY, Errors.MATH_MULTIPLICATION_OVERFLOW);
return (a * RAY + halfB) / b;
}
The key issue lies in the calculation of amountScaled
. If the index is excessively large, it can lead to imprecision, potentially enabling asset theft.
Transaction ID: 0x1ce7e9a9e3b6dd3293c9067221ac3260858ce119ecb7ca860eac28b2474c7c9b
The attacker initially acquired $3 million in USDC through a flash loan. They then deposited $2 million USDC into the pool. As this attack occurred shortly after the pool’s deployment, the index was at its initial value of 1e27. The attacker manipulated this index by controlling the flash loan.
In the first flash loan, the attacker transferred $2 million USDC to the aToken address, then withdrew the USDC, leaving a tiny fraction (1 wei) in the contract. Through repeated flash loan operations, the attacker inflated the index from 1e27 to approximately 27e37.
Following this, the attacker borrowed around 90 ETH from the contract. Although the attacker’s balance in the contract was minimal, the inflated index magnified their assets, enabling the loan.
The attacker then deployed a new contract to repeatedly deposit and withdraw funds. The inflated index caused each deposit of 27e10 USDC to be calculated as 1 in terms of amountScaled
. Similarly, each withdrawal of 4e11 USDC (1.5 times the deposit) was also recorded as 1. This process allowed the attacker to repeatedly drain funds from the pool. Notably, the stolen funds consisted of USDC transferred into the aToken during the flash loan and fees generated from the loan itself.
The attacker then returned the flash loan, completing the heist.
Following the attack, Radiant Protocol suspended all lending functions in its pools. The project team reached out to the hacker via blockchain messages, attempting to negotiate the return of the stolen assets. So far, the hacker has not communicated further with the project team on the blockchain.
To mitigate such precision loss issues, Exvul recommend projects could consider locking liquidity at the outset. This incident also highlights the critical importance of addressing known security flaws in commonly used codebases.