Top 3 lỗi thông dụng ở solidity smart contract

Với sự bùng nổ của công nghệ blockchain nói riêng và ethereum nói riêng trong hơn 2 năm gần đây, ngày càng nhiều smart contract được deploy lên các nền tảng chạy EVM (máy ảo thực thi của ethereum và một số hệ khác như bsc, avax, ... đang sử dụng). Security là vấn đề tiên quyết của smart contract. Rất nhiều vụ hack smart contract đã xảy ra và hàng triệu đôla bị rơi vào tay hacker. Sau đây là 3 lỗi mình nghĩ là thông dụng nhất ở solidity smart contract, biết được những lỗi thông dụng giúp chúng ta tránh lặp lại vết xe đổ và làm mồi cho các hacker.

1. Reentrancy

Đây là lỗi phổ biến nhất mà nhiều blockchain developer không chú ý đến. Thực tế, ethereum smart contract có chức năng rằng có thể gọi và thực thi code từ một contract bên ngoài.
Việc gọi từ contract ngoài này không có cơ chế bảo vệ an toàn, nên các hacker có thể lợi dụng, tạo 1 contract ngoài, gọi đến contract mục tiêu và thực thi vài tác vụ thêm để tấn công. Vì thế, code trong contract mục tiêu có thể bị gọi lại, sử dụng sai thông tin, thậm chí gởi token đến địa chỉ sai.

// contract mục tiêu
contract Victim {
    mapping (address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        (bool success, ) = msg.sender.call.value(amount)("");
        require(success);
        balances[msg.sender] = 0;
    }
}

Để cho phép user rút eth mà họ trữ trước đó ở trong contract, thì:

  1. Đọc dữ liệu xem người dùng đã trữ bao nhiêu eth.
  2. Gởi họ số lượng eth tương ứng.
  3. Cập nhật balance về 0, để họ không rút được nữa.

Nếu gọi bằng 1 account bình thường ở metamask thì contract trên thực hiện như mong đợi. Tuy nhiên, nếu là từ một contract độc hại thì lại là câu chuyện khác.

contract Attacker {
    function beginAttack() external payable {
        Victim(VICTIM_ADDRESS).deposit.value(1 ether)();
        Victim(VICTIM_ADDRESS).withdraw();
    }

    function() external payable {
        if (gasleft() > 40000) {
            Victim(VICTIM_ADDRESS).withdraw();
        }
    }
}


Khi contract độc hại này gọi withdraw, msg.sender.call.value() không chỉ gởi 1 lượng biến amount ETH, nó cũng ngầm định gọi contract để bắt đầu thực thi code. Trình tự tấn công sẽ như sau:

0.) Attacker gọi hàm Attacker.beginAttack() với 1 ETH
0.) Attacker.beginAttack() nạp 1 ETH vào contract Victim.

    1.) Attacker -> Victim.withdraw()
    1.) Victim đọc balances[msg.sender]
    1.) Victim gởi ETH đến Attacker (rồi kích hoạt luôn hàm mặc định - chính là function() external payable ở contract Attacker)
        2.) Attacker -> Victim.withdraw()
        2.) Victim đọc balances[msg.sender]
        2.) Victim gởi ETH đến Attacker (lại kích hoạt hàm mặc định)
            3.) Attacker -> Victim.withdraw()
            3.) Victim đọc balances[msg.sender]
            3.) Victim gởi ETH đến Attacker (lại kích hoạt hàm mặc định)
                4.) Attacker hết gas, không chạy hàm nữa.
            3.) balances[msg.sender] = 0;
            2.) balances[msg.sender] = 0; (đã set = 0 1 lần rồi.)
    1.) balances[msg.sender] = 0; (Cũng đã bằng 0 rồi.)

Qua ví dụ trên bạn thấy lỗi này nguy hại cỡ nào rồi phải không, đây cũng là lỗi gây nên vụ the DAO năm 2016 khiến ethereum phân tách thành 2 chuỗi ethereum và ethereum classic đến bây giờ. Về cách xử lý lỗi này bạn có thể tự tìm hiểu thêm ở đây Lỗi reentrancy

