原标题:见识一下,Python代码可以写的多么乱?
前几天,在我闲逛的时候,发现了一行有趣的Python代码:
这种不合常规的表达式不是那么的清晰易懂,实际上这个字符串格式操作中将会使用port当前的值替换两处{port},它们中间用一个冒号分隔,然后再赋值给port。因此如果port当前的值是“foo”,赋值完之后port最新的值是“foo:foo”。
一些人当然建议使用更有可读性的f-strings格式化字符串:
但是使用这个表达式的人说,他们能够使用的Python版本不够新(f-strings在Python3.6才开始引入)。因此我建议使用这种方式,它在Python2.6以来的所有版本都能运行:
然后我开玩笑的表示如果有需要的话,我可以编写可读性更差的代码实现上面代码功能。有人回复说让我把代码编写出来试试,下面我就花一些时间讲一下如何实现它。然后再优化一下代码,最终的代码如下:
运行下试试,它将打印出“foo:foo”。或者把port定义的初始值改变为任意你想要的值,再运行一下试试。
在实际代码中当然不会使用这种代码。能够写这样的代码并不是一个有用的技能(这同样适用于写出可读性更差的代码,通过更加深入地使用任何技术或者使用其他方式使代码的实际作用模糊不清,这并不是那么困难)。但是它有些愚蠢和搞笑,写这些代码对高级开发者来说是个不错的放松方式。要理解它为什么这样运行,以及输出如何生成,探索Python的一些知识,并在实际中运用才是有用的。
因此让我们来推敲下这段代码。
到底发生了什么
上面大量的代码和我最初建议使用的port = "{0}:{0}".format(port)做的事情完全一致。它只是构建字符串,但是方法调用和参数处理使用了非常迂回的方式。
隐藏方法调用
Python的getattr函数让你传递你进去一个对象和一个属性名(字符串),然后它返回给你对象的属性。比如x.y和getattr(x, 'y')的作用是一致的。这对探查一个对象的属性(特别是和hasattr搭配使用时)是有用的,并且对于处理一个不存在的属性它也提供了一个简洁的语法:你可以向getattr传递第三个参数,这样如果在对象上不存在属性,就会返回这个值。
因此"{0}:{0}".format(port)可以被重写为getattr("{0}:{0}", "format")(port)。你永远不会想在实际代码中这样做,如果你知道Python的str类型有一个format方法,你可以直接调用它,但是可读性在实际中才考虑,不是这里所要考虑的。
一个非常啰嗦的角色
现在让我们使用格式化字符串。"{0}:{0}"这样太简单,看一眼就能理解。因此让我换一种方式生成它。
我们要做的是构建 ":" 和两个独立的"{0}",再把他们结合起来。因此首先需要获得一个冒号字符。如果只是写 ":" ,这太简单,因此通过更复杂的方式获取它,甩掉马虎的读者。
一种方式是通过字节值生成它,然后将它解码成一个字符串。在ASCII码中,这个字符用1个字节表示,它的值为十进制的58。因此我们需要一个方法来生成数字58。当编写这段代码时,这是我最后做的,缺乏新颖的想法,因此只用了位运算。你有很多方式获得58,但是我使用63 ^ 5(这是异或操作)。为了更有迷惑性,63用十六进制表示,5用二进制表示(Python支持整数字面值用二进制、八进制和十六进制表示)。63特别好的一点在于它是2**6 - 1(2的6次方减1),2的幂次方或者是2的幂次方减1的数字能够很好地引起读者的注意。
然后将它从一个字节值解码转成单字符的字符串。从ACSII码解码太明显,因此我们使用 Windows-1252解码,一种很唬人编码方式,因为它即普通又特别,就像周末凌晨3点拉响你的寻呼机一样。代码如下:
两种获取单字符的字符串":"方法都可以使用。哪些{0}格式串又该怎样处理呢?
未定义浮点数准备就绪
为了获取到完整的"{0}:{0}"格式字符串,我们仍然需要那两个位置占位符。一个技巧是我们可以通过其他方式来表示字符串"{0}"。Python可以做到:通过set类型,它是一种包含唯一值的集合类型(比如,一个从列表[1, 1, 1, 1]生成的集合只包含一个元素)。尽管有一个内置的set函数可以用来从可迭代对象创建一个集合,但是你也可以通过字面量语法,用大括号包装内容。这非常有用,因为字面量语法也是集合默认的字符串表示方式,因此如果我们创建一个只包含整数0的集合,它的字符串表示是"{0}",这正是我们想要的。
如果仅仅字面上写出集合{0},这就太明显了。写set([0])也是如此。那么我们怎么能得到整数0呢?
这里我们求助于Python的一些细枝末节:Python最初没有bool类型,因此大多数程序员使用整数0和1作为替代。当布尔类型最终在Python2.3引入时,它是以与旧的整数约定兼容的方式完成的:bool类型是int类型的子类,两个实例False和True的整数值分别为0和1。你可以将他们插入到任何需要整数的位置,他们同样能够正常运行。
因此如果我们需要一个0,我们可以通过找到一些非真值的布尔表达式,然后狡猾地将它转换成一个普通的int类型。
我所选择的布尔表达式是math.nan.is_integer。常数math.nan提供了IEEE 754浮点数规定的NaN值,它是Python的浮点数类型。NAN是某些类型编程的祸根,因为你可能会在浮点数运算时生成它,并导致异常。
但它是浮点数实例,这意味着它具有Python浮点数的常见方法和属性,包括is_integer,用于判断浮点数是否等于某个整数。例如,如果my_float = 2.0,然后my_float.is_integer为真值(但是如果my_float = 2.01,则为非真值)。因为NaN绝不可能是整数,所以math.nan.is_integer将会给我们返回一个布尔值False。
把它转换成一个普通的int,这就不太明显了。内置函数sum很适合;它会欣然地为我们将bool列表中的值加和(bool实例是整数,支持算术运算),返回值为int类型。因此这样写sum([nan.is_integer]) == sum([False]) == sum([0]) == 0。我们将其输入set(再次在列表中包装它,因为set需要一个可迭代的),然后使用repr处理它(repr类似于str,但这样更好调试,可用来重构打印的内容;我故意写的含糊不清,以掩盖使用str和repr的真实意图)。代码如下:
上面表达式计算后为“{0}”,意味着我们可以使用它来代替文字字符串。
现在这里有两个“{0}”!
但是在我们的格式化字符串中,我们不只需要一个"{0}"占位符。我需要两个,一个在冒号的另一侧。我们可使用*操作符,它不只做乘法运算。
Python支持运算符重载,这意味着运算符及流控制协议,通过定义特殊名称方法在任何参数类型上都可实现重载。并且Python的内建类型用到了这一特性。* 运算符重载的实现和行为取决于操作数的类型;当两个操作数都是int类型时,表示两者相乘。但是当一个是int类型,另一个是序列类型时,它对序列每个元素乘积生成新的序列。你试一下:在Python解释器中输入[1,2,3] * 2。
(如果你在没有实现的类型上使用操作符时,比如两个字符串类型,会报TypeError错误)
因此,我们可以使用*运算符将占位符字符串的一个实例转换为任意数量的占位符字符串。我们想要两个,我们可以用列表的形式,通过执行[“{0}”]*2,得到[“{0}”,“{0}”]。我们已经有一个表达式的计算结果是{0}。我们可以把它括在括号里,然后附加上 * 2,但是这有什么乐趣呢?
为了生成2,使用异或运算符,但这次是通过operator.xor函数(标准库中的 operator 模块提供了Python的封装在函数中的标准操作符;operator.xor(x, y) 会返回x ^ y异或运算后的结果)。很多表达式都会这样做,但是我选择6 ^ 4,因为它让我在代码中嵌入了一个可疑的能够转移视线的东西。
我解释一下:程序员对“魔数”很敏感。2的幂很常见。6 ^ 4用两个单独的数字,联合起来是64,这是2的幂。在这里,我们看到实际的int 64—尽管在其八进制形式中指定为0o100—出现在代码中。把它传给str,则返回字符串“64”。
Python的字符串是序列类型,这意味着对其他序列(如列表和元组)的操作也对字符串有效。使用这个特性对序列“64”进行操作,将字符转换为整数,再传给 operator.xor (我们是为了得到6 ^ 4 = 2)。
拼凑起来:[i for i in map(int, "64")]。内置的map接受一个函数和一个序列,返回函数应用于序列中的每项的结果。这里返回[6,4],把它提供给 operator.xor 来执行6 ^ 4,最后得到想要的2。
为此,我们需要使用*来触发Python的可迭代解包行为:some_function(*arg),其中的arg是一个列表或其他可迭代的对象,把 arg “解包”到一系列单独的参数中,然后传递给some_function。因此,如果arg = [1, 2, 3],那么some_function(*arg)完全等价于some_function(1,2,3)。这也意味着两个 * 用途不同。
但这里还有两个技巧。一是我们不直接使用 map(int,…) 。相反,我们提供第二个可选的int类型参数,表明传入的数字时使用什么进制。这里我使用了16进制,它不会影响将“6”和“4”转换成整数的结果,但是让我通过指定基数用十六进制数字 0x10 转换成八进制数字 0o100。
另一个技巧是,在 map 中执行此操作需要lambda表达式,它定义一次性的匿名函数,该函数将接收的字符串传递给 int,额外的进制参数。当然那个匿名函数需要命名它的参数。那么用什么名字呢?为什么,当然是 іn。现在,in是一个Python操作符,所以它是保留符号;在Python中,给任何东西命名都是合法的 - 函数、类、变量、参数,你都可以用in来命名。Python允许你在标识符名称中使用范围广泛的Unicode,这里禁用的拉丁字母 i ,这个外形相似的西里尔字母会替代保留关键字in,这在Python中是完全合法的,创建了一个名称的外形看起来和内置的in操作符一模一样,但至关重要的是它不是in操作符。
因此我们代码这样编写:
这是一种非常迂回的方式实现 ["{0}"] * 2,这就得到了["{0}","{0}"]。我们几乎已经能够编写“{0}:{0}”.format了!
说到格式
我们需要getattr("{0}:{0}", "format")。我们如何得到这个“format”呢?
Python中还有一个叫做format的内置函数。在本例中,format(x, y)与x.format(y)作用相同。这里我们将format函数传递给str。返回的函数的字符串表示,即字符串“”。现在需要提取字符串中的“format”,分三步:
使用Split方法用空格拆分,生成[""]。
使用索引[-1](索引号-1指向列表或元组中的最后一项)来获得“format>”。
提取除最后一个字符之外的所有字符:[:-1]。
最终得到字符串“format”:
现在构建好了getattr("{0}:{0}", "format")序列。我们需要调用它(字符串“{0}:{0}”的format方法)并传入参数:port。
字符组成的字符串
我们最终将需要实际的变量port,但是我们可以通过首先生成字符串“port”来实现。再说一遍,我们不想让它太明显!因此,我们将以一种更复杂的方式,重复使用上面得到字符串“format”的技巧。我们将不再寻找包含精确的子字符串“port”的字符串表示,而是查找包含单个字符“p”、“o”、“r”、“t”的字符串表示,然后将它们连接起来。
这就是repr(repr)、repr(repr)、repr(str)、str(repr)序列在最后所做的:repr函数的字符串表示为“”,而str函数的字符串表示为“”。所以我们要寻找那些特定字符首次出现的字符串中的索引:
p在repr表示中第一个索引为21。
o在repr表示中第一个索引为16。
r在str表示中第一个索引为10。
t在repr表示中第一个索引为5。
它看起来又像使用一个保留名生成序列,在这个例子中保留名是for:
这次的诀窍也是使用西里尔字母(这次替换拉丁字母o)。
但是range函数调用怎么办?现在,您可能已经掌握了足够的技巧,可以计算出它正在执行range(1,5)以获得所需的值(我们需要包含数学序列的下标1-4)。但它还是使用了bool类型是int类型的子类型的技巧,这就是如何获得取值范围起始位置1的方法。对于5从哪里截取,我们的做法是将31(十六进制0x1f)传递给bin,bin返回它的二进制字符串(“0b11111”)。然后我们使用.count来计算字符1在该字符串中出现的次数:它是5。为了获得该操作的单字符字符串“1”,我们使用repr(--True),它再次使用True作为值为1的整数,并对其进行两次取反(第一次取反后值为-1,第二次取反则将其转换回1)。
最后,我们需要真正的提取出正确的字符。我们在这里生成了两个序列:一个是整数列表(21、16、10、5),另一个是字符串列表,在这些整数给出的索引中可以找到字符串中所需的字符。这些序列具有相同的长度,因此我们可以将它们传递给zip,创建一个将它们组合在一起的元组列表。生成的元组列表是这样:
这表明字符串对应的索引,再用operator.itemgetter获取字符。
itemgetter函数是内置操作符的另一个包装器:在本例中,itemgetter(x)(y)相当于y[x]。我们再次使用外形相似字符的技巧做出具有令人迷惑名字的循环变量(在本例中看起来像保留字 not),还有布尔类型是整型子类型的技巧(我们想要的东西在我们生成的元组中索引的位置为0和1,所以布尔类型能够很好地处理)。代码如下:
这段代码生成的序列["p", "o", "r", "t"]。
然后我们将在一个空字符串上使用.join:"".join(["p", "o", "r", "t"])的结果是"port"。
为了生成空字符串,我们使用str(str)[1:1],乍一看它似乎做了一些事情(因为它使用的是非零索引),但实际上只是取了字符串str(str)的一个长度为零的切片。
我们的进展很快
最后,我们需要将字符串“port”转换为对实际变量port的引用。有几种方法可以使用,当我选择vars时,我完全没有使它变的充满迷惑性。这是一个函数,它将返回一个本地定义变量的字典(当无参数调用时),或者一个对象的属性/方法的字典(当使用一个对象作为参数调用时)。你还可以使用locals(它只能获取局部变量的字典,不支持处理对象),甚至globals(全局变量的字典)。vars["port"]实际上是本地变量port。
由于我们已经生成了字符串“port”,我们可以把它作为一个主键传递给vars返回的字典,然后最终完整的代码如下:
它等价于:
但是,为什么要这样做?
再次强调,“编写模糊代码”作为一种通用技能,在大多数情况下并不是特别有用。但是:考虑一下Python和它的标准库中有多少不常用和怪异的特性能实现这个功能。尽管它不是很好的代码(实际上非常糟糕!),但它至少可以让具有几乎任何经验水平的Python程序员学到一些东西。
即使不去学习更多关于Python的知识,上面使用的一些技术对实际场景的代码也有安全方面的影响,并且可能会让你有点紧张,使你联想到“如果有人对我的应用程序做了那样的事情会怎么样?” 我想这就是有像国际模糊C竞赛这样的事物存在的原因。当我讲web应用程序安全课程时,我经常提到JSFuck,它其中一种技术,将JS代码转换成只有6个字符的字母表编写的等价代码。以Django为例,Web框架无法保护您不受这些技术的影响,因此了解这些技术可能是有用的知识。
最终,我这样做的原因是因为我觉得这很有趣。我喜欢编程,但这不是我通常为了娱乐而做的事情;在我的工作和开源社区中,我花了大量的时间编写代码。然而,每隔一段时间,我确实会为了这样的乐趣而编写一些无用的代码(而不是出于愤怒编写一些无用的代码,这种情况偶尔也会发生)。
英文原文:https://www.b-list.org/weblog/2020/jan/20/fun/
责任编辑: