code4rena-2023-01-biconomy-h05
[H-05] Paymaster ETH can be drained with malicious sender
Summary
Paymaster의 Signature를 재사용하여 replay 공격이 가능하다.
Keyword
replay attack, signature, upgradeable
Vulnerability
ERC4337에서는 Paymaster라는 역할이 존재한다. Paymaster를 이용하면 Gas 대납이나 ERC20으로 Gas 지불 기능을 구현할 수 있다.
![]()
출처: https://eips.ethereum.org/EIPS/eip-4337
Paymaster를 이용하는 경우 다음과 같은 과정을 거친다.
- 트랜잭션을 생성할 때, Paymaster를 이용한다면 트랜잭션에 대한 Paymaster의 서명을 받아 파라미터로 함께 보냄
- Paymaster.validatePaymasterUserOp 를 호출하여 Paymaster가 지불할 의사가 있는지 확인 (서명 확인)
- 트랜잭션 실행
- Gas 환급
핵심은 validatePaymasterUserOp 함수에서 이 트랜잭션에 대한 Gas를 대납할지 확인하는 로직이다. require(verifyingSigner == hash.toEthSignedMessageHash().recover(paymasterData.signature), "VerifyingPaymaster: wrong signature");로 Paymaster 관련 계정에서 생성한 Signature가 맞는지 확인한다.
function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
external view override returns (bytes memory context, uint256 deadline) {
(requiredPreFund);
bytes32 hash = getHash(userOp);
PaymasterData memory paymasterData = userOp.decodePaymasterData();
uint256 sigLength = paymasterData.signatureLength;
require(sigLength == 64 || sigLength == 65, "VerifyingPaymaster: invalid signature length in paymasterAndData");
require(verifyingSigner == hash.toEthSignedMessageHash().recover(paymasterData.signature), "VerifyingPaymaster: wrong signature");
require(requiredPreFund <= paymasterIdBalances[paymasterData.paymasterId], "Insufficient balance for paymaster id");
return (userOp.paymasterContext(paymasterData), 0);
}이 서명은 어떤 값에 대한 서명인가? UserOperation, 즉 트랜잭션에 대한 서명이다. 이는 실행할 함수, 파라미터, nonce 값 등이 있다. 따라서 완전히 똑같은 트랜잭션에 동일 nonce를 이용한다면 서명을 재사용할 수도 있다.
그렇다면 validatePaymasterUserOp 는 어디서 호출되는가? 이는 EntryPoint._validatePaymasterPrepayment 에서 호출된다. 그리고 _validatePaymasterPrepayment 를 호출하는 지점을 따라가보면 EntryPoint._validatePrepayment 에서 호출됨을 알 수 있다.
(gasUsedByValidateAccountPrepayment, actualAggregator, deadline) = _validateAccountPrepayment(opIndex, userOp, outOpInfo, aggregator, requiredPreFund);
numberMarker();
bytes memory context;
if (mUserOp.paymaster != address(0)) {
uint paymasterDeadline;
(context, paymasterDeadline) = _validatePaymasterPrepayment(opIndex, userOp, outOpInfo, requiredPreFund, gasUsedByValidateAccountPrepayment);
if (paymasterDeadline != 0 && paymasterDeadline < deadline) {EntryPoint._validatePrepayment 의 상단에서 호출되는 _validateAccountPrepayment 에서 SmartAccount.validateUserOp 를 호출한다. 바로 이 부분에서 계정의 Signature를 확인하고 nonce를 업데이트 한다.
function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, address aggregator, uint256 missingAccountFunds)
external override virtual returns (uint256 deadline) {
_requireFromEntryPoint();
deadline = _validateSignature(userOp, userOpHash, aggregator);
if (userOp.initCode.length == 0) {
_validateAndUpdateNonce(userOp);
}
_payPrefund(missingAccountFunds);
}이 때, 유저는 임의로 자신의 Wallet의 Implementation을 변경할 수 있다. SmartWallet.updateImplementation 함수를 호출하거나, 다음과 같은 컨트랙트에 delegateCall을 하여 특정 슬롯의 주소를 변경시키면 된다.
pragma solidity ^0.8.0;
contract Upgrader {
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x37722d148fb373b961a84120b6c8d209709b45377878a466db32bbc40d95af26;
function upgrade(address _to) external {
assembly {
sstore(_IMPLEMENTATION_SLOT,_to)
}
}
}이를 통해 _validateAndUpdateNonce 함수의 로직을 임의로 변형하면 nonce를 더이상 업데이트 하지 않도록 할 수 있다. 즉, nonce를 재활용할 수 있다. 동일한 트랜잭션, 동일한 nonce를 이용한다면 UserOperation 이 동일하다. 따라서 Replay 가 가능해진다.
/// implement template method of BaseAccount
// @notice Nonce space is locked to 0 for AA transactions
// userOp could have batchId as well
function _validateAndUpdateNonce(UserOperation calldata userOp) internal override {
// No nonce to REUSE THE GAS PAYMENT
//require(nonces[0]++ == userOp.nonce, "account: invalid nonce");
}Paymaster를 통해 환급을 지불한 적이 있던 유저는 해당 트랜잭션에 대한 Paymaster의 서명을 이미 얻었다. 만약 모든 트랜잭션 파라미터가 동일하고, nonce값까지 동일하다면 Paymaster의 서명 역시 동일할 것이다. 이에 자신의 Wallet을 악성 Wallet으로 업그레이드 하여 nonce가 업데이트 되지 않도록 조작한다. 이후 과거 Paymaster의 서명을 재사용하여 지속적으로 Gas 대납을 받는다.
Impact
Paymaster가 허용하지 않은 트랜잭션에 대하여 가스비 대납을 시킨다. Paymaster의 예치금을 소진시킨다.
Mitigation
Hash별로 사용 여부를 확인하는 필드를 추가하고 확인한다.
mapping(bytes32 => boolean) public usedHash
function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
external override returns (bytes memory context, uint256 deadline) {
(requiredPreFund);
bytes32 hash = getHash(userOp);
require(!usedHash[hash], "used hash");
usedHash[hash] = true;Memo
유저가 직접 컨트랙트 로직을 업그레이드하여 악성 행위를 한다는 관점이 신선하다.
tags: bughunting, smart contract, biconomy, account abstraction, erc4337, crypto theft, arbitrary contract call, lack-of-input-validation-vul, signature, upgradeable, replay attack, wallet, severity high