Signature Replay Attack: Dùng chữ ký để tấn công lặp lại, rút tiền liên tục

Trong blockchain, cuộc tấn công chữ ký lặp lại (Signature Replay Attack) là một cuộc tấn công trong đó một giao dịch hợp lệ được thực hiện trước đó bị lặp lại một cách gian lận hoặc ác ý trên cùng một blockchain hoặc một blockchain khác. Trong cuộc tấn công này, kẻ tấn công có thể chặn một giao dịch hợp lệ và sử dụng chữ ký của giao dịch đó để vượt qua các biện pháp bảo mật nhằm thực hiện lại giao dịch một cách gian lận. Bài viết này nhằm mục đích giải thích các tình huống trong đó kẻ tấn công có thể sử dụng cuộc tấn công phát lại chữ ký vào các hợp đồng vững chắc của chúng tôi, các ví dụ mã về cách hoạt động của cuộc tấn công phát lại chữ ký và cách chúng tôi có thể ngăn chặn cuộc tấn công vào hợp đồng của mình.

Điều kiện tiên quyết

Trước khi đọc bài viết này, điều cần thiết là bạn phải:

  1. Có hiểu biết cơ bản về hợp đồng thông minh Solidity
  2. Hiểu băm là gì và nó hoạt động như thế nào

Cuộc tấn công phát lại chữ ký hoạt động như thế nào?

Chúng ta sẽ xem xét một ví dụ về ví multi-sig để hiểu cách thức hoạt động của cuộc tấn công phát lại. Ví nhiều chữ ký là ví kỹ thuật số yêu cầu nhiều hơn một chữ ký trước khi giao dịch có thể được phê duyệt.

Để minh họa ví dụ của chúng tôi, chúng ta hãy xem xét một ví multi-sig có số dư là 20 ETH. Ví có hai quản trị viên là AdminX và AdminY.

Để AdminY rút 4 ETH, AdminX ký vào tin nhắn có chữ ký của anh ấy. AdminY có thể thêm chữ ký của anh ấy và gửi giao dịch đến ví yêu cầu 4 ETH. Phương pháp này liên quan đến việc ký một tin nhắn ngoài chuỗi. Nó làm giảm phí gas. Có ba cách để AdminY có thể thực hiện cuộc tấn công phát lại trong các tình huống sau:

  1. Vì tin nhắn của AdminX đã được ký ngoài chuỗi và gửi tới AdminY, nên AdminY có thể quyết định rút thêm 4 ETH mà AdminX không hề biết. AdminY có thể làm được việc này vì đã có chữ ký của AdminX. Hợp đồng sẽ ghi nhận chữ ký và phê duyệt giao dịch.
  2. Nếu hợp đồng ngăn cản kế hoạch trên hoạt động, AdminY có thể quyết định triển khai hợp đồng tại một địa chỉ khác. Làm điều này sẽ cho phép anh ta thực hiện cùng một giao dịch mà không gặp bất kỳ trở ngại nào.
  3. AdminY có thể triển khai hợp đồng bằng cách sử dụng CREATE2 và gọi selfduration(). Nếu điều này được thực hiện, hợp đồng có thể được tạo lại ở cùng một địa chỉ và được sử dụng lại với tất cả các tin nhắn trước đó.
Smart Contract Replay Attack in Solidity - Be on the Right Side of Change

Cách ngăn chặn các hợp đồng thông minh vững chắc khỏi cuộc tấn công phát lại chữ ký

Để ngăn chặn cuộc tấn công lặp lại trong hợp đồng của chúng ta, chúng ta phải tìm cách làm cho mỗi chữ ký ngoài chuỗi trở nên độc nhất. Chúng ta có thể làm điều này bằng cách thêm một nonce 3. Bằng cách này, khi chữ ký đã được sử dụng, kẻ tấn công không thể sử dụng lại chữ ký vì hợp đồng sẽ nhận ra nonce khi chữ ký đã được sử dụng.

