Setting Up A Bridge With Foundry

Setting Up A Bridge With Foundry

Summary

In 2022 alone, cross-chain exploits led to a total of over $1.3 billion dollars in losses — almost 60% of all funds stolen that year. Cross-chain bridges can be some of the most complex entities and can hold hundreds of millions of dollars, making them perfect victims for sophisticated attacks and overlooked logical vulnerabilities.

For us security researchers, it is incredibly important to understand how these protocols function and learn to diagnose and test them to find their weaknesses, so we can responsibly disclose bugs.

This article was written by haruxe.

This year and in the years to follow, we will undoubtedly see some of the largest cross-chain bridge exploits if no secure standard develops and security is not prioritized above everything else.

Blackhats not finding any vulnerabilities after looking for 100 hours in a bridge protocol because the project is listed on Immunefi

One of the most useful tools in a whitehat’s arsenal is Foundry. Where we once had a whole suite of underpowered scripts that made testing a mess, Foundry does it all — and does it quickly.

So, we will look at how we can set up a bridge and use some of its magic to help us along our way.

Introduction To Bridges

The primary purpose of bridges is to port assets from one blockchain to another in a decentralized and non-custodial fashion — removing the need for a centralized exchange.

For the most part, bridges work by simply locking your deposited token(s) in the bridge contract and minting an equivalent amount of the token on the respective chain of your choice. This is achieved with an off-chain oracle/bot that initiates minting upon seeing a valid deposit.

Since cross-chain protocols are forced to use off-chain monitoring to link the two chains together, many new attack vectors are introduced that normally would not have been present, had it been a single contract — making it unique in that way.

Building Our Own

I’ve gone ahead and built a one-way bridge between Goerli testnet and BSC testnet. It works by observing ETH transfers to the Goerli-sided smart contract bridge, then minting our new token hETH on the BSC side to our chosen address.

To make this decentralized and self-sustaining, upon initiating a bridge transfer on the Goerli side, the transfer is signed by the initiator to ensure that invalid bridge interactions are made on the target side.

Here are the two bridge contracts and basic ERC20 contract I’ve built for this:

BridgeIn.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import '@openzeppelin-contracts/contracts/token/ERC20/IERC20.sol';
contract BridgeIn {
  mapping(address => mapping(uint => bool)) public processedNonces;
  event Transfer(
    address from,
    address to,
    uint amount,
    uint date,
    uint nonce,
    bytes signature
  );
  constructor() {
  }
  function bridge(address to, uint nonce, bytes calldata signature) external payable {
    require(processedNonces[msg.sender][nonce] == false, 'transfer already processed');
    processedNonces[msg.sender][nonce] = true;
    emit Transfer(
      msg.sender,
      to,
      msg.value,
      block.timestamp,
      nonce,
      signature
    );
  }
}

BridgeOut.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol';
import './hETH.sol';
contract BridgeOut {
  address public admin;
  hETH public token;
  mapping(address => mapping(uint => bool)) public processedNonces;
  enum Step { Burn, Mint }
  event Transfer(
    address from,
    address to,
    uint amount,
    uint date,
    uint nonce,
    bytes signature,
    Step indexed step
  );
  constructor(address _token) {
    admin = msg.sender;
    token = hETH(_token);
  }
  function mint(
    address from, 
    address to, 
    uint amount, 
    uint nonce,
    bytes calldata signature
  ) external {
    bytes32 message = prefixed(keccak256(abi.encodePacked(
      from, 
      to, 
      amount,
      nonce
    )));
    require(msg.sender == admin);
    require(recoverSigner(message, signature) == from , 'wrong signature');
    require(processedNonces[from][nonce] == false, 'transfer already processed');
    processedNonces[from][nonce] = true;
    token.mint(to, amount);
    emit Transfer(
      from,
      to,
      amount,
      block.timestamp,
      nonce,
      signature
    );
  }
  function prefixed(bytes32 hash) internal pure returns (bytes32) {
    return keccak256(abi.encodePacked(
      '\x19Ethereum Signed Message:\n32', 
      hash
    ));
  }
  function recoverSigner(bytes32 message, bytes memory sig)
    internal
    pure
    returns (address)
  {
    uint8 v;
    bytes32 r;
    bytes32 s;
  
    (v, r, s) = splitSignature(sig);
  
    return ecrecover(message, v, r, s);
  }
  function splitSignature(bytes memory sig)
    internal
    pure
    returns (uint8, bytes32, bytes32)
  {
    require(sig.length == 65);
  
    bytes32 r;
    bytes32 s;
    uint8 v;
  
    assembly {
        // first 32 bytes, after the length prefix
        r := mload(add(sig, 32))
        // second 32 bytes
        s := mload(add(sig, 64))
        // final byte (first byte of the next 32 bytes)
        v := byte(0, mload(add(sig, 96)))
    }
  
    return (v, r, s);
  }
}

