Lỗi KAWPOW của Ravencoin: header khối tự chọn cách kiểm tra proof of work của chính nó
Trong tuần thứ hai của tháng 8, Ravencoin đã phải quay ngược chuỗi của mình khoảng ba ngày. Nguyên nhân không phải là một nhóm thợ đào cấu kết, một lỗ hổng ví hay một hàm băm bị phá. Đó là một trường duy nhất trong header khối mà node tin tưởng để biết nên chạy cách kiểm tra proof of work nào trong hai cách. Mọi đoạn code được trích dưới đây đều được đọc từ chính mã nguồn đồng thuận của Ravencoin chứ không phải từ một thông báo, và ở chỗ nào các thông báo nói điều mà mã nguồn không cho thấy, bài viết này sẽ nói rõ như vậy thay vì đoán.
Header KAWPOW chứa những gì
KAWPOW là proof of work của Ravencoin, một biến thể ProgPoW mà chuỗi chuyển sang vào tháng 5/2020 để giữ coin này trên các GPU phổ thông. Việc chuyển đổi đó đã thay đổi header khối. Trước thời điểm kích hoạt, header kết thúc bằng nonce 32 bit thông thường mà mọi chuỗi dẫn xuất từ Bitcoin đều dùng. Sau thời điểm đó, bộ tuần tự hóa ghi ba trường vào vị trí ấy, theo thứ tự: nHeight, rồi nNonce64, rồi mix_hash.
Hai trong số đó không có gì đáng chú ý. nNonce64 đơn giản là một nonce rộng hơn. mix_hash là kết quả trung gian của phần memory hard trong thuật toán, được mang trong header để bên xác minh có một giá trị để đối chiếu thay vì một phép tính phải làm lại từ đầu. Trường thứ ba mới là trường đáng chú ý. nHeight là việc khối tự khai, ngay trong header của nó, độ cao mà nó cho rằng mình đang ở.
Vị trí thật của một khối trong chuỗi không phải là chuyện ý kiến. Nó được cố định bởi hashPrevBlock: bạn xây trên khối nào thì khối đó quyết định bạn ở đâu, và bạn không thể nói dối về điều đó mà vẫn gắn được vào đỉnh chuỗi. nHeight là một bản sao thứ hai, ghi được, của cùng sự thật đó, và một bản sao của sự thật chỉ có giá trị bằng đúng phép kiểm tra đối chiếu nó với bản gốc.
Hai hàm băm, một trong số đó không xác minh gì cả
Tệp hash.cpp của Ravencoin định nghĩa hai điểm vào KAWPOW, và chữ ký của chúng đã kể gần hết câu chuyện trước khi bạn đọc một dòng nào trong phần thân của hàm nào. Hàm thứ nhất là uint256 KAWPOWHash(const CBlockHeader& blockHeader, uint256& mix_hash). Hàm thứ hai là uint256 KAWPOWHash_OnlyMix(const CBlockHeader& blockHeader).
Hàm thứ nhất nhận mix_hash làm tham số đầu ra, vì nó tạo ra một giá trị như vậy. Nó chọn dataset bằng ethash::get_epoch_number(blockHeader.nHeight), dựng epoch context đó nếu chưa có, rồi chạy progpow::hash trên nó. Giá trị mix mà nó trả về là sản phẩm phụ của việc đã thực sự làm công việc. Đây là đường memory hard, và tính memory hard chính là toàn bộ lý do KAWPOW tồn tại thay vì một hàm băm đơn giản.
Hàm thứ hai không nhận tham số đó, vì nó không tạo ra gì cả. Nó dùng giá trị mix đã nằm sẵn trong header, thông qua progpow::hash_no_verify(blockHeader.nHeight, header_hash, to_hash256(blockHeader.mix_hash.GetHex()), blockHeader.nNonce64). Thư viện gốc đặt tên hàm đó là hash_no_verify, và cái tên ấy chính xác. Nó thực hiện bước trộn cuối cùng trên một giá trị được đưa cho nó và trả về kết quả. Nó không bao giờ dựng dataset, nên không bao giờ xác lập được rằng giá trị được đưa cho nó chỉ có thể đến từ việc thực sự làm công việc.
Không hàm nào là sai lầm. Hàm rẻ tồn tại vì một lý do chính đáng: một node đang đồng bộ hàng triệu khối cũ mà một checkpoint viết cứng đã chốt thì không nên phải dựng lại một dataset nhiều gigabyte cho mỗi epoch chỉ để xác nhận lại lịch sử mà không ai tranh cãi. Câu hỏi duy nhất mà một thiết kế như vậy phải trả lời là những khối nào được phép đi lối đó.
Cửa chắn, và thứ nó đã tin
Câu hỏi đó được trả lời bởi CheckBlockHeader trong validation.cpp, và dòng chú thích nằm ngay trên câu trả lời nói rõ ý định: "If we are checking a KAWPOW block below a know checkpoint height. We can validate the proof of work using the mix_hash". Ý định đó đúng. Phần cài đặt lại chặn theo block.nHeight <= (uint32_t)pcheckpoint->nHeight, và block.nHeight là con số mà khối tự ghi về chính nó.
Dòng bên dưới gọi hàm chung CheckProofOfWork(uint256 hash, unsigned int nBits, const Consensus::Params&). Hãy nhìn xem chữ ký đó không chứa gì. Không có mix, không có nonce và không có header. Nó nhận một giá trị băm đã hoàn tất cùng một mục tiêu rồi so sánh hai thứ, và nó không có cách nào biết hàm nào trong hai hàm đã tạo ra con số nó nhận được. Với một khối KAWPOW, CBlockHeader::GetHash() trả về KAWPOWHash_OnlyMix(*this), nên con số nó nhận được là con số rẻ.
Ghép lại từ đầu đến cuối, một khối khai báo nHeight đủ thấp sẽ được xác thực chỉ bằng cách hỏi xem giá trị suy ra từ chính mix hash của nó có nằm dưới mục tiêu hay không. Không ở đâu trên đường đó có thứ gì hỏi giá trị mix đến từ đâu, và không ở đâu trên đường đó có thứ gì so sánh độ cao được khai báo với vị trí mà khối thực sự chiếm.
Thấp đến mức nào là đủ thấp hóa ra chẳng có gì tinh vi. Checkpoint cao nhất trong tham số chuỗi mainnet đã phát hành là 2.383.550. Khối giả mạo đầu tiên nằm ở 4.487.776. Header của nó phải khai một vị trí lùi khoảng 2,1 triệu khối so với vị trí thật, và nhánh kiểm tra mà nó nhờ đó chọn được chưa bao giờ nhìn vào khoảng chênh lệch ấy.
Đường tắt mua được gì và không mua được gì
Ở đây cần nói thật chính xác, vì phần lớn các bài đưa tin đã nói khá lỏng về điểm này, và phiên bản nói lỏng khiến lỗi nghe vừa đơn giản hơn vừa kỳ lạ hơn thực tế.
Đường tắt không hạ độ khó. nBits được xác thực theo yêu cầu của khối trước thật, và khối trước thật được chọn bởi hashPrevBlock, thứ mà kẻ tấn công không thể làm giả mà vẫn đặt được khối lên đỉnh chuỗi. Một khối giả mạo phải vượt đúng cùng mục tiêu như một khối trung thực được đào trong cùng phút đó.
Điều thay đổi là cái giá của một lần đoán. Một thợ đào trung thực trả cho mỗi lần thử bằng một lượt duyệt đầy đủ qua một dataset tính bằng gigabyte, đó là toàn bộ ý nghĩa của một thuật toán memory hard và là lý do KAWPOW đã giữ ASIC ngoài Ravencoin suốt sáu năm. Đường tắt chỉ tốn một lần băm header cộng một bước trộn cuối trên một giá trị mà kẻ tấn công tự do chọn. Cùng một cuộc xổ số, cùng tỷ lệ trúng cho mỗi vé, nhưng vé rẻ hơn nhiều bậc độ lớn. 2Miners mô tả kết quả là những khối gần như không tốn công, và so với cái giá mà KAWPOW lẽ ra phải có, đó là một mô tả công bằng.
Điều gì đã xảy ra trên mạng
Khối không hợp lệ đầu tiên được biết đến là ở độ cao 4.487.776, với dấu thời gian 2026-08-07 15:44:01 UTC. Việc khai thác lỗi tiếp diễn cho đến bản phát hành khẩn cấp ngày 10 tháng 8. Trong một đoạn 2.089 khối được xem xét, 2Miners đếm được 96 khối bị ảnh hưởng, nên các khối giả mạo nằm rải rác khắp nhánh chứ không tụ lại thành một dãy liền, và đó là lý do biện pháp khắc phục phải là một lần tái tổ chức chuỗi (reorg) thay vì gỡ bỏ chính xác từng khối.
Với người vận hành node, triệu chứng nhìn thấy được còn tệ hơn việc một khối không hợp lệ được chấp nhận một cách lặng lẽ. Các node bị sập trên những khối này, và sau khi khởi động lại thì ngừng đồng bộ. Tron Black sau đó đã mô tả cơ chế này, và đó là một ví dụ gọn gàng về thiệt hại kéo theo. Chỉ mục khối lưu độ cao mà khối thực sự nằm, không phải độ cao mà header của nó khai báo. Khi khởi động lại, node dựng lại từng header từ chỉ mục và tính lại giá trị băm, nên với một khối giả mạo, nó băm độ cao thật ở chỗ header từng mang một độ cao sai. Giá trị băm không còn khớp, phép kiểm tra proof of work thất bại, và LoadBlockIndexGuts dừng hẳn. Khối đã được chấp nhận một lần rồi trở nên không thể nạp được, và đó là lý do triệu chứng thứ hai kéo dài hơn triệu chứng thứ nhất.
Nhiều sàn giao dịch đã tạm ngừng nạp và rút RVN trong khi nhánh chưa được giải quyết. Đó là phản ứng bình thường và đúng đắn với một chuỗi mà lịch sử gần đây sắp bị viết lại, và bạn nên coi đó là điều có thể đoán trước thay vì đọc nó như một phán quyết về coin.
Bản vá, và nó đến từ đâu
Bản sửa đầu tiên được phát hành ngày 10 tháng 8 với phiên bản 4.6.1.1-hf1, và nó đến từ 2Miners, một pool đào coin, chứ không phải từ dự án Ravencoin. Nó cầm máu cho bất kỳ ai cài nó. Một ngày sau nó đã bị thay thế.
Phiên bản 4.8.0, được phát hành ngày 11 tháng 8 qua kho mã của Ravencoin, là bản bạn nên chạy. Đừng dừng lại ở bất kỳ bản nào cũ hơn. Nó từ chối các khối có độ cao khai báo trong header không khớp với vị trí thật của chúng kể từ 4.487.776 trở đi, đặt checkpoint tại 4.487.775 là khối sạch cuối cùng, tự động dựng lại trạng thái chuỗi bị hỏng thay vì dừng hẳn, và gộp vào một bản sửa lỗi tràn số khi chuyển asset mà bản hotfix khẩn cấp không có, cùng với các thay đổi khác từ upstream.
Chỉ điều đầu tiên trong số đó là phần sửa lỗi thật sự. Từ chối một khối có độ cao khai báo không khớp với vị trí nó thực sự nằm, và cửa chắn được đóng lại. Checkpoint và việc tự động dựng lại là phần khôi phục, và một checkpoint nói riêng là một công cụ thô: đó là một khẳng định viết cứng rằng một khối cụ thể là khối đúng, loại thứ mà một chuỗi muốn có càng ít càng tốt. Nó có mặt ở đây vì nhánh trung thực phải được làm cho bám chắc nhanh hơn mức mà riêng hashrate có thể làm được.
Bản thân điểm khôi phục là một quyết định cân nhắc chứ không phải điều hiển nhiên. Tron Black viết rằng ông đã đề nghị các pool cân nhắc một điểm cắt gần đây hơn, để lần reorg nông hơn và xử lý các khối giả mạo như ngoại lệ. Họ đã từ chối và chọn đường cắt sạch nhất có thể. Trong một hệ thống proof of work, tranh luận đó tự có lời giải: khi phần lớn hashrate trung thực từ chối một lịch sử, lịch sử đó thua, và hai pool lớn nhất cộng lại đã nắm phần lớn đó.
Một lần reorg ba ngày thực sự có nghĩa là gì
Vì các node có lỗ hổng đã chấp nhận các khối giả mạo, nhánh chứa chúng đã tích lũy thêm chuỗi thật ở phía trên. Bỏ nhánh đó đồng nghĩa với một lần reorg, và với thời gian khối mục tiêu một phút của Ravencoin, đoạn từ 4.487.776 đến bản vá là khoảng ba ngày khối.
Nếu bạn nắm giữ RVN, ý nghĩa thực tế là các giao dịch đã được xác nhận trên nhánh bị loại bỏ sẽ không còn được xác nhận nữa. Phần lớn chúng quay lại mempool và được đào lại trên chuỗi còn tồn tại, nhưng điều đó không được đảm bảo, và có một loại giao dịch hoàn toàn không thể quay lại. Một giao dịch tiêu coinbase của một khối bị loại bỏ là đang tiêu những coin không còn tồn tại trên chuỗi còn lại, và mọi giao dịch xây trên nó cũng vậy. Những giao dịch đó đơn giản là không hợp lệ chứ không phải đang chờ. Đó là lý do việc các sàn giao dịch tạm ngừng là quyết định đúng chứ không phải phản ứng thái quá, và vì sao mọi hoạt động sau khối 4.487.775 cần thận trọng cho đến khi chỉ còn một đỉnh chuỗi duy nhất được chốt.
Có một chi tiết đáng biết nếu bạn từng thấy node của mình không chịu di chuyển trong tuần đó. Node Ravencoin có một quy tắc chống reorg dài, nhằm ngăn một cuộc tấn công đa số bất ngờ viết lại lịch sử sâu. Phần lớn các node cũ chạy liên tục cuối cùng đã tụt xuống dưới số peer mà quy tắc đó dựa vào, rồi chấp nhận chuỗi dài hơn đúng như thiết kế. Một số thì không, vì chúng không bao giờ mất đủ peer và vì, theo các quy tắc như đã viết, không có gì bị vi phạm. Đó là một cơ chế bảo vệ hoạt động đúng như đặc tả trên một vấn đề mà nó chưa bao giờ được thiết kế để xử lý. Sự chia tách đến từ một lỗi xác thực, không phải từ một cuộc tấn công nhắm vào đa số, và quy tắc đó không thể phân biệt hai trường hợp.
Bạn nên làm gì nếu đào RVN
Nếu bạn tự chạy node Ravencoin, hãy chạy 4.8.0, và cụ thể là không chạy một trong những bản phát hành trước đó vốn chỉ xử lý một phần vấn đề này. Dừng node, thay tệp chạy, rồi khởi động lại. Không cần chạy reindex và không có bước thủ công nào: phần mềm tự dựng lại những gì nó cần. Một node đi lên từ bản hotfix khẩn cấp khởi động lại nhanh. Một node đi lên từ phần mềm chưa vá phải phát lại và dựng lại, mất vài giờ trên phần cứng thông thường, và các lỗi bad-blk-height trong suốt khoảng thời gian đó là bản vá đang hoạt động chứ không phải lỗi của nó.
Nếu bạn đào qua một pool, phía bạn không có gì phải thay đổi. Không địa chỉ mới, không cổng mới, không cần cập nhật phần mềm đào. Bản thân KAWPOW chưa bao giờ là vấn đề ở đây: thuật toán đã làm đúng những gì nó được thiết kế để làm, và lỗi nằm ở đoạn code quyết định khi nào chạy nó. Hashrate bạn đã hướng vào nhánh cũ là công việc thật. Nó chỉ rơi vào những khối bị loại bỏ cùng với các khối giả mạo nằm rải rác giữa chúng.
Có một cách chỉ một dòng để xác nhận bạn đã ở đúng chuỗi, và bạn nên làm thay vì mặc định là vậy. Hãy kiểm tra giá trị băm của khối 4.494.143. Trên chuỗi chính thức, nó kết thúc bằng 4c67d6a4, và các trình khám phá khối Ravencoin độc lập đều hiển thị cùng chuỗi đó khi dự án tuyên bố mạng đã phục hồi.
Hình dạng của lỗi
Gạt bỏ các chi tiết cụ thể, thứ còn lại là một hình mẫu lặp đi lặp lại liên tục, trong các chuỗi khối và cả ngoài chúng. Một giá trị đến từ bên ngoài đã được dùng để quyết định chính giá trị đó sẽ bị xem xét kỹ đến mức nào. Header tự khai độ cao của mình, và node dùng lời khai đó để chọn phép kiểm tra proof of work nào mà header sau đó phải vượt qua.
Phiên bản đúng không hề phức tạp, và bản vá về cơ bản chỉ là một dòng của nó: so sánh độ cao được khai báo với vị trí mà khối thực sự chiếm trước khi để nó quyết định bất cứ điều gì. Vì sao nó bị thiếu mới là nửa thú vị hơn. Đường tắt được viết cho những khối vốn đã được tin cậy, và bên trong giả định đó, cửa chắn đọc như một khẳng định sự thật chứ không phải một câu hỏi mà ai đó có thể trả lời không trung thực. Nó thôi đọc như vậy ngay khi một khối không đáng tin được phép điền vào trường đó, và không có gì trong code đánh dấu thời điểm điều đó thay đổi.
Có hai điều về cách lỗi được tìm ra đáng ghi lại. Ravencoin đã có các cuộc kiểm toán bảo mật chuyên nghiệp cho code của mình, và chúng không phát hiện ra lỗi này hay lỗi tràn số asset được sửa cùng lúc. Tron Black viết rằng ông rất nghi ngờ cả hai đều được phát hiện nhờ một đợt rà soát bảo mật bằng AI dùng mô hình tiên tiến nhất (frontier model). Dù công cụ là gì, điều đó khớp với hình dạng của lỗi: lỗ hổng vô hình khi bạn đọc đường tắt theo chính logic của nó, và chỉ lộ ra khi bạn hỏi ai được quyền điền vào trường mà đường tắt tin tưởng.
Điều cuối cùng đáng để ý là ai đã hành động, và theo thứ tự nào. Một pool đào coin đã đọc đường xác thực, hiểu nó, và phát hành một bản phần mềm khẩn cấp trong vòng ba ngày. Hai pool lớn nhất sau đó đồng ý đào một chuỗi bỏ đi toàn bộ khoảng thời gian bị tấn công thay vì chọn điểm cắt tiện lợi, và khi được đề nghị chọn điểm cắt nông hơn thì họ từ chối. Dự án theo sau một ngày với bản phát hành thay thế bản hotfix và sửa nhiều hơn nó. Ai đi trước không quan trọng bằng việc cả hai đều đã xảy ra, và thứ thực sự chốt lại mọi chuyện không phải thông báo nào mà là sức nặng tích lũy của công việc thật ở một phía của sự chia tách. Đó là hình ảnh của phi tập trung vào một ngày nó phải gánh sức nặng thật thay vì nằm yên trong một whitepaper.
Các con số này đến từ đâu
Để bất kỳ ai cũng có thể tự kiểm tra thay vì tin lời chúng tôi. Bố cục header, hai hàm băm và chữ ký của chúng, việc chọn epoch và nhánh xác thực cùng dòng chú thích của nó được đọc từ chính mã nguồn của Ravencoin, trong src/primitives/block.h, src/primitives/block.cpp, src/hash.cpp, src/hash.h, src/pow.h và src/validation.cpp. Danh sách checkpoint, gồm cả 2.383.550 là checkpoint cao nhất trên mainnet, được đọc từ src/chainparams.cpp. Độ cao không hợp lệ đầu tiên và dấu thời gian của nó lấy từ thông báo mạng của chính dự án Ravencoin. Đường gây sập chỉ mục khối, các giao dịch phụ thuộc coinbase không thể quay lại, hành vi chống reorg dài, điểm khôi phục nông hơn bị từ chối, các nhận xét về kiểm toán và rà soát bằng AI, nội dung của 4.8.0 và phép kiểm tra khối 4.494.143 lấy từ tường thuật của Tron Black cho dự án Ravencoin. Phiên bản hotfix khẩn cấp và con số 96 khối bị ảnh hưởng trong 2.089 khối lấy từ bản phát hành của 2Miners và các bài đưa tin xung quanh nó. Những gì các nguồn không nêu đã được bỏ ra khỏi bài viết này thay vì được tự điền vào.