
Lỗ hổng nghiêm trọng trong trình biên dịch Solidity: Xóa nhầm phép gán biến trạng thái
Tuyển chọn TechFlowTuyển chọn TechFlow

Lỗ hổng nghiêm trọng trong trình biên dịch Solidity: Xóa nhầm phép gán biến trạng thái
Bài viết này phân tích chi tiết ở cấp độ mã nguồn về nguyên lý lỗ hổng trung bình/cao trong trình biên dịch Solidity (0.8.13<=solidity<0.8.17), xảy ra do cơ chế tối ưu hóa Yul có khuyết tật khiến các thao tác gán giá trị cho biến trạng thái bị xóa nhầm trong quá trình biên dịch, đồng thời nêu rõ các biện pháp phòng ngừa tương ứng.
Bài viết này phân tích chi tiết từ cấp độ mã nguồn về nguyên lý lỗ hổng trung bình/cao và các biện pháp phòng ngừa tương ứng do cơ chế tối ưu hóa Yul trong trình biên dịch Solidity (0.8.13 <= solidity < 0.8.17) gây ra việc xóa nhầm thao tác gán biến trạng thái, dẫn đến ảnh hưởng nghiêm trọng đến tính an toàn của hợp đồng.
Giúp các nhà phát triển hợp đồng nâng cao nhận thức an toàn khi lập trình, hiệu quả tránh hoặc giảm thiểu tác động của lỗ hổng SOL-2022-7 đối với độ an toàn mã hợp đồng.
Chi tiết lỗ hổng
Tối ưu hóa Yul là tùy chọn trong quá trình biên dịch mã hợp đồng Solidity, có thể giúp giảm một số lệnh dư thừa trong hợp đồng thông qua cơ chế này, từ đó giảm chi phí gas trong quá trình triển khai và thực thi hợp đồng. Chi tiết về cơ chế tối ưu hóa Yul có thể tham khảo tài liệu chính thức tại đây.
Trong bước tối ưu hóa UnusedStoreEliminator, trình biên dịch sẽ loại bỏ các thao tác ghi vào Storage được coi là “dư thừa”. Tuy nhiên, do lỗi trong việc xác định thế nào là “dư thừa”, khi một khối hàm Yul gọi tới một hàm do người dùng định nghĩa (hàm này chứa nhánh điều kiện không ảnh hưởng đến luồng thực thi của khối gọi), và trước/sau hàm đó trong khối Yul tồn tại các thao tác ghi vào cùng một biến trạng thái, thì tất cả các thao tác ghi Storage trước khi gọi hàm người dùng sẽ bị xóa vĩnh viễn ở cấp độ biên dịch bởi cơ chế tối ưu hóa Yul.
Xét đoạn mã sau:
contract Eocene {
uint public x;
function attack() public {
x = 1;
x = 2;
}
}
Khi áp dụng tối ưu hóa UnusedStoreEliminator, phép gán x = 1 rõ ràng là dư thừa đối với toàn bộ thực thi hàm attack(). Do đó, mã Yul sau tối ưu hóa sẽ loại bỏ x = 1 để giảm chi phí gas.
Tiếp theo, xét trường hợp chèn thêm lời gọi hàm tự định nghĩa ở giữa:
contract Eocene {
uint public x;
function attack(uint i) public {
x = 1;
y(i);
x = 2;
}
function y(uint i) internal{
if (i > 0){
return;
}
assembly { return( 0, 0) }
}
}
Rõ ràng, do lời gọi hàm y(), ta cần xác định liệu y() có ảnh hưởng đến luồng thực thi của hàm attack() hay không. Nếu y() có thể khiến toàn bộ luồng thực thi dừng lại (lưu ý: không phải revert, mà là dừng hoàn toàn – điều này có thể thực hiện bằng hàm return() trong mã assembly), thì phép gán x = 1 không thể bị xóa. Trong hợp đồng trên, do y() chứa dòng assembly {return(0, 0)}, có thể làm dừng toàn bộ lời gọi tin nhắn, nên x = 1 không được phép xóa.
Tuy nhiên, trong trình biên dịch Solidity, do lỗi logic mã nguồn, x = 1 lại bị xóa nhầm trong quá trình biên dịch, làm thay đổi vĩnh viễn logic chương trình.
Kết quả kiểm thử biên dịch thực tế như sau:
Sốc! Mã Yul x = 1 vốn không nên bị tối ưu hóa lại bị mất! Muốn biết vì sao, mời bạn đọc tiếp phần dưới.

Trong thành phần UnusedStoreEliminator của mã nguồn trình biên dịch Solidity, việc xác định một thao tác ghi Storage có dư thừa hay không được thực hiện thông qua việc theo dõi biến SSA và luồng điều khiển. Khi đi vào một hàm do người dùng định nghĩa, UnusedStoreEliminator sẽ xử lý như sau:
-
Thao tác ghi memory hoặc storage: lưu thao tác ghi memory/storage vào biến m_store, và đặt trạng thái ban đầu là Undecided (chưa quyết định);
-
Lời gọi hàm: lấy vị trí đọc/ghi memory hoặc storage của hàm, so sánh với tất cả các thao tác đang ở trạng thái Undecided trong m_store;
-
Nếu là thao tác ghi đè lên thao tác đã lưu trong m_store, thì đổi trạng thái thao tác đó trong m_store thành Unused (không dùng);
-
Nếu là thao tác đọc dữ liệu đã ghi trong m_store, thì đổi trạng thái tương ứng trong m_store thành Used (đã dùng);
-
Nếu hàm không có nhánh nào cho phép tiếp tục thực thi lời gọi tin nhắn, đổi tất cả thao tác ghi memory trong m_store thành Unused;
-
Trong các điều kiện trên, nếu hàm có khả năng dừng luồng thực thi, thì đổi tất cả thao tác ghi storage đang ở trạng thái Undecided trong m_store thành Used; ngược lại, đánh dấu là Unused;
-
Kết thúc hàm: xóa tất cả các thao tác ghi được đánh dấu là Unused.
Mã khởi tạo cho thao tác ghi memory hoặc storage như sau:
Có thể thấy, các thao tác ghi memory và storage gặp phải sẽ được lưu vào m_store

