本文旨在為審計人員提供帳戶抽象錢包的安全審計檢查項,並提供部分針對性的審計指南。

作者: Kong@慢霧安全團隊

前言

本文旨在為審計人員提供以 EIP4337 標準實現的帳戶抽象錢包的安全審計檢查項,並提供部分針對性的審計指南。本文假設審計人員已經對 EIP4337 帳戶抽象標準和 EIP7562 帳戶抽象驗證範圍規則標準較為熟悉,因此不再贅述這兩個標準。接下來,我們將簡單介紹 EIP4337 架構與錢包交易執行流程。

架構

交易執行

在 EIP4337 設計標準中,首先由 EOA 簽署`UserOperation` 類型的數據,並透過 RPC 提交到一個單獨的 Alt Mempools。此記憶體池獨立於以太坊記憶體池,其匯總了使用者提交的 UserOp 資料。 Bundler 會從此記憶體池中提取 UserOp 以幫助使用者執行,在執行之前將進行本機模擬,模擬失敗的`UserOp` 將被丟棄。所有的 UserOp 執行都由 Bundler 呼叫 EntryPoint 合約執行。 EntryPoint 經過一系列驗證後將呼叫使用者的 AA 錢包,執行使用者的 calldata。在此過程中,用戶需要向 Bundler 支付交易上鍊執行的手續費,或指定一個 Paymaster 進行代付。

圖片

執行詳情

以下是 Bundler 透過 EntryPoint 呼叫使用者錢包的詳細流程,稽核人員應熟悉此流程。

圖片
(https://www.figma.com/board/dWu1j2He9WaJin5pW6o3Hx/4337-Execution-Details_CN?node-id=0-1&t=Iy1930ilcXQY6m9v-1)

檢查項

慢霧安全團隊依據上述架構列出以下檢查項,建議審計人員在對每個 4337 錢包進行安全審計時都確保其通過下述檢查項:

1. 檢查是否相容所有 EVM 相容鏈

大部分 AA 錢包可能不只部署在 Ethereum 主網,因為主網在上海昇級後新增了`PUSH0` 字節碼,而 Solidity 在 0.8.20 版本及之後編譯版本都預設為上海昇級後的版本,因此其編譯後的字節碼可能不適用於所有的 EVM 相容鏈。

審計人員在審計時應該檢查合約編譯使用的 Solidity 版本,或檢查編譯後的檔案是否包含`PUSH0` 字節碼。在進行多鏈部署時,建議使用小於 0.8.20 版本的編譯器,或指定編譯版本為`paris`。

solc = "0.8.19"evm_version = "paris"

2. 檢查介面實作與回傳值是否符合 EIP4337 標準規範

EIP4337 標準規定錢包必須實現以下核心接口,其返回值`validationData` 必須包含三個值:authorizer、validUntil 和 validAfter。

function validateUserOp    (PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)    external returns (uint256 validationData);

同樣的,代付合約 Paymaster 也必須實現以下核心接口,其中`validatePaymasterUserOp` 的返回值`validationData` 也必須包含三個值:authorizer、validUntil 和 validAfter。

function validatePaymasterUserOp    (PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost)    external returns (bytes memory context, uint256 validationData);
function postOp (PostOpMode mode, bytes calldata context, uint256 actualGasCost, uint256 actualUserOpFeePerGas)    external;

當簽章驗證失敗時,authorizer 需要傳回`SIG_VALIDATION_FAILED`(即 1 值),驗證成功時,authorizer 需要傳回`SIG_VALIDATION_SUCCESS`(即 0 值),而不是`revert`。如果是其他情況導致的失敗交易都必須 revert。

3. 檢查錢包呼叫者是否可信

在 EIP4337 標準中,要求錢包或代付人實現的 validateUserOp、executeUserOp、validatePaymasterUserOp、postOp、資料執行介面等函數都應該只允許可信的 EntryPoint 進行調用,以避免錢包被未授權的使用導致資產遺失等風險。

function entryPoint() public view virtual override returns (IEntryPoint) {   return _entryPoint;}
function execute(address dest, uint256 value, bytes calldata func) external { _requireFromEntryPointOrOwner(); _call(dest, value, func);}
function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata func) external { _requireFromEntryPointOrOwner(); ...}

4. 檢查是否實現手續費支付功能

