FROM 漏洞盒子安全团队 jerrypy
0x00 场景
SQLMap是检测SQL注入漏洞公认的神器,其本身并不支持作为模块导入使用,但是提供了sqlmapapi.py ,它能够启动一个基于bottle的API服务器,对外提供了丰富的API接口。在我们的一些内部应用中,有用到sqlmapapi来调用sqlmap进行大规模探测。我们启用了多个Celery的worker,每个worker中使用gevent协程,向一个sqlmapapi server中下任务,在长时间执行后,在日志中出现大量OSError: Too many open files的报错。解决该问题后,又出现了调用sqlmapapi中的stop函数来关闭超时扫描任务时,任务均变为僵尸进程的问题。
作为忠实的SQLMap粉丝,我们向官方提交了一些issue,但不知何故,在收到官方的一次反馈后就没有后续了。只好自己动手,丰衣足食了。
0x01 解决 Too many open files
- 堆栈信息
Traceback (most recent call last):
File "/opt/sqlmap/thirdparty/bottle/bottle.py", line 763, in handle
return route.call(*args)
File "/opt/sqlmap/thirdparty/bottle/bottle.py", line 1627, in wrapper
rv = callback(a, *ka)
File "/opt/sqlmap/thirdparty/bottle/bottle.py", line 1577, in wrapper
rv = callback(a, **ka)
File "/opt/sqlmap/lib/utils/api.py", line 460, in scan_start
DataStore.tasks[taskid].engine_start()
File "/opt/sqlmap/lib/utils/api.py", line 159, in engine_start
shell=False, stdin=PIPE, close_fds=not IS_WIN)
File "/usr/lib/python2.7/subprocess.py", line 672, in __init_
errread, errwrite) = self._get_handles(stdin, stdout, stderr)
File "/usr/lib/python2.7/subprocess.py", line 1038, in _get_handles
p2cread, p2cwrite = self.pipe_cloexec()
File "/usr/lib/python2.7/subprocess.py", line 1091, in pipe_cloexec
r, w = os.pipe()
OSError: [Errno 24] Too many open files
- Sqlmapapi相关源码
def engine_start(self):
self.process = Popen(["python", "sqlmap.py", "--pickled-options", base64pickle(self.options)], shell=False, stdin=PIPE, close_fds=not IS_WIN)
- 分析
根据上面的代码和报错,可以看到问题出现在新建PIPE这里,考虑是由于大量任务开启的无用PIPE句柄过多,而这里的调用并不需要对输入做处理,源码中将stdin定向到PIPE是多余的。 去掉 stdin=PIPE 后,不再出现这个错误,问题成功解决。
- subprocess.PIPE 和 close_fds
我们第一次提交这个issue, 官方给的解决办法是,在suprocess.Popen()中加入一个close_fds=True的参数,这也是一个Python网络编程中常见的一个小坑,但在这里并没有解决我们的问题。至于为什么,让我们从close_fds来说起。这个参数的含义是,在子进程执行之前,关闭所有除0, 1, 2之外所有的文件描述符。
我们知道,子进程会继承父进程几乎所有的资源,这里面包括父进程打开的文件描述符。在网络编程中,如果你没有意识到,子进程也打开了一个父进程的socket文件,那么当你想要close()连接的时候,很可能会出现让你摸不着头脑的错误。
(注: 这篇文章里列出了一些Python中常见的坑,值得阅读。)
在我们这个场景里,SQLMap的作者以为是子进程继承了多余的PIPE文件,所以造成了这个错误,这的确也是一个应当注意的点,但是我们的任务下的太多,而创建的PIPE没有关闭,光主进程里打开的文件句柄也超过了系统限制。
切记,Popen并不会为你关闭PIPE,需要你主动调用PIPE.close()或者使用subprocess.communicate来替你关闭它。
0x02 解决僵尸进程
- 僵尸进程
内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用wait或waitpid时,可以得到这些信息。这些信息至少包括进程ID、该进程的终止状态、已经该进程使用的CPU时间总量。在UNIX术语中,一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息,释放它仍占用的资源)的进程被称为僵尸进程(zoombie)。
-- 《UNIX环境高级编程(第二版)》
- Sqlmapapi相关源码
def engine_stop(self):
if self.process:
return self.process.terminate()
else:
return None
- 分析
当调用terminate子进程结束后,父进程并没有去调用wait()或者waitpid()来接收SIGCHLD信号,导致子进程未正常结束。在terminate()后增加wait()函数来回收子进程的资源,这样就不会再出现僵尸进程了。
def engine_stop(self):
if self.process:
self.process.terminate()
return self.process.wait()
else:
return None
- wait与waitpid
对于subprocess模块,我们只需要简单地调用Popen.wait()这个函数,就可以很方便地回收子进程的资源了。如果需要更高级的操作,需要使用os模块中的wait*系列函数。这里简单介绍一下waitpid的用法。
Wait()这个函数是阻塞的,如果父进程有多个子进程,wait()会阻塞到第一个子进程的结束。Waitpid()则可以指定等待某个特定的子进程的结束,而且它还支持一个WNOHANG的选项,来让该函数立即返回,不阻塞。
如果一个父进程有多个子进程,而我们只调用一次wait(),是不足以防止出现僵尸进程的。这是我们需要waitpid()的原因。
while( (pid = waitpid(-1, &stat,WNOHANG)) > 0) # os.waitpid中不需要第二个参数
printf(“child %d terminated ” , pid);
子进程在父进程之前终止,父进程应该调用上面两个函数之一去获取子进程终止状态。那么如果父进程比子进程先终止呢?那么,对于父进程已经终止的所有进程,他们的父进程都变为init进程。而一个由init进程领养的进程终止是不会变为僵尸进程的,因为init被编写为无论何时只要有一个子进程终止,init就会调用一个wait函数取得其最终状态。基于此,《UNIX环境高级编程》中也给出了一个通过fork两次来避免僵尸进程的方法,具体见书中程序清单8-5。
发文前,解决这两个问题的pull request也被sqlmap官方repo merge.
0x03 如何优雅地处理子进程
像SQLMap这样优秀而成熟的开源应用也会在进程处理这块百密一疏,因此我们把进程调用的场景做了一些总结,也提供了代码片段以供参考。
1,如果对子进程的输入输出感兴趣,可以调用communicate()来获取;如果对子进程的输入输出不感兴趣,且希望等待这个进程的结果,可以使用call(),这两个函数都会wait()回收子进程。
2,对于可能运行很长时间的子进程,我们可以设置一个timeout值,在这个值的时间范围内,轮询地去取输出(如果有输出的话),也可以调用subprocess.poll()函数去查看进程是否结束。当超过timeout后,可以直接调用kill()去清理这个进程。
使用poll()的方法可以参考sqlmapapi的源码, 我们在这里也提供一段比较完整的代码片段来优雅地处理子进程,使之不会出现僵尸或者游离的子进程。
def run_wait(process, timeout, _sleep_time=.1):
for _ in xrange(int(timeout * 1. / _sleep_time + .5)):
time.sleep(_sleep_time)
out = process.stdout.readline()
if out == "":
return process.wait()
else:
sys.stdout.write(out)
sys.stdout.flush()
raise VulScanTimeoutException
def kill_child_processes(parent_pid, sig=signal.SIGTERM):
try:
p = psutil.Process(parent_pid)
except psutil.error.NoSuchProcess:
return
child_pid = p.children(recursive=True)
for pid in child_pid:
os.kill(pid.pid, sig)
try:
process = subprocess.Popen(cmdlst,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
os.chdir(origin_wkdir)
run_wait(process, timeout=TIME_OUT)
except VulScanTimeoutException, e:
warn_msg = "process [%s] is timeout when scanning %s,terminating..." % (process.pid,target)
kill_child_processes(process.pid)
process.kill()
except Exception,e:
warn_msg = "%s when scanning %s,quiting..." % (str(e),target