Nếu hợp đồng được triển khai tại một địa chỉ khác, chúng tôi có thể ngăn chặn cuộc tấn công phát lại bằng cách đưa địa chỉ của hợp đồng vào trong chữ ký. Chúng tôi sẽ thêm một nonce để ngăn chặn trường hợp đầu tiên.

Trong trường hợp hợp đồng được tạo CREATE2và selfdestruct()được gọi, không có cách nào để ngăn chặn cuộc tấn công phát lại. Chúng tôi không thể ngăn chặn điều đó vì khi selfdestruct()được gọi, các nonces được đặt lại và hợp đồng không còn nhận ra các nonces đã sử dụng trước đó.

Trình diễn mã của cuộc tấn công phát lại

Mã bên dưới dễ bị tấn công phát lại. Chúng tôi sẽ kiểm tra mã và biến nó thành mã ngăn chặn các cuộc tấn công lặp lại xảy ra.


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

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

contract MultiSigWallet {
    using ECDSA for bytes32;

    address[2] public admins;

    constructor(address[2] memory _admins) payable {
        admins = _admins;
    }

    function deposit() external payable {}

    function transfer(address _sendto, uint _amount, bytes[2] memory _sigs) external {
        bytes32 txHash = getTxHash(_sendto, _amount);
        require(_checkSignature(_sigs, txHash), "invalid sig");

        (bool sent, ) = _sendto.call{value: _amount}("");
        require(sent, "Failed to send Ether");
    }

    function getTxHash(address _sendto, uint _amount) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(_sendto, _amount));
    }

    function _checkSignature( bytes[2] memory _sigs, bytes32 _txHash) private view returns (bool) {

        bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();

        for (uint i = 0; i < _sigs.length; i++) {
            address signer = ethSignedHash.recover(_sigs[i]);
            bool valid = signer == admins[i];

            if (!valid) {
                return false;
            }
        }

        return true;
    }
}

Trong đoạn mã trên, trước tiên chúng tôi nhập ECDSA.sol 2 từ OpenZeppelin.


import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

Tiếp theo, chúng ta có biến quản trị viên bên trong hợp đồng có tên là MultiSigWallet. Biến chứa địa chỉ của hai quản trị viên hợp đồng.

address[2] public admins;

Chúng ta tiếp tục định nghĩa một hàm tạo. Hàm tạo lấy địa chỉ của hai quản trị viên và gán giá trị của họ tương ứng khi hợp đồng được triển khai.

constructor(address[2] memory _admins) payable {
    admins = _admins;
}

Mã có deposit()chức năng gửi tiền. Nó cũng có một transfer()chức năng.
Hàm này transfer()có ba tham số; địa chỉ sẽ nhận tiền, số tiền cần gửi và chữ ký của cả hai quản trị viên.

  1. Đầu tiên, nó tạo lại hàm băm đã được ký từ tham số _sendtoand _amount.
  2. Tiếp theo, nó kiểm tra hai chữ ký so với hàm băm. Nếu hai chữ ký hợp lệ, nó sẽ tiến hành chuyển ether.
// deposit function
function deposit() external payable {}

//transfer function
function transfer(address _sendto, uint _amount, bytes[2] memory _sigs) external {
        // get hash of _sendto and _amount
    bytes32 txHash = getTxHash(_sendto, _amount);

        // check if signature is valid
    require(_checkSignature(_sigs, txHash), "invalid sig");

        // send ether if signature is valid
    (bool sent, ) = _sendto.call{value: _amount}("");
    require(sent, "Failed to send Ether");
}

Hàm tiếp theo trong mã là getTxHash()hàm. Hàm này sử dụng keccak256thuật toán băm để băm _sendtovà _amount.

function getTxHash(address _to, uint _amount) public view returns (bytes32) {
        return keccak256(abi.encodePacked(_to, _amount));
}

