基础漏洞总结3
拒绝服务
拒绝服务指的是攻击者通过某种方式阻止合约正常执行其预期功能,使得合约无法继续服务合法用户。
简单来说,这是一种”我让你无法正常工作”的攻击方式。在智能合约中,DoS攻击通常通过操纵合约状态或利用执行限制来实现。
示例
一个代币分发合约,负责收集投资并向投资者分发回报:
1 | contract DistributeTokens { |
DistributeTokens
合约中的拒绝服务漏洞
这个合约在代币分发功能中存在严重的DoS风险:
1 | function distribute() public { |
攻击方式:
恶意投资者合约攻击:
- 攻击者可以部署一个恶意合约来调用
invest()
函数 - 该恶意合约的
receive()
或fallback()
函数被设计为执行失败或消耗所有Gas:1
2
3
4
5
6
7
8
9contract MaliciousInvestor {
receive() external payable {
revert(); // 直接回滚,或者...
// 或者执行消耗大量Gas的操作
while(true) {
// 无限循环或复杂计算
}
}
}
- 攻击者可以部署一个恶意合约来调用
循环阻塞:
- 当
distribute()
函数执行到恶意投资者时,transfer()
调用会失败 - 由于整个循环在一个交易中执行,一个失败的
transfer()
会导致整个distribute()
函数回滚 - 结果:所有合法投资者都无法收到应得的代币
- 当
Gas限制攻击:
- 即使恶意合约不直接回滚,而是执行复杂操作消耗Gas
- 由于区块Gas限制,循环可能在完成前耗尽所有Gas
- 特别是当投资者列表很长时,这个问题会更加严重
漏洞影响:
- 资金冻结:所有投资者的资金被永久锁定在合约中
- 功能瘫痪:
distribute()
函数永远无法成功执行 - 状态死锁:
isFinalized
永远无法设置为true
,合约处于不一致状态
结论与安全建议
核心要点: 避免在循环中进行外部调用,特别是当循环次数不可控或可能包含恶意合约时。
如何避免拒绝服务漏洞?
提款模式:让投资者自己提取资金,而不是主动分发:
1
2
3
4
5
6
7
8
9
10mapping(address => uint) public balances;
bool public isFinalized = false;
function withdraw() public {
require(isFinalized, "Distribution not finalized");
uint amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}批量处理与检查点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22uint public distributionIndex = 0;
function distributeBatch(uint batchSize) public {
require(msg.sender == owner);
uint end = distributionIndex + batchSize;
if (end > investors.length) {
end = investors.length;
}
for(uint i = distributionIndex; i < end; i++) {
// 即使某个失败,也可以继续后续批次
(bool success, ) = investors[i].call{value: investorBalances[i]}("");
if (!success) {
// 记录失败,继续执行
}
}
distributionIndex = end;
if (distributionIndex >= investors.length) {
isFinalized = true;
}
}使用推送模式的替代方案:
- 考虑使用拉取模式而非推送模式
- 分离状态更新和资金转移
- 为每个投资者提供独立的提款函数
Gas限制考虑:
- 预估循环执行的最大Gas消耗
- 避免在单次交易中处理过多项目
- 使用批量操作和状态检查点
设计原则: “信任最小化” - 不要假设外部调用总是成功,也不要在一个交易中处理所有用户的操作。
时间戳操纵
时间戳操纵,也称为Block Timestamp Manipulation,指的是矿工(或验证者)通过操纵区块时间戳来影响依赖时间戳的智能合约逻辑。
简单来说,这是一种”我可以控制时间,从而控制游戏结果”的攻击方式。矿工在打包区块时有一定程度的时间戳控制权,这会影响依赖 block.timestamp
(旧版本中为 now
)的合约。
示例
一个轮盘赌合约,根据区块时间戳来决定中奖者:
1 | contract Roulette { |
Roulette
合约中的时间戳操纵漏洞
这个合约使用区块时间戳作为随机性来源,存在严重的安全风险:
1 | if (block.timestamp % 15 == 0) { // winner |
攻击方式:
矿工操纵攻击:
- 矿工在打包包含下注交易的区块时,可以调整区块的时间戳
- 通过选择特定的时间戳值,使得
block.timestamp % 15 == 0
条件成立 - 矿工可以让自己或关联地址成为中奖者,赢取合约中的所有资金
时间戳预测攻击:
- 即使不是矿工,攻击者也可以预测未来几个区块可能的时间戳范围
- 在有利的时间窗口提交交易,增加中奖概率
- 结合Gas价格竞争,提高交易被包含在目标区块的机会
时间戳范围利用:
- 矿工可以在合理范围内(通常在前一个区块时间戳+1到当前时间+900秒之间)选择时间戳
- 这个范围足够让矿工找到满足
% 15 == 0
条件的时间戳值
漏洞影响:
- 资金损失:合约资金被矿工或攻击者盗取
- 公平性破坏:赌博游戏的随机性和公平性完全丧失
- 信任崩塌:用户对合约的信任被彻底破坏
结论与安全建议
核心要点: 区块时间戳不应作为随机数源或关键游戏逻辑的唯一决定因素,因为矿工对其有相当程度的控制权。
如何避免时间戳操纵漏洞?
使用区块哈希:
1
2// 结合区块哈希,增加操纵难度
uint random = uint(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)));使用承诺-揭示方案:
1
2
3
4
5
6
7
8// 用户先提交承诺,然后揭示随机数
bytes32 commitment;
uint revealBlock;
function commit(bytes32 _commitment) public {
commitment = _commitment;
revealBlock = block.number + 10; // 10个区块后揭示
}使用预言机服务:
1
2
3
4// 使用Chainlink VRF等可验证随机函数
function requestRandomness() public returns (bytes32 requestId) {
return requestRandomness(keyHash, fee);
}时间戳容忍设计:
1
2// 使用时间范围而非精确值
require(block.timestamp >= startTime && block.timestamp <= endTime);多数据源混合:
1
2
3
4
5
6
7// 结合多个难以操纵的区块链属性
uint seed = uint(keccak256(abi.encodePacked(
block.difficulty,
blockhash(block.number - 1),
block.timestamp,
msg.sender
)));
重要提醒: 在以太坊合并(The Merge)后,PoS机制下的时间戳操纵风险有所变化,但时间戳仍然不完全可信。对于需要真正随机性的应用,应该使用专业的外部随机数服务。
未初始化的存储指针
未初始化的存储指针,也称为Uninitialized Storage Pointers,指的是在函数内声明结构体或数组时未显式指定数据位置,导致其意外地指向存储槽0。
简单来说,这是一种”我以为我在操作内存,但实际上我在修改关键存储变量”的认知错误。在Solidity中,局部变量如果未初始化数据位置,会默认指向存储,从而可能覆盖重要状态变量。
示例
一个名称注册合约,允许用户注册名称到地址的映射:
1 | contract NameRegistrar { |
NameRegistrar
合约中的未初始化存储指针漏洞
这个合约在函数内声明结构体时存在严重的数据位置问题:
1 | function register(bytes32 _name, address _mappedAddress) public { |
漏洞原理:
存储指针默认指向槽0:
- 在旧版本Solidity中,未指定数据位置的结构体/数组默认指向存储
NameRecord newRecord
实际上指向存储槽0- 槽0对应的是
unlocked
状态变量
状态变量被意外覆盖:
newRecord.name = _name
实际上在修改存储槽0(unlocked
的位置)newRecord.mappedAddress = _mappedAddress
在修改存储槽1- 这意外地修改了合约的关键状态变量
访问控制绕过:
- 由于
unlocked
被意外修改,require(unlocked)
检查可能被绕过 - 攻击者可以通过精心构造的
_name
值将unlocked
设置为true
- 由于
攻击方式:
攻击者可以调用 register
函数并精心选择 _name
参数:
- 前32字节用于覆盖
unlocked
变量 - 通过设置特定值,可以将
unlocked
从false
改为true
- 一旦合约解锁,攻击者可以执行本应被限制的操作
结论与安全建议
核心要点: 在函数内声明引用类型(结构体、数组、映射)时,必须显式指定数据位置(memory
、storage
或 calldata
)。
如何避免未初始化存储指针漏洞?
显式指定数据位置:
1
2
3
4
5
6
7
8
9
10
11function register(bytes32 _name, address _mappedAddress) public {
// 正确:显式指定为memory
NameRecord memory newRecord;
newRecord.name = _name;
newRecord.mappedAddress = _mappedAddress;
resolve[_name] = _mappedAddress;
registeredNameRecord[msg.sender] = newRecord;
require(unlocked);
}直接初始化映射:
1
2
3
4
5
6
7
8
9
10
11function register(bytes32 _name, address _mappedAddress) public {
require(unlocked);
// 直接设置映射,避免中间变量
registeredNameRecord[msg.sender] = NameRecord({
name: _name,
mappedAddress: _mappedAddress
});
resolve[_name] = _mappedAddress;
}使用现代Solidity版本:
- Solidity 0.5.0及以上版本要求显式指定数据位置
- 编译器会对未初始化数据位置报错
代码审查重点:
- 检查所有函数内的引用类型声明
- 确保每个都有明确的数据位置说明符
- 特别注意结构体和数组的声明
安全影响: 这个漏洞可能导致:
- 访问控制被绕过
- 关键状态变量被意外修改
- 合约逻辑被完全破坏
- 资金损失或未授权访问
在Solidity 0.5.0之后,这个漏洞主要通过编译器强制检查来预防,但理解其原理对于维护旧合约和编写安全代码仍然很重要。
浮点和数据精度
浮点和数据精度,也称为Floating Point and Precision,指的是在Solidity中处理小数和数值运算时,由于缺乏原生浮点数支持而导致的精度丢失和计算错误。
简单来说,这是一种”我以为数学计算是精确的,但实际上整数除法会截断小数部分”的认知错误。Solidity没有内置的浮点数类型,所有数值运算都是基于整数,这可能导致严重的精度问题。
示例
一个代币分配合约,涉及百分比计算和资金分配:
1 | contract TokenDistributor { |
数据精度问题的类型和影响
1. 整数除法截断
1 | function badDivision() public pure returns (uint) { |
2. 百分比计算错误
1 | function badPercentage(uint amount, uint percentage) public pure returns (uint) { |
3. 汇率计算不精确
1 | function badExchangeRate(uint tokens, uint rate) public pure returns (uint) { |
攻击方式和风险:
资金计算错误:
- 由于除法截断,用户可能收到比应得数量少的资金
- 累计误差可能导致大量资金被锁定在合约中
套利机会:
- 攻击者可以利用计算误差进行套利
- 例如,在汇率计算中,由于精度问题产生价差
治理投票操纵:
- 在基于代币权重的投票中,精度问题可能影响投票结果
- 大额持有者可能获得不成比例的影响力
结论与安全建议
核心要点: 在Solidity中处理数值计算时,必须考虑整数运算的特性,采用适当的方法来保持精度。
如何避免浮点和数据精度问题?
1. 使用固定点数表示法
1 | // 使用放大因子来处理小数 |
2. 采用”先乘后除”原则
1 | // 错误的顺序 |
3. 使用安全的数学库
1 | import "@openzeppelin/contracts/utils/math/SafeMath.sol"; |
4. 处理剩余资金
1 | function distributePrecisely(uint total, uint[] memory ratios) public { |
5. 使用适当的数据类型
1 | // 对于高精度需求,使用足够的位数 |
最佳实践:
- 始终使用”先乘后除”的顺序
- 为关键计算使用足够大的精度因子
- 使用经过审计的数学库(如OpenZeppelin的SafeMath)
- 测试边界情况,特别是小数值和边界值
- 考虑使用专门处理小数的库(如ABDKMath、DS-Math)
安全影响:
- 资金损失给用户或合约所有者
- 资金被永久锁定在合约中
- 计算错误导致的经济损失
- 治理系统被操纵
tx.origin 判定
tx.origin 判定,也称为tx.origin Authentication,指的是**开发者错误地使用 tx.origin
进行身份验证,而不是使用 msg.sender
**。
简单来说,这是一种”我以为我在验证交易发起者,但实际上我在验证整个调用链的原始发起者”的认知错误。tx.origin
返回的是整个调用链的原始发送者,这可能导致网络钓鱼攻击。
示例
一个可钓鱼合约,使用 tx.origin
进行所有权验证:
1 | contract Phishable { |
一个攻击合约,利用 tx.origin
漏洞:
1 | import "Phishable.sol"; |
Phishable
合约中的 tx.origin 漏洞
这个合约使用 tx.origin
进行身份验证,存在严重的安全风险:
1 | function withdrawAll(address _recipient) public { |
攻击原理:
tx.origin
与msg.sender
的区别:msg.sender
:当前函数的直接调用者tx.origin
:整个交易链的原始发起者(通常是EOA)
攻击流程:
- 合法所有者(EOA)调用
AttackContract
的某个函数 AttackContract
在回调函数中调用Phishable.withdrawAll(attacker)
- 在
Phishable
合约中:tx.origin
= 合法所有者的地址(整个交易的发起者)msg.sender
=AttackContract
的地址(直接调用者)
- 验证
require(tx.origin == owner)
通过,因为所有者确实发起了交易 - 资金被转移到攻击者指定的地址
- 合法所有者(EOA)调用
攻击场景:
网络钓鱼攻击:
- 攻击者部署
AttackContract
- 诱骗合约所有者与攻击合约交互(如领取空投、参与活动等)
- 当所有者调用攻击合约时,攻击合约自动盗取
Phishable
合约中的资金
- 攻击者部署
恶意DApp:
- 攻击者创建恶意DApp网站
- 用户连接钱包并与恶意DApp交互
- DApp在后台调用攻击合约,盗取用户其他合约中的资金
结论与安全建议
核心要点: 在身份验证和授权检查中,应该使用 msg.sender
而不是 tx.origin
。tx.origin
只应用于极少数特殊情况。
如何避免 tx.origin 判定漏洞?
使用
msg.sender
进行身份验证:1
2
3
4function withdrawAll(address _recipient) public {
require(msg.sender == owner); // 正确的方式
_recipient.transfer(address(this).balance);
}如果需要原始调用者信息:
1
2
3
4
5
6// 明确传递调用者地址作为参数
function withdrawAll(address _recipient, address _caller) public {
require(_caller == owner);
require(msg.sender == _caller); // 额外验证
_recipient.transfer(address(this).balance);
}使用修饰器进行权限控制:
1
2
3
4
5
6
7
8modifier onlyOwner() {
require(msg.sender == owner, "Caller is not owner");
_;
}
function withdrawAll(address _recipient) public onlyOwner {
_recipient.transfer(address(this).balance);
}正确的
tx.origin
使用场景:1
2
3
4
5// 仅用于防止中间合约调用,而不是身份验证
function sensitiveOperation() public {
require(tx.origin == msg.sender, "Contract calls not allowed");
// 这个操作只能由EOA直接调用,不能通过合约调用
}
安全影响:
- 资金被盗
- 权限绕过
- 用户被钓鱼攻击
最佳实践: 在99%的情况下,你都应该使用 msg.sender
进行身份验证。只有在明确需要防止合约调用时才考虑使用 tx.origin
,并且要清楚理解其安全含义。
问题总结
1. “竞争条件”中,investor
参数指的是什么?
在之前讨论的“拒绝服务”漏洞的 DistributeTokens
合约中,investors
并不是一个函数参数,而是一个状态变量。
它是一个地址数组,用于记录所有向合约投入了资金的投资者地址。
solidity
1 | contract DistributeTokens { |
investors
数组的作用与漏洞关系:
- 作用:它像一个花名册,记录了所有有资格在
distribute()
函数被调用时获得分红的投资者。 - 与漏洞的关系:攻击者通过
invest()
函数,将一个恶意合约的地址 加入到这个investors
数组中。当所有者调用distribute()
进行循环转账时,一旦尝试向这个恶意地址转账,恶意合约的receive
或fallback
函数就会执行失败(例如直接revert()
),导致整个分发交易被回滚。这样一来,所有诚实投资者(他们的地址也在investors
数组里)都无法收到应得的款项。
总结:investors
是存储投资者名单的关键状态变量,攻击者通过污染这个名单(加入恶意地址),从而对全体诚实投资者造成“拒绝服务”攻击。