前言

这是一篇闲蛋疼的文章, 讲述了闲蛋疼的我是如何历经千辛万苦终于在 Python 中让 'a' is 'b' 得到 True.

(虽然这件事情毫无意义)

(不过拿来坑下一任或许是个好主意呢)

动机

这个想法最早在六月中旬就产生了, 主要是看了知乎上的两个回答,

然后我想, 那能不能来个 'God' is 'girl' -> True 之类的.

当时也尝试了一下, xjb读了一下源码, 尝试使用 ‘a’ 的内存覆盖 ‘b’ 的内存, 然而最终只做到了 hash('a') == hash('b').

而且读完 is 的源码后, 觉得这不大可能实现, 于是就放弃了. (因为发现 is 比较的是两个对象的指针, 而不是内容, 所以两个不同的对象进行比较是无论如何都不可能 True 的, 即使 hash 什么的都一模一样)

四个月后我又回想起了这个问题, 我觉得我应该有能力再次挑战了, 然后果然挑战成功了!

于是写下这篇文章, 记录一下自己在这个过程中学到的东西.

先试试常规手段

1. 直接交换

False = True

很简单粗暴的想法, 然而 python 的回应也很简单粗暴: 关键字不能赋值.

1
2
3
4
5
In [1]: False = True
  File "<ipython-input-1-e3c38088f793>", line 1
    False = True
                ^
SyntaxError: can't assign to keyword

在 Python2 中, 这行代码其实是可以执行的, 不过这只是改变了字面量 False 的值, 并没有达到我的要求.

1
2
3
4
5
6
7
In [1]: False = True

In [2]: 1 == 2
Out[2]: False

In [3]: False
Out[2]: True

2. globals()

既然不能直接赋值, 那试试用 globals() 来赋值?

1
2
3
4
5
6
7
8
9
In [1]: globals()['a'] = 1

In [2]: a
Out[2]: 1

In [3]: globals()['False'] = True

In [4]: False
Out[4]: False

仍然失败, 想想也是, 在 Python3 中 False 已经是一个关键字了, 优先级肯定高于变量. 即使有同名变量也会被遮蔽.

3. 重载 __eq__

想对身为关键字的 TrueFalse 做点什么实在是太难了, 换个思路试试: 重载 str.__eq__, 让它不管三七二十一全部返回 True ! 妙啊妙啊, 我真是太聪明了.

虽然不能做到 'a' is 'b' 但能实现 'a' == 'b' 的话也是一个进步啊.

1
2
3
4
5
6
7
In [10]: int.__eq__ = lambda : True
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-10-71a46054a87d> in <module>()
----> 1 int.__eq__ = lambda : True

TypeError: can't set attributes of built-in/extension type 'int'

然而又失败了…

4. 重载 __repr__

不死心的我又想试试 __repr__, 让 bool 对象的 __repr__ 始终返回 True

仍然失败了, 嘛…毕竟是内置类型.

看来内置的玩意儿通过常规方法是改不了, 那就读源码, 用 ctypes 强行改一波内存.

后来我还想到一种适用于 ipython 的方法, 即自定义一个 ipython formatter, 相当于 ipython 专用的 __repr__

比如我曾经实现过的一个小插件, 可以让一个 bytes 不管是否包含可见字符, 都以十六进制的形式输出 https://gist.github.com/Aloxaf/de3de8e7c0b8913335847afd3ff76cc7

然而还是没有什么卵用…bool类型跟开挂一样无视了 ipython formatter

这个很奇怪, 不过原因以后再找

还是老老实实读源码吧

0. 搭建调试环境

这几个月熟悉了一下棒棒的 gdb, 这次要利用起来

1
2
3
4
5
6
# gitee 的唯一用处
wget https://gitee.com/Aloxaf/cpython/repository/archive/v3.7.1.zip
unzip v3.7.1.zip
cd cpython
./configure --with-pydebug
make -j4

非常迅速地编译完了, 接下来运行 gdb ./python. 不出意外的话会提示你给 python-gdb.py 添加一个啥 add-auto-load-safe-path~/.gdbinit, 照它说的添加就行. 可以提升调试体验. (如果你的 gdb 什么插件都没装的话, 建议安装一个 pwndbg, 能让你从此爱上 gdb)

然后运行 echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope 允许 attach 进程

接着创建一个 Python 文件, 写入如下内容

1
2
3
4
5
from time import sleep

while True:
    print(1 == 1, 1 == 2)
    sleep(1)

然后 ./python test.py& 执行, 记录 pid 并用 gdb ./python $PID attach 到 Python 进程上.

