Heimdall security bug fix for milestones/checkpoints

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 and endBlock align
  • The milestone hash would be committed in place of a checkpoint rootHash
  • The borChainID would be interpreted as the AccountRootHash
  • The MilestoneID would have to be equal to the borChainID in bytes, which can happen since the MilestoneID 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.

2 Likes

Thank you for your help!

Stake capital Team

1 Like