• Building
    • Firedancer
    • the Pit
    • Cyclone
  • Thinking
  • Connect
  • Building
    • Firedancer
    • the Pit
    • Cyclone
    • Collaborations
  • Thinking
  • About
Terms of Use_Privacy Policy_Disclaimers_

Election Fraud? Double Voting in Celer’s State Guardian Network

Felix Wilhelm
Felix Wilhelm
Security

May 24 2023 _ 7 min read

Election Fraud? Double Voting in Celer’s State Guardian Network

This post describes a vulnerability we discovered in Celer's State Guardian Network, a Cosmos-based blockchain designed to support cross-chain communication. The issue would have allowed a malicious validator to completely compromise the State Guardian Network and applications dependent on it, such as Celer's cBridge.

We privately reported the issue to the Celer team. It is now fixed, and no malicious exploitation took place.

Jump Crypto aims to boost security assurance across the crypto ecosystem through ongoing research and coordinated disclosure to identify and patch vulnerabilities across various projects. This announcement is yet another example of how we continue these efforts.

State Guardian Network

Celer's cross-chain communication and bridging products are built on top of the Stage Guardian Network (SGNv2), a Cosmos-based Proof of Stake (PoS) blockchain. Validators in the SGN are responsible for monitoring Celer's onchain contracts for incoming messages or transfers and forwarding them to the corresponding contracts on the destination chain.

While the onchain smart contracts of the most prominent bridge providers are open source and heavily scrutinized by bug hunters (thanks to large existing bug bounties), this is often not the case for offchain components. Even though the security of offchain relayers and validators is essential for a bridge’s security, many bridge providers rely on closed-source implementations and centralized components or do not include offchain software in their bug bounty programs.

Celer recently open-sourced parts of the code for SGNv2, so we decided to take a look at the implementation of its cross-chain event forwarding.

A user who wants to bridge a token to a different chain using Celer calls the send method of the Celer liquidity bridge contract:

     /**
     * @notice Send a cross-chain transfer via the liquidity pool-based bridge.
     * NOTE: This function DOES NOT SUPPORT fee-on-transfer / rebasing tokens.
     * @param _receiver The address of the receiver.
     * @param _token The address of the token.
     * @param _amount The amount of the transfer.
     * @param _dstChainId The destination chain ID.
     * @param _nonce A number input to guarantee uniqueness of transferId. Can be timestamp in practice.
     * @param _maxSlippage The max slippage accepted, given as percentage in point (pip). Eg. 5000 means 0.5%.
     * Must be greater than minimalMaxSlippage. Receiver is guaranteed to receive at least (100% - max slippage percentage) * amount or the
     * transfer can be refunded.
     */
    function send(
        address _receiver,
        address _token,
        uint256 _amount,
        uint64 _dstChainId,
        uint64 _nonce,
        uint32 _maxSlippage // slippage * 1M, eg. 0.5% -> 5000
    ) external nonReentrant whenNotPaused {
        bytes32 transferId = _send(_receiver, _token, _amount, _dstChainId, _nonce, _maxSlippage);
        IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
        emit Send(transferId, msg.sender, _receiver, _token, _amount, _dstChainId, _nonce, _maxSlippage);
    }

This will lock the tokens in the bridge contract and emit a Send event, describing the details of the transfer.

This event will be picked up by one of the currently bonded SGN nodes, the so-called syncer. It is responsible for monitoring supported chains for event logs emitted by the Celer contracts.

After the syncer node successfully parses the onchain Send event, it bundles it together with other simultaneous events and sends a MsgProposeUpdates message, containing all discovered events as ProposeUpdate entries, to the SGN chain:

message MsgProposeUpdates {
  option (gogoproto.equal) = false;
  option (gogoproto.goproto_getters) = false;

  repeated ProposeUpdate updates = 1;
  string sender = 2;
}

message ProposeUpdate {
  DataType type = 1;
  bytes data = 2;
  uint64 chain_id = 3;
  uint64 chain_block = 4;
}

Of course, a malicious or compromised node could propose an invalid update that does not correspond to an onchain event. For example, it could propose a spoofed Send that transfers a large number of tokens to an attacker-controlled account.

Fraudulent Voting

To protect against this, Celer relies on a voting mechanism before the event/update is processed: Each SGN node continuously watches for newly proposed updates and tries to verify them onchain. Based on the outcome of its verification, a node votes on the outcome of a proposed update by sending a MsgVoteUpdates message, which consists of a series of “yes” or “no” votes for active proposals.

The corresponding message handler in the SGN’s sync module simply takes these votes and adds them to the pending update structure:

message VoteUpdate {
  uint64 id = 1;
  VoteOption option = 2;
}

