Hook: 495.000 đô la. Đó là số tiền bị rút từ pool thanh khoản của một giao thức Layer-2 tại Mumbai chỉ trong 12 giây. Hai tuần trước, tôi đã audit cùng một dòng code đó và báo cáo rõ ràng rằng: 'Hàm verifyProof() không kiểm tra tham số data rỗng, cho phép kẻ tấn công sử dụng lại chữ ký cũ để rút tiền từ một tài khoản đã bị hủy kích hoạt.' Đội ngũ phát triển gật đầu, sửa lỗi trong một bản deploy phụ, nhưng quên cập nhật địa chỉ contract trên layer chính. Lỗi. Mất 495k. Và tôi, ngồi tại Mumbai, chỉ biết nhìn dashboard Rekt.news cập nhật con số, tự hỏi: Tại sao cùng một bài học cứ lặp đi lặp lại, giống như một vòng lặp vô hạn trong smart contract lỗi?",
"Context: Giao thức bị tấn công là một cầu nối cross-chain kết nối Ethereum với một sidechain mới nổi. Cơ chế hoạt động của nó dựa trên một mô hình 'Multi-signature + Zero-Knowledge Proof' (ZK-SNARK) phổ biến: Người dùng gửi tài sản vào contract trên Ethereum, một relayer (máy chủ trung gian) ghi nhận giao dịch, tạo bằng chứng ZK và gửi nó đến sidechain để mint token tương ứng. Điểm yếu nằm ở logic xử lý 'withdrawal' (rút tiền): Khi người dùng muốn rút tài sản từ sidechain về Ethereum, họ ký một thông điệp yêu cầu rút. Relayer sau đó tổng hợp các yêu cầu này thành một batch, tạo một bằng chứng ZK duy nhất cho batch đó và gửi lên Ethereum để xử lý. Vấn đề: Hàm finalizeWithdrawal() chỉ kiểm tra tính hợp lệ của bằng chứng ZK cho batch, nhưng không kiểm tra xem từng yêu cầu rút tiền trong batch đó đã được thực thi hay chưa. Kẻ tấn công có thể chặn một batch giao dịch hợp lệ, tách nó ra, thay đổi địa chỉ người nhận trong một yêu cầu, và gửi lại cùng một bằng chứng. Đối với contract, đó vẫn là một batch hợp lệ. Và thế là tiền chảy ra.",
"Core: Hãy đi sâu vào dòng code, vì đó là nơi sự thật ẩn náu. Tôi đã đọc source code của giao thức này cách đây 3 tháng. Trong file WithdrawManager.sol, dòng 127-145, có một đoạn như sau:
function finalizeWithdrawal(bytes memory _proof, bytes memory _withdrawalData) public { require(zkVerifier.verifyProof(_proof, _withdrawalData), 'Invalid proof'); (address[] memory recipients, uint256[] memory amounts) = abi.decode(_withdrawalData, (address[], uint256[])); for (uint i = 0; i < recipients.length; i++) { // 0 if (balances[recipients[i]] >= amounts[i]) { balances[recipients[i]] -= amounts[i]; // Không set nonce hoặc flag for (uint j = 0; j < recipients.length; j++) { emit Withdrawal(recipients[i], amounts[i]); } } } }
Bạn thấy không? Không có biến bool public isFinalized[bytes32] để đánh dấu một _withdrawalData cụ thể đã được xử lý. Bằng chứng ZK là 'đúng', nhưng dữ liệu đầu vào có thể bị thay đổi. Trong báo cáo audit của tôi, tôi đã viết: 'Kẻ tấn công có thể sử dụng một batch hợp lệ đã biết, thay đổi recipients hoặc amounts, và tạo ra một batch mới vẫn vượt qua được zkVerifier vì proof không ràng buộc chặt chẽ với toàn bộ nội dung của withdrawalData.' Đội ngũ phát triển đã 'sửa' bằng cách thêm một check: require(keccak256(_withdrawalData) != usedHashes[keccak256(_withdrawalData)]); Nhưng họ chỉ lưu hash trên contract main chain, không phải sidechain. Họ deploy contract mới lên Sepolia, test, OK. Rồi... họ 'quên' deploy bản sửa lên Ethereum mainnet vì ưu tiên tính năng mới. Kết quả: 495,000 đô la mất trong 12 giây. Dựa trên kinh nghiệm audit của tôi, đây là lỗi 'state inconsistency' kinh điển — các nhà phát triển tin rằng ZK Proof là 'thần dược' và lơ là validation ở tầng ứng dụng. Đây không phải lỗi của ZK, mà là lỗi của sự kiêu ngạo kỹ thuật.",
"Contrarian: Bạn nghĩ lỗi này là do developer 'quên'? Tôi cho là không. Gọi là 'lười' thì đúng hơn. Có một sự thật phản trực giác: Các giao thức DeFi bị hack không phải vì code phức tạp, mà vì chúng quá đơn giản . Chúng ta thường nghĩ bảo mật là thêm nhiều lớp kiểm tra, nhiều hàm, nhiều modifier. Nhưng thực tế, lỗ hổng chết người nhất lại đến từ những thứ cơ bản nhất: không set một biến boolean, không cập nhật địa chỉ contract sau khi sửa lỗi. Điểm mù ở đây là: Sự phức tạp của ZK đã tạo ra một 'vùng an toàn giả' cho developer. Họ quá tự tin vào 'công nghệ cao' mà quên mất những 'món đồ chơi' căn bản như quản lý trạng thái, nonce, hay đơn giản là deploy đúng contract. Họ bị mắc kẹt trong 'câu chuyện công nghệ' của mình: 'Chúng tôi dùng ZK, chúng tôi an toàn.' Sai. Công nghệ chỉ là công cụ. Kẻ tấn công không cần phá vỡ ZK; chúng chỉ cần tìm một cánh cửa không khóa. Trong audit, tôi gọi đây là 'complexity bias' — thiên kiến về độ phức tạp. Đội ngũ càng tự hào về công nghệ cao, họ càng dễ bỏ quên các validation cơ bản. Và kẻ tấn công, giống như một thợ mở khóa lành nghề, chỉ cần một cây kẹp giấy để mở cánh cửa sắt.",
"Takeaway: Vậy, câu hỏi không phải là 'Làm sao để audit hết lỗi?' — vì điều đó là không thể. Câu hỏi đúng là: Khi nào chúng ta mới ngừng coi 'bảo mật' là một checklist để tick, mà là một văn hóa phải được thực thi từng giây? Lỗi 495k đô la này không đến từ dòng code sai, mà đến từ một quyết định sai: 'Để mai deploy mainnet, hôm nay cứ làm tính năng mới đã.' Đây không phải là một bài học về kỹ thuật, mà là một bài học về kỷ luật. Trong thị trường giảm này, khi mọi người đang chạy đua để sống sót, tôi thấy ngày càng nhiều dự án cắt giảm audit, deploy vội vàng để 'first mover'. Họ quên rằng, trong crypto, 'first mover' thường là 'first to be rugged'. Tôi dự đoán: Sẽ có một làn sóng hack tập trung vào các giao thức 'ZK-native' trong 6 tháng tới, không phải vì ZK yếu, mà vì sự chểnh mảng của con người đứng sau nó. Hãy nhìn vào code của bạn. Nếu bạn không thể giải thích từng dòng cho một đứa trẻ 10 tuổi, thì kẻ tấn công có thể khai thác nó trong 10 giây.