
Phân tích lỗ hổng trình biên dịch Solidity: Khuyết điểm trong việc mã hóa lại ABI
Tuyển chọn TechFlowTuyển chọn TechFlow

Phân tích lỗ hổng trình biên dịch Solidity: Khuyết điểm trong việc mã hóa lại ABI
Quá trình này về bản thân nó không có vấn đề logic lớn nào, nhưng khi kết hợp với cơ chế dọn dẹp của Solidity, do sự thiếu sót trong chính mã của trình biên dịch Solidity, đã dẫn đến sự tồn tại của lỗ hổng.
Tổng quan
Bài viết này phân tích chi tiết từ cấp độ mã nguồn về lỗ hổng trong trình biên dịch Solidity (0.5.8 <= phiên bản < 0.8.16) xảy ra trong quá trình ABIReencoding do xử lý sai các mảng kiểu uint và bytes 32 có độ dài cố định, đồng thời đề xuất các giải pháp và biện pháp phòng ngừa liên quan.
Chi tiết lỗ hổng
Định dạng mã hóa ABI là phương thức mã hóa tiêu chuẩn được sử dụng khi người dùng hoặc hợp đồng gọi hàm và truyền tham số tới một hợp đồng khác. Chi tiết có thể tham khảo phần mô tả chính thức của Solidity về mã hóa ABI.
Trong quá trình phát triển hợp đồng, dữ liệu cần thiết sẽ được lấy từ dữ liệu calldata gửi đến từ người dùng hoặc hợp đồng khác, sau đó có thể chuyển tiếp dữ liệu đã lấy được hoặc thực hiện các thao tác như emit. Do tất cả các opcode của máy ảo EVM đều dựa trên memory, stack và storage, nên trong Solidity, bất kỳ thao tác nào cần mã hóa dữ liệu theo ABI đều yêu cầu sao chép dữ liệu từ calldata vào bộ nhớ (memory), sắp xếp lại theo định dạng ABI theo thứ tự mới.
Quá trình này về mặt logic không có vấn đề lớn, nhưng khi kết hợp với cơ chế dọn dẹp (cleanup) của Solidity, do sự sơ suất trong mã nguồn của trình biên dịch Solidity, đã dẫn đến sự tồn tại của lỗ hổng.
Theo quy tắc mã hóa ABI, sau khi loại bỏ bộ chọn hàm (function selector), dữ liệu mã hóa ABI được chia thành hai phần: phần head và phần tail. Khi định dạng dữ liệu là mảng có độ dài cố định kiểu uint hoặc bytes 32, ABI sẽ lưu trữ toàn bộ dữ liệu thuộc kiểu này ở phần head. Cơ chế cleanup của Solidity trong bộ nhớ hoạt động bằng cách sau khi một chỉ mục bộ nhớ hiện tại được sử dụng, nó sẽ đặt giá trị của chỉ mục bộ nhớ kế tiếp về 0 để tránh dữ liệu rác ảnh hưởng đến việc sử dụng chỉ mục bộ nhớ kế tiếp. Đồng thời, khi Solidity thực hiện mã hóa ABI cho một nhóm tham số dữ liệu, việc mã hóa được thực hiện theo thứ tự từ trái sang phải!!
Để thuận tiện cho việc tìm hiểu nguyên lý lỗ hổng phía sau, xét đoạn mã hợp đồng dưới đây:
contract Eocene {
event VerifyABI( bytes[], uint[ 2 ]);
function verifyABI(bytes[] calldata a, uint[ 2 ] calldata b) public {
emit VerifyABI(a, b); // Dữ liệu sự kiện sẽ được mã hóa theo định dạng ABI rồi lưu trữ trên chuỗi
}
}
Hàm verifyABI trong hợp đồng Eocene chỉ đơn giản là thực hiện emit hai tham số đầu vào: mảng bytes[] a có độ dài thay đổi và mảng uint[2] b có độ dài cố định.
Lưu ý rằng, sự kiện (event) cũng kích hoạt việc mã hóa ABI. Hai tham số a, b sẽ được mã hóa theo định dạng ABI trước khi lưu trữ lên chuỗi.
Chúng ta sử dụng phiên bản Solidity v0.8.14 để biên dịch mã hợp đồng, triển khai thông qua Remix và truyền vào verifyABI(['0xaaaaaa'], ['0xbbbbbb']), [0x11111, 0x22222]).
Trước tiên, hãy xem định dạng mã hóa đúng cho verifyABI(['0xaaaaaa'], ['0xbbbbbb']), [0x11111, 0x22222]:
0x52cd1a9c // bytes4(sha3("verify(bytes[], uint[2])"))
0000000000000000000000000000000000000000000000000000000000000060 // chỉ mục của a
0000000000000000000000000000000000000000000000000000000000011111 // b[0]
0000000000000000000000000000000000000000000000000000000000022222 // b[1]
0000000000000000000000000000000000000000000000000000000000000002 // độ dài của a
0000000000000000000000000000000000000000000000000000000000000040 // chỉ mục của a[0]
0000000000000000000000000000000000000000000000000000000000000080 // chỉ mục của a[1]
0000000000000000000000000000000000000000000000000000000000000003 // độ dài của a[0]
aaaaaa0000000000000000000000000000000000000000000000000000000000 // a[0]
0000000000000000000000000000000000000000000000000000000000000003 // độ dài của a[1]
bbbbbb0000000000000000000000000000000000000000000000000000000000 // a[1]
Nếu trình biên dịch Solidity hoạt động bình thường, khi các tham số a, b được ghi nhận bởi sự kiện lên chuỗi, định dạng dữ liệu phải giống như dữ liệu chúng ta gửi đi. Hãy thử gọi hợp đồng thực tế và kiểm tra log trên chuỗi. Nếu muốn so sánh, bạn có thể xem giao dịch này.
Sau khi gọi thành công, bản ghi sự kiện của hợp đồng như sau:
!! Sốc, ngay sau b[1], giá trị độ dài của tham số a đã bị xóa sai!!
0000000000000000000000000000000000000000000000000000000000000060 // chỉ mục của a
0000000000000000000000000000000000000000000000000000000000011111 // b[0]
0000000000000000000000000000000000000000000000000000000000022222 // b[1]
0000000000000000000000000000000000000000000000000000000000000000 // độ dài của a?? Tại sao lại thành 0??
0000000000000000000000000000000000000000000000000000000000000040 // chỉ mục của a[0]
0000000000000000000000000000000000000000000000000000000000000080 // chỉ mục của a[1]
0000000000000000000000000000000000000000000000000000000000000003 // độ dài của a[0]
aaaaaa0000000000000000000000000000000000000000000000000000000000 // a[0]
0000000000000000000000000000000000000000000000000000000000000003 // độ dài của a[1]
bbbbbb0000000000000000000000000000000000000000000000000000000000 // a[1]
Tại sao lại như vậy?
Như đã nói ở trên, khi Solidity gặp một chuỗi tham số cần mã hóa ABI, thứ tự tạo ra các tham số là từ trái sang phải. Cụ thể, logic mã hóa a, b như sau:
-
Solidity trước tiên thực hiện mã hóa ABI cho a, theo quy tắc mã hóa, chỉ mục của a được đặt ở phần đầu, độ dài phần tử và giá trị cụ thể của phần tử được lưu trữ ở phần đuôi (tail).
-
Xử lý dữ liệu b, vì kiểu dữ liệu của b là uint[2], nên giá trị cụ thể được lưu trữ ở phần head. Tuy nhiên, do cơ chế cleanup của Solidity, sau khi lưu trữ b[1] vào bộ nhớ, nó đặt giá trị tại địa chỉ bộ nhớ kế tiếp (địa chỉ này được dùng để lưu trữ độ dài của a) về 0.
-
Thao tác mã hóa ABI kết thúc, dữ liệu mã hóa sai được lưu trữ lên chuỗi, lỗ hổng SOL-2022-6 xuất hiện.
Ở cấp độ mã nguồn, logic sai sót cũng rất rõ ràng: khi cần lấy dữ liệu mảng có độ dài cố định kiểu bytes32 hoặc uint từ calldata vào memory, Solidity luôn đặt giá trị tại chỉ mục bộ nhớ kế tiếp về 0 sau khi hoàn thành việc sao chép dữ liệu. Lại do việc mã hóa ABI có phần head và tail, và thứ tự mã hóa cũng từ trái sang phải, nên dẫn đến sự tồn tại của lỗ hổng.
Mã trình biên dịch Solidity cụ thể chứa lỗ hổng như sau:
Khi vị trí lưu trữ dữ liệu nguồn là Calldata, và kiểu dữ liệu nguồn là ByteArray, String, hoặc kiểu phần tử cơ sở của mảng nguồn là uint hoặc bytes32, thì sẽ vào ABIFunctions::abiEncodingFunctionCalldataArrayWithoutCleanup()

