Understanding Poly Network Exploit

8 min read

Learn how an attacker exploited Poly Network to steal assets worth $7.388 million.

TL;DR#

On July 1, 2023, Poly Network was exploited across multiple chains, including Ethereum Mainnet, Metis, and the BNB chain, resulting in a loss of funds worth approximately $7.388 million.

Introduction to Poly Network#

Poly Network is the cross-chain protocol for heterogeneous chains, enhancing connections between ledgers by providing Web3 interoperability.

Vulnerability Assessment#

The root cause of the exploit is likely to be the compromise of the private keys.

Attack Scenario#

Poly operates a system of inter-blockchain management contracts, enabling the movement of tokens from an origin chain to a destination chain. These contracts accommodate proofs of token transfer modifications on the origin blockchain, along with encoded arguments for a transaction that retrieves these tokens on the existing blockchain.

function verifyHeaderAndExecuteTx(
  bytes memory proof,
  bytes memory rawHeader,
  bytes memory headerProof,
  bytes memory curRawHeader,
  bytes memory headerSig
) public whenNotPaused returns (bool) {
  ECCUtils.Header memory header = ECCUtils.deserializeHeader(rawHeader);
  // Load ehereum cross chain data contract
  IEthCrossChainData eccd = IEthCrossChainData(EthCrossChainDataAddress);

  // Get stored consensus public key bytes of current poly chain epoch and deserialize Poly chain consensus public key bytes to address[]
  address[] memory polyChainBKs = ECCUtils.deserializeKeepers(eccd.getCurEpochConPubKeyBytes());

  uint256 curEpochStartHeight = eccd.getCurEpochStartHeight();

  uint256 n = polyChainBKs.length;
  if (header.height >= curEpochStartHeight) {
    // It's enough to verify rawHeader signature
    require(
      ECCUtils.verifySig(rawHeader, headerSig, polyChainBKs, n - (n - 1) / 3),
      "Verify poly chain header signature failed!"
    );
  } else {
    // We need to verify the signature of curHeader
    require(
      ECCUtils.verifySig(curRawHeader, headerSig, polyChainBKs, n - (n - 1) / 3),
      "Verify poly chain current epoch header signature failed!"
    );

    // Then use curHeader.StateRoot and headerProof to verify rawHeader.CrossStateRoot
    ECCUtils.Header memory curHeader = ECCUtils.deserializeHeader(curRawHeader);
    bytes memory proveValue = ECCUtils.merkleProve(headerProof, curHeader.blockRoot);
    require(ECCUtils.getHeaderHash(rawHeader) == Utils.bytesToBytes32(proveValue), "verify header proof failed!");
  }

  // Through rawHeader.CrossStatesRoot, the toMerkleValue or cross chain msg can be verified and parsed from proof
  bytes memory toMerkleValueBs = ECCUtils.merkleProve(proof, header.crossStatesRoot);

  // Parse the toMerkleValue struct and make sure the tx has not been processed, then mark this tx as processed
  ECCUtils.ToMerkleValue memory toMerkleValue = ECCUtils.deserializeMerkleValue(toMerkleValueBs);
  require(
    !eccd.checkIfFromChainTxExist(toMerkleValue.fromChainID, Utils.bytesToBytes32(toMerkleValue.txHash)),
    "the transaction has been executed!"
  );
  require(
    eccd.markFromChainTxExist(toMerkleValue.fromChainID, Utils.bytesToBytes32(toMerkleValue.txHash)),
    "Save crosschain tx exist failed!"
  );

  // Ethereum ChainId is 2, we need to check the transaction is for Ethereum network
  require(toMerkleValue.makeTxParam.toChainId == chainId, "This Tx is not aiming at this network!");

  // Obtain the targeting contract, so that Ethereum cross chain manager contract can trigger the executation of cross chain tx on Ethereum side
  address toContract = Utils.bytesToAddress(toMerkleValue.makeTxParam.toContract);

  // only invoke PreWhiteListed Contract and method For Now
  require(whiteListContractMethodMap[toContract][toMerkleValue.makeTxParam.method], "Invalid to contract or method");

  //TODO: check this part to make sure we commit the next line when doing local net UT test
  require(
    _executeCrossChainTx(
      toContract,
      toMerkleValue.makeTxParam.method,
      toMerkleValue.makeTxParam.args,
      toMerkleValue.makeTxParam.fromContract,
      toMerkleValue.fromChainID
    ),
    "Execute CrossChain Tx failed!"
  );

  // Fire the cross chain event denoting the executation of cross chain tx is successful,
  // and this tx is coming from other public chains to current Ethereum network
  emit VerifyHeaderAndExecuteTxEvent(
    toMerkleValue.fromChainID,
    toMerkleValue.makeTxParam.toContract,
    toMerkleValue.txHash,
    toMerkleValue.makeTxParam.txHash
  );

  return true;
}