2. Vòng lặp tốn kém và gas limit

Bạn biết đấy chạy code trên ethereum nói riêng hay evm nói chung không hề free. Bạn cần phải tốn ether để mua gas để có một khả năng tính toán nhất định để transaction của bạn được phép thực thi. Vì thế, một smart contract tối ưu nên ít tốn mức độ tính toán nhất có thể, từ đó cũng ít tốt gas, tiết kiệm tiền và thời gian thực thi contract đấy.

Và vì thế, thêm vòng lặp là một trong những vấn đề làm tăng chi phí gas. Thực tế, một mảng có thể được sử dụng bởi nhiều vòng lặp. Tuy nhiên, nếu số lượng elemon trong mảng tăng lên, chi phí tính toán cũng tăng lên để xử lý vòng lặp. Vì thế, nếu một attacker có thể khiến contract bị lặp vô hạn, hắn ta có thể làm tan biến hết số lượng ether trong smart contract của bạn. Việc này làm vô hiệu hoá smart contract của bạn tạm thời để thực hiện vài mưu đồ sau đó.

Theo thống kê có khoảng hơn 8% smart contract bị mắc lỗi này. Mình không chắc lỗi này nằm trong top 2 hay không nhưng lỗi này cũng đáng để ý.

3. Tấn công timestamp

Bạn có thể dùng timestamp ở 1 số tác vụ như khoá fund, tạo timer để một fund được giải phóng, tạo số random giả, thay đổi trạng thái và vân vân.

Tuy nhiên, miner hay validator có một số quyền thay đổi timestamp, sẽ rủi ro nếu bạn dùng không đúng timestamp vào trong contract của mình.

Ví dụ sau đây sẽ minh hoạ kiểu tấn công này.

contract Roulette {
    uint public pastBlockTime;
    constructor() payable {} 

    // call spin and send 1 ether to play
    function spin() external payable {     
       require(msg.value == 1 ether);
       require(block.timestamp != pastBlockTime);    
       pastBlockTime = block.timestamp;     
    // if the block.timestamp is divisible by 7 you win the Ether in the contract
       if(block.timestamp % 7 == 0) {         
         (bool sent, ) = msg.sender.call{value: address(this).balance}("");         
         require(sent, "Failed to send Ether");     
       } 
     }
 }

Roulette là một contract mô phỏng trò chơi nơi bạn có thể thắng tất cả ether hiện có trong contract nếu bạn submit một transaction vào đúng một thời điểm xác định. Để chơi trò này bạn cần gọi hàm spin và nạp vào 1 ether cho contract, nếu block.timestamp chia hết cho 7 bạn sẽ chiến thắng và được quyền ôm trọn số ether có trong contract.

Các miner có thể gian lận contract này như sau:

  • Gọi hàm spin và nạp 1 ether cho contract.
  • Submit một block.timestamp cho block tiếp theo mà chia hết cho 7.

Nghe có vẻ sao đơn giản thế này. Đúng rồi đấy, miner có quyền làm việc đấy, còn bạn thì không. Nếu một miner thắng block tiếp theo, họ có thể khai thác lỗi ở contract Roulette này. Đây là một trong các ví dụ lỗi mà miner có thể lợi dụng quyền của mình để khai thác một số smart contract.

Cách hạn chế lỗi này và sử dụng block.timestamp đúng đắn, bạn có thể tham khảo ở đây - Timestamp manipulation

4. Tổng kết:

Trên đây là 3 lỗi phổ biến, còn rất nhiều lỗi thông dụng khác bạn có thể tham khảo và nghiên cứu thêm ở SWC Registry

Hy vọng qua bài này các bạn sẽ chú trọng vấn đề security trong smart contract hơn và tránh được các lỗi thông dụng.

Tham khảo:

Ethereum docs

Block timestamp manipulation attack