我如何偷走你的 Siacoin
一則「種子」味十足的 reddit 貼文
那天是 2017 年 6 月 9 日的夜晚。對我而言是個典型的週五夜晚。我當時正看著 Netflix、逛著 reddit和酷小孩們一起開趴。
突然,我在 /r/siacoin 子版的「New」分頁中看到了這則貼文:

如果你不熟悉 Siacoin,它是一種讓你能出租閒置硬碟空間或向他人購買空間的加密貨幣。我之前曾寫過幾篇關於這項技術的文章(挖礦指南、NAS 指南)。
這位 reddit 使用者剛剛做了一件非常危險的事。他們把自己的 Sia 錢包種子貼上了網路。Sia 的「種子」是一組密語,任何持有它的人都能完全掌控該錢包中的加密貨幣。對這位使用者來說,這意味著能掌控價值 €2,000 的 Siacoin,因為那是他們轉入該錢包的金額。在截圖中可以看到,該使用者認為這樣做是安全的,因為他們已經確認這組密語無法使用。
差一點就不算數(除了擲馬蹄鐵和 Sia 種子)
這則貼文立刻吸引我注意的是,使用者是用手寫下種子的:
我很確定我抄寫時沒有寫錯,我每次都會再三確認。
我倒是相當確定他們真的抄錯了。但我希望他們只抄錯了一處。如果使用者只是錯了一個字母,或只是有兩個字母對調,我大概就能推算出正確的種子並取回那 €2,000。
我必須盡快行動。我不是唯一能在看到外洩種子時認出來的人,所以我得在別人之前破解種子、搶先把錢拿走。
徒手駭入
我先從檢視那組錯誤種子中的單字開始:
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
我原本不熟悉 Sia 是如何產生種子的,但 Sia 是完全開源的,所以我想應該不難弄清楚。
果真不難。在 Sia 的 wallet.go 檔案中,我找到了一個 SeedToString 函式。這讓我找到了 GitHub 上的 entropy-mnemonics 專案,其中包含了所有可能種子單字的 這份字典:
englishDictionary = Dictionary{
"abbey",
"abducts",
"ability",
"ablaze",
"abnormal",
"abort",
"abrasive",
"absorb",
"abyss",
"academy",
"aces",
"aching",
"acidic",
"acoustic",
"acquire",
"across",
"actress",
...這份 entropy 字典裡只有約 1,600 個單字。我的希望是,使用者在抄寫種子時,不小心寫下了一個根本不在字典裡的字。這樣一來,只要我發現他們貼出的 29 個種子單字中有一個不在字典中,那顯然就是寫錯的那個字。接著我只要在字典中尋找與該錯字相似的單字,就能很快推算出正確的種子。
可惜的是,那組錯誤種子中的 29 個單字全都出現在 entropy 字典裡,所以用肉眼比對是行不通的。
暴力破解
是時候拿出重武器了(我指的是我用來打字的那兩根手指頭,就是我的「重武器」)。我需要一個方法,找出字典中所有與 reddit 上貼出的那組種子僅差一個抄寫錯誤的單字。
我發現 Levenshtein distance(萊文斯坦距離) 可以幫上忙。Levenshtein distance 是指要從一個單字變成另一個單字,需要新增、刪除或替換幾個字母。舉例來說,「cat」和「car」的 Levenshtein distance 是 1,因為只要把「t」替換成「r」就能從「cat」變成「car」。而「cat」和「scar」的距離則是 2,因為必須替換「t」並在前面加上「s」。
為了找出可能的種子,我可以寫一個腳本,找出 entropy 字典中與錯誤種子裡的單字 Levenshtein distance 為 1 的所有單字。
我先把字典下載到本地,並只保留 a 至 z 的字元:
$ wget -qO- https://raw.githubusercontent.com/NebulousLabs/entropy-mnemonics/master/english.go \
| egrep "^\s+\"(.+)\"," \
| egrep -o [a-z]+ \
> dictionary.txt接著我安裝了 python-Levenshtein 函式庫,並寫了一個有點拼湊的 Python 腳本來列出所有可能的種子:
import Levenshtein
seed = raw_input('enter your wallet seed: ')
for seed_word in seed.split():
for dict_word in open('dictionary.txt'):
dict_word = dict_word.strip()
distance = Levenshtein.distance(seed_word, dict_word)
if distance != 1:
continue
print '"%s" -> "%s"\n%s\n' % (seed_word, dict_word,
seed.replace(seed_word, dict_word))坦白說:實際上腳本寫得更拼湊,我是直接把字典的 1,600 行複製貼上到 Python 腳本裡。這裡的程式碼是為了示範而整理過的版本。
打開保險箱
我原本擔心會有數百種可能,必須寫腳本來逐一嘗試每個種子。幸好,我的腳本顯示只有 12 組種子與錯誤種子的 Levenshtein distance 為 1:
$ python recover.py
enter your wallet seed: eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
"wise" -> "wife"
eluded logic wife ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
"tagged" -> "jagged"
eluded logic wise ascend jagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
"tagged" -> "nagged"
eluded logic wise ascend nagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
"aptitude" -> "altitude"
eluded logic wise ascend tagged acoustic situated stylishly younger altitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adapt
"push" -> "lush"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar lush because brunt viking gone august public tonic vulture shrugged otter adapt
"brunt" -> "grunt"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because grunt viking gone august public tonic vulture shrugged otter adapt
"tonic" -> "ionic"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public ionic vulture shrugged otter adapt
"tonic" -> "sonic"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public sonic vulture shrugged otter adapt
"tonic" -> "topic"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public topic vulture shrugged otter adapt
"tonic" -> "toxic"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public toxic vulture shrugged otter adapt
"adapt" -> "adept"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adept
"adapt" -> "adopt"
eluded logic wise ascend tagged acoustic situated stylishly younger aptitude inroads avidly hefty also godfather unrest avatar push because brunt viking gone august public tonic vulture shrugged otter adopt可能性少到我可以直接手動輸入到 Sia 中嘗試。我先試了第一組可能的種子,也就是把錯誤種子中的 wise 替換成 wife 所產生的那組:
> siac wallet init-seed
Seed:
Could not initialize wallet from seed: error when calling /wallet/init/seed: seed failed checksum verification這在意料之中。並非所有種子都會是有效的。我逐一嘗試每個可能的種子,直到試到把 tonic 替換成 ionic 的那組:
> siac wallet init-seed
Seed:
Wallet initialized and encrypted with seed.中獎了!
來看看裡面有什麼:
> siac wallet unlock
Wallet password:
Wallet unlocked
> siac wallet
Wallet status:
Encrypted, Unlocked
Confirmed Balance: 594.8 SC這就奇怪了。當時 594.8 SC(Siacoin)大約只值 €10,與使用者聲稱錢包裡有 €2,000 相去甚遠。
難道我才是被耍的那個人?使用者是否明知自己只有 €10,卻謊稱有更高的金額來誘使別人幫忙?還是已有更厲害的竊賊搶先一步破解了錢包,只留下 €10 來嘲弄我?
確保戰利品安全
雖然我很想坐下來好好思考眼前這奇怪的餘額,但時間緊迫。我不知道還有誰看到了那則貼文,正準備像我一樣解鎖錢包。是時候把 Siacoin 偷走了。
Ben Gates(班·蓋茲):有人也在覬覦這個寶藏。
Riley Poole(萊利·普爾):當然有人也在覬覦它。這是尋寶的公理。
-National Treasure: Book of Secrets(《國家寶藏:古籍秘辛》)
我迅速 把全部餘額轉走到我自己的 Sia 錢包。這樣一來,即使有人在我之後也發現了正確的種子,也無法再取回這筆錢。
回到謎團
既然幣已經安全了,是時候弄清楚到底發生了什麼事。我查看了錢包的交易紀錄:
> siac wallet transactions
[height] [transaction id] [net siacoins] [net siafunds]
108589 427b72c98e8ea64fba234ca2a00288f7a750003a243e6b3e967f5c6d426c2f9f 594.83 SC 0 SF
109002 32ad2729fe6b487aedc1b70d0dff0843404ff1cef69223d5f03699dcd1dbe568 0.00 SC 0 SF
109002 2304da26d61bd2cb7fcac5c7b38a553d788d8dfc386ae4eb47772e36e4a9269d -594.55 SC 0 SF
清單中的最後一筆交易就是那筆提款。那只是我把錢偷走的紀錄,不用在意。其中那筆 0.00 SC 的交易只是雜訊,因為 Sia 錢包在自己地址之間轉移資金時會產生這類 0.0 的交易。
我感興趣的是清單中的第一筆交易。那一行顯示這個錢包從來只收到過一筆在區塊高度 108,589 時存入的 594.83 SC。區塊高度本質上就是以 Siacoin 時間單位來表示的「時間」。查看 Sia 區塊瀏覽器中的該筆交易,我可以看到這筆存款是在 2017 年 6 月 7 日存入的,也就是使用者在 reddit 發文的兩天前。
為什麼使用者聲稱自己在錢包中存入了 €2,000,實際上卻只存了 €10?
卡在半空中的交易
在我這場大膽竊案發生時,最大的 Siacoin 交易所 Poloniex 正 在將 Siacoin 轉入使用者錢包方面出現問題。他們並沒有讓使用者的資金遺失,但交易卡在半空中是很常見的情況:使用者從交易所帳戶把錢發送到自己的 Sia 錢包,但 Poloniex 卻延遲了數天甚至數週才完成轉帳。
也許這位使用者已經向錢包發送了 €2,000,但這筆錢被卡在了 Poloniex 的等待佇列中。這意味著那 €2,000 仍有可能被搶走,因為它最終還是會到達錢包。
這是一個新奇又有趣的問題。如果錢還沒到錢包,而且我不知道它何時會到,我要怎麼偷到它?我決定寫一個批次腳本,持續把外洩錢包中的錢轉到我自己的錢包。更精確地說,我決定現學怎麼寫批次腳本,因為我手邊最容易使用的 Sia 環境是一台 Windows 虛擬機器,而我根本不會寫 Windows 的批次腳本。最後,我硬是擠出了這段精美的批次腳本:
for /l %%x in (1, 0, 100) do (
siac wallet send siacoins 2000SC fff0228f02a01cf8e037047a5ea0db5a88d614913af5f21de209ebf2e58431c68cfc9c27d0e4
)這個腳本會反覆嘗試從該 reddit 使用者已洩漏的錢包中,將 2,000 SC 發送到我自己的錢包地址。它從 1 數到 100,每次遞增 0,所以會無限循環。
只要錢包餘額仍為零,這個指令就只會失敗而沒有任何效果。然而,如果錢包真的收到了我所期盼的 €2,000,它就會一次 2,000 SC、一次 2,000 SC 地把錢吸到我的錢包裡。
我選擇 2,000 SC 是因為相對較小的轉帳額度比較安全。我實際上是在玩 The Price is Right(《價格來了》)的規則。如果我選擇例如 125,000 SC,也就是當時相當於 €2,000 的金額,但實際只到了 124,000 SC,我的指令就會因餘額不足而失敗,什麼也轉不走。
猜得太低其實沒有什麼懲罰,只不過會多付一些交易手續費。2,000 SC 大約相當於 €35,所以如果真的有一筆約 125,000 SC(€2,000)的存款進來,我的批次腳本會在幾分鐘內把錢包清空。
通知受害者
我承認,我確實幻想過如果把那 €10 據為己有可以怎麼花:私人飛機、勞力士、在豪宅裡蓋一座像 Scrooge McDuck(史高治·麥克達克)那樣裝滿錢幣的游泳池。但最終,我決定做正確的事,把 Siacoin 還給那位貼出種子的使用者。

如果我把錢留下來,那時的我。
不過,我找到的金額與他們聲稱遺失的金額之間的落差,可能會讓場面有點尷尬。就像打電話給某人說:「嗨,你就是那個貼海報說弄丟了裝有 €2,000 現金的錢包的人嗎?我找到了,不過裡面只有——呃……€10……」(眼神飄移)。
在使用者發文約兩小時後,我在 reddit 上傳了私人訊息給他們。我解釋了我如何還原了他們的種子並把錢取走,是為了防止其他心術不正的使用者也還原種子後取走。我請他們提供一個與外洩種子無關的 Siacoin 地址,以便我將餘額歸還給他們。
幾小時過去,接著幾天過去,我都沒有收到回覆。我注意到他們已經刪除了那則在 reddit 上洩漏種子的貼文。有誰會弄丟了 €2,000,上網發文求助,然後幾小時後就彷彿完全忘了這件事?
謎團解開
終於,在週一早上,這位慘遭我「滔天大罪」毒手的受害者回覆了我。他們解釋說,在發文後不久,他們就發現自己的錢其實還在交易所裡,從未到達錢包(被我說中了!)。他們已成功將資金轉移到另一個種子安全的錢包中。當他們發現自己其實沒有弄丟錢時,就沒想到要再回來查看 reddit。
他們很高興我還原了種子,因為我解開了他們關於密語到底哪裡出錯的謎團。他們其實正確地寫下了 ionic,卻一直誤把它看成 tonic,因為後者對他們來說是更熟悉的單字。使用者甚至主動提出讓我留下全額,但我覺得這樣在這篇部落格文章裡形象會更好這些幣本就理應屬於遺失它們的使用者。我堅持歸還,他們最終讓步,傳給我一個地址讓我歸還那 594.8 SC。
心得與啟示
千萬不要在網路上公開你的 Sia 錢包種子。正如這個故事所顯示的,即使是不正確或不完整的種子版本,也可能讓你的錢包完全被攻破。
這不僅適用於 Siacoin,也適用於所有加密貨幣。並非所有加密貨幣都像 Sia 那樣使用密語,但它們都使用某種私鑰,如果你不想失去自己的幣,就必須妥善保密。
隨機一篇部落格