Đầu tiên, tôi sẽ giúp bạn hiểu một cách đơn giản cuộc tấn công reentrancy là gì và cách bạn có thể ngăn chặn nó, sau đó, tôi sẽ đi sâu hơn vào các ví dụ về mã để chỉ ra các lỗ hổng ở đâu, mã của kẻ tấn công là gì và quan trọng nhất là tôi sẽ chỉ cho bạn các phương pháp được xác minh mới nhất để bảo vệ không chỉ một mà tất cả các hợp đồng thông minh trong dự án của bạn.

Tiết lộ nội dung: Nếu bạn đã nghe nói về công cụ sửa đổi nonReentrant(), hãy tiếp tục đọc vì bạn sắp khám phá ra một vài dòng bên dưới công cụ sửa đổi GlobalNonReentrant() và mẫu kiểm tra-hiệu ứng-tương tác.



Trong hình trên, chúng ta có ContractA và ContractB. Bây giờ, như bạn đã biết, một hợp đồng thông minh có thể tương tác với một hợp đồng thông minh khác, như trong trường hợp này, ContractA có thể gọi ContractB. Vì vậy, ý tưởng cơ bản nhất của việc đăng ký lại là ContractB có thể gọi lại ContractA trong khi ContractA vẫn đang thực thi.

Vì vậy, làm thế nào kẻ tấn công có thể sử dụng điều này?

Ở trên, chúng ta có ContractA có 10 Ether và chúng ta thấy rằng ContractB đã lưu trữ 1 Ether trong ContractA. Trong trường hợp này, ContractB sẽ có thể sử dụng chức năng rút tiền từ ContractA và gửi Ether trở lại chính nó khi nó vượt qua bước kiểm tra khi số dư của nó lớn hơn 0, sau đó tổng số dư của nó được sửa đổi thành 0.



Bây giờ chúng ta hãy xem làm thế nào ContractB có thể sử dụng reentrancy để khai thác chức năng rút tiền và đánh cắp tất cả Ether từ ContractA. Về cơ bản, kẻ tấn công sẽ cần hai hàm: tấn công() và dự phòng().

Trong Solidity, hàm dự phòng là một hàm bên ngoài không có tên, tham số hoặc giá trị trả về. Bất kỳ ai cũng có thể gọi hàm dự phòng bằng cách: Gọi một hàm không tồn tại trong hợp đồng; Gọi một hàm mà không truyền dữ liệu cần thiết; Gửi Ether mà không có bất kỳ dữ liệu nào đến hợp đồng.

Cách thức hoạt động của tính năng quay lại (hãy làm theo các mũi tên từng bước một) là kẻ tấn công gọi hàm tấn công() mà bên trong đang gọi hàm rút() từ ContractA. Bên trong hàm, nó sẽ xác minh xem số dư của ContractB có lớn hơn 0 hay không và nếu có thì nó sẽ tiếp tục thực thi.



Vì số dư của ContractB lớn hơn 0 nên nó sẽ gửi lại 1 Ether đó và kích hoạt chức năng dự phòng. Lưu ý rằng tại thời điểm này ContractA có 9 Ether và ContractB đã có 1 Ether.



Tiếp theo, khi chức năng dự phòng được thực thi, nó sẽ kích hoạt lại chức năng rút tiền của ContractA, kiểm tra lại xem số dư của ContractB có lớn hơn 0 hay không. Nếu kiểm tra lại hình ảnh trên, bạn sẽ nhận thấy số dư của nó vẫn là 1 Ether.



Điều đó có nghĩa là quá trình kiểm tra đã thành công và nó sẽ gửi một Ether khác đến ContractB, điều này sẽ kích hoạt chức năng dự phòng. Lưu ý rằng vì dòng mà chúng ta có “balance=0” không bao giờ được thực thi nên điều này sẽ tiếp tục cho đến khi hết Ether từ ContractA.

__________

Bây giờ chúng ta hãy xem một hợp đồng thông minh nơi chúng ta có thể xác định khả năng đăng ký lại bằng mã Solidity.



Trong hợp đồng EtherStore, chúng ta có hàm deposit() lưu trữ và cập nhật số dư của người gửi, sau đó là hàm rútAll() sẽ lấy tất cả số dư được lưu trữ cùng một lúc. Vui lòng lưu ý việc triển khai rútAll() trong đó trước tiên nó kiểm tra với yêu cầu rằng số dư lớn hơn 0 và ngay sau khi gửi Ether một lần nữa, để đến cuối bản cập nhật số dư của người gửi về 0.



Ở đây chúng ta có hợp đồng Attack sẽ sử dụng reentrancy để rút hợp đồng EtherStore. Hãy phân tích mã của nó:

  • Trong hàm tạo của nó, kẻ tấn công sẽ chuyển địa chỉ EtherStore để tạo một phiên bản và do đó có thể sử dụng các chức năng của nó.

  • Ở đó, chúng ta thấy hàm dự phòng() sẽ được gọi khi EtherStore gửi Ether đến hợp đồng này. Bên trong nó sẽ gọi rút từ EtherStore miễn là số dư bằng hoặc lớn hơn 1.

  • Và bên trong hàm tấn công() chúng ta có logic sẽ khai thác EtherStore. Như chúng ta có thể thấy, đầu tiên chúng ta sẽ bắt đầu cuộc tấn công bằng cách đảm bảo có đủ ether, sau đó gửi 1 ether để có số dư lớn hơn 0 trong EtherStore và do đó vượt qua kiểm tra trước khi bắt đầu rút tiền.

