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: MIT
pragma solidity ^0.8.16;
import {ERC20} from "./ERC20.sol";
import {Pool} from "./Pool.sol";
// Dummy WETH Token
contract DummyWETH is ERC20 {
constructor(uint256 supply) ERC20("Dummy WETH", "WETH-D") {
_mint(msg.sender, supply);
}
}
// Dummy PUFETH Token
contract DummyPUFETH is ERC20 {
constructor(uint256 supply) ERC20("Dummy PUFETH", "PUFETH-D") {
_mint(msg.sender, supply);
}
}
// Deployment Script
contract 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: MIT
pragma 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.
Challenge Analysis
The challenge has several checks we need to pass:
Code Size Check:
codeSize := extcodesize(addr)
if gt(codeSize, maxCodeSize) {
revert(0, 0)
}
Our deployed contract must be less than 30 bytes in size.
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).
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.
!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.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
Breaking Down the Secret:
// Original string: "I'm_L0yal;)"
// Length: 10 bytes
// After right shift by 24*7 bits:
// 0x00000000000000000000000000000000000000000049276d5f4c3079616c3b29