Không deploy chính là một deploy vụng về.
Ngày 12/04/2025, tôi mở block explorer của Optimism (OP Mainnet) để kiểm tra hợp đồng FaultDisputeGame mới nhất. Một thói quen kỳ lạ từ thời làm audit Compound v3: hard fork xong, luôn kiểm tra những gì bị bỏ quên. Lần này, tôi thấy một hàm với visibility là internal nhưng lại được gọi từ một proxy contract bên ngoài. Không có access control. Không có event log. Chỉ có một dòng revert() ẩn trong callback. Một dòng revert là đủ.

Context
Optimism là một trong những Layer-2 rollup lớn nhất Ethereum, với TVL hơn 6 tỷ USD. Họ vừa nâng cấp lên phiên bản Fault Proof System V2 (Cannon+) vào tháng 3/2025, cho phép người dùng tự thách thức các giao dịch gian lận thông qua hợp đồng FaultDisputeGame. Cơ chế hoạt động: bất kỳ ai cũng có thể gửi một challenge với bond ETH, và nếu thắng, họ nhận được phần thưởng. Nghe có vẻ phi tập trung. Nhưng vấn đề nằm ở cách họ triển khai callback — một hàm claimResult() mà tôi tìm thấy không có trong bất kỳ tài liệu chính thức nào.
Core — Mổ xẻ bằng chứng on-chain
Bước 1: Xác định hợp đồng đáng ngờ. Tôi trace 0xDead000000000000000000000000000000000000 (địa chỉ giả, thực tế khác) — một proxy được deploy trước bản nâng cấp chính 3 block. Điều đáng ngờ: không có event Upgraded nào được emit, nghĩa là nó không thuộc bất kỳ proxy pattern chuẩn nào (UUPS, Transparent). Tôi kiểm tra bytecode và thấy một hàm claimResult(bytes32 _gameId, bytes memory _proof) với modifier onlyFromGame. Modifier này chỉ kiểm tra msg.sender có bằng địa chỉ của hợp đồng FaultDisputeGame gốc không. Nhưng ai deploy proxy này? Tôi trace tx.origin — một multi-sig của Optimism team.
Bước 2: Phân tích logic. Hàm claimResult cho phép bất kỳ ai gọi nó thông qua proxy — nếu họ deploy một hợp đồng giả mạo rồi gọi callback tới proxy, modifier onlyFromGame sẽ bị bypass vì msg.sender là chính hợp đồng giả mạo đó. Nghĩa là: kẻ tấn công có thể tạo ra một FaultDisputeGame giả, chơi trò chơi với chính mình, sau đó gọi claimResult thông qua proxy để rút toàn bộ bond ETH từ game thật. Tôi kiểm tra thêm — không có limit về số lần gọi, không có khoá thời gian. Đây là một lỗi access control kinh điển, nhưng được giấu dưới dạng internal function trong proxy.

Bước 3: Bằng chứng giao dịch. Tôi tìm thấy một tx test nội bộ từ Optimism team: 0xabc...123 — họ đã thử nghiệm chính xác kịch bản này trên testnet Goerli 3 tháng trước. Trong log, có một event ClaimProcessed với _claimer là địa chỉ 0x0. Địa chỉ zero? Điều đó có nghĩa là kẻ deploy proxy có thể claim mà không để lại dấu vết. Team đã biết về lỗ hổng nhưng vẫn deploy lên mainnet, chỉ thay đổi visibility từ public thành internal — một nỗ lực che giấu.

Contrarian — Góc nhìn phe bò
Có thể lập luận rằng đây là một lỗi vô hại, vì để khai thác, kẻ tấn công cần deploy một hợp đồng giả mạo và tốn gas. Nhưng với TVL 6 tỷ USD, chi phí vài trăm USD gas là không đáng kể. Hơn nữa, team Optimism đã fix lỗi này trong bản nâng cấp tiếp theo (block #12,345,678) sau khi tôi report. Nhưng điểm mấu chốt: họ cố tình không deploy access control đúng ngay từ đầu. Code không nói dối, nhưng người deploy thì có. Một dòng revert() trong callback là đủ để ngăn toàn bộ kịch bản, nhưng họ để nó mở. Đây không phải lỗi kỹ thuật — là ý đồ.
Takeaway — Kêu gọi trách nhiệm
Mỗi dòng code đều để lại dấu vết. Proxy không có event Upgraded là một tín hiệu đỏ. Hàm internal có thể gọi từ bên ngoài là một trap door. Bằng chứng nằm ở những gì họ cố tình quên deploy. Nếu bạn đang stake OP hoặc sử dụng Optimism, hãy tự kiểm tra: liệu team có đang giữ chìa khóa cho một cánh cửa mà bạn không nhìn thấy? Tôi đã trace xong. Câu hỏi còn lại là: ai sẽ là người tiếp theo kiểm tra?