遭遇病毒
当我们在家里远程办公躲避新冠病毒的时候,没想到被另一种病毒偷袭了后院。
昨天早上,合作伙伴在微信上发消息说:“有个客户的 Windows 机器中了 Globeimposter 病毒,把 JuiceFS 里的文件也加密了,这个有办法恢复数据吗?”
Globeimposter 病毒是什么?它是一种勒索病毒,感染后会加密机器上的文件,并提示用户交赎金才能解密恢复数据。加密方法一般是使用存储在远端的服务器上的私钥来加密,普通大众完全无法破解,如果不能找到数据恢复方法,或许只能乖乖交赎金 - 还不一定管用呢。
常规疗法
要恢复数据,第一件要做到事情就是停止写入,阻止更多的数据被破坏。小伙伴已经第一时间停掉了 Samba 服务来隔离病毒,又让他把 JuiceFS 的客户端都卸载了,因为所有数据写入和删除操作都是通过客户端进行的,停止客户端就能阻止更多的数据被后台任务删除或者覆盖。
最简单的恢复办法就是找隔离的备份数据 - 没有!这个客户并没有对这些数据做备份!
另外一个可能的办法就是回滚存储系统,但不是所有系统都支持这一点,这就要看 JuiceFS 的架构和实现了。JuiceFS 使用元数据和数据完全分离的策略,元数据是完全用户态的,由全内存的状态机和持久化在磁盘的快照和日志组成。因此元数据可以回滚到从最老的快照到最新的一行日志之间任意时间点的状态。整个文件系统是由元数据来呈现的,只要回滚了元数据,应该就能够回滚文件系统的状态。
那数据部分呢?JuiceFS 的数据是存储在对象存储中,使用的是唯一的名字,只会创建新或者删除对象,不会更新或覆盖已有对象。为了能够及时回收空间,当JuiceFS 中的数据被覆盖或者删除时,它会异步删除对象存储中的数据。有些对象存储支持多版本(包括已经删除的),可惜这个客户使用的对象存储并没有启用多版本支持。这样即使我们将元数据回滚到病毒破坏之前,有些文件可能还是没法访问。
在合作伙伴尝试恢复对象存储中的数据时,我们来看看怎么回滚 JuiceFS 的元数据。
JuiceFS 的元数据持久化为一系列的快照文件和日志文件,默认会保留最新的两个快照(大概8小时一个),以及最近 24 个日志文件(一小时 1 个),这样可以恢复到大概最近16个小时的任意时刻。此外,如果 日志增长太快,为了缩短意外重启时的恢复时间,它会更频繁地生成快照(最低一小时)。在这个环境里,因为病毒在高频地破坏文件,产生了很多日志,导致最早的快照只有昨天上午 9 点的,大概 2 个小时前。根据日志文件的大小,估计病毒是在早上 5 点多开始破坏的,那没有办法恢复到被病毒破坏之前的状态了。对于 SaaS 版本的 JuiceFS,我们会在全球 3 个不同的公有云备份完整的快照和日志,是可以恢复到从最开始到现在的任意状态的,而这个私有化部署并没有做这种粒度的元数据备份。
常规的恢复方法都不行,没有特效药,怎么办?
病理研究
JuiceFS 的日志是文本格式的,通过它可以知道文件系统的任何变化。因为被加密的文件都改成了这个扩展名,我们在日志里查找 Globeimposter,找到了第一条:
59566600-59: 1581543436|MOVE(76551,AAA.txt,76551,AAA.txt.Globeimposter-Beta865qqz):933570
它表示 Raft 的第 59 次选举后,在 2020 年 2 月 13 日 5 点 37 分16秒(1581543436 表示)进行的第 59566600 次操作,病毒将目录 76551 里的 AAA.txt (化名)更改为 AAA.txt.Globeimposter-Beta865qqz
,这个文件的 inode 是 933570。我们继续用这个文件的 inode 进行查找,会有更多信息:
59566600-59: 1581543436|MOVE(76551,AAA.txt,76551,AAA.txt.Globeimposter-Beta865qqz):933570
59566601-59: 1581543436|ATTR(933570,484,17699,17699,1575511878,1565681265,0,484)
59566602-59: 1581543436|ACQUIRE(81,933570)
59566603-59: 1581543436|TRUNC(933570,808,3)
59566604-59: 1581543436|ATTR(933570,484,17699,17699,1575511878,1581543436,0,0)
59566635-59: 1581543437|WRITE(933570,0,0,1):63762984
59566801-59: 1581543438|AMTIME(933570,1575511878,1581543437,1581543437)
59566825-59: 1581543438|WRITEEND(933570,0,63762984,808)
59567382-59: 1581543453|ATTR(933570,484,17699,17699,1575511878,1581543454,0,0)
59619978-59: 1581544078|RELEASE(81,933570)
它把文件长度设置为 808 字节,并且覆盖写入了 808 字节数据,应该是加密后的数据,而原始数据所在的 Chunk 被删除了:
61031532-59: 1581550294|CHUNKDEL(63968616)
这里要补充一些 JuiceFS 的架构信息:在 JuiceFS 里,每个文件对应一个 Inode,文件的数据会按照 64MB 拆分为 Chunk,每个 Chunk 有一个唯一ID(自增的),该 Chunk ID 是从对象存储读写数据的 Key 的关键部分。每次有数据写入时,都会生成一个新的 ChunkID 用来表示它。
再看一个大一点的文件(非重要的日志已省略):
60829070-59: 1581548510|MOVE(360632,6773.tif,360632,6773.tif.Globeimposter-Beta865qqz):360634
60829075-59: 1581548510|TRUNC(360634,2185930056,3)
60829151-59: 1581548511|WRITE(360634,0,0,1):63965106
60829153-59: 1581548511|WRITE(360634,32,33554432,1):63965107
60829164-59: 1581548511|WRITEEND(360634,32,63965107,1048576,33554432)
60829165-59: 1581548511|WRITE(360634,32,38445056,1):63965112
60829374-59: 1581548512|WRITEEND(360634,32,63965112,1352,38445056)
60829375-59: 1581548512|WRITEEND(360634,0,63965106,1048576)
它先尝试在文件的开始处写入 1MB(位于第 0 个 64MB 的 Chunk),然后在文件的接近末尾写入 1MB,最后在文件末尾写入 1352 字节。由于数据持久化是并行的,实际上文件后部的数据写入先完成。如果继续搜索其他被破坏的大文件(大于 2MB),基本都是这个规律,原来病毒为了提升破坏速度,并没有把整个文件的数据做加密覆盖,只是选择了一头一尾的两 MB 数据进行破坏。因为绝大部分文件格式都是在头部和尾部存储索引信息,这两部分被破坏后,基本没法用了,跟全部被破坏效果差不多。
对于这种局部覆盖写操作,JuiceFS 是把新的数据以新的 Chunk ID 写入到对象存储,然后再在元数据里叠加到原来的数据上面,避免写入放大。这意味着,保存着原始数据的 Chunk 仍然还在被使用而没有删除。那么我们有可能通过撕掉叠加在上面的这一小段加密数据,从而恢复原始数据!
再来验证一下:对于上面的文件,它一开始是这样写入的:
58421288-59: 1578902781|CREATE(360632,6773.tif,1,484,0,17699,17699,0):360634
58421290-59: 1578902781|ATTR(360634,484,17699,17699,1578902781,1578902781,0,484)
58421291-59: 1578902781|TRUNC(360634,2185929262,3)
58421292-59: 1578902781|WRITE(360634,0,0,1):63516549
58421299-59: 1578902783|WRITEEND(360634,0,63516549,67108864)
......
58421399-59: 1578902816|WRITE(360634,32,0,1):63516581
58421414-59: 1578902818|WRITEEND(360634,32,63516581,38445614)
我们在日志里没有找到 CHUNKDEL(63516549), 但不幸的是,最后一段原始数据被删除了:
60964322-59: 1581549675|CHUNKDEL(63516581)
这是因为 JuiceFS 会对碎片进行合并以改进读的性能和减少空间浪费,我们能够在日志里找到类似这种操作:
60829252-59: 1581548511|COMPACT(28,63908031)
它会把上面因为覆盖写导致的 4 段碎片,重写为一个连续的 Chunk,然后再删掉原来的 Chunk,这时候我们就没办法恢复原先的数据了。所幸这个操作是异步的,只会对碎片比较多的进行处理,而且速度比较慢,可能很多文件还没有被碎片整理。
再仔细看上面的操作,还有一个规律是,它总是先写头部的 1MB,拿到第一个 ChunkID(比如上面的63965106),然后再改写文件尾部的 一到两个片段,它们的 ChunkID 都更大,比如上面的 63965107 和 63965112,因为 ChunkID 是自增生成的。这样就很容易识别哪些分段是被病毒覆盖的。
治病救人
有了上面的发现,我们找到了一种恢复大文件的方法:
- 先找到被破坏的文件(文件名后缀为 Globeimposter-Beta865qqz),
- 找到文件头部的大小为 1MB 的 片段的 ID
- 遍历整个文件的所有分块,留下所有比上面的 ID 小的 Chunk,去掉所有比它大的片段。
- 重新计算文件的长度,恢复为正确的值(因为病毒加密的数据可能比原文长)。
基于上面的恢复方法,再用 Go 把上面的恢复逻辑实现到文件系统里面并不难。因为我们可以通过 ChunkID 的大小明确区分原始数据和加密后的数据,可以知道哪些文件能够被正确恢复。对于能够完整恢复了文件,会把它文件名里面的病毒后缀去掉,而对于有任何部分不能恢复的(完全被覆盖写或者被合并了),则仍然保留病毒后缀,之后可以尝试其他方法继续恢复。
使用具有恢复功能的程序加载最新的元数据快照和日志,然后进行恢复操作,并确保没有问题后生成新的快照文件,再上传到线上环境去恢复服务,确认了能够正确读取恢复的文件。其他被破坏的文件也能够读取,只是里面的部分内容已经无法识别。
经过一天的努力,最终恢复了 99% 被破坏的数据,也是因为这次客户数据大部分是大文件,才侥幸得以恢复。仍然有一万多个文件无法恢复,得继续寻找其他方法。
特效药
勒索病毒已经是一个典型的数据安全隐患,虽然这一次我们能够利用 JuiceFS 的数据写入特点恢复了绝大部分数据,但实属侥幸。
如果存储系统能够支持时间回溯,在遭到这里破坏时我们能够将整个文件系统的状态恢复到破坏之前的某个时间点,那可以更加从容地应对这类威胁。
因为 JuiceFS 已经具有元数据的历史回溯能力,只要延迟数据的删除操作就可以实现完整的历史回溯,类似于 Mac 系统的时间机器功能,比它的精度更高。JuiceFS 的时间机器功能已经在计划中,敬请期待。