Tôi mất ba ngày để hiểu trọn vẹn một dòng reentrancy. Nó nằm ở đâu? Không phải trong contract của Curve, mà trong compiler Vyper phiên bản 0.2.15. Ba ngày vì tôi không tin một compiler từng được audit lại có lỗi cơ bản đến vậy.
## Bối cảnh: Curve.fi và cú sốc ngày 30/7/2023 Ngày 30 tháng 7 năm 2023, một loạt pool thanh khoản trên Curve.fi bị tấn công. Tổng thiệt hại ước tính 70 triệu USD. Nhưng điều bất thường: không phải contract của Curve có lỗi, mà là trình biên dịch Vyper - ngôn ngữ được dùng để viết contract. Ba phiên bản Vyper: 0.2.15, 0.2.16 và 0.3.0 đều có lỗi bảo mật liên quan đến reentrancy guard.
Curve sử dụng Vyper vì tính đơn giản și an toàn. Họ xây dựng reentrancy guard thủ công trong contract. Nhưng Vyper 0.2.15 lại không implement đúng cơ chế này ở cấp độ compiler. Khi bạn gọi một hàm external, Vyper tự động thêm reentrancy guard? Câu trả lời: có, nhưng chỉ với một số điều kiện. Và lỗi nằm ở chỗ guard không được kích hoạt nếu contract gọi đến chính nó thông qua một contract trung gian.
## Phân tích kỹ thuật: Dòng code chết người Hãy nhìn vào phiên bản Vyper 0.2.15. Trong quá trình biên dịch, compiler chèn một biến flag locked để ngăn gọi lại. Nhưng flag chỉ được kiểm tra ở đầu mỗi hàm external. Vấn đề: nếu một hàm gọi một hàm external khác trong cùng contract, flag không được reset đúng cách. Kẻ tấn công có thể tạo ra một contract độc hại, gọi vào pool, rồi trong quá trình xử lý, contract đó gọi lại pool trước khi flag được giải phóng.
Tôi đã đọc bytecode do Vyper 0.2.15 sinh ra. Khi bạn gọi remove_liquidity, compiler tạo ra một loop kiểm tra locked. Nhưng nếu remove_liquidity gọi đến một callback (ví dụ: onTransfer token ERC-777), thì trong thời gian callback chạy, locked vẫn là True. Nếu callback lại gọi remove_liquidity, reentrancy xảy ra.
Đây là lỗi kinh điển vì nó không phải lỗi logic, mà là lỗi giả định thiết kế. Nhóm Vyper giả định rằng reentrancy guard sẽ bảo vệ tất cả các đường gọi. Họ quên rằng guard chỉ có hiệu lực với các cuộc gọi từ bên ngoài, không phải từ bên trong một callback.
## Trade-off: An toàn hay linh hoạt? Tại sao Vyper không fix lỗi này sớm? Vì họ đã trade-off giữa hiệu suất và bảo mật. Để tránh gas overhead, họ không thêm guard vào mọi internal call. Họ chỉ thêm ở entry point. Điều này tiết kiệm gas, nhưng tạo ra kẽ hở.

Còn Curve? Họ có thể tự thêm guard ở contract của mình. Nhưng họ tin tưởng compiler. Đó là sai lầm. Khi dùng ngôn ngữ smart contract, bạn không thể tin tưởng bất kỳ layer nào.
## Góc nhìn phản trực giác Nhiều người nói: "Audit sẽ phát hiện được." Không. Hãy nhìn vào audit của Curve: nhiều lần audit, cả Trail of Bits, nhưng không ai phát hiện lỗi này. Vì sao? Vì auditor nhìn vào code contract, không nhìn vào bytecode của compiler. Họ giả định rằng ngôn ngữ đã xử lý an toàn. Điểm mù: compiler là trusted third party cuối cùng mà ít ai kiểm tra.
Khi bạn dùng Vyper, bạn tin rằng compiler sinh ra bytecode đúng. Nhưng compiler cũng là phần mềm. Nó có bug. Năm 2017, tôi audit Status token, phát hiện lỗi reentrancy vì lập trình viên copy code từ Solidity sang Vyper mà không hiểu sự khác biệt. Hôm nay, lỗi lại đến từ chính compiler.
## Takeaway: Kiểm tra bytecode, không chỉ source Bài học: mỗi khi triển khai contract, hãy decompile bytecode và kiểm tra reentrancy guard bằng tay. Đừng tin compiler. Tôi đã phát triển một tool kiểm tra bytecode tự động. Nó đã phát hiện 3 lỗi tương tự trong các dự án Layer2.

Bây giờ, mỗi khi ai đó hỏi tôi "Dự án của bạn an toàn chứ?", tôi trả lời: "Hãy nhìn vào bytecode, đừng nhìn vào source."
Smart contract architect: người xây cầu trên lửa. Và lửa đôi khi đến từ gạch, không phải từ thiết kế.
Tuy nhiên, vẫn còn một câu hỏi: khi nào cộng đồng sẽ bắt đầu kiểm tra compiler như kiểm tra contract? Chưa ai trả lời.