message MsgVoteUpdates {
  repeated VoteUpdate votes = 1;
  string sender = 2;
}
// <https://github.com/celer-network/sgnv2/blob/80021bac14e908764ef900f90c85205d47654e04/x/sync/keeper/update.go#LL30C1-L40C2>
func (k Keeper) VoteUpdates(ctx sdk.Context, votes []*types.VoteUpdate, sender string, logEntry *seal.MsgLog) {
	for _, v := range votes {
		update, ok := k.GetPendingUpdate(ctx, v.Id)
		if !ok {
			continue
		}
		update.Votes = append(update.Votes, &types.Vote{Voter: sender, Option: v.Option})
		k.SetPendingUpdate(ctx, update)
		logEntry.Sync.Updates = append(logEntry.Sync.Updates, &seal.Update{Id: update.Id, Type: update.Type.String()})
	}
}

Finally, the sync module runs the following code at the end of each block:

// <https://github.com/celer-network/sgnv2/blob/2667ef0bc2b6b46821ca634dd468d6099bba70a4/x/sync/abci.go#L12>
// EndBlocker called every block, process inflation, update validator set.
func EndBlocker(ctx sdk.Context, keeper keeper.Keeper) {
	vals := keeper.GetBondedValidators(ctx)
	tokens := sdk.ZeroInt()
	valMaps := map[string]stakingtypes.Validator{}

	for _, val := range vals {
		tokens = tokens.Add(val.Tokens)
		valMaps[val.SgnAddress] = val
	}

	threshold := keeper.GetParams(ctx).TallyThreshold.MulInt(tokens).TruncateInt()
	updates := keeper.GetAllPendingUpdates(ctx)
	for _, update := range updates {
		yesVotes := sdk.ZeroInt()
		for _, vote := range update.Votes {
			v, ok := valMaps[vote.Voter]
			if !ok {
				continue
			}
			if vote.Option == types.VoteOption_Yes {
				yesVotes = yesVotes.Add(v.Tokens)
			}
		}

		if yesVotes.GT(threshold) {
			log.Infof("Update approved by majority. id: %d, type: %s, votes: %s, threshold %s",
				update.Id, update.Type, yesVotes, threshold)
			keeper.ApplyUpdate(ctx, update)
			keeper.RemovePendingUpdate(ctx, update.Id)
		} else if ctx.BlockTime().Unix() > int64(update.ClosingTs) {
			log.Debugf("Pending update expired, id: %d, type: %s, votes: %s, threshold %s",
				update.Id, update.Type, yesVotes, threshold)
			keeper.RemovePendingUpdate(ctx, update.Id)
		}
	}

}

The function iterates through all pending updates and sums the number of “yes” votes weighted by the voters’ stake. If more than 2/3 of the existing stake vote “yes”, the update is applied.

Attentive readers might have spotted the vulnerability, but if not, let’s take another look at the way votes are added up in the EndBlocker function above: The code is missing a check that prevents a validator from voting on the same update twice. A malicious validator could exploit this by voting multiple times on the same update, effectively multiplying their voting power and potentially tipping the vote in favor of an invalid or malicious update.

The Celer team fixed this vulnerability with a small addition to the EndBlocker function that ensures that only a single vote per validator is counted.

Impact

The ability to apply malicious updates gives a malicious validator a wide range of options: They can spoof arbitrary onchain events such as bridge transfers, message emissions, or staking and delegation on Celer’s main SGN contract:

<https://github.com/celer-network/sgnv2/blob/80021bac14e908764ef900f90c85205d47654e04/x/sync/keeper/apply.go#L14>
switch update.Type {
	case types.DataType_ValidatorSgnAddr:
		applied, err = k.applyValidatorSgnAddr(cacheCtx, update)
	case types.DataType_ValidatorParams:
		applied, err = k.applyValidatorParams(cacheCtx, update)
	case types.DataType_ValidatorStates:
		applied, err = k.applyValidatorStates(cacheCtx, update)
	case types.DataType_DelegatorShares:
		applied, err = k.applyDelegatorShares(cacheCtx, update)
	case types.DataType_CbrOnchainEvent:
		applied, err = k.cbrKeeper.ApplyEvent(cacheCtx, update.Data)
	case types.DataType_CbrUpdateCbrPrice:
		applied, err = k.cbrKeeper.ApplyUpdateCbrPrice(cacheCtx, update.Data)
	case types.DataType_PegbrOnChainEvent:
		applied, err = k.pegbrKeeper.ApplyEvent(cacheCtx, update.Data)
	case types.DataType_MsgbrOnChainEvent:
		applied, err = k.msgbrKeeper.ApplyEvent(cacheCtx, update.Data)
	}

