11 月,npm 包 event-stream 被恶意依赖项 flatmap-stream 利用。 整个过程都写在这里,这篇文章的重点是将其用作 JavaScript 逆向工程的案例研究。 与 flatmap-stream 相关的 3 个有效负载足够简单,易于编写,同时也足够复杂,有趣。 虽然了解这个事件的背景故事对于理解这篇文章来说并不重要,但如果您对细节不太熟悉,我会做出一些可能不太明显的假设。
对大多数 JavaScript 进行逆向工程比在桌面操作系统上运行二进制可执行文件更直接 - 毕竟源代码就在您面前 - 但设计得难以理解的 JavaScript 代码通常会经过几次混淆以掩盖其意图。 部分混淆源自所谓的“最小化”,即为了节省空间而尽可能减少源文件总体字节数的过程。 这涉及将变量缩短为单个字符标识符,并将表达式(如 true)转换为更短但等效的表达式(如 !0)。 由于 JavaScript 起源于 Web 浏览器,因此缩小化是 JavaScript 生态系统所独有的,并且由于工具的重用而偶尔出现在节点包中,并不是一种安全措施。 要了解常见缩小和混淆技术的基本逆转,请查看 Shape 的取消缩小工具。 专用的混淆过程可能来自专门设计用于混淆的工具,或者由开发人员手动执行
第一步是找到隔离源并进行分析。 flatmap-stream 包经过特殊设计,看上去似乎是无辜的,但实际上只有一个版本(0.1.1 版)中包含恶意负载。 您可以通过比较0.1.2 版和0.1.1 版,甚至只是在两个选项卡中交替切换 URL,来快速查看源代码的更改。 对于本文的其余部分,我们将附加的源称为有效载荷 A。下面是有效载荷 A 的格式化源。
首先要说的是: 切勿运行恶意代码(除非在隔离环境中)。 我编写了自己的工具来帮助我使用Shift 解析器套件和 JavaScript 转换器动态重构代码,但您可以使用 Visual Studio Code 之类的 IDE 来跟随这篇文章。
当对 JavaScript 进行逆向工程时,将脑力劳动保持在最低限度是很有价值的。 这意味着摆脱任何不会立即增加价值的表达式或语句,并扭转任何已自动或手动优化的代码的 DRYness。 由于我们正在静态分析 JavaScript 并在头脑中跟踪执行情况,因此你的思维越深,你迷失的可能性就越大。
您可以做的最简单的事情之一就是取消分配了全局属性(如 require 和 process)的变量的最小化,如第 3 行和第 4 行所示。
您可以使用任何提供重构功能的 IDE 来执行此操作(通常是在要重命名的标识符上按“F2”)。 之后,我们看到一个函数定义 e,它似乎只是解码一个十六进制字符串。
第一行有趣的代码似乎导入了一个文件,该文件来自函数 e 解码字符串“2e2f746573742f64617461”的结果
故意混淆 JavaScript 以掩盖任何文字字符串值的情况极为常见,这样任何路过的人都不会被清晰可见的特别不祥的字符串或属性所警告。 大多数开发人员都认识到这是一个非常低的障碍,所以你经常会发现可以轻松撤消的编码,这里也不例外。 e 函数只是反转十六进制字符串,您可以通过在线工具或使用您自己的便利函数手动完成此操作。 即使您确信自己了解 e 函数的作用,最好不要使用恶意文件中的输入来运行它(即使您提取它),因为您无法保证攻击者没有发现由数据触发的安全漏洞。
反转该字符串后,我们发现该脚本包含一个数据文件“./test/data”,该文件位于分布式 npm 包中。
将 n 重命名为 data 并将对 e(n[2]) 的调用反混淆为 e(n[9]) 后,我们开始更好地了解我们在这里处理的内容。
我们也很容易看出为什么这些字符串被隐藏了,在简单的平面图库中找到任何对解密的引用都会表明有些地方出了问题。
从这里我们可以看到脚本正在导入node.js的“crypto”库,在查看API之后,我们发现createDecipher的第二个参数,这里的o,是用于解密的密码。 现在我们可以根据 API 将该参数和以下返回值重命名为合理的名称。每次我们发现难题的新部分时,通过重构或注释将其永久保存非常重要,即使它只是一个看似微不足道的重命名变量。 当你花费数小时钻研外部代码时,你可能会忘记当前位置、分心,或者由于一些错误的重构而需要回溯,这些都是很常见的情况。 在重构期间使用 git 保存检查点也很有价值,但我会把这个决定权留给你。 代码现在如下所示,删除了 e 函数,因为它不再与语句 if (!o) {... 一起使用,因为它不会为分析增加价值。
您还会注意到我已将 f 重命名为 newModuleInstance。 对于这么短的代码来说,这并不重要,但对于可能有数百行长的代码来说,让所有内容尽可能清晰就很重要。
现在有效载荷 A 已基本被反混淆了,我们可以仔细研究它,了解它的作用。
第三行导入我们的外部数据。
第 4 行从环境中获取密码。process.env 允许您从节点脚本中访问变量,而 npm_package_description 是当您运行 package.json 文件中定义的脚本时,npm(节点的包管理器)设置的变量。
第 5 行创建一个解密实例,使用 npm_package_description 中的值作为密码。 这意味着仅当通过 npm 执行此脚本并针对其 package.json 中具有特定描述字段的特定项目执行时,才能解密加密的有效负载。 这会很艰难。
第 6 行和第 7 行解密外部文件中的第一个元素并将其存储在变量“decrypted”中
第 8-11 行创建一个新模块,然后将解密的数据输入未记录的方法 _compile。 然后,该模块导出外部数据文件的第二个元素。module.exports 是节点将数据从一个模块公开到另一个模块的机制,因此 newModuleInstance.exports(data[1]) 公开了在外部数据文件中找到的第二个加密有效负载。
此时,我们已经有了加密数据,这些数据只能使用在 package.json 中的密码解密,并且其解密数据会被输入到 _compile 方法中。 现在我们面临一个问题:如何在不知道密码的情况下解密数据? 这是一个不简单的问题,如果很容易暴力破解 aes256 加密,那么我们遇到的问题将比 npm 包被接管还要多。 幸运的是,我们处理的不是一组完全未知的可能密码,而只是碰巧在某个地方输入到 package.json 中的任何字符串。package.json 文件起源于 npm 包元数据的文件格式,因此我们不妨从官方 npm 注册表开始。 幸运的是,有一个 npm 包为我们提供了所有包元数据的流。
无法保证我们的目标文件位于 npm 包中,许多非 npm 项目使用 package.json 来存储基于节点的工具的配置,并且 package.json 描述可能因版本而异,但它是一个很好的起点。 可以使用多个密钥解密该有效载荷,从而产生乱码,因此我们需要某种方式在此暴力破解过程中验证解密的有效载荷。 由于我们正在处理输入到Module.prototype._compile 的内容,而该内容又输入到vm.runInThisContext,因此我们可以合理地假设输出是 JavaScript,并且我们可以使用任意数量的 JavaScript 解析器来验证数据。 如果我们的密码失败,或者密码成功但是我们的解析器抛出错误,那么我们需要转到下一个 package.json。 方便的是,Shape Security 已经构建了自己的一套 JavaScript 解析器,可用于 JavaScript 和 Java 环境。 使用的暴力破解脚本如下:
运行 92.1 秒并处理 740543 个程序包后,我们得到了密码——“A Secure Bitcoin Wallet”——该密码成功解码了下面包含的有效载荷:
这真是幸运。 这个原本可能需要耗费大量人力才能解决的问题最终只需要不到一百万次迭代。 受影响的包含有问题密钥的软件包最终是比特币钱包 Copay 的客户端应用。 接下来的两个有效载荷深入到应用本身,鉴于目标应用主要存储比特币,你大概可以猜出它的去向。
如果您发现此类主题很有趣,并且想阅读对其他两种有效载荷或未来攻击的分析,请务必“喜欢”这篇文章或在推特上通过@jsoverson告诉我。