How I Stole Your Siacoin

Michael Lynch

我如何盗走了你的 Siacoin

原文由 Michael Lynch 发布,订阅该博客

一条泄露“种子”的 Reddit 帖子

那是 2017 年 6 月 9 日的夜晚。对我来说,那是一个典型的周五夜晚。我当时正在看 Netflix 刷 Reddit和酷孩子们一起狂欢。

突然,我在 /r/siacoin 版块的“最新”标签页里看到这样一条帖子:

Reddit 截图

如果你还不了解 Siacoin,它是一种加密货币,可以让你把闲置的硬盘空间出租出去,或向他人购买存储空间。我之前也写过几篇关于这项技术的文章(挖矿指南NAS 指南)。

这位 Reddit 用户刚刚做了一件非常危险的事——他在网上公布了自己的 Sia 钱包种子。Sia 的“种子”是一串助记词,任何拿到它的人都能完全掌控该钱包里的加密货币。对这位用户来说,这意味着价值 2000 欧元的 Siacoin 岌岌可危,因为他刚往这个钱包里转入了这么多钱。从截图中可以看到,用户以为这样做是安全的,因为他已经确认过这串助记词“无法使用”。

差一点也不算——除了掷马蹄铁和 Sia 种子

这条帖子最吸引我的地方在于,用户是手写抄下种子的:

我很确定抄的时候没抄错,我每次都会反复检查的。

我倒很确定他肯定抄错了。不过我希望他只抄错了一处。如果用户只是写错了一个字母,或者把两个字母写反了,我大概就能推算出正确的种子,找回那 2000 欧元。

我必须抓紧时间。能认出泄露种子的人不止我一个,所以我得在别人之前破解种子、抢先把钱拿走。

手工破解

我先仔细检查了那串错误种子里的单词:

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 词典里只有大约 1600 个单词。我原本指望用户抄写时恰好写下了一个完全不在词典里的词。那样的话,只要发现他贴出的 29 个种子词里有一个不在词典中,就能立刻锁定错误的那一个,然后在词典里找个相近的词,很快就能还原出正确的种子。

可惜,错误种子里的 29 个词全都在 entropy 词典里,光靠肉眼是找不出来了。

暴力破解

是时候祭出大杀器了(我说的“大杀器”就是我敲代码用的两根手指)。我需要一种方法,能找出词典里那些与 Reddit 上那串种子仅有一个抄写错误之差的单词。

我想到 莱文斯坦距离或许能派上用场。莱文斯坦距离是指把一个单词变成另一个单词所需的最少增、删、改操作次数。比如,“cat”和“car”的莱文斯坦距离是 1,因为只要把“t”换成“r”就行;而“cat”和“scar”的距离是 2,因为既要把“t”换掉,还要在前面加上一个“s”。

要找出可能的种子,我可以写个脚本,在 entropy 词典里找出与错误种子中单词的莱文斯坦距离为 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))

坦白一下:实际上当时的脚本更糙,我是直接把词典里那 1600 行复制粘贴进了 Python 脚本。这里展示的是为了演示而整理得更漂亮的版本。

打开保险箱

我本来担心会有几百种可能性,不得不用脚本去逐一尝试。好在脚本跑完后显示,只有 12 种种子的莱文斯坦距离与那串错误种子相差为 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 欧元,和用户声称钱包里有 2000 欧元相差甚远。

难道被耍的是?用户明知只有 10 欧元却故意说成 2000 欧元来引人帮忙?还是有更厉害的窃贼已经捷足先登,只留下 10 欧元来嘲弄我?

转移赃物

虽然我很想坐下来好好琢磨一下这奇怪的余额,但时间紧迫。我不知道还有谁看到了那条帖子,也正准备像我一样解锁钱包。是时候把 Siacoin 偷走了。

Ben Gates:还有人在打这笔宝藏的主意。

Riley Poole:当然有人在打它的主意,这是寻宝的公理。

-国家宝藏:夺宝秘笈

我迅速把全部余额转到了我自己的 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 的交易。

我关心的是列表里的第一笔交易。那一行显示,这个钱包自始至终只收到过一笔 594.83 SC 的充值,区块高度是 108,589。区块高度本质上就是 Siacoin 世界里的时间。在 Sia 区块浏览器里查询这笔交易,可以看到这笔充值是在 2017 年 6 月 7 日,也就是用户发帖的两天前完成的。

用户为什么会声称自己往钱包里放了 2000 欧元,而实际上只充了 10 欧元呢?

悬而未决的交易

