在Python 3 (IPython)中执行shell命令的一万种方法

事情要从几个月前说起。有一天某位学金融的同学找到我,问有没有兴趣参加一个量化股票交易比赛。当时我觉得反正也无聊就答应了。等拿到竞赛规则一看,发现该竞赛竟然使用同花顺MindGo量化交易平台。我之前稍微接触过一些相关产品,虽然对此类平台的通病有心理准备,但是当时同花顺MindGo平台的糟糕程度仍然让我大吃一惊。该文中提到的界面和功能上的问题现在大多已经解决,不过不知出于什么原因,该平台不希望用户执行shell命令。在比赛期间,我为了解决一些该平台API的糟糕设计,写了一个回测框架加上部分API的wrapper,但是上述限制让我没法方便地使用 git pull 更新代码了。于是我和MindGo展开了一场旷日持久的在IPython中执行shell命令的战争。

先简单检查一下运行环境。Docker里启动一个Jupyter服务器运行IPython3,正好是熟悉的场景。4路E5-2650v3(甚至可以用来挖矿),上百G内存,只限制了每用户1G存储空间和1G内存,真是太爽了!

一场没有硝烟的战争即将打响。

Round 0

IPython允许把shell命令作为python语句执行,语法是 !do-something 。也可以%%sh 这一line magic把整个cell作为shell脚本解释执行。

随后MindGo通过IPython的custom input transformation禁用了 get_ipython() 函数,这一功能就无法使用了。

Round 1

Python被设计为所有东西都可以monkey patch,即在运行时被改写。MindGo的基础功能在同花顺私有的 backtest 库中实现;通过 open() 读出 transformer.py 内容可知,被拦截的AST对象存储在两个变量 DIS_ALLOWED_IMPORT 和 DIS_ALLOWED_GLOBAL 里面。于是直接 import 该库,monkey patch掉相应变量:

接下来就可以该干啥干啥了。

随后MindGo把 backtest 库和 ast 库加入了禁止访问的对象列表,该方法失效。


Round 2

Python有很多内置库可以执行shell命令,比如著名的 os , sys 和 subprocess 。MindGo那边虽然想法比较蠢但是技术上没有那么蠢,当然一起把这几个库也禁止导入了。不过,既然拦截是在AST层面上完成的,如果让 import 不出现在AST中,是不是就能骗过AST检查器了呢?于是显而易见:

很快 exec() 也进入了函数黑名单。

Round 3

继续上面的思路骗过AST检查器,考虑到 import 有个等价的内置函数 __import__() ,对之前的代码稍作修改,改用尚未被屏蔽的 eval() :

函数黑名单++。

Round 3.5

Python的很多函数都可以在不止一个地方找到。比如说你 import builtins 以后就可以继续用 builtins.open() 和 builtins.__import__() 这些被禁用的函数了。不过这跟上面的思路其实一样。

很快 builtins 库也被拉黑了。

Round 3.667

您接着屏蔽,我还是能找出 __import__() 来。

这里还有一些漏下的东西:

  • __future__.__builtins__["__loader__"]
  •  (需要导入 _frozen_importlib 库,是上面那个东西的本体)

Round 4

Python的 import 功能是由 importlib 库实现的。于是:

库黑名单++。

Round 5

CPython的Linux实现里有一个 pty 库用来spawn一个pseudo terminal,用它可以启动一个shell并和它的stdin/stdout/stderr进行数据交换。不过经过尝试,MindGo甚至连 open() 都加入了黑名单,从pty拿到file descriptor以后没有办法读写数据,盲调用则运行到该行时直接卡死。看来思路不可行。

这次对 open() 的屏蔽则是让战争从白盒状态进入了黑盒状态,因为没有办法读取到 backtest 库的源代码了(当然还是有的)。我隐约地感觉到,前方的敌人越发危险了。

Round 6

Python内置库被封了一大片,看来得换思路了。如果不导入,只是运行该库呢? runpy 库正好提供了这样的功能:找到某个库,运行其 __init__.py ,然后返回 globals() 。这样我们就能获取到某个库内部的函数:

一天以后库黑名单++,反应真是迅速啊。而且不仅库被封,所有名字看起来像是有问题的(比如一个叫做 os 的对象或者一个叫做 __import__ 的对象)都会在AST上加以拦截。

看来有些用户得改一些变量名喽。

Round 7

于是我在某个周五晚上提出了新的方案,看同花顺程序员是今晚加班还是明天周六加班。有一个deprecated的老库叫 imp ,之前被用来实现 import 功能,现在基本上已经被 importlib 替代了。廉颇老矣,让我们来看看它尚能饭否。

成功了!

Round 8

如果把代码在一个不受IPython影响的环境里运行呢?Python有一个叫 code 的库可以用来启动一个干净的Python运行环境;只不过,和原来代码的交互可能构成一些问题。我们来试一下:

是可以正常输出的。

下一步

文章写到这儿,其实思路已经比较局限了,从执行shell命令的一万种方法变成了花式import库的一万种方法。如果自带的方法不给用,那就想办法导入一些别的;如果导入都没法导入,那就想办法找新的导入函数。其实除了以上这些方法,我们还有一些更加投机取巧的办法,比如:

  • pkgutil等漏下的库
  • 自己写一个具有执行shell命令的module,上传,import然后使用
  • 或者把module用zipapp等方案打包,上传的时候会方便很多
  • 用ctypes或者其它类似方案执行native code
  • gc.get_objects()获得对所有对象的引用,然后在里面找到想要的东西(参考https://twitter.com/adolli/status/1170267214571696128
  • ……

不过这些就超出本文探讨的范围了。这比赛我不想玩了,写下本篇文章,记录下与人斗的其乐无穷。

在Python 3 (IPython)中执行shell命令的一万种方法》有5个想法

  1. 怪兽

    这他妈太好玩了,学习的乐趣所在啊。还是衍生了很多安全问题,我自己开放了自己的vps上的一个jupyter。。。

    回复

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据