hETH.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import 'openzeppelin-contracts/contracts/token/ERC20/ERC20.sol';
contract hETH is ERC20 {
  address public admin;
  constructor() ERC20('hETH', 'hETH') {
    admin = msg.sender;
  }
  function mint(address reciever, uint amount) external {
    require(msg.sender == admin);
    _mint(reciever, amount);
  }
}

With these contracts in hand, the next step is to deploy to each network using forge create with each respective network — BridgeIn.sol on Goerli and BridgeOut.sol + hETH.sol on BSC.

Here’s where mine were deployed:

0xb9054ef8812cc50d48b9c0f50a4b87cffd1f7da2 — BridgeIn.sol (Goerli)

0x69975b1d1fc09c8bcc142efbfaf7f29199d499f5 — BridgeOut.sol (BSC testnet)

Now, you’ll notice that the ETH balance of BridgeIn.sol is locked inside of the contract. Clearly, something looks off — there is no backwards bridging from hETH to ETH. See if you can figure out how that might be possible.

hmmm

In most cases, the bridge API will be available on a project’s Immunefi/GitHub page during a bounty or provided given an audit. The API is most often written in Javascript, Go, or Rust so having a basic understanding of each will help in different cases.

Here is an example of an extremely basic bridge API I wrote in Javascript with EthersJS that will be able to interact with both chains (keep in mind I am using Ethers version 5.4, version 6.0 is not working properly at the moment):

const ethers = require("ethers");
const BridgeIn = require("../out/BridgeIn.sol/BridgeIn.json");
const BridgeOut = require("../out/BridgeOut.sol/BridgeOut.json");
require("dotenv").config();
const providerEth = new ethers.providers.WebSocketProvider(
  "wss://eth-goerli.g.alchemy.com/v2/" + process.env.ALCHEMY_API_KEY
);
const bridgeEth = new ethers.Contract(
  "0xb9054eF8812cc50D48B9C0F50a4B87cffd1F7DA2",
  BridgeIn.abi,
  providerEth
);
const providerBsc = new ethers.providers.JsonRpcProvider(
  "https://data-seed-prebsc-1-s3.binance.org:8545"
);
const adminWalletBsc = new ethers.Wallet(
  process.env.PRIVATE_KEY_ADMIN,
  providerBsc
);
const bridgeBsc = new ethers.Contract(
  "0x69975B1d1FC09c8bcc142efBfaf7F29199d499f5",
  BridgeOut.abi,
  adminWalletBsc
);
bridgeEth.on("Transfer", (from, to, amount, date, nonce, signature, step) => {
  bridgeBsc.mint(from, to, amount, nonce, signature);
  console.log(`
       Processed transfer:
       - from ${from}
       - to ${to}
       - amount ${amount} wei
       - date ${date}
       - nonce ${nonce}
       - signature ${signature}
     `);
});

The main function is at the bottom of the js file, bridgeEth.on() which uses the Binance Smart Chain RPC provider to execute some code after observing the Transfer event in the bridge contract.

To initiate this trade, though, a signature must be provided for security. This is typically done on the front-end of a webpage with MetaMask to sign a message, but for this example, I’ve created a simple Node.js script for constructing the function arguments:

require("dotenv").config();
const web3 = require("web3");
const ethers = require("ethers");
const adminWallet = new ethers.Wallet(process.env.PRIVATE_KEY_ADMIN);
const nonce = 12;
const amount = 10;
async function main() {
  const message = ethers.utils.solidityKeccak256(
    ["address", "address", "uint256", "uint256"],
    [
      "0x2fa9dee334eb8bdb44f5c377e5c32ef42ea00205",
      "0x2fa9dee334eb8bdb44f5c377e5c32ef42ea00205",
      amount,
      nonce,
    ]
  );
  const signature = await adminWallet.signMessage(message);
  console.log(signature);
}
main();

The outcome looks something like this:

0xc4659427c9191d6bdf9d69d2c1c0cb570225797a8293afcce1b716cd232f0d926e0f3305326cc232e

While the bridge API runs in the background watching for the Transfer event, let’s call the contract from Etherscan:

The bridge API immediately reacts, processing the transfer on the BSC side!

Closing Thoughts

Understanding how a simple cross-chain protocol might be created is an extremely important foundation for investigating a much more complicated one.

Bugs worth tens of thousands if not millions of dollars could be crawling around on almost every bridge. At the moment, a little over 30 cross-chain protocols exist on Immunefi’s platform. So, with your new-found knowledge, try your best to visualize how the bridge API works between chains or even simulate the bridge locally to see what makes it crack.

There is no way for certain to eliminate bridge exploitation entirely without widespread education — and those who wield it right now will reap enormous benefits.

Will you be next in the Whitehat Hall of Fame?