How I Stole Your Siacoin

Michael Lynch

我是如何偷走你的 Siacoin

一条可疑的 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 函数。它又把我引向了 entropy-mnemonics GitHub 项目,其中包含了这个词典,里面是所有可能的种子单词:

  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 的种子仅有一个抄写错误之差的所有单词。

我意识到 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))

坦白说:实际上,当时的脚本要简陋得多,是直接把词典里的 1600 行复制粘贴到 Python 脚本里的。这里的代码是为了演示而优化过的版本。

打开保险箱

我担心会有数百种可能性,不得不用脚本去逐个尝试每个种子。幸运的是,我的脚本报告说,与错误种子 Levenshtein distance 为 1 的种子只有 12 种:

$ 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 欧元,却故意声称有更高的金额来引诱别人帮忙?还是有更厉害的加密窃贼先我一步拿走了钱包里的钱,只留下 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 帖子的两天前存入的。

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

悬而未决的交易

在我大胆行窃的时候,最大的 Siacoin 交易所 Poloniex 正在用户钱包提现方面出现问题。他们并没有丢失用户的资金,但交易卡在中途是很常见的,用户从交易所账户向自己的 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 是因为相对较小的转账更安全。我实际上是在按The Price is Right的规则来玩。如果我选择了比如 125,000 SC,也就是当时相当于 2000 欧元的金额,但最终钱包里只到了 124,000 SC,我的命令就会因余额不足而再次失败,什么也转不走。

猜得太低其实没什么惩罚,除了要多付一些交易手续费。2000 SC 大约相当于 35 欧元,所以如果一笔约 125,000 SC(2000 欧元)的存款到账,我的批处理脚本会在几分钟内把钱包清空。

告知受害者

我承认,我确实幻想过如果把这 10 欧元据为己有能买些什么:私人飞机、劳力士、拥有一座像史高治·麦克老鸭那种钱币游泳池的豪宅。但最终,我决定做正确的事,把 Siacoin 归还给发布种子的用户。

美国精神病人

如果我私吞了那笔钱,那时的我。

不过,我发现的金额与他们声称丢失的金额之间的差异,可能会让事情变得有点尴尬。这就像给某人打电话说:“嘿,你就是那个贴寻物启事说丢了装有 2000 欧元现金钱包的人吗?我找到了,但里面只有——呃……10 欧元……”(眼神飘忽)。

在用户发布原帖约两小时后,我在 Reddit 上给他们发了一条私信。我解释了我是如何恢复他们的种子并把钱取走以保护其免受不那么正派的用户侵害的,我本也可以同样恢复它。我请他们提供一个与泄露种子无关的 Siacoin 地址,以便我把余额归还给他们。

数小时过去了,接着是数天,我都没有收到任何回复。我注意到他们已经删除了那篇泄露种子的 Reddit 帖子。谁会丢了 2000 欧元,在网上发帖求助,然后几小时后就似乎完全忘了这件事呢?

谜团解开

终于,在周一早上,我这起骇人罪行的受害者回复了我。他们解释说,在发帖后不久,他们就意识到自己的钱还在交易所里,根本没有到达钱包(被我猜中了!)。他们已经能够把钱转移到一个种子安全的独立钱包中。当他们意识到自己其实并没有丢钱时,就没想着再回 Reddit 上查看。

他们很高兴我恢复了种子,因为我解开了他们关于助记词出了什么问题的谜团。他们正确地记下了 ionic,但却一直错误地把它读回为 tonic,因为后者对他们来说是更熟悉的单词。用户甚至主动提出让我保留全额,但我觉得这样在这篇博文里我会显得更高尚这些币理应属于丢失它们的用户。我坚持要归还,他们最终让步,发给我一个地址,让我得以归还那 594.8 SC。

经验教训

永远不要在网上发布你的 Sia 钱包种子。正如我们从这个故事中看到的,即使是种子不正确或不完整的版本,也可能彻底危及你的钱包安全。

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

原文由 Michael Lynch 发布

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