~~阅读源码我没找到啥好软件(有棒棒的"跳转到定义"的那种), ~~ 我用的 clion, 然而跳转太残废了, 也有可能是我不会配吧 (一般拿 clion 写的是 rust 233

所以我采用了 ripgrep 来搜索定义…然后在 clion 里手动定位….

(这个效率太感人了, 如果谁有什么好的方式还望不啬赐教.)

Google了一下这个问题, Viewing CPython Code in CLion

只需要创建一个小小的 CMakeLists.txt, 就能使用自动跳转了!!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
make_minimum_required(VERSION 3.0)
project(cpython)

file(GLOB SOURCE_FILES
    Python/*.c
    Parser/*.c
    Objects/*.c
    Modules/*.c)

include_directories(Include)

add_executable(cpython ${SOURCE_FILES})

1. PyObject *

因为有了先前的经验, 这里直接 b Python/ceval.c:2585 就可以断在 COMPARE_OP 的地方.

(先前是不断单步走到这儿的…)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
────────────────────────────[ SOURCE (CODE) ]─────────────────────────────
   2580         TARGET(COMPARE_OP) {
   2581             PyObject *right = POP();
   2582             PyObject *left = TOP();
   2583             PyObject *res = cmp_outcome(oparg, left, right);
   2584             Py_DECREF(left);
  2585             Py_DECREF(right);
   2586             SET_TOP(res);
   2587             if (res == NULL)
   2588                 goto error;
   2589             PREDICT(POP_JUMP_IF_FALSE);
   2590             PREDICT(POP_JUMP_IF_TRUE);

通过 py-list可以查看当前执行到 .py 文件的哪一行.

p res输出了 $1 = False, res 明明是 PyObject *类型, 却直接输出了内容, 这就是开始那个 python-gdb.py 的作用.

p *res 解引用看一下这个结构体, 发现看不懂

1
2
3
4
5
6
$2 = {
  _ob_next = 0x7fd80d9afba0, 
  _ob_prev = 0x7fd80d9afc08, 
  ob_refcnt = 138, 
  ob_type = 0x6b2d00 <PyBool_Type>
}

rg '} PyObject' 搜索一下定义, 在 Include/object.h:110

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#define _PyObject_HEAD_EXTRA            \
    struct _object *_ob_next;           \
    struct _object *_ob_prev;

/* Nothing is actually declared to be a PyObject, but every pointer to
 * a Python object can be cast to a PyObject*.  This is inheritance built
 * by hand.  Similarly every pointer to a variable-size Python object can,
 * in addition, be cast to PyVarObject*.
 */
typedef struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;
    struct _typeobject *ob_type;
} PyObject;

大概意思就是不管什么对象大家都可以是 PyObject *, Python 内部也以这个指针来传递各种对象, 显得统一. 要取这个对象内容的时候再根据 ob_type 特事特办

第一次调试到这里的时候我没认真看注释, 天真地以为通过 memmove(id(True), id(False), sizeof(PyObject)) 就能用 True 的内容覆盖掉 False, 事实上 sizeof(PyObject) 根本根本不是 True Object 的真实大小.

那么, 把 sizeof(PyObject) 改大一点行不行? 其实还是不行, 原因在下面…

2. Py_False_Py_FalseStruct

那么问题来了, False 这玩意儿真实的(C)类型是什么?

Python/ceval:4686 行可以找到 cmp_outcome 的定义, 结尾是这么写的.

1
2
3
4
    v = res ? Py_True : Py_False;
    Py_INCREF(v);
    return v;
}

也就是说 (上一个)res 的值是 Py_TruePy_False, 而它俩的定义在 Include/boolobject.h:21

1
2
3
4
5
6
7
8
9
/* Py_False and Py_True are the only two bools in existence.
Don't forget to apply Py_INCREF() when returning either!!! */

/* Don't use these directly */
PyAPI_DATA(struct _longobject) _Py_FalseStruct, _Py_TrueStruct;

/* Use these macros */
#define Py_False ((PyObject *) &_Py_FalseStruct)
#define Py_True ((PyObject *) &_Py_TrueStruct)

可以发现, Py_False 这玩意儿只是个宏, 代表的是 _Py_FalseStruct 的地址. 显然, 即使我们用 _Py_TrueStruct 的内容覆盖 _Py_FalseStruct 的内容, Py_TruePy_False 的值也不会受影响.

而且 is 操作符的实现非常简单粗暴, 也不大可能对 is 操作符动什么手脚

1
2
3
    case PyCmp_IS:
        res = (v == w);
        break;

就这么结束了吗? 不, 上次在 python repl 环境中无法重载内置类型的方法, 那直接写内存有没有可能重载呢?

3. bool_repr

Python/boolobject.c:12 可以看到 bool_repr 的实现,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
static PyObject *false_str = NULL;
static PyObject *true_str = NULL;

static PyObject *
bool_repr(PyObject *self)
{
    PyObject *s;

    if (self == Py_True)
        s = true_str ? true_str :
            (true_str = PyUnicode_InternFromString("True"));
    else
        s = false_str ? false_str :
            (false_str = PyUnicode_InternFromString("False"));
    Py_XINCREF(s);
    return s;
}

第一反应: 能不能改掉这个函数, 比如把这个 == 改成 != 啥的? –不可能的 .text 段的东西哪儿能说改就改.

那有没有可能 hook 这个函数呢? read -s ./python 看一下, 真的有 bool_repr 这个函数, 可以 hook 的样子! –想多了这是 Debug 版才保留了这些符号…