Cuối cùng, có một chức năng được gọi là _checkSignature(). Công việc của chức năng này là kiểm tra xem chữ ký của người ký có khớp với chữ ký của quản trị viên hay không.

  1. Đầu tiên, nó tính lại hàm băm đã ký bằng cách gọi toEthSignedMessageHash().
  2. Tiếp theo, nó chạy vòng lặp for để khôi phục người ký của mỗi chữ ký.
  3. Sau đó, vòng lặp sẽ kiểm tra để xác nhận xem người ký tin nhắn có thực sự là quản trị viên của hợp đồng hay không. Nếu không, nó trả về sai.
function _checkSignature( bytes[2] memory _sigs, bytes32 _txHash) private view returns (bool) {

                // recompute hash
    bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();

    for (uint i = 0; i < _sigs.length; i++) {
                                // get the signer of the signature
        address signer = ethSignedHash.recover(_sigs[i]);

                                //check if the signer is an admin
        bool valid = signer == admins[i];

        if (!valid) {
            return false;
        }
    }

    return true;
}

Bây giờ chúng ta đã phân tích hợp đồng, hãy xem xét các cách để bảo vệ nó khỏi cuộc tấn công phát lại chữ ký.

Để ngăn kẻ tấn công sử dụng lại chữ ký được ký ngoài chuỗi, chúng tôi cần tạo mỗi dấu hiệu duy nhất cho mỗi giao dịch bằng cách tạo một hàm băm giao dịch duy nhất. Chúng ta có thể làm điều này bằng cách thêm một số nonce vào hàm băm giao dịch.

Sau đó, chúng tôi sẽ vô hiệu hóa hàm băm sau khi giao dịch được thực hiện. Hãy để chúng tôi phân tích mã dưới đây:

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

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

contract MultiSigWallet {
using ECDSA for bytes32;

address[2] public admins;
mapping(bytes32 => bool) public is_executed;

constructor(address[2] memory _admins) payable {
    admins = _admins;
}

function deposit() external payable {}

function transfer(address _sendto, uint _amount, uint _nonce, bytes[2] memory _sigs) external {
    bytes32 txHash = getTxHash(_sendto, _amount, _nonce);

    require(!is_executed[txHash], "transaction has been previously executed");

    require(_checkSignature(_sigs, txHash), "invalid sig");

    is_executed[txHash] = true;

    (bool sent, ) = _sendto.call{value: _amount}("");
    require(sent, "Failed to send Ether");
}

function getTxHash(address _sendto, uint _amount, uint _nonce) public pure returns (bytes32) {
    return keccak256(abi.encodePacked(_sendto, _amount, _nonce));
}

function _checkSignature( bytes[2] memory _sigs, bytes32 _txHash) private view returns (bool) {

    bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();

    for (uint i = 0; i < _sigs.length; i++) {
        address signer = ethSignedHash.recover(_sigs[i]);
        bool valid = signer == admins[i];

        if (!valid) {
            return false;
        }
    }

    return true;
}
    }

Trong đoạn mã trên, chúng tôi đã thêm a noncelàm tham số cho getTxHash()hàm. Chúng tôi cũng đã thực hiện điều này với hàm transfer(). Bằng cách này, chúng tôi đã tạo thành công mỗi chữ ký và hàm băm duy nhất.

Một điều khác cần lưu ý là ánh xạ được gọi is_executedở đầu hợp đồng.

mapping(bytes32 => bool) public is_executed;

Chúng tôi sử dụng điều này để vô hiệu hóa từng hàm băm sau khi giao dịch được thực hiện. Để làm điều này, trước tiên chúng tôi kiểm tra xem có is_executedsai không. Nếu đúng như vậy và chữ ký hợp lệ, chúng tôi đặt is_executed thành true, sau đó gửi ether cần thiết.

function transfer(address _sendto, uint _amount, uint _nonce, bytes[2] memory _sigs) external {
    bytes32 txHash = getTxHash(_sendto, _amount, _nonce);

                            // check if is_executed is still false
    require(!is_executed[txHash], "transaction has been previously executed");
                            // check for valid signatures
    require(_checkSignature(_sigs, txHash), "invalid sig");
                                    // change is_executed to true
    is_executed[txHash] = true;

                            // send ether
    (bool sent, ) = _sendto.call{value: _amount}("");
    require(sent, "Failed to send Ether");
}

