Solidity通用安全编码指南

11个月前
标签:比特币0980
文章来源: Odaily星球日报

一、变量存储/赋值/删除:

1.不存储任何敏感信息到链上

漏洞详情:

基于区块链的透明性,任何部署到链上的合约数据,都是透明可见,既便是private修饰的变量也是如此。因为private的可见性仅仅是对函数和外部合约的而言,任意用户都可以通过检索链上数据获取到这些值。这种情况下,任何希望通过基于链上private修饰来保证的机密性操作都是不安全的。

如果存在下面的简单的抽奖代码:

contract Eocene{

mapping(address => bytes32) candidate;

uint private seed = 0x123413d;

function select() public{

bytes32 result = keccak256(abi.encodePacked(seed));

if(result == candidate[msg.sender]){

payable(msg.sender).transfer(1 ether);

}

}

}

即便seed已经声明为private变量,但是任何人都可以通过合约地址以及seed的slot位置在链上检索到seed值,借此计算出对应的值来获取到eth。

修复措施:

不要在合约中存储任何用于验证的关键值,将关键值存储到链下,链上仅仅实现相应的验证逻辑。

2.注意变量默认值

漏洞详情:

在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){

ERC20(token).transfer(msg.sender,averageDrop);

}

}

}

合约本意是给所有已经解锁的address分发token, 但是却忽略了在solidity中,所有的变量初始值均为0/false。而在mapping类型中,key仅仅用于和slot拼接后,通过keccak256计算storage中该key对应的地址,这也就意味着,任何address不管是否经过初始化,都会存在一个storage对应,而该初始值通常为0/false。

修复措施:

不要在任何情况下基于变量的默认值来做关键判断,特别是在基于mapping类型的变量中,严格预防此类问题。

3.将不再使用的struct类型值delete

漏洞详情:

对任何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中。

二、函数定义:

1.必须显示的声明函数的可见性

漏洞详情:

函数默认可见性为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。

2.函数重入攻击

漏洞详情:

对任何函数,必须考虑在重入后可能导致的问题。这里的重入包括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操作。当然也可以通过全局变量和修饰器的结合实现。

三、外部交互

1.限定外部调用的地址及函数名单

漏洞详情:

对外部函数的调用,在合理情况下,必须限定调用的合约地址和合约函数

考虑以下代码形式:

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被转走且合约被破坏。

修复措施:

对任意外部合约的调用,优先考虑地址能否进行白名单限制,并进一步考虑指定地址的函数名是否能够限制。

2.使用call,send,delegatecall,staticcall时对外部调用的判断不能仅仅依赖于异常,还要通过返回值判断

漏洞详情:

上诉函数并不会因为内部错误而导致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。

四、访问控制:

1.不基于tx.origin做身份认证

漏洞详情:

不要基于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');来做判断。

2.不基于extcodesize返回值对eos账户做判断

漏洞详情:

在合约代码的初始化阶段,即便该地址是合约地址,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。

修复措施:

任何时候不要基于外部地址是否会合约地址做判断,尽可能的保证合约代码在任意种类账户下的功能正常。

五、算数运算

1.任何数值运算时,考虑溢出问题

漏洞详情:

溢出问题是指当合约做整数运算时导致的溢出问题。主要原因在于任何数值类型都有其最大长度,两整数的运算超出其最大值时,超出部分会被截断,导致问题产生。

考虑下列代码形式:

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库,或在每次进行计算前首先判断值的正确性,确保最终的计算结果不会导致溢出。

2.在做任何整数运算时,慎重使用int类型

漏洞详情:

在做任何整数类型的计算时,慎重将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类型运算实现。

3.任何可能丢失精度的运算中,通过扩展防止不可接受的精度丢失

漏洞详情:

做任意整数运算,均考虑精度丢失可能引起的问题,并对其精度进行扩展。

考虑下列形式代码:

contract Eocene {

uint totalsupply;

mapping(address=>uint) balancesof;

uint BasePrice = 1e16;

function mint() public payable {

uint tokens = msg.value/BasePrice;

balancesof[msg.sender] += tokens;

totalsupply += tokens;

}

}

考虑上诉合约,mint中通过通过msg.value/basePrice来计算应该获得的token数量,但是由于`/`计算的精度问题,会导致当msg.value小于1e16的部分被全部锁死在该合约中,这不但会导致eth的浪费,对于用户的体验来说也相当不好。

修复措施:

对可能存在精度缺失整数计算中,先通过 `*1eN` 来对整数进行扩展(N是需要的精度大小)。

六、随机值:

1.不使用任何可猜测/被操作链上数据用作随机数种子

漏洞详情:

由于区块链的特殊性,链上不存在任何真正的随机值,不应该使用任何链上数据用作随机值或随机数种子,考虑从链下获取随机值。

代码示例如下:

contract Eocene{

function winner(bytes32 value) public payable{

require(msg.value > 0.5 ether,"not enough value");

if(value == keccak256(abi.encodePacked(block.timestamp))){

msg.sender.transfer(1 ether);

}

}

}

对上诉合约来说,使用当前区块时间标签来计算随机值,并和用户提交的随机值进行对比,给予相同随机值用户奖励。看起来是基于时间的随机情况,但实际上任何使用keccak256(abi.encodePacked(block.timestamp))的用户都可以通过合约调用计算出该值,并发送给winner函数的合约代码,获取到eth。此外,我们也应当明白,block.timestamp时可以被矿工恶意篡改的值,并不是一定公正的。

修复措施:

不使用任何链上数据(block.*/now)作为随机数或随机数种子,考虑通过chainlink来获取线下随机值

七、DOS:

1.禁止任何将整个状态变量数组复制给内存变量的操作

漏洞详情:

solidity 对函数可用内存大小的使用限制远低于storage(0xffffffffffffffff),任何将动态数组整体拷贝到内存的行为,都可能超出可用内存大小,导致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;`的长度超过`(0xffffffffffffffff-0x40)/0x20-1`时,就会导致内存占用过大,revert。也就意味着该合约的pop函数永远无法执行成功,或者说,任何存在`uint[] memory _id=id;`操作的函数均无法执行成功。

修复措施:

任何时候不要出现可变动态数组复制到内存中的操作,此外需要注意`0xffffffffffffffff`是solidity限制的函数内部可用内存的大小,任何内存占用超出该值的函数都无法执行成功

2.任何for循环中,循环判断不能基于外部可修改变量

漏洞详情:

任何for循环的判断如果基于外部可修改变量,可能会存在外部可修改变量过大导致gas消耗太高的问题。当gas消耗高到每个合约调用者的承受时,DOS攻击出现。

考虑下面的代码:

contract Eocene{

uint[] id;

function pop(uint amount) public{

require(amount>0,'not valid amount');

for(uint i=0;iid.length;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问题存在。

3.在循环中,使用try/catch捕获无法确定的异常

漏洞详情:

在任何循环内部中如果存在可能因为外部地址导致的revert,必须考虑对revert的捕获。否则一旦有任意一次内部循环执行失败,之前所有的gas消耗都失去意义。而当循环内部的执行失败与否可以被外部地址控制,如果没有try/catch来捕获可能的异常,就可能会导致循环判断永远无法完整进行,实现DOS攻击。

contract Eocene{

address[] candidates;

mapping(address=>uint) balanceof;

function claim() public{

for(uint i=0;icandidates.length;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攻击

八、使用高版本编译器:

1.使用0.8.17以上的编译器编译合约

在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]

免责声明:本文章仅代表作者个人观点,不代表本平台的立场和观点。本文章仅供信息分享,不构成对任何人的任何投资建议。用户与作者之间的任何争议,与本平台无关。如网页中刊载的文章或图片涉及侵权,请提供相关的权利证明和身份证明发送邮件到support@aicoin.com,本平台相关工作人员将会进行核查。

评论

暂时没有评论,赶紧抢沙发吧!