Back to CTF's to be sharper in problem solving. I played Backdoor CTF 2023 with our amazing team Infobahn. We got 4th place in this CTF. I solved few Blockchain challs as usual. Solutions,
Curvy Pool
No description required, isSolved() is the problem statement for us (most of the time).
// SPDX-License-Identifier: MITpragma solidity ^0.8.16;import {ERC20} from "./ERC20.sol";import {Pool} from "./Pool.sol";// Dummy WETH Tokencontract DummyWETH is ERC20 { constructor(uint256 supply) ERC20("Dummy WETH", "WETH-D") { _mint(msg.sender, supply); }}// Dummy PUFETH Tokencontract DummyPUFETH is ERC20 { constructor(uint256 supply) ERC20("Dummy PUFETH", "PUFETH-D") { _mint(msg.sender, supply); }}// Deployment Scriptcontract Setup { mapping(address => bool) public hasClaimedWETH; mapping(address => bool) public hasClaimedPUFETH; DummyWETH private wethToken = new DummyWETH(11 ether); DummyPUFETH private pufethToken= new DummyPUFETH(11 ether); Pool public immutable pool = new Pool(address(wethToken), address(pufethToken), 0); constructor() payable{ // Deploy Dummy Tokens // Deploy Pool // Approve and add liquidity wethToken.approve(address(pool), 10 ether); pufethToken.approve(address(pool), 10 ether); pool.addLiquidity(10 ether, 10 ether); // Log addresses for reference // console.log("WETH-D Token:", address(wethToken)); // console.log("PUFETH-D Token:", address(pufethToken)); // console.log("Pool:", address(pool)); } function claimWETH() external { require(!hasClaimedWETH[msg.sender], "Already claimed WETH"); hasClaimedWETH[msg.sender] = true; wethToken.transfer(msg.sender,1 ether); } function claimPUFETH() external { require(!hasClaimedPUFETH[msg.sender], "Already claimed PUFETH"); hasClaimedPUFETH[msg.sender] = true; pufethToken.transfer(msg.sender,1 ether); } function isSolved(address user) public view returns(bool){ uint256 sharesHeldByPoolOwner = pool.balanceOf(address(this)); uint256 sharesToWin = sharesHeldByPoolOwner/2; uint256 sharesHeldBySender = pool.balanceOf(user); return sharesHeldBySender > sharesToWin; }}
First of all understaning protocol setup is necessary for any DeFi challenge. Seems like the challenge is mentioning about Curve pool and we have a liquidity token and a pool contract. The Pool contract have the basic swap, add and remove liquidity functions to swap for tokens, adding and removing liquidity in the pool. The two tokens in the pool are WETH and PuffETH.
Intially in setUp contract minted 11 ether of tokens WETH and PuffETH and provided liquidity to Pool contract. Setup contract has two claim functions to claim 1 ether of WETH and PuffETH tokens for us. To solve this challenge we need to hold more than half of the shares held by owner.
Owner provided 10 ether of each token to the pool, but we have only 1 ether of each, as soon as the protocol is secured we can't actually do this. But we know there is a bug :)
Let's find it.
Points to note in the protocol
In the swap() function we can swap one token at a time. No flash loan kind of thing (Uniswap)
addLiquidity() function was checking the in tokens ratios incorrectly, if (amount0 < 0 || amount1 < 0) this improper check will allow us to add 0 amount of liquidity of a token.
This line uint256 liquidityTokens = ((amount0*amount0) + (amount1*amount1))/(1 ether) is calculating the liquidity tokens to mint for us. To get more LP tokens we can make amount0 or amount1 soo bigger then the square of will be even big and it will be devided by 1 ether, but we will get decent number of LP tokens.
The removeLiquidity() function will transfer both the tokens equally (liquidityTokens/2).
So, now what we can do is, we will swap our WETH completely for PuffETH token and then we provide liquidity of PuffETH token only so that we can make liquidityTokens somewhat higher in calculation. Then we remove the liquidity to get equal amount of WETH and PuffETH tokens. We will continue this for 2 iterations, so we will get more than 10 ether of WETH and PuffETH tokens. At last providing liquidity of both tokens to the pool will mint us more LP tokens than owner.
// SPDX-License-Identifier: UNLICENSEDpragma solidity ^0.8.15;import "./challenge.sol";contract Setup { Challenge public challenge; constructor() payable{ challenge = new Challenge(); } function isSolved() external view returns (bool) { return challenge.solved(); }}
// SPDX-License-Identifier: MITpragma solidity ^0.8.17;contract Challenge { bytes32 private constant stick = 0xd4fd4e189132273036449fc9e11198c739161b4c0116a9a2dccdfa1c492006f1; uint256 private constant maxCodeSize = 30; bool public solved=false; function func(bytes memory input) external payable { address addr; bytes4 value4; uint256 codeSize; uint combined; assembly { let base := add(input, 0x20) let first20 := shr(96, mload(base)) addr := first20 codeSize := extcodesize(addr) if gt(codeSize, maxCodeSize) { revert(0, 0) } let data := mload(add(input, 0x34)) value4 := data let value := callvalue() let value1 := value let value3 := 0 let value2 := 0 for { } gt(value1, 0) { value1 := shr(1, value1) } { value3 := shl(1, value3) value3 := or(value3, and(value1, 1)) } let bool1 := eq(value, value3) value1 := value for { } gt(value1, 0) { value1 := and(value1, sub(value1, 1)) } { value2 := add(value2, 1) } let bool2 := or(lt(value2, 4), eq(value2, 3)) combined := and(bool1, bool2) } require(combined==1, "Condition failed"); (bool success1, bytes memory ret1) = addr.call(""); require(success1, "Call failed"); require(ret1.length > 0, "No return data"); bytes1 retValue1 = bytes1(ret1[0]); require(retValue1 == "L", "Invalid return value"); (bool success2, bytes memory ret2) = addr.call{value: msg.value}(""); require(success2, "Call failed"); require(ret2.length > 0, "No return data"); bytes1 retValue2 = bytes1(ret2[0]); require(retValue2 == "M", "Invalid return value"); bytes32 hashedValue = keccak256(abi.encodePacked(value4)); require(hashedValue == stick, "Hash mismatch"); solved = true; }}
Solution
Solution is simple, we need to call func() function on Challenge contract. And we have to pass through all the checks and reach the last line of execution to solve this.
We need to pass a bytes input that satisfies all the conditions written in assembly.
Let's analyze the challenge and its solution step by step.
Is exactly 24 bytes in size (satisfying the size check)
Has proper control flow to handle both cases
The contract logic:
If CALLVALUE is 0 (normal call):
Store "L" in memory
Return "L"
If CALLVALUE is non-zero (call with value):
Store "M" in memory
Return "M"
Attack Script Explanation
contract SolveScript is Script { Setup public set = Setup(0x457C34237d573e6207c8f65eAcc2f48dDa2ddD12); Challenge public challenge; function run() public { // Deploy our minimal contract AttackDeployer attack = new AttackDeployer(); address addr = attack.exploit(); // Create input with our contract address and deadbeef bytes memory input = abi.encodePacked(address(addr), hex"deadbeef"); // Call func with 3 wei (satisfies both value checks) challenge.func{value: 3 wei}(input); }}
The attack works because:
Our deployed contract is exactly 24 bytes (satisfying size check)
We send 3 wei which:
Has exactly 2 bits set (11 in binary)
Is palindrome in binary (11)
Our contract returns correct values based on callvalue
The deadbeef value hashes to the required stick value
This is a great example of EVM bytecode optimization and understanding low-level contract behavior. The challenge tests knowledge of:
EVM opcodes and bytecode
Contract deployment and size constraints
Binary number properties
Contract return value handling
Memory operations in EVM
The flag h4v333_y0uuuu_r34d_EVMMM? is well deserved for anyone who can solve this challenge!
Betray
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;import "./Challenge.sol";contract Setup { address public immutable master; Treasury public treasury; SecretChecker public secretChecker; constructor() payable { master = msg.sender; treasury = new Treasury{value : 1 ether}(msg.sender); secretChecker = new SecretChecker(); } function isSolved() public returns (bool) { (bool MasterCanWithdraw, ) = address(treasury).call{gas : 1000000}(abi.encodeWithSignature("withdraw()")); bool IKnowTheSecret = secretChecker.SecretIsLeaked(); return (!MasterCanWithdraw) && IKnowTheSecret; } receive() external payable {}}
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;interface Servant { function spillSecret() external view returns (bytes32);}// Master uses the below contract to pay your salarycontract Treasury { address public servant; address public immutable master; uint256 public timesWithdrawn; mapping(address => uint256) servantBalances; constructor(address _master) payable{ master = _master; } function withdraw() public { uint256 dividend = address(this).balance / 100; servant.call{value: dividend}(""); payable(master).transfer(dividend); timesWithdrawn++; servantBalances[servant] += dividend; } function BecomeServant(address _servant) external { servant = _servant; } function remainingTreasure() public view returns (uint256) { return address(this).balance; } receive() external payable {}}contract SecretChecker { bool public SecretIsLeaked; mapping (bytes32 => bool) public attempted; function IKnowTheSecret(address _servant) public { require(!attempted[keccak256(abi.encodePacked(_servant))], "Won't give another chance :p"); uint256 length; assembly { length := extcodesize(_servant) } require(length <= 20, "HaHa! try again xD"); Servant servant = Servant(_servant); bytes32 encodedSecret = servant.spillSecret(); bytes32 secret = bytes32(abi.encodePacked("I'm_L0yal;)")) >> (24 * 7); require(keccak256(abi.encodePacked(secret)) == keccak256(abi.encodePacked(encodedSecret)), "You don't know the secret!"); attempted[keccak256(abi.encodePacked(_servant))] = true; SecretIsLeaked = true; }}
This challenge involves a Treasury contract that pays dividends to a servant and a master. The goal is to:
Make the master unable to withdraw funds (revert the withdraw call)
!MasterCanWithdraw: The master's withdraw call must revert
This means our servant contract must make the withdraw() function revert
We can do this by making our receive() function consume all gas
IKnowTheSecret: The secret must be leaked
We need to deploy a contract that returns the correct secret
The contract must be ≤ 20 bytes in size
The secret is "I'm_L0yal;)" shifted right by 24*7 bits
The key contracts are:
Treasury: Manages funds and pays dividends
SecretChecker: Verifies if the secret is leaked
Servant: Interface that requires implementing spillSecret()
The vulnerability lies in the withdraw() function of the Treasury contract, which uses a low-level call to send funds to the servant without checking the return value. This allows us to deploy a malicious contract that can revert the transaction when receiving funds.
Solution
Let's analyze the vulnerability and solution in detail:
Vulnerability Analysis
Treasury Contract Vulnerability:
function withdraw() public { uint256 dividend = address(this).balance / 100; servant.call{value: dividend}(""); // No return value check! payable(master).transfer(dividend); timesWithdrawn++; servantBalances[servant] += dividend;}
The key vulnerability is in the withdraw() function:
It uses a low-level call to send funds to the servant
The return value is not checked
If the servant's receive() function reverts, the master's transfer never happens
We can exploit this by making our servant contract revert on receive
SecretChecker Requirements:
function IKnowTheSecret(address _servant) public { require(!attempted[keccak256(abi.encodePacked(_servant))], "Won't give another chance :p"); uint256 length; assembly { length := extcodesize(_servant) } require(length <= 20, "HaHa! try again xD"); Servant servant = Servant(_servant); bytes32 encodedSecret = servant.spillSecret(); bytes32 secret = bytes32(abi.encodePacked("I'm_L0yal;)")) >> (24 * 7); require(keccak256(abi.encodePacked(secret)) == keccak256(abi.encodePacked(encodedSecret)), "You don't know the secret!");}
We need to:
Deploy a contract ≤ 20 bytes
Implement spillSecret() to return the correct secret
The secret is "I'm_L0yal;)" shifted right by 24*7 bits
Preventing Master's Withdrawal:
We need to make the withdraw() function revert when sending funds to our servant
We can do this by implementing a receive() function that consumes all gas
An infinite loop in receive() will cause the transaction to revert
This prevents the master's transfer from happening
Leaking the Secret:
We need to deploy a minimal contract that:
Is exactly 20 bytes in size
Returns the correct secret when spillSecret() is called
The secret is "I'm_L0yal;)" shifted right by 24*7 bits
Attack Flow:
Deploy our malicious contract that:
Has a gas-consuming receive() function
Returns the correct secret
Set our contract as the servant
Call withdraw() which will:
Send funds to our contract
Our receive() function will revert
Master's transfer never happens
Verify the secret with SecretChecker
Writing the Required Smart Contract in Bytecode
Let's understand how we craft the minimal contract that satisfies all requirements:
Understanding Requirements:
// From SecretChecker.solrequire(length <= 20, "HaHa! try again xD"); // Contract must be ≤ 20 bytesbytes32 secret = bytes32(abi.encodePacked("I'm_L0yal;)")) >> (24 * 7); // Required secret
Breaking Down the Secret:
// Original string: "I'm_L0yal;)"// Length: 10 bytes// After right shift by 24*7 bits:// 0x00000000000000000000000000000000000000000049276d5f4c3079616c3b29