首页 资讯 正文

Solidity通用安全编码指南

odaily 2023年05月27日 16:01

漏洞详情:

基于区块链的透明性,任何部署到链上的合约数据,都是透明可见,既便是?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]