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

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

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

# uname -a
Linux bffe1b883883 4.7.1-040701-generic #201608160432 SMP Tue Aug 16 08:34:52 UTC 2016 x86_64 GNU/Linux

# cat /proc/cpuinfo
……
processor: 39 # count from 0
model name: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz

# free -h
             total       used       free     shared    buffers     cached
Mem:          157G       137G        19G        36G       840M       111G
-/+ buffers/cache:        25G       131G
Swap:          59G       5.1G        54G

# cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 8 (jessie)"
……

# df -h
Filesystem                                                                                      Size  Used Avail Use% Mounted on
/dev/mapper/docker-8:3-263238-5c6fb3c144e2936e9935cc87f464818a8566afcd2381d80b80093a3b5fbbd4d4  9.8G  8.2G  1.1G  89% /
tmpfs                                                                                            79G     0   79G   0% /dev
tmpfs                                                                                            79G     0   79G   0% /sys/fs/cgroup
tmpfs                                                                                            79G   36G   44G  45% /dev/shm
/dev/sda3                                                                                        92G   50G   37G  58% /etc/hosts
192.168.207.171:/home/quant/dataapi-data                                                        3.6T  756G  2.7T  22% /home/jovyan/dataapi-data
192.168.207.171:/date/jupyter-data/[user id]                                                    3.6T  756G  2.7T  22% /home/jovyan/work
192.168.207.171:/home/quant/backtest-data                                                       886G  476G  365G  57% /home/jovyan/backtest-data

# uptime
19:14:54 up 125 days,  8:22,  0 users,  load average: 2.93, 2.47, 3.29

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

Round 0

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

!git clone https://github.com/Jamesits/MindGoWrapper.git
!sh -c "cd MindGoWrapper; git pull"

随后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掉相应变量:

import backtest

backtest.utils.transformer.DIS_ALLOWED_IMPORT=()
backtest.utils.transformer.DIS_ALLOWED_GLOBAL=()

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

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


Round 2

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

exec('import os') # or exec(compile('import os', 'fakemodule', 'exec'))
os.popen("do-something").readlines()

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

Round 3

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

os = eval('__import__("os")')
os.popen("do-something").readlines()

函数黑名单++。

Round 3.5

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

import builtins
os=builtins.__import__("os")
os.popen("do-something").readlines()

很快builtins 库也被拉黑了。

Round 3.667

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

import __future__
os=__future__.__builtins__["__import__"]("os")
os.popen("do-something").readlines()

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

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

Round 4

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

import importlib
os=importlib.__import__('os')
os.popen('do-something').readlines()

库黑名单++。

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() 。这样我们就能获取到某个库内部的函数:

import runpy
os=runpy.run_module("os")
os['popen']('do-something').readlines()

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

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

Round 7

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

import imp
def import2(name, globals=None, locals=None, fromlist=None):
    fp, pathname, description = imp.find_module(name)

    try:
        return imp.load_module(name, fp, pathname, description)
    finally:
        if fp:
            fp.close()
            
os2=import2("os")
os2.popen('do-something').readlines()

成功了!

Round 8

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

import code
console = code.InteractiveConsole(locals())
console.push("import sys; print(sys.version)")

是可以正常输出的。

下一步

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

  • pkgutil等漏下的库
  • 自己写一个具有执行shell命令的module,上传,import然后使用
  • 或者把module用zipapp等方案打包,上传的时候会方便很多
  • 用ctypes或者其它类似方案执行native code
  • ……

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

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

  1. 怪兽

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

    回复

发表评论

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

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