Reentrancy and frontrunning for racing the block to take over your assets

It’s the Autumn already, at random day it’s 9 PM already and I’m recovering from ~5 months of mental health issues, it’s the night, all the lights went down and the silence is all around that’s enough to push the urge to hack, and I got myself up this time and I put the target at a web3 project, let’s do it …

Before we dive into the vulnerability and the exploitation, note that here I’m not going to disclose the project’s name nor real contract and functions, so these are just similar and explain the same concept,

let’s give a quick a summary about the project and how does it works, The project is a tasks controller which is created for users to automate their trading tasks and assets management.

the project gives the user three smart contracts to deal with: Transfers, Libraries and Caller

The transfer contracts are the contract that are responsible to transfer the assets (NFTs, Tokens …etc) and the Libraries are used to verify the authenticity of the caller that he must be the owner of the call so that he can trade, the Caller is the proxy made for the transfer contract to trigger the transfer to verify that the assets has been moved and received in order to make a successful transfer and avoid errors

The transfer contract functions are similar to this :

function tokenToNft( uint256 bitmapIndex, uint256 bit, address tokenIn, IERC721 nftOut, uint256 tokenInAmount, uint256 expiryBlock, address to, bytes calldata data ) external {
require(expiryBlock > block.number, 'Expired');
Bit.useBit(bitmapIndex, bit);

uint256 nftOutBalance = nftOut.balanceOf(address(this));

if (tokenIn.isEth()) {
  CALL_EXECUTOR.proxyCall{value: tokenInAmount}(to, data);
} else {
  IERC20(tokenIn).transfer(to, tokenInAmount);
  CALL_EXECUTOR.proxyCall(to, data);
}

   uint256 nftOutAmountReceived = nftOut.balanceOf(address(this)) - nftOutBalance;
   require(nftOutAmountReceived >= 1, 'NotEnoughReceived');

}

The Library check is quite similar to this :

function useBit(uint256 bitmapIndex, uint256 bit) internal {
    if (!validBit(bit)) {
      revert InvalidBit();
    }
    bytes32 ptr = bitmapPtr(bitmapIndex);
    uint256 bitmap = loadUint(ptr);
    if (bitmap & bit != 0) {
      revert BitUsed();
    }
    uint256 updatedBitmap = bitmap | bit;
    assembly { sstore(ptr, updatedBitmap) }
  }

Finally the proxy is quite alike :

function proxyCall(address to, bytes memory data) external payable {
   assembly {
      let result := call(gas(), to, callvalue(), add(data, 0x20),     mload(data), 0, 0)
      returndatacopy(0, 0, returndatasize())
      switch result
   case 0 {
      revert(0, returndatasize())
     }
   default {
      return(0, returndatasize())
    }
  }
}

the transfer function works as accepts the following args

  • the token contract

  • the amount of the token to be transfered (from the contract to the user, means the price of that nft on that token)

  • bitmapIndex and bit to prevent replay attacks

  • the To address which stands for the receiver address

  • the data to be used when calling the receiver address

Once done this function (tokenToNft) will :

  • checks the bitmapIndex and bit if they were already used

  • then it defines the balance of that address on the NFT address as nftOutBalance

  • later it will transfer the tokens of the exchange token to the to address

  • with the transfer it will forwards a call to the to receiver using the call proxy contract (used as notification)

  • once done it will now re-check if the balance of that address on the nft token contract minus the nftOutBalance is equal or higher than 1 means not to be zero, this is used to check that this contract has received the token on the nft address (to prevent exchange without giving NFT)

Race the condition (Reentrancy) in the code

The main issue here is that those functions does not prevent against reentrancy, furthermore these functions do calls during the transfer and checks balance of the received nfts (the 5th step) after the call,

all of these can result for the functions to be harmfully exploited, as example a malicious actor can exchange an nft that worth 2 tokens XToken against many more tokens (while it worth 2 tokens only) by :

  • user makes a contract that have a nft that worth 2 tokens on Xtoken

  • the contract makes a call to the tokentonft function with a valid bits (bitmapIndex and bit) to swap that nft against the XToken and the receiver is the malicious contract

  • once done the vulnerable contract on the transfer of the tokens of the XToken to the to address will makes a call to the to address (in our case is that malicious contract)

  • using the second call the contract will re-make the call with more updated bits and expiryBlock

  • this second call will result for a second transfer of the XToken to the to address while it didn't update it's balance on the nftOut address yet,

  • for now the to address received 4 token (2x2) while the send didn't send any nft yet

  • on the that call in the transfer phase it will makes a further call to the malicious contract, which that last will reenter the call and makes it send one more 2 tokens ...etc

  • until the contract is drained a user will eventually sends the token

  • the contract will checks the balance and it will finds that it have received the nft and the transaction will succeed

That’s the final results, however the protocol wasn’t producing that way, the owner of the asset actually transfers the assets from another proxy which uses the Delegatecall() function, this works with the ecrecover to identify the owner’s identity, this proxy is called Accounts, that said the signed call data called on the Accounts.sol needs to pass an ECDSA signature check. The executor of this function cannot provide any bit/bitmapIndex value in order to bypass the bit check. Those params need to be signed by the proxy owner.

We can make our malicious contract make a call to tokenToNft with the two valid useBits templates (1,1 and 1,2) our malicious contract works that way : maliciousFunction > makes the first call to tokenToNft with the first useBit valid bits 1,1 Fallback > Re-do the call to tokenToNft with the second useBit valid bits 1,2

Scenario happens : Malicious contract makes a call to transfer 1 token and receive 1 eth (example) tokenToNft with the first valid bits, The tokenToNft function won't revert on the bits (as they are already valid) and it stores 1,1 template The contract will stores it's balance on the tokenOut "uint256 nftOutBalance = nftOut.balanceOf(address(this));" Say this will be 10 The token in will be ETH (in case it's not the CALL_EXECUTOR contract makes the call as well), the function will make a call to the malicious contract with the ETH balance (says 1 ETH) which will trigger it's fallback, the malicious contract fallback will receive 1 eth and make a call to the tokenToNft function with the second valid bits (1,2) and this won't make it revert as this template didn't used yet and it will allows the the function to be succeed again, allowing it to transfer an extra 1 eth ...etc and on every call the fallback updates the bits, and in the last transfer the attack contract on the nftOut token it sends 1 token to the vulnerable contract, this will makes it confirms " uint256 nftOutAmountReceived = nftOut.balanceOf(address(this)) - nftOutBalance;" and all the transaction will succeed, While the user was supposed to send 1 token against 1 ETH, they ended up sending 1 token against more than 1 ETH

But since it’s 1 transaction per transfer, means we can’t override the current Account owner transaction, since it’s authenticated with the one signature … our attack seems to be protected too well but unfortunately the chain suffers from something called FrontRunning :

Front-running :

Before the order is confirmed in the chain, miners and anyone who can access the mempool can see these “pending“ transactions,

Front-running is a form of market manipulation where traders take advantage of pre-market knowledge of an order to buy or sell a cryptocurrency before the order is executed. Frontrunning occurs in everyday transactions. One of the main differences is the decentralized nature of the cryptocurrency market (taken from Omniatech)

what can this produces to our case is a very critical that it can lead us to front run an original transaction, and in our case we say two, by doing so we can finally achieve this attack flow :

Account contract owner makes two transfers (Transfer X exchanges 2 tokens to 2 and Transfer Y transfer 1 vs 1 ) on specific token to a malicious contract (to address), the to address (malicious) as they can access the mempool they can access the data, later they frontrun the two transactions into the reentrancy contract (re-submit the both of metaDelegateCall transactions with the given bytes by the malicious contract (reentrancy abuser) at once and be called twice by the fallback abuse as explained in the report and pay a much higher gas price will makes it front run over the original two transactions), making the ownerProxy transfers 3 token to the other address vs 2 or 1 token only back (As explained)

And many more may occurs depends on the situation, basically said if the Account.sol makes two calls or more in the same block anyone on the mempool may frontrun them into one transaction via a reentrancy exploit contract and allowing them to spend twice and receive once only

We wrote our exploit finally

This is a simple fork of exploit, deploying this contract on the specific transaction will make the user loses his NFT/ASSETS/Tokens …etc

The team thankfully was responsive and confirmed the issue and assigned a $20,000 bounty on it

The issue was addressed and fixed later,

Thanks to Immunefi and the responsible team for this disclosure, and hope you enjoy reading it !

Regards,

Adam

Subscribe to Adam
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.