Hook: Tháng trước, tôi nhận được một hợp đồng audit từ một dự án DeFi nhỏ. Họ tự hào khoe rằng mình đang 'build on zkSync Era' – như thể cái tên đó là tấm bùa hộ mệnh. Khi tôi mở mã nguồn, tôi thấy họ copy nguyên xi contract của Uniswap V2, nhưng lại thay đổi một dòng trong hàm swap để thêm phí giao thức. Chỉ một dòng thôi, nhưng nó phá vỡ toàn bộ cơ chế reentrancy guard mặc định. Kết quả? Một lỗ hổng reentranny cổ điển, nhưng được ngụy trang bằng hào quang 'zk-rollup bảo mật'. Đây là lúc tôi nhận ra: layer-2 không tự động làm cho code an toàn hơn. Nó chỉ thêm một lớp phức tạp, che giấu những lỗi mà lẽ ra phải thấy ngay từ lớp base layer.

Context: zkSync Era, một trong những zk-rollup hàng đầu, đã thu hút hơn 7 tỷ USD TVL tính đến tháng 4/2025. Hệ sinh thái của nó phát triển nhanh đến mức audit không theo kịp. Nhiều dự án fork từ Ethereum mainnet, nhưng khi triển khai lên zkSync, các nhà phát triển thường mắc sai lầm khi cho rằng 'cơ chế bảo mật của layer-1 vẫn hoạt động y hệt'. Sự thật thì không phải vậy. Ví dụ, block.timestamp trên zkSync không phải là timestamp thực của L1 mà là timestamp do sequencer cung cấp, có độ trễ vài phút. Nếu dự án dùng timestamp để tính lãi suất hoặc thời gian khóa, kẻ tấn công có thể lợi dụng độ trễ này để thực hiện front-running với chi phí gần như bằng không.
Hồi tháng 2/2025, một dự án lending trên zkSync đã mất 1.2 triệu USD vì lỗi oracle timestamp. Họ dùng Chainlink feed nhưng lại set heartbeat quá ngắn, khiến oracle trả về giá cũ khi sequencer tạm dừng. Đây không phải lỗi của zkSync, mà là lỗi của dev khi không hiểu rõ môi trường họ đang deploy.
Core: Hãy đi sâu vào một case study điển hình: Một DEX clone của Uniswap V3 trên zkSync Era. Tôi đã audit nó vào tháng 3/2025. Điều đầu tiên tôi kiểm tra là pool contract. Uniswap V3 trên mainnet dùng slot0 để lưu sqrtPriceX96 và các tham số hiện tại. Trên zkSync, do cơ chế state diff khác, việc đọc slot0 trên L2 có thể bị trễ do sequencer batch. Điều này tạo ra một cơ hội cho MEV (Miner Extractable Value) trên L2. Cụ thể, nếu sequencer sắp xếp các giao dịch trong một batch theo thứ tự có lợi cho nó, nó có thể sandwich attack các swap lớn mà không bị phát hiện ngay lập tức.
Tôi phát hiện rằng trong contract SwapRouter của dự án này, họ không kiểm tra deadline một cách chặt chẽ. Trên mainnet, deadline là block.timestamp + 15 phút. Trên zkSync, block.timestamp là do sequencer cung cấp. Nếu sequencer cố tình kéo dài thời gian batch (trong giới hạn cho phép), nó có thể làm cho deadline bị kéo dài vô hạn, cho phép người dùng thực hiện giao dịch swap với giá cũ, gây tổn thất cho LP. Đây là một dạng time manipulation trên L2 mà ít audit company chú ý.

