Summary
A security bug on Heimdall was previously reported and confirmed via the Bug Bounty Program. Under specific conditions being met, the bug could have potentially allowed a malicious validator to perform a Denial of Service (DoS) on the Heimdall network, causing the PoS network to halt.
Root Cause
In the milestones workflow, when a validator proposes a milestone, a stream of bytes is generated, and then signed by all the validators. Given that the structure of this bytes stream is very similar to the one that gets created for the checkpoints, a single malicious validator could manipulate the data, get the signatures from all validators, and then submit it as a valid checkpoint to the RootChain
contract on Ethereum. Specifically, we refer to the MsgMilestone
struct in heimdall/checkpoint/types/msg_milestone.go
, where the GetSignSideBytes()
function returns the side sign bytes that validators are supposed to sign during the voting process of a side block.
func (msg MsgMilestone) GetSideSignBytes() []byte {
// keccak256(abi.encoded(proposer, startBlock, endBlock, rootHash, accountRootHash, bor chain id))
borChainID, _ := strconv.ParseUint(msg.BorChainID, 10, 64)
return appendBytes32(
msg.Proposer.Bytes(),
new(big.Int).SetUint64(msg.StartBlock).Bytes(),
new(big.Int).SetUint64(msg.EndBlock).Bytes(),
msg.Hash.Bytes(),
new(big.Int).SetUint64(borChainID).Bytes(),
[]byte(msg.MilestoneID),
)
}
Similarly, the MsgCheckpoint
is what gets eventually committed by the proposer to the RootChain
contract. The GetSideSignBytes()
function for this message looks like the following.
func (msg MsgCheckpoint) GetSideSignBytes() []byte {
// keccak256(abi.encoded(proposer, startBlock, endBlock, rootHash, accountRootHash, bor chain id))
borChainID, _ := strconv.ParseUint(msg.BorChainID, 10, 64)
return appendBytes32(
msg.Proposer.Bytes(),
new(big.Int).SetUint64(msg.StartBlock).Bytes(),
new(big.Int).SetUint64(msg.EndBlock).Bytes(),
msg.RootHash.Bytes(),
msg.AccountRootHash.Bytes(),
new(big.Int).SetUint64(borChainID).Bytes(),
)
}
By looking at the submitCheckpoint()
function in contracts/root/RootChain.sol
, we can see that the ABI decoding accepts as parameter the side sign bytes of the MsgCheckpoint
.
The bug here is that both MsgMilestone
and MsgCheckpoint
are signed by the same validators to reach consensus, hence a signed MsgMilestone
can be used in place of a MsgCheckpoint
.
In the case of the RootChain
contract, a MsgMilestone
could be submitted and this would be interpreted as a valid checkpoint because:
- The malicious validator could make sure that
proposer
,startBlock
andendBlock
align - The milestone
hash
would be committed in place of a checkpointrootHash
- The
borChainID
would be interpreted as theAccountRootHash
- The
MilestoneID
would have to be equal to theborChainID
in bytes, which can happen since theMilestoneID
can be arbitrarily chosen by the validator each time
function submitCheckpoint(bytes calldata data, uint[3][] calldata sigs) external {
(address proposer, uint256 start, uint256 end, bytes32 rootHash, bytes32 accountHash, uint256 _borChainID) = abi
.decode(data, (address, uint256, uint256, bytes32, bytes32, uint256));
require(CHAINID == _borChainID, "Invalid bor chain id");
require(_buildHeaderBlock(proposer, start, end, rootHash), "INCORRECT_HEADER_DATA");
// check if it is better to keep it in local storage instead
IStakeManager stakeManager = IStakeManager(registry.getStakeManagerAddress());
uint256 _reward = stakeManager.checkSignatures(
end.sub(start).add(1),
/**
prefix 01 to data
01 represents positive vote on data and 00 is negative vote
malicious validator can try to send 2/3 on negative vote so 01 is appended
*/
keccak256(abi.encodePacked(bytes(hex"01"), data)),
accountHash,
proposer,
sigs
);
require(_reward != 0, "Invalid checkpoint");
emit NewHeaderBlock(proposer, _nextHeaderBlock, _reward, start, end, rootHash);
_nextHeaderBlock = _nextHeaderBlock.add(MAX_DEPOSITS);
_blockDepositId = 1;
}
With these premises, if a malicious validator is able to modify the data in a way that matches the previous points, it can submit a valid signed milestone instead of a checkpoint on the RootChain
contract on Ethereum. This would lead to chain failure of Polygon PoS.
Resolution and Recovery
A patch was successfully released on May 23rd 2024, with Heimdall tag v1.0.6.
It consists of one main PR, where the GetSideSignBytes
function for milestones workflow has been modified to return nil
. This is basically what happens for every kind of sideTx
in Heimdall, exception made for checkpoints, which is the only case that requires the data to be broadcasted to all validators, signed, and then submitted to the RootChain
contract, which has the ultimate task of checking the validity of the bytes stream, and reverting the execution in case the arguments do not match with the expected ones.
Moreover, to increase the security around milestones submission, the ValidateMilestone
function - which is invoked by the milestones side handler at time of voting on correct data - has been enriched with an additional check. Since the milestoneID
is of format UUID - HexAddress
, the validation now ensures that the first part of such string is a valid UUID and the second part is a valid hex-based address. The implemented behaviour has been covered with unit tests.
The patch was first tested on a devnet, then tested and rolled out on Amoy and Mainnet nodes simultaneously . A release announcement was shared, allowing all the validators to upgrade.