code4rena-2024-08-chakra-h07

[H-07] Anyone can manipulate user nonce (nonce_manager) in settlement contract

보고서

Summary

한 체인에 nonce를 관리하는 두 컨트랙트의 nonce의 싱크가 맞지 않게 할 수 있다. 이로 인해 서명을 이용하는 기능이 마비된다.

Keyword

access control, nonce, signature, dos

Vulnerability

한 체인에 nonce를 관리하는 두 컨트랙트가 있고, 이들은 싱크가 되어야 한다. 그런데 그중 한 쪽을 임의로 업데이트시켜 nonce의 싱크가 맞지 않게 할 수 있다. 이로 인해 서명을 이용하는 기능이 마비된다. 동일한 취약점이 Solidity와 Cairo 버전의 코드에 있었다.

Solidity에서 취약점

유저는 일반적으로 ChakraSettlementHandler.cross_chain_erc20_settlement 를 호출하여 크로스체인 토큰 이동을 요청한다. 이 함수의 마지막에 ChakraSettlement.send_cross_chain_msg 를 호출하여 Validator가 인식 가능한 이벤트를 발생시킨다. 그리고 이때 txid 를 계산하기 위해 nonce를 이용하며, nonce는 ChakraSettlementHandler 와 ChakraSettlement 가 각자의 스토리지 변수로 관리한다.

ChakraSettlementHandler.cross_chain_erc20_settlementChakraSettlement.send_cross_chain_msg는 호출될 때마다 해당 유저의 nonce를 1씩 증가하므로 일반적으로는 ChakraSettlementHandler 와 ChakraSettlement 의 nonce가 동일하게 유지된다. 하지만 ChakraSettlement.send_cross_chain_msg 는 누구나 호출할 수 있으며 from 파라미터도 마음대로 설정할 수 있다. 즉, 공격자가 유저의 ChakraSettlement.nonce를 증가시켜 ChakraSettlementHandler.nonce 와 불일치하게 만들 수 있다.

    function send_cross_chain_msg(
        string memory to_chain,
@>      address from_address,
        uint256 to_handler,
        PayloadType payload_type,
        bytes calldata payload
@>  ) external {
@>      nonce_manager[from_address] += 1;
 
        address from_handler = msg.sender;
 
        uint256 txid = uint256(
            keccak256(
                abi.encodePacked(
                    contract_chain_name, // from chain
                    to_chain,
                    from_address, // msg.sender address
                    from_handler, // settlement handler address
                    to_handler,
@>                  nonce_manager[from_address]
                )
            )
        );
 
        create_cross_txs[txid] = CreatedCrossChainTx(
            txid,
            contract_chain_name,
            to_chain,
            from_address,
            from_handler,
            to_handler,
            payload,
            CrossChainMsgStatus.Pending
        );
 
        emit CrossChainMsg(
            txid,
            from_address,
            contract_chain_name,
            to_chain,
            from_handler,
            to_handler,
            payload_type,
            payload
        );
    }

nonce가 불일치하면 동일한 요청에 대한 txid 가 ChakraSettlementHandler 와 ChakraSettlement 에서 다르게 계산된다. 이는 목적지 체인에서 토큰을 수령 또는 실패한 뒤 source 체인의 ChakraSettlement.receive_cross_chain_callback 을 호출하여 결과를 등록할 때 문제를 일으킨다.

동일한 메시지에 대한 txid 가 source 체인의 ChakraSettlementHandler 에서는 0x01, ChakraSettlement 에서는 0x02로 계산된다고 하자. CrossChainMsg 이벤트에 쓰이는 txid 는 0x02 의 것이고, 따라서 목적지 체인에서 사용되는 txid 역시 0x02 이다. 목적지 체인에서 메시지를 성공 또는 실패하면 source 체인의 ChakraSettlement .receive_cross_chain_callback 를 호출하며 이때 사용되는 txid 는 0x02 이다.

ChakraSettlement.receive_cross_chain_callback 에서는 ChakraSettlementHandler.receive_cross_chain_callback 를 호출하는데, ChakraSettlementHandler 에는 0x02 인 txid가 등록된 적이 없다. 따라서 트랜잭션은 revert 된다.

    function receive_cross_chain_callback(
        uint256 txid,
        string memory from_chain,
        uint256 from_handler,
        CrossChainMsgStatus status,
        uint8 /* sign_type */, // validators signature type /  multisig or bls sr25519
        bytes calldata /* signatures */
    ) external onlySettlement returns (bool) {
        //  from_handler need in whitelist
        if (is_valid_handler(from_chain, from_handler) == false) {
            return false;
        }
 
        require(
@>          create_cross_txs[txid].status == CrossChainTxStatus.Pending,
            "invalid CrossChainTxStatus"
        );
 
        if (status == CrossChainMsgStatus.Success) {
            if (mode == SettlementMode.MintBurn) {
                _erc20_burn(address(this), create_cross_txs[txid].amount);
            }
 
            create_cross_txs[txid].status = CrossChainTxStatus.Settled;
        }
 
        if (status == CrossChainMsgStatus.Failed) {
            create_cross_txs[txid].status = CrossChainTxStatus.Failed;
        }
 
        return true;
    }

만약 Handler 가 MintBurn 모드이고, 목적지 체인에서 메시지 전달에 성공했다면 유저가 맡겼던 토큰을 burn 해야 한다. 하지만 receive_cross_chain_callback 가 실패하므로 토큰을 소각할 수 없다. 공격자가 다수의 유저에게 공격하여 nonce를 불일치시키면 토큰이 민팅만 되고 소각되지 않아 언젠가 토큰의 totalSupply 가 overflow 되어 더이상 민팅할 수 없게 되고, 이 체인은 메시지를 더이상 받을 수 없게 된다.