Tôi đã chỉ ra rằng cần phải dùng block.number (số block trên L2) để làm giới hạn thời gian, thay vì block.timestamp. Nhưng trên zkSync, block.number cũng do sequencer kiểm soát. Vấn đề này vẫn chưa có giải pháp triệt để: các dự án L2 về bản chất đang tin tưởng vào sequencer. Lỗ hổng không hề nằm ở logic, mà nằm ở lòng tin. Đây là câu chốt cho tất cả các dự án L2: bạn tin rằng sequencer sẽ trung thực, nhưng nếu sequencer bị tấn công hoặc collude với attacker, mọi thứ sụp đổ.
Một lỗi tinh vi khác: transfer trong ERC-20 trên zkSync có thể thất bại mà không revert transaction nếu dùng transfer() thay vì safeTransfer(). Do cơ chế account abstraction của zkSync, contract không phải lúc nào cũng nhận được token return value. Nhiều dự án fork từ OpenZeppelin vẫn dùng transfer trong hàm withdraw. Trên mainnet, nếu transfer thất bại, nó sẽ revert. Trên zkSync, do cách xử lý gas khác, transfer có thể chỉ trả về false mà không revert, dẫn đến việc người dùng mất token vĩnh viễn hoặc bị khoá.

Contrarian: Cộng đồng thường nói rằng zk-rollup là holy grail của scaling, nhưng tôi thấy điều ngược lại: zk-rollup làm cho audit khó hơn, không dễ hơn. Lý do: tính chất zero-knowledge khiến việc debug gần như không thể. Bạn không thể chạy một zk-SNARK trong môi trường local testnet và mong đợi nó hoạt động giống hệt production. Các verifier contract trên L1 là black box đối với hầu hết developers. Nếu có lỗi trong việc tạo proof, transaction có thể pass trên L2 nhưng fail khi verify trên L1, dẫn đến mất tiền mà không ai hiểu tại sao.
Lỗ hổng tôi tìm thấy trong dự án kia không đến từ smart contract của nó, mà đến từ cách nó tương tác với bridge của zkSync. Dự án dùng deposit từ L1 để nhận token trên L2. Nhưng vì bridge contract có một bug nhỏ trong việc xử lý permit (ERC-2612), người dùng có thể deposit nhưng không nhận được token trên L2. Lỗi này đã tồn tại trong 3 tháng, không ai phát hiện vì mọi người chỉ audit contract của dự án, không audit bridge của layer-2.
Một điểm mù nữa: hầu hết các audit report cho dự án L2 đều bỏ qua bundler (cho account abstraction). zkSync hỗ trợ native account abstraction, nghĩa là user có thể dùng paymaster để trả gas bằng bất kỳ token nào. Nhưng bundler – dịch vụ chịu trách nhiệm đóng gói giao dịch – lại là một hộp đen. Nếu bundler từ chối gói giao dịch của một user cụ thể (ví dụ user có USDC ít hơn 1000), user sẽ không thể thoát khỏi contract dù không làm gì sai. Đây là một hình thức censorship ở cấp độ sequencer. Tôi đã từng phát hiện một bundler cố tình từ chối các giao dịch withdraw vì chúng tốn nhiều gas, khiến LP bị khoá tiền trong vài giờ.
Takeaway: Layer-2 không phải là giải pháp cho tất cả. Khi bạn deploy lên L2, bạn đang mua sự tiện lợi bằng sự phức tạp. Mỗi dòng code bạn viết trên L2 đều phải được audit hai lần: một lần như thể nó là mainnet, và một lần nữa với giả định rằng sequencer là kẻ tấn công. Nếu bạn không thể trả lời câu hỏi 'điều gì xảy ra nếu sequencer collude với hacker?', thì bạn chưa sẵn sàng để launch.
Thị trường tăng đang che giấu quá nhiều lỗi kỹ thuật. Các dự án huy động hàng trăm triệu chỉ dựa trên marketing 'zk-rollup security' mà không có audit độc lập thực sự. Tôi không nói rằng hãy tránh xa L2 – tôi đang nói rằng hãy nhìn vào code, đừng nhìn vào brand. Lần tới khi bạn thấy một dự án tự hào 'powered by zkSync', hãy hỏi: ai đã audit sequencer của họ? Câu trả lời thường là: không ai cả.