前几天 Samba
公开了一个远程代码执行的漏洞,然后各种 POC 也随之出现, exploit-db
上也有这样一个 Python 版本的 POC: Samba 3.5.0 - Remote Code Execution.
直接执行 POC,报错误信息:
这种情况非常简单,直接 pip install smb
就行,但是:
好吧,我们还是需要 Google 一下这个 smb
的 package 名字,最后发现原来是 pysmb
:
最后 POC 终于跑了起来.
我们再回过头来看看整个流程,似乎并没有什么地方不对劲。
直接说问题所在吧,如果你在 2017-05-24
到 2017-05-31
这段时间内执行过 pip install smb
或者pip download smb
, 那么恭喜你,你的名字可能出现在我的绵羊墙上。
第一天,我在 PyPI 上投放了 4 个 evil package: python-dev
、mongodb
、proxy
、shadowsock
测试一下不检查 package、随意安装 package 的人有多少。
其中所有的内容都是用 cookiecutter
根据模版 cookiecutter-evilpy-package 生成。
每个 package 都会收集用户的
username
hostname
ip
hostinfo
我选择了 GitHub Issues + webtask.io 的方式,将安装 evil package 的用户信息通过 webtask.io 中转到 GitHub Issues 上对外公开。
所以我就在 Github 上注册了个小马甲 evilpackage 专门提交 Issue。
因为 webtask.io 获取客户端 ip 的时候,其实获取到的是 webtask.io 前面 nginx 的 ip 地址,并不是用户的 ip,所以就只能在代码里面获取客户端的外网 ip. 使用 webtask.io 和 GitHub Issues 的主要原因是这两都是免费的。
查看了一天的 Issues 数量,大概有 700+,效果非常不错,决定继续投放 evil package。 与此同时,@ztz同学也加入了游戏,也在 RubyGems 上投放 Gems。
继续投放 evil package,就必须想一些比较好的名字,我主要使用下面两种方法:
Google 搜索提示框
直接根据 Google 的搜索框提示:
便收集到了没有在 PyPI 上注册,而且比较流行的 Package 名字:
caffe
ffmpeg
git
mkl
opencl
opencv
openssl
pygpu
tkinter
vtk
proxy
想象力
依据平时写代码的经验总结出下面可能觉得会常用,但并没有在 PyPI 上注册的 Package 名字:
ftp
smb
hbase
samba
rabbitmq
zookeeper
phantomjs
memcached
requirement.txt
requirements.txt
其中 requirements.txt
并没有注册成功,稍后再说。
晚上回家的时候又统计了一下安装量,一天安装量达到了 2000+,效果已经很显著,不必再增加新的 package 了,但是到了晚上 23:00 的时候, 我的 GitHub Issues 被恶意插入脏数据,所以只能暂停服务:
之所以只能暂停服务,那是因为 webtask.io 没法获取客户端 ip,我也没法 ban 掉对应的 ip,作出任何相对的处理,只能停服务。
话说到底谁才是攻击者。
我专门提交 Issue 的小马甲 evilpackage 因为触发了 GitHub 对 Spam 的检测,所以被封号了。 早上起床看到消息后,立马写邮件申诉,直到 2017-05-26 13:00 终于回复我的邮件了:
为了避免和之前一样被恶意插入脏数据,决定要放弃 webtask.io,每月花费 $10 巨款购入一台 vps。
使用 nginx + flask 的配置,继续将 user data 提交到 GitHub Issues 上。
nginx 的 ngx_http_limit_req_module
模块最大能够支持 1s/m
,也就是最多可以限制每个 ip 在每分钟内最多请求一次, 所以我们必须修改 ngx_http_limit_req_module
模块代码
// src/http/modules/ngx_http_limit_req_module.c if (ngx_strncmp(value[i].data, "rate=", 5) == 0) { len = value[i].len; p = value[i].data + len - 3; if (ngx_strncmp(p, "r/s", 3) == 0) { scale = 1; len -= 3; } else if (ngx_strncmp(p, "r/m", 3) == 0) { scale = 60; len -= 3; } else if (ngx_strncmp(p, "wtf", 3) == 0) { scale = 1000; len -= 3; }
增加一个 else if
block,直接将 scale 增加到 1000,这样就能限制每个 ip 在 16 min 内只能访问一次我们的接口, 除非使用大量代理,不然很难在短时间内插入大量脏数据。
早上起床刷新一下 GitHub Issues 页面,结果发现:
邮件:
赶紧先上服务器加上一行代码,将用户上传的数据先暂时存在本地(之前太懒)。 然后马上回邮件,问情况,两天后:
解封无望,之前的数据大概就是没了。
目前还能通过 GitHub Search 找到以前的部分数据 GitHub Issue
由于之前一直在忙,最后拖到了30号才开始写 web 展示界面 http://evilpackage.fatezero.org/
也准备好新的 cookiecutter 模版 cookiecutter-evilpy-package
新的 cookiecutter 模版会提示用户安装了 evilpackage,并打开用户的浏览器去访问 http://evilpackage.fatezero.org/,让用户知道,自己已经是绵羊墙上的一员了。
计划打算第二天再往 PyPI 上提交新版本的 Package。
早上查找资料的时候发现,原来已经有好几批人干过和我一样类似的事情了
2013-06-06: requestes 0.0.1
2016-01-25: requirements-dev 1.0.0
2016-03-17: Typosquatting in Programming Language Package Managers
前两批都只是上传一个 package 用来提示安装用户,也防止恶意用户使用这些 package 名字, 后面一个小哥和我一样收集了用户不太敏感的信息,只不过他的数据一直没有公开。
过了一会 @ztz 同学告诉我他的 RubyGems 被清空了。
再过了一会我这边也被 PyPI 管理员警告要删除账号了,所以我就把所有的 Package 给删除了,账号也给删除了。
目前为止所有的 package 又回到了 unregister 的状态, 任何人都可以继续注册使用我之前注册的 package.
#https://www.secpulse.com/archives/58515.html
目前我只能对在 http://evilpackage.fatezero.org/ 上那 10685 条数据进行统计
从 2017-05-27 10:38:03 到 2017-05-31 18:24:07,总计 106 个小时内, 有 9726 不重复的 ip 安装了 evil package,平均每个小时有 91 个 ip 安装了 evil package。
每个 package 命中排名:
2862 opencv 2834 tkinter 810 mkl 789 python-dev 713 git 683 openssl 535 caffe 328 ffmpeg 224 phantomjs 200 smb 191 vtk 179 pygpu 113 mongodb 70 requirement.txt 56 memcached 31 rabbitmq 15 ftp 14 shadowsock 12 samba 10 proxy 10 hbase 5 zookeeper |
前 50 个国家命中排名
2507 United States 1667 China 772 India 481 Germany 448 Japan 331 France 319 Republic of Korea 306 United Kingdom 305 Russia 297 Canada 225 Brazil 183 Australia 179 Netherlands 167 Poland 147 Taiwan 129 Italy 127 Israel 126 Spain 106 Singapore 103 Ukraine 89 Hong Kong 87 Switzerland 76 Sweden 74 Turkey 60 Ireland 57 Vietnam 57 Iran 54 Belgium 53 Finland 52 Austria 49 Pakistan 49 Indonesia 47 Argentina 43 New Zealand 42 Mexico 41 Romania 40 Thailand 37 Norway 37 Czechia 31 South Africa 31 Denmark 31 Colombia 29 Portugal 29 Greece 29 Chile 24 Philippines 23 Malaysia 20 Hungary 20 Belarus 19 Nepal |
每个访问排名
28 114.255.40.3 25 46.105.249.70 16 54.84.16.79 16 54.237.234.187 16 54.157.41.7 16 54.145.106.255 16 52.90.178.211 13 34.198.151.69 12 52.221.7.193 11 54.235.37.25 10 34.224.47.129 9 172.56.26.43 7 94.153.230.50 7 80.239.169.204 7 73.78.62.6 7 54.87.185.66 7 52.207.13.234 7 113.140.11.125 6 52.55.104.10 6 24.108.0.220 |
光从这几天来看,在 PyPI 上投放 evilpackage 的效果还是非常不错的, 每天都会有大概 2200+ 个独立 ip 进行访问,数据量稍微比之前那位小哥好一点, 也就是说,即便是类似的文章发出来,过了一年之后,随意安装 package 的这种情况完全没有改善,可能更严重了。
那位小哥释放掉所有的 package 之后,我作为一个 “恶意者” 再次使用他之前使用的 git
、openssl
名字来统计数据, 我作为一个 “恶意者”,被官方勒令删除所有的 package,这些 package 名字再次被释放,我比较好奇下一位 “恶意者” 会是谁, 会在 package 里放什么?会是和我一样收集数据,还是直接rm -rf /
,还是勒索。拭目以待。
一般经常使用 Python 的人都知道 requirements.txt
是整个项目的依赖文件,一般这样使用:
pip install -r requirements.txt
不过也有可能一时手速过快,敲打成
pip install requirements.txt
所以 requirements.txt
也是一个比较好的 evil package 的名字
requirements.txt
在 2017-05-24 19:00 晚上,我尝试在 PyPI 注册上传 requirements.txt
的时候:
嗯,都失败了,但是 GitHub Issues 上竟然会有 153 个和 requirements.txt
相关的 Issues:
我并不怀疑这些 requirements.txt
数据的真实性,因为就没有人知道我尝试上传过requirements.txt
,所以这些数据肯定是真实的。
PyPI 上也并不存在 requirements.txt
信息,本地尝试安装也失败了,至今仍未明白这种情况为何发生。
requirements.txt
的限制在 PyPI 账号被删除之后,我还是对 requirements.txt
很好奇,为什么之前 GitHub 上会有记录? 能不能绕过 PyPI 的限制?下面简单讲一下如何绕过 PyPI 的限制。
我们直接查看提交 Package 时,PyPI 对 Package 名字限制的地方:
# from: https://github.com/pypa/pypi-legacy/blob/master/webui.py#L2429 @must_tls def submit_pkg_info(self): # ... # ... name = data['name'] version = data['version'] if name.lower() in ('requirements.txt', 'rrequirements.txt', 'requirements-txt', 'rrequirements-txt'): raise Forbidden, "Package name '%s' invalid" % name
通过上面的代码,我们可以看到 PyPI 直接硬编码 'requirements.txt', 'rrequirements.txt', 'requirements-txt', 'rrequirements-txt'
禁止用户上传这些文件。
我们再看看 pip install xxx
的时候,PyPI 是怎么查找 Package 的:
# from: https://github.com/pypa/pypi-legacy/blob/master/store.py#L611 def find_package(self, name): '''Return names of packages that differ from name only in case.''' cursor = self.get_cursor() sql = 'select name from packages where normalize_pep426_name(name)=normalize_pep426_name(%s)' safe_execute(cursor, sql, (name, )) return [r[0] for r in cursor.fetchall()]
好吧,直接查找数据库,我们再跟下来看 normalize_pep426_name
:
# from: https://github.com/pypa/warehouse/blob/master/warehouse/migrations/versions/3af8d0006ba_normalize_runs_of_characters_to_a_.py#L27 def upgrade(): op.execute( """ CREATE OR REPLACE FUNCTION normalize_pep426_name(text) RETURNS text AS $$ SELECT lower(regexp_replace($1, '(\.|_|-)+', '-', 'ig')) $$ LANGUAGE SQL IMMUTABLE RETURNS NULL ON NULL INPUT; """ ) op.execute("REINDEX INDEX project_name_pep426_normalized")
看到中间那个正则了吧,这也就意味着
pip install youtube-dl pip install youtube_dl pip install youtube.dl pip install youtube-_-dl pip install youtube.-.dl
这几条命令其实都是等价的,都是在安装 youtube_dl
, 那么我们就可以很容易的就绕过 PyPI 的限制, 直接上传一个 requiremnets--txt
:
twine register dist/requirements--txt-0.1.0.tar.gz twine upload dist/requirements--txt-0.1.0.tar.gz
来来来,我们直接尝试 pip install requirements.txt
:
通过上面的图,我们可以看到 PyPI 已经返回我们的 package url, 到了 pip 准备安装这个 package 的时候报错了,所以直接看 pip 代码:
# https://github.com/pypa/pip/blob/master/pip/index.py#L650 if not version: version = egg_info_matches(egg_info, search.supplied, link) if version is None: self._log_skipped_link( link, 'wrong project name (not %s)' % search.supplied) return
看了代码,也就是没法在 url 中获取 package 的版本号, 因为 package 的名字(requirements--txt
)和搜索名字(requirements.txt
)对不上,我们得找找其他方法:
# https://github.com/pypa/pip/blob/master/pip/index.py#L626 if ext == wheel_ext: try: wheel = Wheel(link.filename) except InvalidWheelFilename: self._log_skipped_link(link, 'invalid wheel filename') return if canonicalize_name(wheel.name) != search.canonical: self._log_skipped_link( link, 'wrong project name (not %s)' % search.supplied) return if not wheel.supported(self.valid_tags): self._log_skipped_link( link, 'it is not compatible with this Python') return version = wheel.version
看到这里,大家应该也知道了,之前我们一直都是使用 source 的方式提交 package,如果我们直接打包成 wheel, 根据上面的代码,就不会再报错了,我们重新打包,再次上传:
终于成功了,当然 wheel 安装方式并不能直接执行命令, 不过我们可以通过给 requirements.txt
添加一个恶意依赖达到执行任意代码的效果。
在这里,我就添加了一个名为 ztz
的 source package,用于提醒安装 requirements.txt
的用户
想做一件坏事情真不容易,快去看看 http://evilpackage.fatezero.org/ 上面有没有你的名字。
【原文:Package 钓鱼 作者:fatezero 安全脉搏整理发布】