Tôi đã giải thích từng bước ở trên trong ví dụ của ContractA và ContractB về cách mã sẽ chạy, vì vậy bây giờ, hãy tóm tắt lại mã sẽ chạy như thế nào. Trước hết, kẻ tấn công sẽ gọi attack(), bên trong sẽ gọi pullAll() từ EtherStore, sau đó sẽ gửi Ether tới chức năng dự phòng của hợp đồng Attack. Và ở đó nó sẽ bắt đầu quay trở lại và tiêu hao số dư của EtherStore.

Vì vậy, làm cách nào chúng ta có thể bảo vệ hợp đồng của mình khỏi các cuộc tấn công reentrancy?

Tôi sẽ chỉ cho bạn ba kỹ thuật phòng ngừa để bảo vệ chúng hoàn toàn. Tôi sẽ đề cập đến cách ngăn chặn việc đăng nhập lại trong một chức năng duy nhất, chức năng chéo đăng nhập lại và hợp đồng chéo đăng nhập lại.



Kỹ thuật đầu tiên để bảo vệ một chức năng duy nhất là sử dụng một công cụ sửa đổi có tên là noReentrant.

Công cụ sửa đổi là một loại hàm đặc biệt mà bạn sử dụng để sửa đổi hành vi của các hàm khác. Công cụ sửa đổi cho phép bạn thêm các điều kiện hoặc chức năng bổ sung vào một hàm mà không cần phải viết lại toàn bộ hàm.

Những gì chúng tôi làm ở đây là khóa hợp đồng trong khi hàm được thực thi. Bằng cách này, nó sẽ không thể nhập lại một hàm vì nó sẽ phải duyệt qua mã của hàm rồi thay đổi biến trạng thái đã khóa thành sai để vượt qua lại quá trình kiểm tra đã thực hiện trong yêu cầu.

__________



Kỹ thuật thứ hai là sử dụng mẫu Kiểm tra-Hiệu ứng-Tương tác sẽ bảo vệ hợp đồng của chúng ta khỏi việc đăng nhập lại nhiều chức năng. Bạn có thể nhận ra trong hợp đồng EtherStore cập nhật ở trên những gì đã thay đổi không?

Để tìm hiểu sâu hơn về mẫu Kiểm tra-Hiệu ứng-Tương tác, tôi khuyên bạn nên đọc https://fravoll.github.io/solidity-patterns/checks_effect_interactions.html





Ở trên, chúng ta thấy sự so sánh giữa mã dễ bị tấn công từ hình ảnh bên trái, nơi số dư được cập nhật sau khi gửi Ether, như đã thấy ở trên có thể không bao giờ đạt được và ở bên phải, những gì nó đã được thực hiện là di chuyển số dư[ msg.sender] = 0 (hoặc hiệu ứng) ngay sau require(bal > 0) (kiểm tra) nhưng trước khi gửi ether (tương tác).

Bằng cách này, chúng tôi sẽ đảm bảo rằng ngay cả khi một chức năng khác đang truy cập rútAll(), hợp đồng này sẽ được bảo vệ khỏi kẻ tấn công vì số dư sẽ luôn được cập nhật trước khi gửi Ether.

Mẫu được tạo bởi https://twitter.com/GMX_IO

Kỹ thuật thứ ba tôi sắp chỉ cho bạn là tạo hợp đồng GlobalReentrancyGuard để bảo vệ khỏi việc đăng ký lại nhiều hợp đồng. Điều quan trọng là phải hiểu rằng điều này có thể áp dụng cho các dự án có nhiều hợp đồng tương tác với nhau.

Ý tưởng ở đây giống như trong công cụ sửa đổi noReentrant mà tôi đã giải thích ở kỹ thuật đầu tiên, nó nhập công cụ sửa đổi, cập nhật một biến để khóa hợp đồng và nó không mở khóa cho đến khi nó không hoàn thành mã. Sự khác biệt lớn ở đây là chúng ta đang sử dụng một biến được lưu trữ trong một hợp đồng riêng được dùng làm nơi kiểm tra xem hàm đã được nhập hay chưa.







Tôi đã tạo ở đây một ví dụ không có mã thực tế và chỉ có tên hàm để tham khảo để hiểu ý tưởng vì theo kinh nghiệm của tôi, nó có thể giúp hình dung tình huống nhiều hơn là chỉ viết bằng chữ.

Ở đây, kẻ tấn công sẽ gọi hàm trong hợp đồng ScheduledTransfer, sau khi đáp ứng các điều kiện, nó sẽ gửi Ether được chỉ định đến hợp đồng AttackTransfer, do đó, sẽ nhập hàm dự phòng và do đó “hủy” giao dịch từ điểm của hợp đồng ScheduledTransfer xem và nhận được Ether. Và theo cách này, nó sẽ bắt đầu xem xét cho đến khi rút hết Ether khỏi ScheduledTransfer.

Chà, sử dụng GlobalReentrancyGuard tôi đã đề cập ở trên sẽ tránh được tình huống tấn công như vậy.

__________________

Twitter @TheBlockChainer để tìm thêm thông tin cập nhật hàng ngày về Hợp đồng thông minh, Bảo mật Web3, Solidity, Hợp đồng thông minh kiểm tra, v.v.

__________________