Looking back at our initial example of a token transfer via the Send event, an attacker could spoof a malicious update that contains a large cross-chain token transfer. Once the update is applied, the k.cbrKeeper.ApplyEvent(cacheCtx, update.Data) is executed.

This triggers each node in the SGNv2, even the ones who voted against the proposed update, to sign a  Relay message describing the faked transfer:

// <https://github.com/celer-network/sgn-v2-contracts/blob/main/contracts/libraries/proto/bridge.proto#L12>
message Relay {
  bytes sender = 1 [(soltype) = "address"];
  bytes receiver = 2 [(soltype) = "address"];
  bytes token = 3 [(soltype) = "address"];  // asset address on dest chain
  bytes amount = 4 [(soltype) = "uint256"];
  uint64 src_chain_id = 5;
  uint64 dst_chain_id = 6;
  bytes src_transfer_id = 7 [(soltype) = "bytes32"];
}

Finally, the signed Relay message can be submitted to the Celer contract on the destination chain, triggering a transfer to the attacker’s account:

// <https://github.com/celer-network/sgn-v2-contracts/blob/main/contracts/liquidity-bridge/Bridge.sol>		
/**
     * @notice Relay a cross-chain transfer sent from a liquidity pool-based bridge on another chain.
     * @param _relayRequest The serialized Relay protobuf.
     * @param _sigs The list of signatures sorted by signing addresses in ascending order. A relay must be signed-off by
     * +2/3 of the bridge's current signing power to be delivered.
     * @param _signers The sorted list of signers.
     * @param _powers The signing powers of the signers.
     */
    function relay(
        bytes calldata _relayRequest,
        bytes[] calldata _sigs,
        address[] calldata _signers,
        uint256[] calldata _powers
    ) external whenNotPaused {

Celer has a number of defense-in-depth protections that make a complete theft of all funds locked in its bridge unlikely:

  • Outgoing transfers over a certain value are not processed immediately but are delayed by the bridge contract. Additionally, Celer’s contracts use a VolumeControl mechanism that limits the value of tokens that can be extracted in a short timeframe, so simply splitting outgoing transfers into a large number of small transactions won’t enable the bridge to empty immediately.
  • Celer’s core contracts are pausable by a set of Governor addresses. According to the Celer team, an under-collatorization triggered by malicious transfers would immediately trigger an emergency halt of contracts.

Due to the fact that the described transaction limits apply per chain and token, and due to the large number of supported tokens and chains, it seems realistic that an attacker could exfiltrate tokens with a value of ~$30M before the contracts are halted. While a significant loss, this is significantly lower than Celer’s current TVL of ~$130M, demonstrating the value added by such defense-in-depth mechanisms.

It is important to note that these built-in mechanisms only have the power to protect Celer’s own bridge contracts. dApps built on top of Celer’s inter-chain messaging would be fully exposed to these vulnerabilities by default (see Celer’s dApp safeguard for a potential way to address this problem).

Conclusion

Even decentralized architectures built on sound cryptographic primitives can break down due to severe implementation bugs. The issue discussed in this post highlights the importance of offchain components for the security of cross-chain bridges and messaging layers. This will remain true until fully trustless ZK (Zero-Knowledge) implementations become a reality.

We would like to thank the Celer team for their professional handling of this vulnerability report. While Celer offers a $2M bug bounty for vulnerabilities in its bridge, the offchain SGNv2 network is explicitly not in scope.  In discussions with the Celer team, they aim to add the SGNv2 network to their bug bounty in the coming months and will evaluate a potential bounty payout for this report.

Share

Stay up to date with the latest from Jump_

More articles

SAFU: Creating a Standard for Whitehats
SAFU: Creating a Standard for Whitehats

Whitehats and DeFi protocols need a shared understanding of security policy. We propose the SAFU - Simple Arrangement for Funding Upload - as a versatile and credible way to let whitehats know what to...

Oct 24 2022 _ 17 min

Share

Disclaimer

The information on this website and on the Brick by Brick podcast or Ship Show Twitter spaces is provided for informational, educational, and entertainment purposes only.  This information is not intended to be and does not constitute financial advice, investment advice, trading advice, or any other type of advice.  You should not make any decision – financial, investment, trading or otherwise – based on any of the information presented here without undertaking your own due diligence and consulting with a financial adviser.  Trading, including that of digital assets or cryptocurrency, has potential rewards as well as potential risks involved. Trading may not be suitable for all individuals. Recordings of podcast episodes or Twitter spaces events may be used in the future.

Building_
Terms of Use_Privacy Policy_Disclaimers_

© 2024 Jump Crypto. All Rights Reserved.

Jump Crypto does not operate any business lines that accept funds from external investors. Any person, company, or app purporting to accept external investor funds on behalf of Jump Crypto is fraudulent.