有没有其他办法? 注意到这个函数中为了提高速度缓存了 true_strfalse_str, 那直接 false_str=true_str 不就行了吗? 在 gdb 中执行 p false_str=true_str 然后继续执行, 可以在命令行里看到后面的输出都变成了 True True!

这个方法真是绝妙啊, 唯一的问题就是在 Python 中没有办法获得 false_strtrue_str 的地址, 修改也就无从谈起了…

4. PyUnicode_Type

不要慌, 还没走到绝路.

false_str 的真实类型是什么? 这玩意儿一定有某个地方存着 “False” 这个字符串吧, 而且这个对象是动态生成的, 我肯定有权限修改.

p *false_str 看一下, 发现是 PyUnicode_Type

1
2
3
4
5
6
$41 = {
  _ob_next = 0x6b2ba0 <_Py_FalseStruct>, 
  _ob_prev = 0x6b2b60 <_Py_TrueStruct>, 
  ob_refcnt = 6, 
  ob_type = 0x6cf020 <PyUnicode_Type>
}

Objects/unicodeobject.c:12538 可以找到 unicode_repr 函数, 部分内容如下

1
2
3
4
5
6
7
8
repr = PyUnicode_New(osize, max);
if (repr == NULL)
    return NULL;
okind = PyUnicode_KIND(repr);
odata = PyUnicode_DATA(repr);

PyUnicode_WRITE(okind, odata, 0, quote);
PyUnicode_WRITE(okind, odata, osize-1, quote);

repr 为返回值, 可以看出 repr 由 PyUnicode_New 初始化, 通过 Py_Unicode_DATA 获取其中的 data 部分, 目测为字符串存放的位置

先到 Objects/unicodeobject.c:1233 看看 PyUnicode_New 函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    unicode = (PyCompactUnicodeObject *)obj;
    if (is_ascii)
        data = ((PyASCIIObject*)obj) + 1;
    else
        data = unicode + 1;
    ...
    ...
    if (is_ascii) {
        ((char*)data)[size] = 0;
        _PyUnicode_WSTR(unicode) = NULL;
    }

! 将 obj 转为 PyASCIIObject* 再 +1 再转为 char*, 似乎就是字符串存放的真正地址了?

1
2
pwndbg> p (char *)((PyASCIIObject *)false_str + 1)
$47 = 0x7fd80d9afbe0 "True" //注意因为先前已经 false_str=true_str 了所以这里是 True

wow, 没错! 这真是相当 excited.

又因为sizeof(PyASCIIObject) == 64, 那么 false_str + 64 也就是 id('False') + 64 就是字符串的位置了.

写成 Python 代码如下

1
2
3
4
5
import ctypes
print(True, False) # 确保已经生成了对应的字符串
false_str_addr = id('False') + 64 # Debug 版
for i, c in zip(range(5), b'True\x00'):
    ctypes.c_char.from_address(false_str_addr + i).value = c # 直接 strcpy 出错了 不造为啥

不过有一点比较迷, 就是系统自带的 Python, 偏移量并不是64, 而是 48. 难道是gcc的迷之优化?

最终代码: 互换 True False

注意到 PyASCIIObject 有个 length 指定了字符串长度, 修改的时候最好一起改掉

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
pwndbg> p *(PyASCIIObject *)true_str
$1 = {
  ob_base = {
    _ob_next = 0x6b2ba0 <_Py_FalseStruct>, 
    _ob_prev = 0x6b2b60 <_Py_TrueStruct>, 
    ob_refcnt = 6, 
    ob_type = 0x6cf020 <PyUnicode_Type>
  }, 
  length = 4, 
  hash = -8576775955766428395, 
  state = {
    interned = 1, 
    kind = 1, 
    compact = 1, 
    ascii = 1, 
    ready = 1
  }, 
  wstr = 0x0
}

完整代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import ctypes
print(True, False) # 确保已经生成了对应的字符串
false_addr = id('False')
true_addr = id('True')

read_char = lambda n: ctypes.c_char.from_address(n)

tmp = b''.join([read_char(false_addr + i).value for i in range(100)])
str_offset = tmp.index(b'False') # 定位字符串所在位置
length_offset = tmp.index(b'\x05') # 定位长度所在位置(这个有点不放心...)

# 修改字符串
for i, c in zip(range(5), b'True\x00'):
    read_char(false_addr + str_offset + i).value = c
for i, c in zip(range(6), b'False\x00'):
    read_char(true_addr + str_offset + i).value = c
    
# 修改长度
read_char(false_addr + length_offset).value = b'\x04'
read_char(true_addr + length_offset).value = b'\x05'

效果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
In [2]: print True, False
------> print(True, False)
False True

In [3]: 1 == 2
Out[3]: True

In [4]: 1 == 1
Out[4]: False

In [5]: 'God' is 'girl'
Out[5]: True

In [6]: 'cat' is 'dog'
Out[6]: True

正确用法:

1
2
3
4
5
6
7
8
In [2]: 'Emacs' is 'the best editor'
Out[2]: False

In [3]: 我给你一次重新组织语言的机会
Out[3]: ...

In [4]: 'Emacs' is 'the best editor'
Out[4]: True