在用戶錢包中有充足原生代幣情況下,其可以無需在 EntryPoint 中質押且無需指定代付人,即可使用錢包中的代幣付款。付款費用由 EntryPoint 在呼叫 validateUserOp 函數時透過`missingAccountFunds` 參數傳入。因此稽核人員需要檢查錢包中的 validateUserOp 函數是否實作了向 EntryPoint 合約轉帳`missingAccountFunds` 金額的原生代幣的邏輯。

function validateUserOp(    PackedUserOperation calldata userOp,    bytes32 userOpHash,    uint256 missingAccountFunds) external virtual override returns (uint256 validationData) {    ...    _payPrefund(missingAccountFunds);}

5. 檢查錢包創建方式

當使用者錢包尚未建立時,其可以在 UserOp 中指定工廠進行錢包建立。工廠必須使用`CREATE2` 建立錢包,以避免創建地址受到創建順序的干擾。一些用戶需要在還未完成創建之前就知道錢包地址,並向其轉移資金以支付創建費用,因此審計人員在對工廠合約進行審計時,需要確保錢包創建方式使用了`CREATE2`。

function createAccount(address owner,uint256 salt) public returns (SimpleAccount ret) {    address addr = getAddress(owner, salt);    uint256 codeSize = addr.code.length;    if (codeSize > 0) {        return SimpleAccount(payable(addr));    }    ret = SimpleAccount(payable(new ERC1967Proxy{salt : bytes32(salt)}(            address(accountImplementation),            abi.encodeCall(SimpleAccount.initialize, (owner))        )));}

6. 檢查重複建立相同錢包的回傳值

EIP 4337 標準要求,對於已經創建的錢包,如果傳入相同的數據,應返回同一位址。這是為了讓客戶端更容易查詢地址,而無需知道錢包是否已部署,也為了避免非預期的創建失敗或創建非預期地址的錢包。

function createAccount(address owner,uint256 salt) public returns (SimpleAccount ret) {    address addr = getAddress(owner, salt);    uint256 codeSize = addr.code.length;    if (codeSize > 0) {        return SimpleAccount(payable(addr));    }    ...}

7. 檢查錢包創建時是否可被接管

在錢包創建前,用戶可能會先向預創建地址轉入手續費以供創建時支付費用,並且使得在創建同一地址時也不會交易失敗。因此,稽核人員需要檢查錢包是否可以被搶先創建,如果可以,應該檢查被搶先創建後的錢包所有權是否正確,並著重檢查是否存在未參與地址計算的初始化參數,這些參數又能否被修改。

錯誤範例程式碼(entryPoint 不參與位址運算,可被搶先建立修改為惡意的 entryPoint):

