*本文原创作者:ForrestX386,本文属FreeBuf原创奖励计划,未经许可禁止转载
严正声明:本文仅限于技术讨论与学术学习研究之用,严禁用于其他用途(特别是非法用途,比如非授权攻击之类),否则自行承担后果,一切与作者和平台无关,如有发现不妥之处,请及时联系作者和平台。
0x00. 前言
在各大热门语言排行榜中,Python语言多次名列前茅,其高效的开发效率和优雅的编程风格吸引不少开发人员的青睐,不少公司将技术栈切换至Python。随着Python 语言的愈来愈流行,其安全问题也愈发受到安全人员的关注。作为新一代的语言,虽然其相较于PHP等传传统(资格老一些的语言)语言在安全性上有诸多改进,但仍然面临不少安全问题,本文以最为流行的Python 子进程库subprocess为例分析其在使用中常见的安全陷阱,详文如下。
0x01. 函数调用死锁风险
1)死锁形式1
subprocess.call
subprocess.check_call
subprocess.check_output
以上三个函数在使用stdout=PIPE or stderr=PIPE 存在死锁风险
处理方案:
若要使用stdout=PIPE or stderr=PIPE,建议使用popen.communicate()
subprocess 官方文档在上面几个函数中都标注了安全警告:
2) 死锁形式2
对于popen , popen.wait() 可能会导致死锁
处理方案:
那死锁问题如何避免呢?官方文档里推荐使用 Popen.communicate()。这个方法会把输出放在内存,而不是管道里,所以这时候上限就和内存大小有关了,一般不会有问题。而且如果要获得程序返回值,可以在调用 Popen.communicate() 之后取 Popen.returncode 的值。
3)死锁形式3
call、check_call、popen、check_output 这四个函数,参数shell=True,命令参数不能为list,若为list则引发死锁
处理方案:
参数shell=True时,命令参数为字符串形式
0x02. 关闭subprocess.Popen 子进程时存在子进程关闭失败而成为僵尸进程的风险
Python 标准库 subprocess.Popen 是 shellout 一个外部进程的首选,它在 Linux/Unix 平台下的实现方式是 fork 产生子进程然后 exec 载入外部可执行程序。
于是问题就来了,如果我们需要一个类似“夹具”的子进程(比如运行 Web 集成测试的时候跑起来的那个被测试 Server), 那么就需要在退出上下文的时候清理现场,也就是结束被跑起来的子进程。
最简单粗暴的做法可以是这样:
@contextlib.contextmanager
def process_fixture(shell_args):
proc = subprocess.Popen(shell_args)
try:
yield
finally:
# 无论是否发生异常,现场都是需要清理的
proc.terminate()
proc.wait()
if __name__ == '__main__':
with process_fixture(['python', 'SimpleHTTPServer', '8080']) as proc:
print('pid %d' % proc.pid)
print(urllib.urlopen('http://localhost:8080').read())
那个 proc.wait() 是不可以偷懒省掉的,否则如果子进程被中止了而父进程继续运行, 子进程就会一直占用 pid 而成为僵尸,直到父进程也中止了才被托孤给 init 清理掉。
这个简单粗暴版对简单的情况可能有效,但是被运行的程序可能没那么听话。被运行程序可能会再fork 一些子进程来工作,自己则只当监工 —— 这是不少 Web Server 的做法。 对这种被运行程序如果简单地 terminate ,也即对其 pid 发 SIGTERM , 那就相当于谋杀了监工进程,真正的工作进程也就因此被托孤给 init ,变成畸形的守护进程…… 嗯没错,这就是我一开始遇到的问题,CI Server上明明已经中止了 Web Server 进程了,下一轮测试跑起来的时候端口仍然是被占用的。
处理方案:
这个问题稍微有点棘手,因为自从被运行程序 fork 以后,产生的子进程都享有独立的进程空间和pid ,也就是它超出了我们触碰的范围。好在 subprocess.Popen 有个 preexec_fn 参数,它接受一个回调函数,并在 fork 之后 exec 之前的间隙中执行它。我们可以利用这个特性对被运行的子进程做出一些修改,比如执行 setsid() 成立一个独立的进程组。Linux 的进程组是一个进程的集合,任何进程用系统调用 setsid 可以创建一个新的进程组,并让自己成为首领进程。首领进程的子子孙孙只要没有再调用 setsid 成立自己的独立进程组,那么它都将成为这个进程组的成员。 之后进程组内只要还有一个存活的进程,那么这个进程组就还是存在的,即使首领进程已经死亡也不例外。 而这个存在的意义在于,我们只要知道了首领进程的 pid(同时也是进程组的 pgid ), 那么可以给整个进程组发送 signal ,组内的所有进程都会收到。
因此利用这个特性,就可以通过 preexec_fn 参数让 Popen 成立自己的进程组, 然后再向进程组发送 SIGTERM 或 SIGKILL ,中止 subprocess.Popen 所启动进程的子子孙孙。当然,前提是这些子子孙孙中没有进程再调用 setsid 分裂自立门户。
前文的例子经过修改是这样的:
import signal
import os
import contextlib
import subprocess
import logging
import warnings
@contextlib.contextmanager
def process_fixture(shell_args):
proc = subprocess.Popen(shell_args, preexec_fn=os.setsid)
try:
yield
finally:
proc.terminate()
proc.wait()
try:
os.killpg(proc.pid, signal.SIGTERM)
except OSError as e:
warnings.warn(e)
Python 3.2 之后 subprocess.Popen 新增了一个选项 start_new_session ,Popen(args, start_new_session=True) 即等效于 preexec_fn=os.setsid 。这种利用进程组来清理子进程的后代的方法,比简单地中止子进程本身更加“干净”。基于 Python 实现的 Procfile 进程管理工具 Honcho 也采用了这个方法。当然,因为不能保证被运行进程的子进程一定不会调用 setsid , 所以这个方法不能算“通用”,只能算“相对可用”。如果真的要百分之百通用,那么像 systemd 那样使用 cgroups 来追溯进程创建过程也许是唯一的办法。也难怪说 systemd是第一个能正确地关闭服务的 init 工具。
0x04. 参数拼接引发的命令注入风险
1)命令注入场景1:shell=True时,命令参数可控
案例:
s=subprocess.Popen('ls;id', shell=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
处理方案:
1)shell=True,使用 pipes.quote() 对参数进行过滤
如果是python3,推荐使用shlex.quote()
2)shell=False,参数使用list,此时能防止部分命令注入(其他风险见2))
缺点是写参数时会稍微麻烦点
2)命令注入场景2:shell=False时,参数选项拼接引发的命令注入风险
使用subprocess执行命令的时候,如果使用外部传入参数,且参数可控,要注意,参数不要变成命令中的 参数选项
像subprocess.call([]) 执行的是list 拼接起来的命令,如果可控参数 在拼接之后使得参数变成了参数选项,则存在命令注入风险
案例:
import subprocess
query = '--open-files-in-paper=id;'
r = subprocess.call(['git', 'grep', '-i', '--line-number', query, 'master'], cwd='/root/op-scripts')
默认情况下,python的subprocess接受的是一个列表。我们可以将用户输入的query放在列表的一项,这样也就避免了开发者手工转义query的工作,也能从根本上防御命令注入漏洞。但可惜的是,python帮开发者做的操作,也仅仅相当于是PHP中的escapeshellarg。我们可以试试令query等于--open-files-in-pager=id;:
php 中方式命令注入的两个函数
escapeshellcmd
escapeshellarg
二者分工不同,前者为了防止用户利用shell的一些技巧(如分号、反引号等),执行其他命令;后者是为了防止用户的输入逃逸出“参数值”的位置,变成一个“参数选项”。
如果开发者在拼接命令的时候,将$query直接给拼接在“参数选项”的位置上,那用escapeshellarg也就没任何效果了,与之类似的是如果将$query直接给拼接在“参数选项”的位置上,python中的shlex.quote() 或者pipes.quote() 也没了作用
为什么 shlex.quote() 不会奏效?
1)git grep -i --line-number '--open-files-in-pager=id;' master
2)git grep -i --line-number --open-files-in-pager=id; master
1)和 2)没有区别,单引号并不是区分一个字符串是“参数值”或“选项”的标准。
处理方案:
解决此类命令注入风险的关键是如何让shell 认为 '--open-files-in-pager=id;' 不是个参数选项
在前面加上 -- 就可以, 比如这样:git grep -i --line-number -- '--open-files-in-pager=id;' master
这样--open-files-in-pager 就不会作为参数选项了,原理如下:
在命令行解析器中,--的意思是,此后的部分不会再包含参数选项(option):
A -- signals the end of options and disables further option processing. Any arguments after the -- are treated as filenames and arguments. An argument of - is equivalent to --.
If arguments remain after option processing, and neither the -c nor the -s option has been supplied, the first argument is assumed to be the name of a file containing shell commands. If bash is invoked in this fashion, $0 is set to the name of the file, and the positional parameters are set to the remaining arguments. Bash reads and executes commands from this file, then exits. Bash's exit status is the exit status of the last command executed in the script. If no commands are executed, the exit status is 0. An attempt is first made to open the file in the current directory, and, if no file is found, then the shell searches the directories in PATH for the script.
-e 与 -- 具有等同效果
0x05. 参考资料
https://docs.python.org/2/library/subprocess.html
https://www.leavesongs.com/PENETRATION/escapeshellarg-and-parameter-injection.html
*本文原创作者:ForrestX386,本文属FreeBuf原创奖励计划,未经许可禁止转载