Với các biện pháp phòng ngừa được thực hiện, chúng tôi có thể bảo vệ hợp đồng của mình khỏi cuộc tấn công lặp lại sử dụng chữ ký của quản trị viên.

Tiếp theo, chúng ta phải bảo vệ hợp đồng khỏi cuộc tấn công lặp lại trong đó kẻ tấn công triển khai hợp đồng tại một địa chỉ khác.

Chúng ta có thể làm điều này bằng cách đưa địa chỉ của hợp đồng vào trong getTxHash() hàm. Vì vậy, bất cứ khi nào quản trị viên ký vào txHash, họ sẽ ký một hàm băm duy nhất cho hợp đồng.

function getTxHash(address _sendto, uint _amount, uint _nonce) public view returns (bytes32) {
    return keccak256(abi.encodePacked(address(this), _sendto, _amount, _nonce));
}

Bạn có thể tìm thấy mã đầy đủ cho hợp đồng được bảo vệ chống lại cả hai hình thức tấn công lặp lại bên dưới:

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

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

contract MultiSigWallet {
    using ECDSA for bytes32;

    address[2] public admins;
    mapping(bytes32 => bool) public is_executed;

    constructor(address[2] memory _admins) payable {
        admins = _admins;
    }

    function deposit() external payable {}

    function transfer(address _sendto, uint _amount, uint _nonce, bytes[2] memory _sigs) external {
        bytes32 txHash = getTxHash(_sendto, _amount, _nonce);

        require(!is_executed[txHash], "transaction has been previously executed");

        require(_checkSignature(_sigs, txHash), "invalid sig");

        is_executed[txHash] = true;

        (bool sent, ) = _sendto.call{value: _amount}("");
        require(sent, "Failed to send Ether");
    }

    function getTxHash(address _sendto, uint _amount, uint _nonce) public view returns (bytes32) {
        return keccak256(abi.encodePacked(address(this), _sendto, _amount, _nonce));
    }

    function _checkSignature( bytes[2] memory _sigs, bytes32 _txHash) private view returns (bool) {

        bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();

        for (uint i = 0; i < _sigs.length; i++) {
            address signer = ethSignedHash.recover(_sigs[i]);
            bool valid = signer == admins[i];

            if (!valid) {
                return false;
            }
        }

        return true;
    }
}

Phần kết luận

Bài viết này đã xem xét kỹ lưỡng cách hoạt động của cuộc tấn công phát lại và cách ngăn chặn nó trong hợp đồng của chúng tôi. Chúng ta phải luôn có ý thức bảo mật bất cứ khi nào chúng ta viết hợp đồng vì một khi chúng ta triển khai hợp đồng lên blockchain, nó không thể được sửa đổi để sửa bất kỳ lỗ hổng nào có thể bị phát hiện sau này, khiến hợp đồng của chúng ta dễ bị hacker xâm nhập.

Click Digital

  • Đầu tư vào các công ty quảng cáo blockchain hàng đầu bằng cách MUA token Saigon (SGN) trên Pancakeswap: https://t.co/KJbk71cFe8 (đừng lo lắng về tính thanh khoản, hãy trở thành nhà đầu tư sớm)
  • Được hỗ trợ bởi Công ty Click Digital
  • Nâng cao kiến thức về blockchain
  • Lợi nhuận sẽ dùng để mua lại SGN hoặc đốt bớt nguồn cung SGN để đẩy giá SGN tăng.
  • Địa chỉ token trên mạng BSC: 0xa29c5da6673fd66e96065f44da94e351a3e2af65
  • Twitter: https://twitter.com/SaigonSGN135
  • Staking SGN: http://135web.net

Leave a Reply

Your email address will not be published. Required fields are marked *