function deployCounterFactualWallet(address _owner, address _entryPoint, address _handler, uint _index) public returns(address proxy){    bytes32 salt = keccak256(abi.encodePacked(_owner, address(uint160(_index))));    bytes memory deploymentData = abi.encodePacked(type(Proxy).creationCode, uint(uint160(_defaultImpl)));    // solhint-disable-next-line no-inline-assembly    assembly {        proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)    }    require(address(proxy) != address(0), "Create2 call failed");    // EOA + Version tracking    emit SmartAccountCreated(proxy,_defaultImpl,_owner, VERSION, _index);    BaseSmartAccount(proxy).init(_owner, _entryPoint, _handler);    isAccountExist[proxy] = true;}

8. 檢查簽名有效性驗證

錢包必須支援 `validateUserOp` 介面以驗證 EntryPoint 傳入的 UserOp 的有效性,同樣的,Paymaster 也必須支援 `validatePaymasterUserOp` 介面以驗證代付資訊的有效性。因此,審計人員必須在 validateUserOp/validatePaymasterUserOp 中嚴格檢查 UserOp 中簽署的有效性以避免錢包被惡意執行,或簽名被重播。

function validateUserOp(    PackedUserOperation calldata userOp,    bytes32 userOpHash,    uint256 missingAccountFunds) external virtual override returns (uint256 validationData) {    _requireFromEntryPoint();    validationData = _validateSignature(userOp, userOpHash);    _validateNonce(userOp.nonce);    _payPrefund(missingAccountFunds);}

9. 檢查是否正確實施了 ERC1271

帳戶抽象錢包可能實施 EIP1271 標準以支援驗證來自合約的 1271 簽名,標準規定了透過`isValidSignature` 介面以實現具體的驗證邏輯。審計人員需要嚴格檢查 ERC1271 標準的實施是否符合規範,並確保簽章驗證邏輯安全可靠。

function isValidSignature(bytes32 _dataHash, bytes calldata _signature) public view override returns (bytes4) {    // Caller should be a Safe    ISafe safe = ISafe(payable(msg.sender));    bytes memory messageData = encodeMessageDataForSafe(safe, abi.encode(_dataHash));    bytes32 messageHash = keccak256(messageData);    if (_signature.length == 0) {        require(safe.signedMessages(messageHash) != 0, "Hash not approved");    } else {        safe.checkSignatures(messageHash, _signature);    }    return EIP1271_MAGIC_VALUE;}

10. 檢查向 EntryPoint 的質押代幣是否可永久鎖定

帳戶抽象錢包為了支付執行費用,可能實現向 EntryPoint 質押 Native 代幣的邏輯。同樣的,為了提高聲譽,代付合約 Paymaster 與錢包工廠合約也可能實現向 EntryPoint 質押原生代幣的邏輯。 EIP7562 標準要求質押實體的最小質押時間必須是 `MIN_UNSTAKE_DELAY`(1 天),因此通常質押時間是由呼叫者自行決定或合約硬編碼決定。審計人員需要檢查是否任何人都可以傳入任意鎖定時間 `unstakeDelaySec` 進行質押,這可能導致質押代幣被永久鎖定。

function addStake(uint32 unstakeDelaySec) external payable onlyOwner {    entryPoint.addStake{value: msg.value}(unstakeDelaySec);}

11. 檢查錢包是否可以不透過 EntryPoint 執行交易

EIP4337 標準要求 EntryPoint 透過直接呼叫錢包執行使用者 calldata 或呼叫 executeUserOp 函數以執行數據,並由錢包驗證呼叫者與簽署。因此,審計人員需要檢查錢包是否允許在不通過 EntryPoint 的情況下執行呼叫者的資料。如果可以,審計人員需要檢查錢包是否正確實施了權限檢查,以避免錢包被執行惡意資料。

function execute(address dest, uint256 value, bytes calldata func) external {    _requireFromEntryPointOrOwner();    _call(dest, value, func);}

12. 檢查錢包是否只存取與發送者關聯的儲存字段

為了避免 Bundler 執行 UserOp,遭受 DoS,然後使得 Bundler 無法收回 gas 費成本,EIP7562 標準規定了只允許錢包訪問與其相關聯的存儲,否則 Bundler 將不會接受此 UserOp。審計人員需要檢查錢包中對儲存的存取是否符合規定。

13. 檢查執行失敗後 Paymaster 的處理邏輯是否符合預期

在 EntryPoint 中,當 innerHandleOp 執行後,EntryPoint 將透過 _postExecution 函數進行後續的手續費處理邏輯,並呼叫 Paymaster 的 postOp 函數對手續費進行處理(例如:多退少補)。而當 innerHandleOp 執行失敗時,也會透過 postExecution 進行費用處理,但此時不會觸發 Paymaster 的 postOp 函數(即使使用者指定了代付)。因此在審計 Paymaster 時,需要特別注意使用者惡意使得 innerHandleOp 和 postOp 都執行失敗的情況下,Paymaster 能否正確地處理費用,以避免 Paymaster 中的資金被耗盡。

14. 檢查模組化錢包的實作是否安全

EIP4337 錢包可能支援用戶自訂拓展錢包功能,如:運行社交恢復、合約升級、保險庫等模組。稽核人員需要檢查錢包能否安全地對模組的「增加/移除/使用」進行管理。如果使用了`DELEGATECALL` 運作模組,稽核人員應確保錢包插槽儲存的資料安全。

總結

上述基礎檢查項目是基於目前 EIP4337 的帳戶抽象標準,供審計人員對帳戶抽象錢包進行檢查。不同的錢包實現不盡相同,且目前基於 EIP4337 的實施仍處於早期階段,因此審計人員仍需要根據錢包的實際實現情況進行嚴格檢查。對於正在開發的專案來說,慢霧安全團隊建議開發者在開發過程中仔細考慮上述檢查項目。最後,文中相關連結可透過點擊閱讀原文跳轉至 GitHub 查看。

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