在我这次大胆“行窃”的时候,最大的 Siacoin 交易所 Poloniex 正出现转账问题,无法及时把 Siacoin 转到用户的钱包里。用户的资金并没有丢失,但常见的情况是交易会卡在半空中——用户已经从交易所账户发出了提币到个人 Sia 钱包的请求,但 Poloniex 出现积压,可能要过几天甚至几周才能到账。

也许这位用户确实已经发出了 2000 欧元的转账,只是钱还卡在 Poloniex 迟迟未到。这意味着那 2000 欧元或许还有机会拿到手,因为它最终还是会到达钱包。

这就带来了一个新的、有趣的问题:如果钱还没到账,而且我也不知道什么时候会到,我该怎么偷呢?我决定写个批处理脚本,不断尝试把钱从这个已泄露的钱包转到我自己的钱包里。更准确地说,我决定现学一下怎么写批处理脚本,因为我手头最方便的 Sia 环境是一台 Windows 虚拟机,而我根本不会写 Windows 批处理。最后,我硬是憋出了这样一段堪称杰作的批处理脚本:

for /l %%x in (1, 0, 100) do (
   siac wallet send siacoins 2000SC fff0228f02a01cf8e037047a5ea0db5a88d614913af5f21de209ebf2e58431c68cfc9c27d0e4
)

这个脚本会反复尝试从 Reddit 用户那个已泄露的钱包向我自己的钱包地址发送 2000 SC。它从 1 到 100、步长为 0 循环,也就是无限循环。

只要钱包余额还是零,这个命令就只会失败,不会产生任何效果。但如果钱包真的收到了我期待的那 2000 欧元,它就会以每次 2000 SC 的速度把钱吸到我的钱包里。

我选 2000 SC 是因为转小额更保险。这其实是在玩《价格猜猜看》的规则。如果我直接选 125,000 SC,也就是当时 2000 欧元对应的数量,但实际到账的只有 124,000 SC,那我的命令就会因为余额不足而失败,一分钱都转不出来。

猜得小一点其实没什么惩罚,无非就是多付点交易手续费。2000 SC 当时大约值 35 欧元,所以如果真有一笔约 125,000 SC(2000 欧元)的充值到账,我的批处理脚本几分钟内就能把钱包搬空。

通知受害者

我承认,我的确幻想过如果把这 10 欧元据为己有能怎么花:私人飞机、劳力士、还有一座带史高治式金币泳池的豪宅。但最后,我还是决定做正确的事,把 Siacoin 还给那位泄露种子的用户。

美国精神病人

如果我把钱私吞后的样子。

不过,我找到的金额和他声称丢失的金额对不上,可能会让场面有点尴尬。就像你打电话给别人说:“嘿,你是不是那位贴寻物启事说丢了装有 2000 欧元现金钱包的人?我找到了,不过里面只有——呃……10 欧元……” (眼神飘忽)。

在用户发帖约两小时后,我通过 Reddit 私信联系了他。我解释了我是如何恢复出他的种子、并为了防止心术不正的人也破解种子而先把钱取走的。我请他提供一个与泄露种子无关的 Siacoin 地址,以便我把余额还给他。

几个小时过去了,几天也过去了,我都没有收到回复。我发现他已经删除了那条泄露种子的帖子。谁会丢了 2000 欧元,在网上发帖求助,几小时后却好像完全忘了这回事呢?

谜底揭晓

终于,在周一早上,这位被我“洗劫”的受害者回复了我。他解释说,发帖后不久他就发现钱其实还在交易所里,根本没到钱包里(被我猜中了!)。他已经把钱转到了另一个种子安全的钱包里。当他发现钱其实没丢后,就没再去看 Reddit。

他很高兴我恢复出了种子,因为我也解开了困扰他的谜团:他当初正确地记下了 ionic,却在回读时一直误看成了 tonic,因为后者对他来说是个更熟悉的单词。用户甚至主动提出让我留下全部金额,但我觉得这样写在这篇博客里会显得我更光彩这些币理应归还给失主。我坚持要还,他最终让步,发来了一个地址,让我把那 594.8 SC 还了回去。

经验教训

永远不要在网上发布你的 Sia 钱包种子。正如这个故事所展示的,哪怕是不完整或有误的种子,也足以让你的钱包彻底失守。

这一点不仅适用于 Siacoin,也适用于所有加密货币。并非所有币种都像 Sia 这样使用助记词,但它们都有某种私钥,如果你不想丢币,就必须妥善保密。

本文章由 muse-spark-1.2-contributor 进行翻译

评论