만약 목적지 체인에서 메시지 전달에 실패했다면 CrossChainTxStatus.Failed 상태를 저장한 뒤 메시지를 보낼 때 맡겼던 토큰을 돌려주는 등의 처리를 할 수 있다. 하지만 receive_cross_chain_callback 가 실패하므로 CrossChainTxStatus.Failed 상태를 기록할 수 없고 후처리를 할 수 없게 된다.

Cairo에서 취약점

Handler와 Settlement 컨트랙트에서 txid를 각자 계산한다. txid(cross_chain_settlement_id)를 계산할 때는 nonce 값이 사용되는데, 이 값이 다르다다면 동일한 메시지임에도 txid가 다르게 계산된다. Handler 와 Settlement 는 각자 nonce 값을 msg_counttx_count 변수에 따로 저장한다. 따라서 여러 핸들러가 Settlement에 연동되거나 공격자가 Settlement.send_cross_chain_msg 를 호출하면 Settlement의 nonce 가 더 높아져 동일한 메시지의 txid가 서로 다르게 계산되게 된다.

fn cross_chain_erc20_settlement(ref self: ContractState, to_chain: felt252, to_handler: u256, to_token: u256, to: u256, amount: u256) -> felt252{
    ...
    
@>  let tx_id = LegacyHash::hash(get_tx_info().unbox().transaction_hash, self.msg_count.read());
    ...
    self.msg_count.write(self.msg_count.read()+1);
    ...
}
  fn send_cross_chain_msg(
      ref self: ContractState, to_chain: felt252, to_handler: u256, payload_type :u8,payload: Array<u8>,
  ) -> felt252 {
      let from_handler = get_caller_address();
      let from_chain = self.chain_name.read();
@>    let cross_chain_settlement_id = LegacyHash::hash(get_tx_info().unbox().transaction_hash, self.tx_count.read());
      self.created_tx.write(cross_chain_settlement_id, CreatedTx{
          tx_id:cross_chain_settlement_id,
          tx_status: CrossChainMsgStatus::PENDING,
          from_chain: from_chain,
          to_chain: to_chain,
          from_handler: from_handler,
          to_handler: to_handler
      });
      
      self
          .emit(
              CrossChainMsg {
@>                cross_chain_settlement_id: cross_chain_settlement_id,
                  from_address: get_tx_info().unbox().account_contract_address,
                  from_chain: from_chain,
                  to_chain: to_chain,
                  from_handler: from_handler,
                  to_handler: to_handler,
                  payload_type: payload_type,
                  payload: payload
              }
          );
@>    self.tx_count.write(self.tx_count.read()+1);
      return cross_chain_settlement_id;
  }

콜백 호출 시 txid 를 통해 메시지를 조회하고 성공 또는 실패 처리를 한다. Settlement에서 계산해 사용했던 txid 는 Handler 의 txid와 다르므로 callback은 정상적으로 처리될 수 없다.

다음은 Handler.receive_cross_chain_callback 함수이다. 이 함수에서는 cross_chain_msg_id 가 Pending 상태임을 확인하지 않으므로 등록된 적 없는 txid 를 사용하여도 revert 되지는 않는다. 하지만 잘못된 txid에 데이터를 덮어쓰고 있으며, MintBurn 모드인 경우 callback에서 토큰을 소각하지 못하므로(created_tx.amount 가 0) 언젠가 totalSupply 가 오버플로우되어 더이상 메시지를 받을 수 없게 될 것이다.

  fn receive_cross_chain_callback(ref self: ContractState, cross_chain_msg_id: felt252, from_chain: felt252, to_chain: felt252,
  from_handler: u256, to_handler: ContractAddress, cross_chain_msg_status: u8) -> bool{
      assert(to_handler == get_contract_address(),'error to_handler');
 
      assert(self.settlement_address.read() == get_caller_address(), 'not settlement');
 
      assert(self.support_handler.read((from_chain, from_handler)) && 
              self.support_handler.read((to_chain, contract_address_to_u256(to_handler))), 'not support handler');
 
      let erc20 = IERC20MintDispatcher{contract_address: self.token_address.read()};
      if self.mode.read() == SettlementMode::MintBurn{
@>        erc20.burn_from(get_contract_address(), self.created_tx.read(cross_chain_msg_id).amount);
      }
      let created_tx = self.created_tx.read(cross_chain_msg_id);
@>    self.created_tx.write(cross_chain_msg_id, CreatedCrossChainTx{
          tx_id: created_tx.tx_id,
          from_chain: created_tx.from_chain,
          to_chain: created_tx.to_chain,
          from:created_tx.from,
          to:created_tx.to,
          from_token: created_tx.from_token,
          to_token: created_tx.to_token,
          amount: created_tx.amount,
          tx_status: CrossChainTxStatus::SETTLED
      });
 
      return true;
  }

Impact

정상적인 서명이 처리될 수 없도록 막아 receive_cross_chain_callback 가 정상 작동 할 수 없다.

Mitigation

이 버그는 공격자가 의도적으로 ChakraSettlement.send_cross_chain_msg 를 호출하여 공격했을 때 뿐만 아니라 다수의 핸들러가 Settlement 컨트랙트에 연동되었을 때도 발생한다. 따라서 ChakraSettlement.send_cross_chain_msg 의 접근을 제한하는 것보다는 nonce를 관리하는 컨트랙트를 따로 두어 공유하여 사용하도록 하기를 권장한다.


tags: bughunting, chakra, smart contract, starknet, cairo, solidity, access control vulnerability, dos, bridge, cross chain, signature, severity high