从SQLMap踩坑到如何优雅地控制子进程

摘要

‍ ‍ 漏洞盒子团队将不定期发布安全技术方面的研究成果和报告,欢迎提出宝贵建议 ‍

漏洞盒子团队将不定期发布安全技术方面的研究成果和报告,欢迎提出宝贵建议

0×00 场景

SQLMap是检测SQL注入漏洞公认的神器,其本身并不支持作为模块导入使用,但是提供了sqlmapapi.py ,它能够启动一个基于bottle的API服务器,对外提供了丰富的API接口。在我们的一些内部应用中,有用到sqlmapapi来调用sqlmap进行大规模探测。

我们启用了多个Celery的worker,每个worker中使用gevent协程,向一个sqlmapapi server中下任务,在长时间执行后,在日志中出现大量OSError: Too many open files的报错。解决该问题后,又出现了调用sqlmapapi中的stop函数来关闭超时扫描任务时,任务均变为僵尸进程的问题。

作为忠实的SQLMap粉丝,我们向官方提交了一些issue,但不知何故,在收到官方的一次反馈后就没有后续了。只好自己动手,丰衣足食了。

0×01 解决 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来替你关闭它。

0×02 解决僵尸进程

- 僵尸进程

内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用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/n” , pid);

子进程在父进程之前终止,父进程应该调用上面两个函数之一去获取子进程终止状态。那么如果父进程比子进程先终止呢?那么,对于父进程已经终止的所有进程,他们的父进程都变为init进程。而一个由init进程领养的进程终止是不会变为僵尸进程的,因为init被编写为无论何时只要有一个子进程终止,init就会调用一个wait函数取得其最终状态。基于此,《UNIX环境高级编程》中也给出了一个通过fork两次来避免僵尸进程的方法,具体见书中程序清单8-5。

发文前,解决这两个问题的pull request也被sqlmap官方repo merge.

从SQLMap踩坑到如何优雅地控制子进程

0×03 如何优雅地处理子进程

像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

* 作者/漏洞盒子安全团队 jerrypy,转载请注明来自FreeBuf黑客与极客(FreeBuf.COM)

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: