为什么这么设计(Why’s THE Design)是一系列关于计算机程序设计中决策的文章,在这个系列的每一篇文章中都会提出一个具体的问题,并从不同的角度讨论这种设计的优缺点、对具体实现造成的影响。

🙋‍♂️ 简要回答

TL;DR

这样的机制是保护用户在无意中调用脚本中的代码。以下面的脚本为例子:

💻 示例代码

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# file_utils_without_a_name_eq_main_sentry.py
import sys
from pathlib import Path

def rmdir(directory):
    directory = Path(directory)
    for item in directory.iterdir():
        if item.is_dir():
            rmdir(item)
        else:
            item.unlink()
    directory.rmdir()

print(f"__name__ is {__name__}")
# ! NOTE: 没有判断 __name__ == "__main__"
# argv[1] is the first command line argument passed to the script
rmdir(Path(sys.argv[1])) 

上面的代码的初衷是暴露一个 rmdir 方法——它可以用来递归地删除指定目录下的所有文件。但是同时又想要在同一个代码文件中,测试该函数能不能正常使用。也就是说,它包含了两种使用场景:

  1. 外:作为函数库,将公用函数接口提供给外界使用——别人 import 这个库
  2. 内:函数开发者,内部用来调试或验证功能正确性——作者 debug/test 该库

问题就出在,两种意图混在一起,代码上没有做判断执行前的防护。如果你在另一个脚本 app.py 中导入了无防护的脚本(比如导入 file_utils_without_a_name_eq_main_guard.py),那么 app.py 会在导入时触发第一个脚本的运行,并使用app.py 的命令行参数。若是 app.py 的第一个命令行参数是指向项目目录或重要的用户数据目录,这是灾难性的结果,绝不亚于 rm -rf / tmp/(多打了个空格😨)。


🧯 问题实测

为了 debug 该问题,我们在本地构建一个测试环境,文件的树形结构如下

│  app.py  <===== 应用入口
│  file_utils_without_a_name_eq_main_sentry.py  <====== 问题源头
│
└─foo  <====== 待删除的文件夹
    │  bar.txt
    │
    └─tex
            simple.txt

app.py 的定义比较简单,就是从问题所在的 py 文件中导入函数,该函数将会被用于接口的函数体中,比如用户发起 DELETE /api/v1/dataset/1,便将数据集对应的文件夹删除。也就是说删除操作是用户手动触发,而不是程序刚启动时就自动执行。

此处刻意做了简化处理,读者感兴趣可自行了解如何设计 RESTful 接口1,这里不再展开。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# file: app.py
from file_utils_without_a_name_eq_main_sentry import rmdir

def app():
    # rmdir is a callback function of app
    # rmdir 是在 App 某一回调函数或接口使用
    pass

app()

当我们在命令行在运行 python app.py 时,会意外地发现,程序刚一运行,第一个命令行参数所指向的目录就被删除了,真是人在家中坐,锅从天上来🤕。

rm_dir_recur_accidentally.jpg

🔥 问题源头

众所周知,Python 中是没有 main 函数,也就是没有唯一的入口函数定义。故而,当 python 文件执行时,所有缩进等级为 0 的代码都会被无差别地执行,这就是上面问题的源头。官方为了解决这一问题,增加了 __name__ 这一特殊的变量:

name_eq_main_definition.jpg

按照官方的定义2,分为两种情况:

  1. 被导入时
    • 若是导入一整个 Py 模块或文件,那么它的 __name__ 被设置为模块名(文件夹名)或文件名(去掉 .py 后缀后的名称)
    • 若是从包中导入某个函数、标识符(变量,类等),只是包的一部分,那么它的 __name__ 被定为包目录到该模块的相对文件路径,其中路径分隔符为 .,而不是文件系统常见的 /(Unix) 或 \(Windows)
  2. 直接运行时,import 别人,而不是被别人 import,那么其 __name__ 为 "main"

🐛 代码藏毒?

🌚 隐蔽关联

我们再讨论另一种更加隐蔽的场景,需要有三个触发要素:

  1. 在脚本中,直接调用了有副作用的方法(比如删除目录,断开连接,关闭通道等),也就是无卫兵保护
  2. 在无 __name__ 卫兵保护的脚本中,再创建了一个自定义的类或方法,供外界调用
  3. 并将该类或函数使用 pickle.dump 保存在一个 pickle 文件中

那么在另一个脚本中反序列化它,就会隐式地导入无卫兵脚本(在系统底层,类似于创建了个符号链接,关联到了原来的所在文件),其产生问题与上一节所述的一样严重,但是更加隐蔽得多——无形之刃,最为致命。

(TODO: 补一组实验截图)


📝 总结

我们从一份内有副作用函数调用的无 __name__ 卫兵保护的 Python 脚本说起,经实验测试,无需直接执行该脚本,只需 import 该脚本,就会导致有副作用的函数被间接调用。至此,我们知道了为什么官方要提出 __name__ 这一特殊的魔术变量,正是为了弥补没有 main 入口函数导致代码无差别执行的缺陷。最后,作者附上了一种更加隐蔽的漏洞利用方式,意在警醒各位读者,做好必要的卫兵防护。

🔗 参考链接

1: What is RESTful API ↩︎
2: Python 对于 __main__ 的官方定义 ↩︎

添加新评论