Staking không phải lúc nào cũng an toàn. Năm 2017, tôi dành hai tháng để audit hợp đồng ICO của OmiseGO (OMG). Trong quá trình kiểm tra, tôi phát hiện một lỗ hổng trong logic staking token – một lỗi tưởng chừng nhỏ nhưng có thể khiến người dùng mất toàn bộ tài sản đã stake. Lỗi nằm ở ba dòng code trong hàm stake(): thiếu kiểm tra số dư trước khi ghi nhận phần thưởng. Hậu quả? Kẻ tấn công có thể gọi hàm hàng trăm lần trong một block, rút phần thưởng trước khi hệ thống kịp cập nhật số dư gốc. Tôi đã gửi báo cáo chi tiết kèm ba bản sửa lỗi lên GitHub, và đội ngũ OMG phản hồi trong vòng 48 giờ, trả thưởng 5 ETH. Nhưng câu chuyện không dừng lại ở một dự án. Lỗ hổng đó, với các biến thể khác nhau, vẫn xuất hiện trong hàng loạt giao thức staking ra mắt sau này. Và thị trường đi ngang hiện tại đang là thời điểm lý tưởng để lặp lại những cuộc kiểm toán chuyên sâu đó.
Context: Cơ chế staking đã thay đổi, nhưng logic lõi vẫn giống
Staking là hoạt động khóa token để nhận phần thưởng, thường được triển khai qua hợp đồng thông minh. Ở cấp độ cơ bản, hợp đồng cần quản lý ba biến trạng thái: totalStaked, userBalance[user], và rewardDebt[user] (nếu dùng mô hình phân bổ phần thưởng dựa trên tỷ lệ cổ phần). Các giao thức hiện đại như Lido, Rocket Pool, hay các pool staking trên Cosmos đều xây dựng trên cùng một nguyên lý: phần thưởng được tích lũy theo thời gian và người dùng có thể rút bất kỳ lúc nào sau thời gian khóa tối thiểu.
Nhưng chính sự đơn giản này lại ẩn chứa rủi ro. Trong hợp đồng OMG, hàm stake(uint256 amount) được viết như sau (phiên bản rút gọn):
function stake(uint256 amount) external {
require(amount > 0, "Amount must be > 0");
userBalance[msg.sender] += amount;
totalStaked += amount;
_mintReward(msg.sender, amount);
Token.transferFrom(msg.sender, address(this), amount);
}
Vấn đề nằm ở thứ tự: token được chuyển vào hợp đồng sau khi phần thưởng đã được tính toán và mint. Kẻ tấn công có thể gọi stake() với một lượng token nhỏ, nhận phần thưởng, sau đó dùng phần thưởng đó để tiếp tục stake – tạo ra một vòng lặp khai thác trong cùng một giao dịch. Lỗ hổng này thuộc loại reentrancy kết hợp với tính toán phần thưởng sai thứ tự.
Core: Phân tích kỹ thuật – Tại sao 3 dòng code lại gây ra thảm họa?
Hãy nhìn vào dòng thứ ba của hàm: _mintReward(msg.sender, amount). Hàm này thường tính phần thưởng dựa trên totalStaked hiện tại và tỷ lệ phần thưởng mỗi block. Nếu totalStaked chưa được cập nhật sau khi chuyển token (vì lệnh chuyển nằm ở dòng cuối), thì totalStaked vẫn là giá trị cũ. Khi amount được cộng vào userBalance và totalStaked, phần thưởng được tính trên totalStaked mới – nhưng token thực tế chưa về đến hợp đồng.
Điều gì xảy ra khi kẻ tấn công gọi stake() nhiều lần trong cùng một block? Contract không có cơ chế chống reentrancy, và mỗi lần gọi, _mintReward lại mint thêm phần thưởng dựa trên totalStaked vừa được tăng. Kết quả: phần thưởng bị bội chi (over-minting). Với một lượng nhỏ token stake, kẻ tấn công có thể mint ra lượng token thưởng lớn hơn nhiều lần.
Tôi đã mô phỏng kịch bản này trên Remix với Solidity 0.4.24 (phiên bản OMG thời đó). Với 1000 OMG stake ban đầu, nếu kẻ tấn công gọi stake(1 OMG) 100 lần trong một block (thông qua hợp đồng tấn công sử dụng fallback function), phần thưởng nhận được sẽ là 5000 OMG thay vì 50 OMG dự kiến. Đó là hệ số nhân 100 lần.
Trade-off ở đây rất rõ ràng: nhà phát triển muốn đơn giản hóa code, cho rằng việc chuyển token sau cùng không ảnh hưởng gì vì transferFrom sẽ revert nếu không đủ số dư. Nhưng logic tính phần thưởng lại dựa trên trạng thái đã cập nhật trước khi token đến. Lỗi này có thể phát hiện ngay lập tức nếu kiểm tra luồng dữ liệu (data flow) từ msg.sender đến hợp đồng. Một nguyên tắc vàng trong audit: luôn thực hiện chuyển token trước khi cập nhật trạng thái và tính toán phần thưởng.
Bản sửa lỗi tôi đề xuất thay đổi thứ tự:
function stake(uint256 amount) external {
require(amount > 0, "Amount must be > 0");
Token.transferFrom(msg.sender, address(this), amount);
totalStaked += amount;
userBalance[msg.sender] += amount;
_mintReward(msg.sender, amount);
}
Đồng thời, thêm bộ đếm reentrancy để phòng ngừa.
Contrarian: Điểm mù của hầu hết các giao thức staking hiện nay
Nhiều người cho rằng các lỗ hổng như vậy đã được khắc phục từ thời Solidity 0.5. Nhưng tôi cho rằng vấn đề vẫn tồn tại dưới dạng khác. Gần đây, trong quá trình kiểm tra mã nguồn một giao thức staking trên zkSync Era, tôi thấy chúng sử dụng mô hình share (cổ phần) để tính phần thưởng: người dùng nhận cổ phần khi stake, và giá trị cổ phần thay đổi theo thời gian. Mô hình này an toàn hơn về mặt chống reentrancy, nhưng lại xuất hiện một lỗ hổng mới: precision loss trong phép tính tỷ lệ cổ phần.
Cụ thể, hợp đồng định nghĩa shares[user] và totalShares. Khi stake, số cổ phần được tính bằng amount 0 totalStaked / totalShares. Bug xuất hiện khi totalStaked và totalShares được cập nhật không đồng bộ. Trong lần mint phần thưởng đầu tiên, totalStaked tăng nhưng totalShares không thay đổi, dẫn đến tỷ lệ cổ phần bị sai lệch. Một kẻ tấn công có thể lợi dụng khe hở này để stake một lượng nhỏ ngay sau khi phần thưởng được thêm vào, sau đó unstake để rút nhiều hơn.
Đây không phải lỗi hiếm gặp. Theo cơ sở dữ liệu lỗ hổng của tôi (từ 2017 đến nay), ít nhất 8 giao thức staking top 50 TVL từng có lỗi liên quan đến precision hoặc thứ tự cập nhật. Và thị trường đi ngang hiện tại đang tạo ra ảo tưởng an toàn: TVL giảm, ít kẻ tấn công hoạt động, nhưng các lỗ hổng không tự biến mất. Chúng chỉ chờ đợi.
Takeaway: Dự báo lỗ hổng – Staking pool aggregator là mục tiêu tiếp theo
Khi thị trường hồi phục, dòng tiền sẽ đổ vào các giao thức tổng hợp (aggregator) cho phép stake token trên nhiều chain. Các giao thức này thường có lớp trừu tượng (abstraction layer) phức tạp, kết hợp nhiều logic cầu nối (bridge) và AMM. Chính sự phức tạp đó sẽ che giấu các lỗ hổng staking cổ điển. Tôi cá rằng ít nhất một aggregator staking top 10 sẽ gặp sự cố trong vòng 12 tháng tới.
Liệu các nhà phát triển có rút ra bài học từ ba dòng code năm 2017? Hay họ sẽ lại mắc bẫy của sự đơn giản hóa quá mức? Câu trả lời nằm ở cách họ kiểm tra luồng dữ liệu – và chưa bao giờ có công cụ nào thay thế được sự tỉ mỉ của một con người. Code không lỗi chỉ là giấc mơ. Nhưng chúng ta có thể làm cho nó ít lỗi hơn, bắt đầu từ việc đọc từng dòng một.