漏洞详情:
基于区块链的透明性,任何部署到链上的合约数据,都是透明可见,既便是 private 修饰的变量也是如此。因为 private 的可见性仅仅是对函数和外部合约的而言,任意用户都可以通过检索链上数据获取到这些值。这种情况下,任何希望通过基于链上 private 修饰来保证的机密性操作都是不安全的。
如果存在下面的简单的抽奖代码:
contract Eocene{
mapping(address => bytes 32) candidate;
uint private seed = 0x 12341 3d;
function select() public{
bytes 32 result = keccak 256(abi.encodePacked(seed));
if(result == candidate[msg.sender]){
payable(msg.sender).transfer( 1 ether);
}
}
}
即便 seed 已经声明为 private 变量,但是任何人都可以通过合约地址以及 seed 的 slot 位置在链上检索到 seed 值,借此计算出对应的值来获取到 eth。
修复措施:
不要在合约中存储任何用于验证的关键值,将关键值存储到链下,链上仅仅实现相应的验证逻辑。
漏洞详情:
在 solidity 中,变量的初始值为 0/false。这种情况下,在基于某个变量做判断时如果不考虑变量初始值的影响,可能会导致相应的安全问题。
考虑如下空投解锁代码:
contract Eocene{
mapping(address => bool) unlocked;
uint averageDrop;
address token;
function setAverageDrop() public {
averageDrop = 1000;
}
function drop() public {
if(unlocked[msg.sender] == false){
ERC 20(token).transfer(msg.sender, averageDrop);
}
}
}
合约本意是给所有已经解锁的 address 分发 token, 但是却忽略了在 solidity 中,所有的变量初始值均为 0/false。而在 mapping 类型中,key 仅仅用于和 slot 拼接后,通过 keccak 256 计算 storage 中该 key 对应的地址,这也就意味着,任何 address 不管是否经过初始化,都会存在一个 storage 对应,而该初始值通常为 0/false。
修复措施:
不要在任何情况下基于变量的默认值来做关键判断,特别是在基于 mapping 类型的变量中,严格预防此类问题。
漏洞详情:
对任何 mapping 类型,当 value 字段类型为 struct 且对应值不再需要使用时, 应当使用 delete 置删除该值。否则该值会依旧残留在对应 slot 中。
考虑如下形式代码:
contract Eocene{
struct Stake{
uint amount;
uint needReceive;
uint startTime;
}
mapping(address => Stake) stakes;
mapping(address => bool) staker;
function getStake() public{
Stake memory _stake = stakes[msg.sender];
(msg.sender).transfer(_stake.needReceive);
staker[msg.sender] = false;
// delete stakes[msg.sender] // need do but don't
}
function calReceive() public{
require(staker[msg.sender],'not staker');
stakes[msg.sender].needReceive = stakes[msg.sender].amount * (block.time - stakes[msg.sender].startTime);
stakes[msg.sender].amount = 0;
}
}
上面的合约代码根据质押数量和质押时间来计算获取的 eth 量,但是在质押完成后,仅仅将 staker[msg.sender]的值设置为 false,而对应的 stakes[msg.sender]依旧存在。所以攻击者可以无限制调用 getStake()函数来获取 eth。
修复措施:
当然,上面的代码也存在一些其他的辅助问题导致了漏洞的存在,但是你应当意识到,对 storage 中存储的 struct 类型变量,在不使用后都应当通过 delete 将整个 struct 值删除(或者说,对于任何变量都应当如此), 当然也可以通过全部置 0 实现,否则该值对一直存在于对应 slot 中。
漏洞详情:
函数默认可见性为 public,对任意函数,都必须显示的声明其可见性,以防止疏忽导致的漏洞问题存在,特别是当函数多层嵌套调用底层函数时,防止因为疏忽导致底层函数没有被正确赋予可见性。
考虑以下漏洞代码示例:
contract Eocene{
mapping(address => bool) whitelist;
function _a() {
payable(msg.sender).transfer( 1 ether);
}
function a() public{
require(whitelist[msg.sender],'not in whitelist');
_a();
}
}
a()函数通过 require 限定白名单地址,通过后给对应地址转账,正常情况下_a()应当不能被外部调用,但是这里因为未对_a()可见性做显式声明,而被当作 public,导致可以被外部直接调用。
修复措施
对所有函数的可见性做显式声明,特别是对不能被外部直接调用的函数,必须显式声明为 protect 或 private。
漏洞详情:
对任何函数,必须考虑在重入后可能导致的问题。这里的重入包括 transfer/send/call/staticall 等外部调用所导致的所有重入问题。
考虑下列代码形式:
contract Fund {
mapping(address => uint) shares;
function withdraw() public {
if (payable(msg.sender).send(shares[msg.sender]))
shares[msg.sender] = 0;
}
}
对上诉合约来说,当 msg.sender 是恶意的时,可以导致 msg.sender 无限制提取所有当前合约的 balance。但是我们也必须意识到,调用 transfer/send/call/staticall 以及任何外部合约函数时,都可能导致重入问题。
#### 修复措施
可以根据合约具体实现细节,先修改关键变量实现,比如在上诉合约中,可以先记录下 shares[msg.sender]的值,然后将 shares[msg.sender]置 0 之后再进行 send 操作。当然也可以通过全局变量和修饰器的结合实现。
漏洞详情:
对外部函数的调用,在合理情况下,必须限定调用的合约地址和合约函数
考虑以下代码形式:
contract Eocene{
function callExt(address _target, bytes calldata data) public{
_target.call(data);
}
function delegateCallExt(address _target, bytes calldata data) public{
_target.delegatecall(data);
}
}
函数 callExt 被用于调用任意函数的任意地址,这种情况下,很容易导致重入问题,且一旦该合约在任意钱包中有任何 Token 资产,都可以通过该函数直接调用对应 Token 的 transfer 函数转走。
而如果在调用 delegateCallExt 函数时没有限制,则有可能导致合约直接被 destruct,导致整个合约地址 balance 被转走且合约被破坏。
修复措施:
对任意外部合约的调用,优先考虑地址能否进行白名单限制,并进一步考虑指定地址的函数名是否能够限制。
漏洞详情:
上诉函数并不会因为内部错误而导致 revert,而是只返回 revert。在任何时候使用他们时,必须通过函数返回值来判断执行是否成功。
考虑下列示例代码:
contract Eocene{
address token; //any token address
function deposit(uint amount) public{
token.call(abi.EncodeWithSignature("transferfrom(address from, address receipt, uint amount)"), msg.sender, address(this), amount);
mint(msg.sender, amount);
}
}
在 deposit()函数中,合约首先尝试将 msg.sender 的指定 token 转入当前地址,转入成功后,即给 msg.sender 铸造一些当前币种。但由于.call 函数并不会在失败时 revert 整个 transaction,即便未能从 msg.sender 转入任何币种到当前地址,依旧会给 msg.sender 铸造 amount 的当前币种。
修复措施:
对 call,send,delegatecall,staticcall 的执行结果的判断必须基于其返回值,而不是寄望于其是否 revert。
不要基于 tx.origin 做身份认证,tx.origin 是整个交易的发起人,不会随合约的递归调用改变,任何基于 tx.origin 的认证,都无法保证 tx.origin 是 msg.sender。其基于 tx.origin 的认证也增加了用户的账户安全性。
考虑下列漏洞示例:
contract Eocene{
mapping(address=>bool) whitelist;
function freeDeposit() public{
require(whitelist[tx.origin],'not in whitelist');
payable(msg.sender).transfer( 1 ether);
}
}
当任何位于白名单中的地址被某些钓鱼链接诱导调用了任何看似无害的恶意合约地址和函数,而该恶意地址又调用示例代码的 freeDeposit 函数时,本应该属于该白名单地址的资产会被转给恶意合约地址。
修复措施:
不基于 tx.origin 做身份认证。或者针对上诉代码来说,当改为 payable(tx.origin).transfer( 1 ether)时,也不会导致问题。但是,更推荐的做法是,不要使用 tx.origin 来做身份认证,而是使用 require(whitelist[msg.sender],'not in whitelist');来做判断。
漏洞详情:
在合约代码的初始化阶段,即便该地址是合约地址,extcodesize 的返回值也会是 0 ,如果基于该返回值做判断,所得到的结果是不准确的。
考虑下列代码形式:
contract Eocene{
function withdraw() public{
uint size;
assembly {
size := extcodesize(caller())
}
require(size== 0,"not eos account");
msg.sender.transfer( 1 ether);
}
}
上诉合约在 withdraw 函数中,希望通过 extcodesize 返回值限定只允许 EOS 账户获取 token,但是却忽略了当合约初始化阶段,针对合约地址的 extcodesize 返回值也是 0 。导致判断不准确,任意地址都可以从该合约中获取 token。
修复措施:
任何时候不要基于外部地址是否会合约地址做判断,尽可能的保证合约代码在任意种类账户下的功能正常。
漏洞详情:
溢出问题是指当合约做整数运算时导致的溢出问题。主要原因在于任何数值类型都有其最大长度,两整数的运算超出其最大值时,超出部分会被截断,导致问题产生。
考虑下列代码形式:
contract Eocene{
mapping(address=>uint) balanceof;
function withdraw(uint amount) public{
payable(msg.sender).transfer(amount);
balanceof[msg.sender] = balanceof[msg.sender]-amount;
require(balanceof[msg.sender] >= 0,'not enough balance');
}
}
对上诉函数,考虑当 balanceof[msg.sender] < amount 时,因为 balanceof 类型限定为无符号整形,最总计算结果会导致 int 类型的负值,而转换为 uint 类型时,就是极大的正值,此时,require 的限制条件被绕过,攻击者可以从合约汇总窃取任意数量的 token。
修复措施:
使用 SafeMath 库,或在每次进行计算前首先判断值的正确性,确保最终的计算结果不会导致溢出。
漏洞详情:
在做任何整数类型的计算时,慎重将 uint 类型转为 int 类型进行计算,除非你需要这种操作。因为当将 uint 类型整数转为 int 类型时,一些对于 uint 类型为溢出的情况在 int 类型中会失效。
考虑下列代码形式:
contract Eocene{
int public result;
uint public uresult;
function cal(uint _a, uint _b) public{
result = int(_a)-int(_b);
uresult = uint(result);
}
}
使用 0.8.0 以上版本的 solidity 进行编译时,如果调用`cal( 0, 1)`,即便` 0-1 ` 在 uint 里造成了溢出,但是在 int 类型的计算中并不会引发因为溢出导致的 revert(因为 0-1 的结果在 int 类型的范围内)。而当再将结果值转为 uint 类型时,则是实际 uint 类型计算溢出后的结果值,变相导致了溢出问题的存在。
但是需要注意的是,如果这里调用 cal(type(int).min, type(int).max),依旧会引发 revert,因为此时的整数计算也超出了 int 类型的范围
修复措施:
在进行任何形式的整数运算时,慎重使用 int 类型。如果整数运算本身需要溢出,考虑使用 uncheck 来包裹 uint 类型运算实现。
漏洞详情:
做任意整数运算,均考虑精度丢失可能引起的问题,并对其精度进行扩展。
考虑下列形式代码:
contract Eocene {
uint totalsupply;
mapping(address=>uint) balancesof;
uint BasePrice = 1 e 16;
function mint() public payable {
uint tokens = msg.value/BasePrice;
balancesof[msg.sender] = tokens;
totalsupply = tokens;
}
}
考虑上诉合约,mint 中通过通过 msg.value/basePrice 来计算应该获得的 token 数量,但是由于`/`计算的精度问题,会导致当 msg.value 小于 1 e 16 的部分被全部锁死在该合约中,这不但会导致 eth 的浪费,对于用户的体验来说也相当不好。
修复措施:
对可能存在精度缺失整数计算中,先通过 `* 1 eN` 来对整数进行扩展(N 是需要的精度大小)。
漏洞详情:
由于区块链的特殊性,链上不存在任何真正的随机值,不应该使用任何链上数据用作随机值或随机数种子,考虑从链下获取随机值。
代码示例如下:
contract Eocene{
function winner(bytes 32 value) public payable{
require(msg.value > 0.5 ether,"not enough value");
if(value == keccak 256(abi.encodePacked(block.timestamp))){
msg.sender.transfer( 1 ether);
}
}
}
对上诉合约来说,使用当前区块时间标签来计算随机值,并和用户提交的随机值进行对比,给予相同随机值用户奖励。看起来是基于时间的随机情况,但实际上任何使用 keccak 256(abi.encodePacked(block.timestamp))的用户都可以通过合约调用计算出该值,并发送给 winner 函数的合约代码,获取到 eth。此外,我们也应当明白,block.timestamp 时可以被矿工恶意篡改的值,并不是一定公正的。
修复措施:
不使用任何链上数据(block.*/now)作为随机数或随机数种子,考虑通过 chainlink 来获取线下随机值
漏洞详情:
solidity 对函数可用内存大小的使用限制远低于 storage(0x ffffffffffffffff),任何将动态数组整体拷贝到内存的行为,都可能超出可用内存大小,导致 revert。
考虑下面代码形式:
contract Eocene{
uint[] id;
function pop(uint amount) public{
require(amount>0,'not valid amount');
uint[] memory _id=id; // this may be revert because of memory space limit
for(uint i= 0;i<_id.length;i )
{
if(amount==_id[i]){
id[i] = 0;
}
}
}
function push(uint amount) public{
require(amount>0,'not valid amount');
id.push(amount);
}
}
上面代码中,`uint[] memory _id=id;` 会将 storage 中`uint[] id;`的变量值放到内存中,而 push 函数可以向`uint[] id;`插入值, 而由于 solidity 对内存空间的限制,一旦`uint[] id;`的长度超过`(0x ffffffffffffffff-0x 40)/0x 20-1 `时,就会导致内存占用过大,revert。也就意味着该合约的 pop 函数永远无法执行成功,或者说,任何存在`uint[] memory _id=id;`操作的函数均无法执行成功。
修复措施:
任何时候不要出现可变动态数组复制到内存中的操作,此外需要注意`0x ffffffffffffffff`是 solidity 限制的函数内部可用内存的大小,任何内存占用超出该值的函数都无法执行成功。
漏洞详情:
任何 for 循环的判断如果基于外部可修改变量,可能会存在外部可修改变量过大导致 gas 消耗太高的问题。当 gas 消耗高到每个合约调用者的承受时,DOS 攻击出现。
考虑下面的代码:
contract Eocene{
uint[] id;
function pop(uint amount) public{
require(amount>0,'not valid amount');
for(uint i= 0;i
{
if(amount==id[i]){
id[i] = 0;
}
}
}
function push(uint amount) public{
require(amount>0,'not valid amount');
id.push(amount);
}
}
这里我们删除了从 storage 复制数组到 memory 的操作,但是该代码的另一个问题是 for 循环是基于`uint[] id;`的长度,而 id 的长度在合约中只能增加不能减少,这意味着 pop()函数所消耗的 gas 会越来越大,当 gas 大到超出执行 pop 函数所能承受的最大 gas 消耗,很少有人会执行 pop,也就实现了 DOS 攻击。
修复措施:
防止基于没有限制的外部可修改变量导致的循环操作出现,任何循环操作,都应该能判断其执行的最大长度,防止 dos 问题存在。
漏洞详情:
在任何循环内部中如果存在可能因为外部地址导致的 revert,必须考虑对 revert 的捕获。否则一旦有任意一次内部循环执行失败,之前所有的 gas 消耗都失去意义。而当循环内部的执行失败与否可以被外部地址控制,如果没有 try/catch 来捕获可能的异常,就可能会导致循环判断永远无法完整进行,实现 DOS 攻击。
contract Eocene{
address[] candidates;
mapping(address=>uint) balanceof;
function claim() public{
for(uint i= 0;i
{
address candidate = candidates[i];
require(balanceof[candidate]>0,'no balance');
payable(candidate).transfer(balanceof[candidate]);
}
}
}
代码中通过 for 循环来给每个 candidate 转账,但是并未考虑到当有任何一个 candidate 在 fallback 或 reveice 函数中直接 revert 时该循环永远无法执行成功,实现 DOS 攻击。
修复措施:
在任何 for 循环中,如果存在外部调用,并且无法判断调用是否会 revert,必须使用 try/catch 来尝试补货异常,以防止因为 revert 导致的 DOS 攻击。
在 0.8.17 之下的合约存在一些中高危的漏洞问题,可能会将合约代码暴露在危险之中,这些问题存在于编译阶段,有些并不容易被发现,特别是当测试用例不足时。建议你直接使用高版本的编译器来避免这些问题,当然如果你一定要使用受影响的编译器版本,请确保自己了解其风险,并寻求专业的安全人士的帮助。
具体编译器漏洞的危害可以参考我们对 Solidity 编译器漏洞的分析或 Solidity 官网
- SOL-2022-7
- SOL-2022-6
- Solidity
At Eocene Research, we provide the insights of intentions and security behind everything you know or don't know of blockchain, and empower every individual and organization to answer complex questions we hadn't even dreamed of back then.
Learn more: [Website] | [Medium] | [Twitter]