Sau khi vào, sẽ trước tiên sử dụng fromArrayType.isDynamicallySized() để kiểm tra xem dữ liệu nguồn có phải là mảng có độ dài cố định hay không, chỉ có mảng có độ dài cố định mới thỏa điều kiện kích hoạt lỗ hổng.
Kết quả kiểm tra isByteArrayOrString() được truyền vào YulUtilFunctions::copyToMemoryFunction(), dựa vào kết quả này để xác định có thực hiện cleanup tại vị trí chỉ mục kế tiếp sau thao tác calldatacopy hay không.
Việc kết hợp các điều kiện hạn chế trên dẫn đến chỉ khi dữ liệu nguồn trong calldata có định dạng là mảng có độ dài cố định kiểu uint hoặc bytes32 được sao chép vào bộ nhớ thì mới kích hoạt được lỗ hổng. Đây cũng là nguyên nhân hình thành các điều kiện ràng buộc để kích hoạt lỗ hổng.

Do việc mã hóa tham số ABI luôn theo thứ tự từ trái sang phải, xét đến điều kiện khai thác lỗ hổng, chúng ta cần hiểu rõ rằng, phải có dữ liệu kiểu độ dài thay đổi đứng trước mảng có độ dài cố định kiểu uint và bytes32, dữ liệu này được lưu trữ ở phần tail của định dạng mã hóa ABI, và mảng có độ dài cố định kiểu uint hoặc bytes32 phải nằm ở vị trí cuối cùng trong danh sách tham số cần mã hóa.
Nguyên nhân rất rõ ràng: nếu dữ liệu có độ dài cố định không nằm ở vị trí cuối cùng của các tham số cần mã hóa, thì việc đặt giá trị 0 cho vị trí bộ nhớ kế tiếp sẽ không gây ảnh hưởng gì, vì tham số mã hóa tiếp theo sẽ ghi đè lên vị trí đó. Nếu trước dữ liệu có độ dài cố định không có dữ liệu nào cần lưu trữ ở phần tail, thì dù vị trí bộ nhớ kế tiếp bị đặt về 0 cũng không sao, vì vị trí đó không được ABI sử dụng.
Ngoài ra, cần lưu ý rằng, tất cả các thao tác ABI ngầm định hoặc tường minh, cũng như mọi Tuple (nhóm dữ liệu) phù hợp định dạng, đều chịu ảnh hưởng bởi lỗ hổng này.
Các thao tác cụ thể liên quan như sau:
-
event
-
error
-
abi.encode*
-
returns // giá trị trả về của hàm
-
struct // cấu trúc do người dùng định nghĩa
-
all external call
Giải pháp
-
Khi mã hợp đồng chứa các thao tác bị ảnh hưởng nêu trên, đảm bảo tham số cuối cùng không phải là mảng có độ dài cố định kiểu uint hoặc bytes32
-
Sử dụng trình biên dịch Solidity không bị ảnh hưởng bởi lỗ hổng
-
Tìm kiếm sự giúp đỡ từ các chuyên gia an ninh, tiến hành kiểm toán an ninh chuyên nghiệp cho hợp đồng
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














