Kể từ khi Uniswap v4 ra mắt mainnet, cơ chế Hook đã trở thành một trong những đổi mới được quan tâm nhất trong DeFi. Nền tảng phóng memecoin Flaunch trên Base chain đã sử dụng Hook để triển khai cơ chế giá bán trước cố định và phát hành tự động thanh lý; Giao thức thanh khoản Bunni v2 sử dụng Hook để xây dựng mô hình thanh khoản có thể lập trình và tái thế chấp; Những token xoay quanh cách chơi với Hook như SATO, uPEG (Unipeg), Slonks năm nay cũng đã đạt được mức tăng hàng chục lần trong thời gian ngắn.
Ở một mặt khác của sự phồn thịnh của hệ sinh thái Hook, các cuộc tấn công nhắm vào lỗi triển khai Hook cũng đang gia tăng đáng kể. Bài viết này sẽ bắt đầu từ cơ chế Hook của Uniswap v4, phân tích từng bước ngăn xếp gọi lõi của nó, giúp các dự án hiểu được các lỗ hổng tiềm ẩn.
Bảo mật Hook của Uniswap v4
1. Giới thiệu
Thay đổi kiến trúc nổi bật nhất của Uniswap v4 so với v3 là việc giới thiệu cơ chế Hook (móc): cho phép nhà phát triển gắn các hợp đồng tùy chỉnh vào các sự kiện vòng đời của pool thanh khoản, tiêm các logic tùy ý vào các điểm quan trọng như swap, thêm/bớt thanh khoản, khởi tạo, v.v.
Một số thay đổi quan trọng của v4 như sau:
- Chế độ Singleton: Trạng thái của tất cả các pool được quản lý tập trung bởi một hợp đồng PoolManager duy nhất, không còn triển khai hợp đồng riêng biệt cho từng pool
- Flash accounting (Kế toán chớp nhoáng): Thay đổi số dư trung gian trong quá trình giao dịch chỉ được ghi nhận trong bộ nhớ tạm thời (transient storage), và chỉ được quyết toán đồng loạt khi giao dịch kết thúc
- Cơ chế Hook: Mỗi pool có thể liên kết với một hợp đồng Hook, PoolManager sẽ gọi lại hợp đồng này tại các điểm mấu chốt (beforeInitialize, beforeSwap, afterAddLiquidity, v.v.)
- Hook không thể thay thế: Một khi pool được khởi tạo xong, địa chỉ Hook được liên kết sẽ cố định vĩnh viễn (Địa chỉ Hook được liên kết với pool không thể sửa đổi, nhưng bản thân hợp đồng Hook có thể nâng cấp hay không phụ thuộc vào cách triển khai của nó)
Trong thời kỳ v3, nhà phát triển chỉ cần tin tưởng vào chính giao thức Uniswap; còn trong thời kỳ v4, tính bảo mật của mỗi pool phụ thuộc vào Hook mà nó liên kết. Hook biến AMM từ một nguyên thủy tài chính cố định, thành một cơ sở hạ tầng tài chính có thể lập trình, nhưng mô hình bảo mật cũng bị phân mảnh từ cấp "giao thức" xuống cấp "pool".
2. Kiến trúc Hook
2.1 PoolManager và mô hình unlock/callback
Hợp đồng cốt lõi của v4 là PoolManager đơn thể. Bất kỳ thao tác nào thay đổi trạng thái pool (swap, thêm/bớt thanh khoản) đều phải gọi PoolManager.unlock() trước, nhận quyền gọi lại một lần, sau đó thực hiện hành động cụ thể trong unlockCallback(). Khi toàn bộ quy trình kết thúc, PoolManager sẽ xác minh xem sổ sách có cân bằng hay không:
Khi NonzeroDeltaCount != 0 sẽ revert trực tiếp, đây là ràng buộc cốt lõi của flash accounting trong v4. Bất kỳ Hook nào trong quá trình thực thi cũng có thể tạm thời làm mất cân bằng sổ sách, nhưng trước khi giao dịch kết thúc phải tự settle, nếu không toàn bộ giao dịch sẽ rollback.
Mỗi pool được xác định duy nhất bởi cấu trúc PoolKey, trong đó có trường hooks:
PoolId được tính bằng keccak256(PoolKey), do đó địa chỉ hooks khác nhau sẽ tạo ra pool khác nhau. Đồng thời, điều này có nghĩa là PoolManager sẽ không xác minh xem một địa chỉ Hook có từng được sử dụng cho pool khác hay chưa, cùng một hợp đồng Hook có thể được nhiều pool liên kết đồng thời.
2.2 Quyền hạn Hook được mã hóa trong địa chỉ
Một thiết kế trái với trực giác của v4 là: Quyền hạn của Hook không được quyết định bởi một biến nào đó bên trong hợp đồng, mà được quyết định bởi địa chỉ triển khai hợp đồng Hook.
PoolManager kiểm tra 14 bit thấp của địa chỉ Hook để xác định xem Hook đó có cần được gọi tại một điểm nào đó trong vòng đời hay không:
Ví dụ BEFORE_SWAP_FLAG = 1 << 7. Nếu bit thứ 7 của địa chỉ Hook là 1, PoolManager sẽ gọi beforeSwap() của Hook đó trước khi swap; ngược lại, ngay cả khi hợp đồng Hook đã triển khai beforeSwap(), nó cũng sẽ không bao giờ được PoolManager gọi.
Điều này có nghĩa là khi triển khai Hook phải sử dụng CREATE2 + salt để tính toán địa chỉ, tạo ra một địa chỉ có các bit thấp hoàn toàn khớp với quyền hạn mục tiêu. Uniswap chính thức cung cấp công cụ HookMiner cho mục đích này:
Khi quyền hạn bit và việc triển khai hàm không khớp sẽ tạo ra hai loại vấn đề:
(1) Đã triển khai một hàm hook nào đó, nhưng địa chỉ chưa mã hóa bit quyền tương ứng——PoolManager sẽ không bao giờ gọi hàm đó, logic trở nên vô dụng
(2) Địa chỉ đã mã hóa một bit quyền nào đó, nhưng hook không triển khai hàm tương ứng——PoolManager khi gọi lại có thể xảy ra revert, gây DOS hoặc xác minh giá trị trả về thất bại, khiến thao tác liên quan không thể thực hiện.
Đồng thời, đây cũng là trở ngại tự nhiên cho việc nâng cấp Hook: Nếu Hook có thể nâng cấp thông qua proxy, địa chỉ triển khai không thay đổi khi nâng cấp, do đó sau khi nâng cấp chỉ có thể sửa đổi việc triển khai của các hàm hook hiện có, mà không thể thêm loại hook mới. Để dự phòng khả năng mở rộng trong tương lai, bắt buộc phải "đào" trước tất cả các bit quyền có thể sử dụng ngay từ lần triển khai ban đầu.
2.3 BaseHook và một bẫy kiểm soát truy cập bị bỏ qua phổ biến
BaseHook là hợp đồng trừu tượng do phiên bản cũ của Uniswap v4 periphery cung cấp, nhà phát triển có thể kế thừa nó để triển khai Hook tùy chỉnh. Một tác dụng quan trọng của BaseHook là cung cấp bộ chỉnh sửa onlyPoolManager cho hàm unlockCallback():
Tuy nhiên – ở đây tồn tại một bẫy thiết kế rất dễ bị bỏ qua – BaseHook phiên bản đầu chỉ thêm onlyPoolManager cho unlockCallback, không có biện pháp bảo vệ nào đối với các hàm gọi lại hook khác (beforeSwap, afterSwap, beforeAddLiquidity, v.v.). Việc kiểm soát truy cập cho các hàm này phải được nhà phát triển Hook tự thêm vào một cách rõ ràng.
3. Đi qua mã vòng đời Hook
Lấy một lần swap exact-input làm ví dụ, dưới đây phân tích ngăn xếp gọi đầy đủ từ khi người dùng khởi tạo giao dịch đến khi quyết toán.
3.1 Khởi tạo pool và liên kết Hook
Bất kỳ ai cũng có thể gọi PoolManager.initialize() để tạo pool mới:
isValidHookAddress chỉ kiểm tra tính tương thích giữa bit quyền của địa chỉ và trường fee, không kiểm tra xem Hook đã được sử dụng trong pool khác hay chưa, cũng không kiểm tra Hook đó có "muốn" chấp nhận PoolKey này hay không. Nếu khi thiết kế Hook không thêm logic danh sách trắng hoặc liên kết đơn pool trong beforeInitialize, bất kỳ ai cũng có thể tạo một pool mới sử dụng cùng Hook nhưng với cặp token bất kỳ và kích hoạt tất cả các lần gọi lại tiếp theo của Hook.
3.2 beforeSwap và BeforeSwapDelta
Lối vào quy trình swap là PoolManager.swap(), nó sẽ gọi Hooks.beforeSwap() trước khi thực thi logic swap cốt lõi:
Giá trị trả về của beforeSwap là một bộ ba (bytes4, BeforeSwapDelta, uint24):
- bytes4: Phải bằng IHooks.beforeSwap.selector, nếu không PoolManager sẽ revert trực tiếp
- BeforeSwapDelta: Điều chỉnh delta của Hook đối với specified token và unspecified token trong lần swap này
- uint24: Giá trị ghi đè phí LP động (chỉ có hiệu lực khi pool bật phí động)
BeforeSwapDelta là bí danh của int256, 128 bit cao là delta của specified token (tức token mà người dùng chỉ định số lượng), 128 bit thấp là delta của unspecified token:
Cần lưu ý, ngữ nghĩa của BeforeSwapDelta là Hook thu phí nên trả về giá trị dương, Hook hoàn trả token nên trả về giá trị âm. Nhà phát triển rất dễ nhầm lẫn dấu; đồng thời, quan hệ tương ứng giữa specified và unspecified phụ thuộc vào params.zeroForOne và dấu của amountSpecified, chỉ cần viết sai một chút sẽ xảy ra lỗi vị trí token.
PoolManager sẽ cộng trực tiếp specifiedDelta được trả về từ beforeSwap vào amountToSwap:
Dòng này ẩn chứa một ngữ nghĩa then chốt: Hook có thể giữ lại số lượng swap. Khi hookDeltaSpecified bằng -params.amountSpecified, amountToSwap trực tiếp về 0, tương đương với việc Hook hoàn toàn tiếp quản swap này——đây chính là Async Hook hay Custom Curve Hook được nhắc đến.
Async Hook là một trong những mô hình thiết kế rủi ro cao nhất trong v4: về bản chất, nó sử dụng logic riêng của Hook để thay thế logic swap của Uniswap. Nếu Hook tồn tại lỗ hổng hoặc bản thân nó là độc hại, tiền của người dùng sẽ không còn bị ràng buộc bởi logic định giá gốc của Uniswap, mà chủ yếu phụ thuộc vào tính chính xác của việc triển khai Hook.
3.3 Quyết toán Delta và NonzeroDeltaCount
Delta được trả về bởi beforeSwap và afterSwap sẽ không ngay lập tức kích hoạt chuyển khoản, mà được ghi lại vào sổ sách nội bộ của PoolManager:
Mỗi khi tổng delta tích lũy của một token chuyển từ 0 sang khác 0, NonzeroDeltaCount tăng lên; khi chuyển về 0 thì giảm xuống. Như đã nói ở 2.1, nếu khi unlock() kết thúc mà NonzeroDeltaCount != 0, toàn bộ giao dịch sẽ revert.
Hook thông qua hai hành động settle() (chuyển khoản cho PoolManager) và take() (lấy từ PoolManager) để cân bằng delta của mình:
Cơ chế này mang lại ngữ nghĩa bảo mật rõ ràng: Cuối cùng tất cả mọi người đều phải cân bằng sổ sách. Nhưng nó chỉ đảm bảo "bảo toàn sổ sách", không đảm bảo "sổ sách chính xác". Nếu Hook trả về một delta bị cấu tạo độc hại trong beforeSwap, PoolManager sẽ trung thành ghi sổ theo delta này, chỉ cần cuối cùng được settle cân bằng, giao dịch sẽ thành công – ngay cả khi điều này có nghĩa là Hook có thể thông qua việc giả mạo trạng thái nghiệp vụ, khiến hệ thống nhận định sai rằng kẻ tấn công có một số quyền lợi tài sản nào đó, mà PoolManager không thể nhận ra sai lầm ở cấp độ nghiệp vụ này.
Sự kiện bảo mật trước đây của Cork Protocol là do Hook của nó tồn tại lỗ hổng, và trước khi bị tấn công, nó đã được 4 công ty kiểm toán thẩm tra. Sau khi phân tích lại, chúng tôi phát hiện:
- Trong 4 công ty kiểm toán, có 3 công ty có phạm vi kiểm tra không bao gồm hợp đồng CorkHook
- Công ty duy nhất kiểm tra CorkHook đã xác định được một số vấn đề mã và đưa ra đề xuất cải tiến, nhưng chưa bao phủ hoàn toàn vấn đề kiểm soát truy cập
- Một công ty kiểm tra khác đã nêu rõ trong báo cáo: "một sự tham gia thú vị tiếp theo sẽ là chứng minh các bất biến cho các hàm CorkHook đang được các thành phần khác nhau xác minh trong phạm vi của sự tham gia này". Từ góc độ phân tích lại sự việc, đề xuất này có tính mục tiêu khá cao.
Điều này phơi bày một điểm mù kiểm tra mới trong thời đại Hook v4: Độ phức tạp của giao thức tăng theo cấp số nhân khiến việc xác định phạm vi trở thành một quyết định bảo mật. Đường dẫn tương tác giữa Hook và các hợp đồng khác của giao thức rất dài, việc kiểm tra riêng hợp đồng Hook là không đủ để phát hiện các vấn đề kết hợp xuyên hợp đồng; ngược lại, kiểm tra các hợp đồng xung quanh và bỏ Hook ra ngoài phạm vi, sẽ bỏ lỡ mặt tấn công lớn nhất trong thời đại v4.
4. Suy ngẫm
Nhìn lại cơ chế giao thức và phân tích lại cuộc tấn công Cork, có thể tổng kết một số điểm cốt lõi của mô hình bảo mật Hook v4:
(1) Nếu hàm gọi lại Hook phụ thuộc vào ngữ cảnh gọi do PoolManager cung cấp, nên hạn chế rõ ràng chỉ được gọi bởi PoolManager. BaseHook sẽ không làm việc này thay cho nhà phát triển, đây là bẫy thiết kế dễ gây xung đột nhất với kinh nghiệm kiểm tra hợp đồng thông thường trong v4
(2) Mối quan hệ liên kết giữa Hook và pool không bị PoolManager hạn chế. Nhà phát triển phải tự triển khai logic danh sách trắng pool hoặc liên kết đơn pool trong beforeInitialize
(3) Bit quyền của địa chỉ Hook phải hoàn toàn khớp với việc triển khai hàm. Địa chỉ được tính toán nên bao gồm trước tất cả các bit quyền có thể mở rộng trong tương lai
(4) Async / Custom Curve Hook về bản chất là việc triển khai swap hoàn toàn tùy chỉnh. Nó không có bất kỳ sự bảo vệ nào ở cấp độ giao thức Uniswap, phải được kiểm tra theo tiêu chuẩn "hợp đồng tài chính hoàn toàn tự trị"
(5) "Bảo toàn" của kế toán Delta không bằng "chính xác". NonzeroDeltaCount == 0 chỉ có thể đảm bảo sổ sách cuối cùng cân bằng, không thể đảm bảo nội dung sổ sách không bị thao túng độc hại
(6) Sự nhầm lẫn loại token xuyên thị trường là mặt tấn công mới trong thời đại v4. Khi giao thức cho phép người dùng tạo thị trường, việc xác minh ngữ nghĩa của token là bắt buộc, không thể chỉ dựa vào kiểm tra giao diện
Mỗi Hook là một miền tin cậy độc lập, tính bảo mật của mỗi pool được quyết định bởi Hook mà nó liên kết. Do đó, độ phức tạp của việc kiểm tra bảo mật Hook không còn là "kiểm tra một đoạn mã", mà là "kiểm tra một giao thức con hoàn chỉnh" – sự thay đổi này đối với dự án và bên kiểm tra đều có nghĩa là nâng cấp phương pháp luận.
Xem bài gốc



