Logic xử lý khi gặp lời gọi hàm như sau:
Trong đó, operationFromFunctionCall() và applyOperation() thực hiện logic xử lý 2.1, 2.2 nêu trên. Câu lệnh If ở dưới dựa trên canContinue và canTerminate của hàm để thực hiện logic 2.3.
Lưu ý rằng chính lỗi trong câu lệnh If dưới đây đã dẫn đến sự tồn tại của lỗ hổng!!!

operationFromFunctionCall() dùng để lấy tất cả thao tác đọc/ghi memory hoặc storage của hàm. Cần lưu ý rằng Yul có nhiều hàm nội bộ như sstore(), return(). Có thể thấy cách xử lý khác nhau giữa hàm nội bộ và hàm do người dùng định nghĩa.

Hàm applyOperation() so sánh tất cả các thao tác đọc/ghi lấy được từ operationFromFunctionCall() với các thao tác đã lưu trong m_store để xác định liệu chúng có bị đọc/ghi trong lời gọi hàm này hay không, từ đó cập nhật trạng thái tương ứng trong m_store.

Xét logic tối ưu hóa UnusedStoreEliminator xử lý hàm attack() của hợp đồng Eocene:
Lưu thao tác x = 1 vào biến m_store, đặt trạng thái là Undecided
1. Gặp lời gọi hàm y(), lấy tất cả thao tác đọc/ghi của y()
2. Duyệt biến m_store, phát hiện tất cả thao tác đọc/ghi do y() gây ra đều không liên quan đến x = 1, trạng thái x = 1 vẫn là Undecided
- Lấy logic luồng điều khiển của hàm y(), do y() có nhánh trả về bình thường nên canContinue là True, không vào câu lệnh If. Trạng thái x = 1 vẫn là Undecided!!!
3. Gặp thao tác ghi x = 2:
-
Duyệt m_store, phát hiện x = 1 đang ở trạng thái Undecided, x = 2 ghi đè lên x = 1, nên đặt trạng thái x = 1 là Unused.
-
Lưu thao tác x = 2 vào m_store, trạng thái ban đầu undecided.
4. Kết thúc hàm:
-
Đổi tất cả thao tác ở trạng thái undecided trong m_store thành Used
-
Xóa tất cả thao tác ở trạng thái Unused trong m_store
Rõ ràng, khi gọi hàm, nếu hàm được gọi có thể dừng thực thi tin nhắn, tất cả các thao tác ghi ở trạng thái Undecided trước đó phải được đổi thành Used, chứ không thể giữ nguyên là Undecided, dẫn đến việc xóa nhầm các thao tác ghi xảy ra trước lời gọi hàm.
Ngoài ra, cần lưu ý rằng cờ luồng điều khiển của mỗi hàm do người dùng định nghĩa là có tính kế thừa, do đó trong trường hợp gọi đệ quy nhiều lớp, ngay cả khi hàm底层满足上述逻辑,x=1仍可能被删除。
Trong bài viết của Solidity, có đưa ra ví dụ về một đoạn mã hợp đồng tương tự nhưng không chịu ảnh hưởng của lỗ hổng. Tuy nhiên, lý do mã này không bị ảnh hưởng không phải do logic xử lý của UnusedStoreEliminator có điểm đặc biệt, mà là do ở bước tối ưu hóa Yul trước đó, quá trình FullInliner sẽ nội tuyến hóa (inline) các hàm nhỏ hoặc chỉ được gọi một lần vào bên trong hàm gọi, do đó tránh được điều kiện kích hoạt lỗ hổng (tức là tránh việc gọi hàm do người dùng định nghĩa).
contract Normal {
uint public x;
function f(bool a) public {
x = 1;
g(a);
x = 2;
}
function g(bool a) internal {
if (!a)
assembly { return( 0, 0) }
}
}
Kết quả biên dịch như sau:
Hàm g(bool a) được nội tuyến hóa vào hàm f(), tránh được điều kiện gọi hàm do người dùng định nghĩa, do đó ngăn chặn được lỗ hổng.

Giải pháp khắc phục
Giải pháp triệt để nhất là không sử dụng trình biên dịch Solidity nằm trong phạm vi bị ảnh hưởng để biên dịch. Nếu bắt buộc phải dùng phiên bản bị lỗi, có thể cân nhắc tắt bước tối ưu hóa UnusedStoreEliminator khi biên dịch.
Nếu muốn giảm thiểu lỗ hổng ở cấp độ mã hợp đồng, do sự phức tạp của nhiều bước tối ưu hóa cũng như luồng gọi hàm thực tế, hãy nhờ các chuyên gia an ninh thực hiện kiểm tra mã nguồn để phát hiện các vấn đề an toàn do lỗ hổng này gây ra.
Chào mừng tham gia cộng đồng chính thức TechFlow
Nhóm Telegram:https://t.me/TechFlowDaily
Tài khoản Twitter chính thức:https://x.com/TechFlowPost
Tài khoản Twitter tiếng Anh:https://x.com/BlockFlow_News














