本文講解如何通過 Contract Size Check 判斷調用者身份。

作者:小白, 慢霧安全團隊

背景概述

第一期文章中我們提過防止重入攻擊的多種方式,其中一種是通過檢查調用者身份並禁止合約調用來防止重入攻擊。 這期文章我們就來瞭解如何通過 Contract Size Check 判斷調用者身份。

前置知識

眾所周知,合約是由代碼編寫的,所以部署后的合約位址肯定有相應的位元組碼,而 EOA 位址是沒有位元組碼的,所以我們只需要檢查調用者位址是否有位元組碼就可以判斷調用者是否為合約。 這裡需要使用 Solidity 內聯彙編中的 extcodesize()函數。 下面我們還是通過實例來看看這個函數的實際效果。

代碼示例

首先,我們用一個代碼示例來瞭解 extcodesize()的作用。

// SPDX-License-Identifier: MITpragma solidity ^0.8.20;
contract CheckCodeSize { function Size(address account) public view returns (uint256) { uint size; assembly { size := extcodesize(account) } return size; }}

我們在 Remix 上部署后,調用 Size()並傳入 Deployer 的 EOA 位址(0x787··· cabaB),發現返回 0。

我們再次調用 Size()並傳入合約自身位址(0xC58··· 905F2),返回 348。

綜上可知,當 account 為 EOA 位址時,extcodesize()會返回 0 ,當 account 為合約位址時,extcodesize()會返回合約部署後的位元組碼大小。 到這裡,相信大家不難想到可以通過調用 extcodesize()並看其返回值是否為 0 來判斷該位址是 EOA 還是部署後的合約。 诶,漏洞就這麼寫出來了。

漏洞示例

// SPDX-License-Identifier: MITpragma solidity ^0.8.20;
contract Target { function isContract(address account) public view returns (bool) { 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; }}

漏洞分析

可以看到,isContract()判斷 account 位元組碼是否為 0 並返回 bool 類型的值。 protected()則會根據 isContract()的返回值來修改 pwned 的狀態。 但是這裡忽略了一個點,即合約部署時,constructor 函數中的代碼邏輯會跟隨部署合約的交易一起發送給礦工打包上鏈。 由於此時合約部署的操作還沒有完成,所以合約位址還沒有存入相應的位元組碼,這時在 consturctor 函數中調用 extcodesize()檢查合約地址的位元組碼就會返回 0 。 下面我們來看正常調用的情況下會返回什麼:

// SPDX-License-Identifier: MITpragma solidity ^0.8.20;
contract FailedAttack { function pwn(address _target) external { Target(_target).protected(); }}

調用 pwn()並將合約位址 0x058··· 4c899 傳入會被 revert,這裡說明合約部署后調用 protected()會無法通過 require(!isContract(msg.sender)檢查。

攻击合约

下面我们将调用逻辑放在 constructor 函数中再次尝试:

// SPDX-License-Identifier: MITpragma solidity ^0.8.20;
contract Hack { 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(); }}

部署 Hack 合約時傳入 0xbd7··· 9EFB3 合約地址,發現成功繞過了 require(!isContract(msg.sender)檢查,而且成功修改了 pwned 的狀態。

修復建議

作為開發者

使用 extcodesize()判斷位址是否為 EOA 位址並不準確,在實際開發中最穩妥的判斷調用者身份的方式還是通過 tx.origin 來判斷。 類似下面這樣:

// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
contract EOAChecker { function isEOA() external pure returns (bool) { return msg.sender == tx.origin; }}

由於 tx.origin 只可能是 EOA 位址,我們只需要判斷上層調用者的 msg.sender 是否與 tx.origin 為同一位址就可以。 以剛剛的漏洞代碼為例,我們只需要稍加修改就可以修復漏洞。

// SPDX-License-Identifier: MITpragma solidity ^0.8.20;
contract Target { function isContract() public view returns (bool) { return (msg.sender == tx.origin); }
bool public pwned = false;
function protected() external { require(isContract(), "no contract allowed"); pwned = true; }}

此時部署下面這個 Hack 合約再進行攻擊,就會被 revert 並返回「no contract allowed」。。

// SPDX-License-Identifier: MITpragma solidity ^0.8.20;contractHack {    addresspublicaddr;constructor(address _target) {        Target(_target).protected();    }}

作為審計者

在審計過程中,如果遇到限制合約位址調用的邏輯,應當結合實際業務邏輯判斷該檢查是否嚴謹以及是否存在被繞過的可能。

免責聲明:作為區塊鏈資訊平臺,本站所發佈文章僅代表作者及嘉賓個人觀點,與 Web3Caff 立場無關。 本文內容僅用於資訊分享,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。