The process of moving tokens from the origin chain is referred to as lock, while the method to recover these tokens is referred to as unlock. Poly uses a network of consensus nodes, which are basically externally owned accounts (EOAs) that approve the unlock event on the destination chain by adding relevant information, or entropy, from the original chain that confirms the lock event.

function lock(address fromAssetHash, uint64 toChainId, bytes memory toAddress, uint256 amount)
  public
  payable
  returns (bool)
{
  require(amount != 0, "amount cannot be zero!");

  require(
    _transferToContract(fromAssetHash, amount), "transfer asset from fromAddress to lock_proxy contract  failed!"
  );

  bytes memory toAssetHash = assetHashMap[fromAssetHash][toChainId];
  require(toAssetHash.length != 0, "empty illegal toAssetHash");

  TxArgs memory txArgs = TxArgs({toAssetHash: toAssetHash, toAddress: toAddress, amount: amount});
  bytes memory txData = _serializeTxArgs(txArgs);

  IEthCrossChainManagerProxy eccmp = IEthCrossChainManagerProxy(managerProxyContract);
  address eccmAddr = eccmp.getEthCrossChainManager();
  IEthCrossChainManager eccm = IEthCrossChainManager(eccmAddr);

  bytes memory toProxyHash = proxyHashMap[toChainId];
  require(toProxyHash.length != 0, "empty illegal toProxyHash");
  require(
    eccm.crossChain(toChainId, toProxyHash, "unlock", txData), "EthCrossChainManager crossChain executed error!"
  );

  emit LockEvent(fromAssetHash, _msgSender(), toChainId, toAssetHash, toAddress, amount);

  return true;
}
function unlock(bytes memory argsBs, bytes memory fromContractAddr, uint64 fromChainId)
  public
  onlyManagerContract
  returns (bool)
{
  TxArgs memory args = _deserializeTxArgs(argsBs);

  require(fromContractAddr.length != 0, "from proxy contract address cannot be empty");
  require(Utils.equalStorage(proxyHashMap[fromChainId], fromContractAddr), "From Proxy contract address error!");

  require(args.toAssetHash.length != 0, "toAssetHash cannot be empty");
  address toAssetHash = Utils.bytesToAddress(args.toAssetHash);

  require(args.toAddress.length != 0, "toAddress cannot be empty");
  address toAddress = Utils.bytesToAddress(args.toAddress);

  require(
    _transferFromContract(toAssetHash, toAddress, args.amount),
    "transfer asset from lock_proxy contract to toAddress failed!"
  );

  emit UnlockEvent(toAssetHash, toAddress, args.amount);
  return true;
}

This entropy comprises a state root that represents the locked tokens on the origin chain. The verifySig function makes sure that the consensus nodes have signed the header structure appropriately. The header includes the state root of a Merkle tree. Given that the entire header is signed, the state root is also signed, and consequently, the full state as depicted by the Merkle tree is implicitly signed as well.

It was reported that the verifySig function was correctly invoked and that the header was signed by three of their centralized keepers, satisfying the (k-1) out of k keeper signature scheme. Additionally, the keepers were not modified prior to the attack and remained unchanged over the previous years.

function verifySig(bytes memory _rawHeader, bytes memory _sigList, address[] memory _keepers, uint256 _m)
  internal
  pure
  returns (bool)
{
  bytes32 hash = getHeaderHash(_rawHeader);

  uint256 sigCount = _sigList.length.div(POLYCHAIN_SIGNATURE_LEN);
  address[] memory signers = new address[](sigCount);
  bytes32 r;
  bytes32 s;
  uint8 v;
  for (uint256 j = 0; j < sigCount; j++) {
    r = Utils.bytesToBytes32(Utils.slice(_sigList, j * POLYCHAIN_SIGNATURE_LEN, 32));
    s = Utils.bytesToBytes32(Utils.slice(_sigList, j * POLYCHAIN_SIGNATURE_LEN + 32, 32));
    v = uint8(_sigList[j * POLYCHAIN_SIGNATURE_LEN + 64]) + 27;
    signers[j] = ecrecover(sha256(abi.encodePacked(hash)), v, r, s);
    if (signers[j] == address(0)) return false;
  }
  return Utils.containMAddresses(_keepers, signers, _m);
}

The merkleProve function takes as input a byte sequence in the _auditPath parameter containing the leaf node, followed by a path through the Merkle tree that proves the existence of the leaf node, given the state root _root. As the state root was already signed by the keepers, a proof could be easily constructed and cheaply verified.

