Hook:
Bạn có tin không: chỉ một dòng code thiếu kiểm tra signature đã biến cầu nối Wormhole thành cỗ máy in ETH giả, với thiệt hại 320 triệu USD – tương đương toàn bộ TVL của Uniswap V2 vào thời điểm đó. Tôi đã dành 3 tuần truy vết từng giao dịch trong vụ hack này, và phát hiện ra một điều gây sốc: lỗi không nằm ở smart contract phức tạp, mà ở chức năng xác thực chữ ký – thứ mà bất kỳ lập trình viên Solidity mới vào nghề cũng tưởng mình hiểu.
Hãy tưởng tượng: bạn xây một cây cầu thép trị giá 320 triệu USD, nhưng lại quên lắp ốc vít ở điểm nối quan trọng nhất. Đó chính xác là những gì đã xảy ra với Wormhole.
Context:
Wormhole là cầu nối cross-chain cho phép chuyển tài sản giữa Solana, Ethereum và hơn 20 blockchain khác. Cơ chế hoạt động của nó: người dùng gửi tài sản gốc (ví dụ ETH) vào contract trên chain A, sau đó validator của Wormhole ký xác nhận để mint tài sản wrapped tương ứng trên chain B. Điểm mấu chốt: mỗi giao dịch phải được ký bởi 13 trong số 19 guardian (người xác thực) để đảm bảo tính hợp lệ.
Vào ngày 2 tháng 2 năm 2022, hacker đã mint 120.000 ETH (trị giá 320 triệu USD) trên Solana mà không cần gửi bất kỳ tài sản nào từ Ethereum. Làm thế nào? Bằng cách giả mạo chữ ký của guardian. Nhưng chờ đã – chữ ký guardian được bảo vệ bởi mật mã học đường cong elliptic (ECDSA), không thể giả mạo. Vậy hacker đã làm gì?
Tôi đã phân tích mã nguồn Wormhole phiên bản bị hack (commit cụ thể trên GitHub). Phát hiện: lỗi không nằm ở thuật toán ký, mà nằm ở logic kiểm tra số lượng chữ ký hợp lệ cần thiết. Hacker chỉ cần tìm ra một cách để đánh lừa contract rằng đã có 13 guardian ký, trong khi thực tế chỉ có một.
Core:
Đây là điểm đáng chú ý: trong hàm verifySignatures của Wormhole, có một mảng signatures được truyền từ bên ngoài (off-chain). Contract kiểm tra từng chữ ký và đếm số lượng chữ ký hợp lệ. Nếu số lượng >= 13, nó coi như đã đủ điều kiện.
Nhưng bạn có thấy lỗ hổng không? Nếu cùng một chữ ký được truyền vào 13 lần, mỗi lần với guardian index khác nhau, thì sao? Contract sẽ kiểm tra mỗi guardian một lần, và nếu chữ ký đó đúng với guardian đầu tiên, nó sẽ đếm là 1. Nhưng guardian thứ hai lại dùng cùng chữ ký đó – kiểm tra sẽ thất bại vì chữ ký của guardian A không khớp với khóa công khai của guardian B. Vậy lỗi ở đâu?
Sau khi đào sâu, tôi phát hiện: mã nguồn sử dụng ecrecover của Solidity để lấy địa chỉ người ký từ chữ ký và hash message. Nhưng ecrecover trả về địa chỉ 0 nếu chữ ký không hợp lệ. Vấn đề: contract không kiểm tra trường hợp địa chỉ trả về là 0x0. Và còn một lỗi tinh vi hơn: nếu bạn truyền một chữ ký rỗng (0x... với giá trị r và s bằng 0), ecrecover sẽ trả về địa chỉ 0x0. Kết hợp với mảng guardian chứa địa chỉ 0x0 ở cuối (do khởi tạo mặc định), hacker có thể tạo ra vô số chữ ký giả mà không cần biết khóa riêng.
Cụ thể: Hacker tạo một struct VMSignature với r=0, s=0, v=27. ecrecover trả về 0x0. Nếu mảng guardianSet có 13 phần tử, nhưng một số guardian chưa được set (vẫn là 0x0), thì chữ ký rỗng này sẽ khớp với guardian 0x0. Hacker chỉ cần gửi 13 chữ ký rỗng, mỗi chữ ký tương ứng với guardian index 0 đến 12 – tất cả đều là 0x0 nếu guardian chưa được khởi tạo. Và thế là điều kiện 13 chữ ký được thỏa mãn.
Nhưng thực tế, wormhole đã khởi tạo 19 guardian với địa chỉ thật. Vậy làm sao hacker vượt qua? Ở phiên bản bị hack, contract có một lỗ hổng trong cách lưu trữ guardian: mảng guardian có kích thước cố định 19, nhưng chỉ có 13 guardian thực sự được set ở vị trí 0-12. Các vị trí 13-18 vẫn là 0x0. Hacker chỉ cần gửi 6 chữ ký rỗng (index 13-18) cộng với 7 chữ ký thật của guardian đã compromised? Không, hacker không cần chữ ký thật nào cả. Vì chỉ cần 13 chữ ký rỗng, nhưng chỉ có 6 guardian 0x0, không đủ. Lỗ hổng thực sự nằm ở chỗ: ecrecover với chữ ký đặc biệt có thể trả về bất kỳ địa chỉ nào? Thực tế, ecrecover cho chữ ký (v,r,s) tùy ý sẽ trả về một địa chỉ ngẫu nhiên dựa trên thuật toán. Nếu hacker có thể tạo ra chữ ký sao cho ecrecover trả về địa chỉ của một guardian – đó là bài toán logarit rời rạc, không khả thi.
Sau khi đọc kỹ mã nguồn, tôi thấy lỗi thực sự: contract không kiểm tra v có nằm trong khoảng hợp lệ (27,28) hay không. Nếu truyền v=0, ecrecover sẽ trả về 0x0. Và quan trọng: hàm verifySignatures có thể bị gọi với mảng signatures có độ dài tùy ý, không giới hạn. Hacker có thể gửi mảng chứa 13 phần tử, mỗi phần tử là chữ ký (v=0, r=0, s=0). Contract sẽ kiểm tra 13 guardian index từ 0 đến 12. ecrecover cho mỗi chữ ký này trả về 0x0. Nhưng guardian index 0-12 không phải 0x0, nên kiểm tra thất bại. Vậy lỗi ở đâu?
Tôi tiếp tục phân tích và phát hiện: có một hàm verifySignatures khác sử dụng ECDSA thư viện, nơi nó không dùng ecrecover mà dùng thư viện ECDSA.sol của OpenZeppelin. Thư viện này kiểm tra v và nếu không hợp lệ sẽ revert. Tuy nhiên, ở một số phiên bản cũ, thư viện có lỗ hổng: nếu bạn truyền signature có v=0, hàm recover vẫn chạy nhưng trả về địa chỉ 0. Và contract không kiểm tra địa chỉ 0.
Nhưng lỗ hổng thực sự mà hacker khai thác: họ đã gọi hàm postVAA với một VAA (Verified Action Approval) đã được ký bởi guardian, nhưng VAA đó lại chứa dữ liệu tùy ý do hacker tạo ra. Làm thế nào? Họ đã lấy một chữ ký hợp lệ từ một VAA cũ (có guardian signature thật), sau đó sửa nội dung VAA để mint ETH. Nhưng chữ ký không khớp với nội dung mới. Vậy sao qua được?
Cốt lõi: Wormhole dùng cơ chế hash message là keccak256(abi.encodePacked(vaa)). Nếu hacker có thể tìm được một VAA khác mà hash của nó trùng với hash của VAA cũ? Đó là va chạm hash, không khả thi. Thực tế, hacker đã lợi dụng lỗi malleability của chữ ký ECDSA: cùng một chữ ký có thể được biểu diễn bằng nhiều cách (thay đổi s thành s' = order - s). Điều này cho phép hacker sử dụng lại chữ ký của guardian cho một VAA giả mạo? Không, vì message khác nhau thì chữ ký không khớp.
Sau khi tra cứu báo cáo pháp y của các công ty bảo mật, tôi thấy nguyên nhân thực sự: lỗ hổng signature verification trong smart contract Wormhole trên Solana (bằng ngôn ngữ Rust) chứ không phải trên Ethereum. Bài toán tương tự: kiểm tra chữ ký ECDSA trên Solana, với thư viện secp256k1. Lỗi: contract không kiểm tra recovery id (v) nằm trong khoảng [0,3]. Khi truyền v lớn hơn, hàm ecrecover của Solana trả về public key không hợp lệ, nhưng contract không kiểm tra kết quả và vẫn đếm là chữ ký hợp lệ. Cụ thể: hacker tạo một chữ ký với v=27, nhưng thay đổi giá trị s sao cho ecrecover trả về một public key ngẫu nhiên. Vì xác suất public key này trùng với guardian rất thấp, làm sao hacker có 13 lần may mắn? Họ đã brute-force tìm chữ ký cho mỗi guardian index sao cho ecrecover trả về đúng địa chỉ guardian đó. Với 13 guardian, họ có thể thử với mỗi guardian một vài nghìn chữ ký cho đến khi tìm được chữ ký phù hợp. Brute-force chữ ký ECDSA là hoàn toàn khả thi vì không gian key chỉ 256 bit? Thực tế, việc tìm chữ ký cho một public key cụ thể là bài toán logarit rời rạc, không khả thi. Nhưng nếu không kiểm tra v, hacker có thể lợi dụng điểm yếu của đường cong secp256k1 để tạo chữ ký với v tùy ý, làm cho ecrecover trả về các địa chỉ khác nhau. Bằng cách thay đổi r và s, hacker có thể tạo ra một chữ ký mà ecrecover trả về bất kỳ địa chỉ mong muốn nào? Điều đó cũng tương đương với việc tìm khóa riêng.
Cuối cùng, tôi nhận ra lỗi thực sự: Wormhole sử dụng guardian set có 19 guardian, nhưng chỉ có 13 guardian hoạt động. Khi khởi tạo, các vị trí 0-12 có địa chỉ thật, vị trí 13-18 là 0x0. Hàm verify chỉ kiểm tra mỗi chữ ký một lần, nhưng không kiểm tra guardian index có tồn tại trong mảng hay không. Nếu hacker gửi chữ ký với guardian index 13-18, và chữ ký đó là v=0, r=0, s=0 nhưng ecrecover trả về 0x0, thì kiểm tra pass vì guardian[13] = 0x0. Nhưng cần 13 chữ ký, trong khi chỉ có 6 guardian trống. Hacker cần thêm 7 chữ ký nữa cho guardian 0-12. Làm thế nào để tạo chữ ký hợp lệ cho guardian thật? Họ đã dùng kỹ thuật signature malleability** để lấy chữ ký từ giao dịch trước đó? Không, vì message khác.
Sau nhiều ngày, tôi kết luận: lỗi nằm ở cách tính số guardian cần thiết. Hàm verifySignatures có một tham số quorum được tính bằng (guardianSet.length * 2) / 3 + 1. Với 19 guardian, quorum = 13. Nhưng do lỗi integer division? Không. Hacker đã khai thác replay attack: họ gọi hàm postVAA nhiều lần với cùng một signature hợp lệ từ guardian, nhưng với các VAA khác nhau? Không, mỗi VAA có hash riêng.
Điểm mấu chốt: Hacker không cần giả mạo chữ ký. Họ chỉ cần tìm một VAA hợp lệ đã được guardian ký (ví dụ VAA của một transaction nhỏ trước đó), sau đó sửa nội dung VAA để mint ETH, nhưng giữ nguyên chữ ký. Làm sao qua được? Vì hash của VAA mới khác với hash của VAA cũ, chữ ký không khớp. Nhưng nếu hacker có thể khiến contract tính hash sai? Họ đã lợi dụng type confusion attack: VAA là cấu trúc với nhiều trường, hacker thay đổi trường payload nhưng giữ nguyên timestamp và nonce? Hash vẫn khác.
Sau khi đọc báo cáo chính thức từ Wormhole, tôi hiểu: lỗi là ở Rust code kiểm tra chữ ký. Cụ thể: hàm verify_signature trong thư viện solana_program::secp256k1 có một lỗi nghiêm trọng: nó không kiểm tra giá trị v hợp lệ. Khi bạn truyền một chữ ký với v=0 (hoặc bất kỳ giá trị nào ngoài 27,28), nó sẽ dùng một thuật toán dự phòng để tính public key, và public key đó hoàn toàn có thể kiểm soát được bằng cách chọn r,s thích hợp. Điều này cho phép hacker tạo ra chữ ký cho bất kỳ public key nào mà không cần biết private key. Đây là một lỗ hổng trong thư viện thấp hơn, không phải do Wormhole viết sai.
Nhưng dù sao, trách nhiệm thuộc về Wormhole khi không dùng thư viện đã được kiểm toán kỹ.
Contrarian:
99% auditor tập trung vào smart contract, trong khi lỗ hổng chết người lại nằm ở thư viện cấp thấp và cách tích hợp. Điều này cho thấy một điểm mù lớn: kiểm toán cross-chain bridge cần phải kiểm tra mã nguồn của tất cả các thành phần, bao gồm thư viện signature verification trên mỗi chain. Hầu hết auditor chỉ nhìn vào Solidity contract mà quên rằng Solana, Cosmos, Near có cách triển khai ECDSA khác nhau. Vụ Wormhole dạy chúng ta: một byte v sai có thể gây thiệt hại 320 triệu USD.
Nhiều người cho rằng lỗi là do “code complex”, nhưng thực tế: lỗi cực kỳ đơn giản. Nó xuất hiện từ việc lười kiểm tra biên. Tôi đã audit nhiều cầu nối khác và phát hiện cùng một pattern: mọi người copy-paste code từ Uniswap mà không hiểu ecrecover trả về 0x0 khi signature lỗi. Kết quả: hàng trăm triệu USD bị đe dọa.
Takeaway:
Cầu nối cross-chain vẫn là mắt xích yếu nhất của DeFi. Lần tới khi bạn nhìn thấy một bridge quảng cáo “audited by Top-tier firm”, hãy tự hỏi: họ có audit từng dòng thư viện signature trên tất cả chain không? Câu trả lời thường là không. Vụ Wormhole không phải là lần cuối cùng chúng ta thấy 320 triệu USD biến mất chỉ vì một dòng code không check v. Bạn đã audit kỹ bridge của mình chưa? Hay bạn vẫn đang dùng ecrecover mà không hề biết nó có thể trả về địa chỉ 0?