电脑疯子技术论坛|电脑极客社区

微信扫一扫 分享朋友圈

已有 2665 人浏览分享

安全开发 | Python Subprocess库在使用中可能存在的安全风险总结

[复制链接]
2665 0

0×00. 前言

在各大热门语言排行榜中,Python语言多次名列前茅,其高效的开发效率和优雅的编程风格吸引不少开发人员的青睐,
不少公司将技术栈切换至Python。随着Python 语言的愈来愈流行,其安全问题也愈发受到安全人员的关注。
作为新一代的语言,虽然其相较于PHP等传传统(资格老一些的语言)语言在安全性上有诸多改进,
但仍然面临不少安全问题,本文以最为流行的Python 子进程库subprocess为例分析其在使
用中常见的安全陷阱,详文如下。

0×01.  函数调用死锁风险

1)死锁形式1

subprocess.call
subprocess.check_call
subprocess.check_output


以上三个函数在使用stdout=PIPE or stderr=PIPE 存在死锁风险

处理方案:

若要使用stdout=PIPE or stderr=PIPE,建议使用popen.communicate()

subprocess 官方文档在上面几个函数中都标注了安全警告:

1536043877_5b8e2b652d9ce.png!small.jpg


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时,命令参数为字符串形式

0×02. 关闭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 的做法。 对这种被运行程序如果简单地 term
inate ,也即对其 pid 发 SIGTERM , 那就相当于谋杀了监工进程,真正的工作进程也就因此被托孤给
init ,变成畸形的守护进程…… 嗯没错,这就是我一开始遇到的问题,CI Server上明明已经中
止了 Web Server 进程了,下一轮测试跑起来的时候端口仍然是被占用的。

处理方案:

这个问题稍微有点棘手,因为自从被运行程序 fork 以后,产生的子进程都享有独立的进程空间和pid ,
也就是它超出了我们触碰的范围。好在 subprocess.Popen 有个 preexec_fn 参数,它接受一个回
调函数,并在 fork 之后 exec 之前的间隙中执行它。我们可以利用这个特性对被运行的子进程
做出一些修改,比如执行 setsid() 成立一个独立的进程组。Linux 的进程组是一个进程的集
合,任何进程用系统调用 setsid 可以创建一个新的进程组,并让自己成为首领进程。首领
进程的子子孙孙只要没有再调用 setsid 成立自己的独立进程组,那么它都将成为这个进程
组的成员。 之后进程组内只要还有一个存活的进程,那么这个进程组就还是存在的,即使
首领进程已经死亡也不例外。 而这个存在的意义在于,我们只要知道了首领进程的 pi
d(同时也是进程组的 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 工具。

0×04. 参数拼接引发的命令注入风险

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等于–o
pen-files-in-pager=id;:

php 中方式命令注入的两个函数

escapeshellcmd
escapeshellarg


二者分工不同,前者为了防止用户利用shell的一些技巧(如分号、反引号等),执行其他
命令;后者是为了防止用户的输入逃逸出“参数值”的位置,变成一个“参数选项”。

如果开发者在拼接命令的时候,将$query直接给拼接在“参数选项”的位置上,那用escape
shellarg也就没任何效果了,与之类似的是如果将$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 t
he -- 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 sup
plied, the first argument is assumed to be the name of a file containing shell commands. If ba
sh is invoked in this fashion, $0 is set to the name of the file, and the positional parameters a
re set to the remaining arguments. Bash reads and executes commands from this file, then e
xits. Bash's exit status is the exit status of the last command executed in the script. If no com
mands are executed, the exit status is 0. An attempt is first made to open the file in the curr
ent directory, and, if no file is found, then the shell searches the directories in PATH for the script.


-e 与 –  具有等同效果







您需要登录后才可以回帖 登录 | 注册

本版积分规则

1

关注

0

粉丝

9021

主题
精彩推荐
热门资讯
网友晒图
图文推荐

Powered by Pcgho! X3.4

© 2008-2022 Pcgho Inc.