function merkleProve(bytes memory _auditPath, bytes32 _root) internal pure returns (bytes memory) {
  uint256 off = 0;
  bytes memory value;
  (value, off) = ZeroCopySource.NextVarBytes(_auditPath, off);

  bytes32 hash = Utils.hashLeaf(value);
  uint256 size = _auditPath.length.sub(off).div(33);
  bytes32 nodeHash;
  bytes1 pos;
  for (uint256 i = 0; i < size; i++) {
    (pos, off) = ZeroCopySource.NextByte(_auditPath, off);
    (nodeHash, off) = ZeroCopySource.NextHash(_auditPath, off);
    if (pos == 0x00) {
      hash = Utils.hashChildren(nodeHash, hash);
    } else if (pos == 0x01) {
      hash = Utils.hashChildren(hash, nodeHash);
    } else {
      revert("merkleProve, NextByte for position info failed");
    }
  }
  require(hash == _root, "merkleProve, expect root is not equal actual root");
  return value;
}

The attacker made full use of the verifier's implementation's flexibility, which allowed for zero-length witnesses. Essentially, the attacker passed in the leaf node, which is exactly 240 bytes in this case, and an empty path as proof.

The hash of the leaf node needs to correspond to the state root in order for this proof to succeed. Therefore, it is likely that the Poly chain keepers were compromised and signed a state root that turned out to be artificially constructed.

Compromised Assets#

The hacker minted over $34 billion worth of assets on multiple chains, which had no available sell liquidity. Therefore, most of these stolen assets are illiquid and will have no real value as such. These are some of the assets issued by the exploiter on various chains:

MetisDAO- 99,999,184 BNB and 10 billion BUSD
HECO- 999.8127T SHIB
Polygon- 87,579,118 COW, 999,998,434 OOE, 636,643,868 STACK, and 88,640,563 GM
Avalanche- 378,028,371 STACK, 82,854,568 XTM, 11,026,341 SPAY, and 89,383,712 GM
BNB chain- 8,882,911 METIS, 926,160,132 DOV, and 978,102,855 SLD

Some of the stolen tokens have been swapped by exploiters for mainstream assets worth $1.22 million via Uniswap and PancakeSwap, while the remaining funds have been distributed to more than 60 addresses in multiple chains and have not been transferred yet.

At the time of this writing, approximately $7.388 million worth of assets are held at these four addresses: Address 1Address 2Address 3, and Address 4.

Aftermath#

Following the occurrence of the exploit, the team associated with Metis stated that the newly minted BNB and BUSD on their platform would have no value as there was no sell liquidity available. The team remained in contact with the PolyNetwork team to minimize the impact of the attack and further assess the situation.

According to them, all newly created METIS tokens from PolyBridge have limited liquidity because PolyNetwork locked them on BNBChain.

Poly Network confirmed the occurrence of the exploit and stated that they have temporarily suspended its services. According to them, 57 different assets were affected on 10 blockchains. The team has also initiated communication with centralized exchanges and law enforcement agencies to seek their assistance. To minimize further risks, they have reached out to the majority of project teams and urged them to promptly withdraw liquidity from decentralized exchanges.

Solution#

As the facts of this exploit have demonstrated, security breaches can have a significant financial impact. In this case, if the Poly Network had purchased comprehensive coverage from Neptune Mutual, the aftermath of the hack could have been significantly mitigated.

Neptune Mutual offers coverage to users who have suffered losses of funds or digital assets as a result of smart contract vulnerabilities. Our parametric policies are designed to make claim processing hassle-free. There's no need for users to provide evidence of loss to receive payouts. As soon as an incident is resolved through our incident resolution system, payouts can be claimed.

In the Poly Network scenario, Neptune Mutual could have initiated coverage claims for the affected assets, helping to offset the losses incurred by the attack. Through our incident resolution system, payouts could have been rapidly distributed, alleviating the financial distress caused by the sudden loss of funds. At the moment, our marketplace is available on three popular blockchain networks, EthereumArbitrum, and the BNB chain.

We also promote preventive measures, which are often the best line of defense. For instance, a number of multi signature wallets and pause contract events could have been used as industry-standard preventative measures to mitigate the risk of such attacks. In the event of unusual activity, the operations could have been paused, limiting the extent of the attack and providing valuable time for further inspection and countermeasures.

Our security team would have conducted a comprehensive evaluation of Poly Network's platform for potential vulnerabilities, including DNS and web-based security, frontend and backend security, as well as intrusion detection and prevention.

Reference Source Dedaub

By

Tags