智能合约安全性

智能合约既可以实现代币,又可以基于智能合约代码运行不可变的逻辑。虽然智能合约已经形成了一个充满活力和创造力的互联生态系统,但它也吸引着攻击者利用智能合约中的漏洞和TRON网络中的意外行为而从中牟利。由于智能合约代码不支持更改,也就无法修改安全漏洞,并且从智能合约被盗的资产是不可恢复的,而且很难追踪。

因此,在向主网发布任何代码之前,一定要做好充足的预防措施,来保护智能合约中任何有价值的东西。下面将介绍不同类型的攻击和最佳实践,以确保您的合约正确、安全地运行。

智能合约开发注意事项

安全性始于适当的设计和开发过程。有关智能合约开发流程中的注意事项有很多,但至少要保证如下几点:

  • 所有的代码都存储在一个版本控制系统中,比如git
  • 所有的代码修改通过Pull Requests
  • 所有Pull Requests都至少有一个审阅者
  • 使用TRON智能合约开发工具,如Tronbox,一键编译、部署和运行测试代码
  • 在合并每个pull request之前,应该通过基本的代码分析工具(如Mythril和Slither)运行了你的代码
  • 代码不抛出任何编译器警告
  • 代码有良好的文档记录

攻击和漏洞

下面介绍一些常见的漏洞

重入

可重入性是开发智能合约时需要考虑的最大、最重要的安全问题之一。TVM不能同时运行多个合约,当一个合约调用其它合约时,TVM会暂停调用合约的执行和内存状态,直到调用返回,然后再继续执行。这种暂停和重新启动可能会出现被称为“重新进入”的漏洞。

下面是一个存在重入漏洞的合约示例:

// THIS CONTRACT HAS INTENTIONAL VULNERABILITY, DO NOT COPY
contract Victim {
    mapping (address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        (bool success, ) = msg.sender.call.value(amount)("");
        require(success);
        balances[msg.sender] = 0;
    }
}

withdraw函数允许用户提取他的余额,它将依次进行以下操作:

  1. 获取用户的余额
  2. 发送余额给用户
  3. 设置用户余额为0

如果该函数调用来自一个普通外部帐户(比如你自己的Tronlink帐户),它的功能和预期是一样的:msg.sender.call.value()只是给你的帐户发送TRX。然而,其它智能合约也可以调用该合约的函数。如果一个自定义的恶意合约调用了withdraw()函数, msg.sender.call.value()不仅会发送TRX,它还会隐式的执行该合约中的代码,例如如下这个恶意的合约:

contract Attacker {
    uint count;
    function beginAttack() external payable {
        count = 5;
        Victim(VICTIM_ADDRESS).deposit.value(1 trx)();
        Victim(VICTIM_ADDRESS).withdraw();
    }

    function() external payable {
        if(count>0)
        {
            count -=1;
            Victim(VICTIM_ADDRESS).withdraw(); 
        }
            
    }
}

调用 Attacker.beginAttack()将开始一个如下的循环:

0.) 攻击者通过外部账户触发合约调用:Attacker.beginAttack() 并存入1 TRX。
0.) Attacker.beginAttack() 将 1 TRX 存入 Victim合约: Victim.deposit.value(1 trx)();

  1.) Attacker 调用Victim的提现函数 : Victim.withdraw()
  1.) Victim 合约获取调用者的余额为1TRX: balances[msg.sender]
  1.) Victim 发送 TRX 给 Attacker ,并触发调用Attacker的默认的回调函数
    2.) Attacker回调函数内执行 -> Victim.withdraw()
    2.) Victim 读取余额: balances[msg.sender]
    2.) Victim 发送 TRX 给 Attacker 并触发调用Attacker的默认的回调函数
      3.) Attacker回调函数内执行 -> Victim.withdraw()
      3.) Victim 读取余额: balances[msg.sender]
      3.) Victim 发送 TRX 给 Attacker 并触发调用Attacker的默认的回调函数
        4.) Attacker 为了不超过合约允许执行的最大时间, 执行若干次后,不再继续执行withdraw,直接返回。
      3.) balances[msg.sender] = 0;
    2.) balances[msg.sender] = 0; 
  1.) balances[msg.sender] = 0; 

攻击者调用Attacker.beginAttack并转入1trx,然后对Victim合约进行重入攻击,取走了其他用户的存入的TRX。

如何解决重入攻击

通过调整存储更新和外部调用的顺序,可以有效防止可重入攻击。如下示例中,withdraw函数先将存储的余额信息设置为0,再进行TRX转账,避免了恶意者对代码进行重入攻击。

contract NoLongerAVictim {
    function withdraw() external {
        uint256 amount = balances[msg.sender];
        balances[msg.sender] = 0;
        (bool success, ) = msg.sender.call.value(amount)("");
        require(success);
    }
}

当你想要将TRX发送到一个不可信的地址或与一个未知的合约进行交互(例如调用用户提供的令牌地址的transfer())时,就为自己打开了重入的可能性。因此通过设计既不发送TRX也不调用不可信合约,将有效防止重入攻击。

更多类型的攻击

除了上述由智能合约编码导致的可重入攻击外,还有很多其它类型的攻击,例如:

  • TRX发送拒绝
  • 整数向上溢出和向下溢出