之前文章结论有问题,修正后形成本文。原文如下
问题背景
之前线上的漏洞扫描器遇到一个问题:扫描刚开始时,内存占用不超过200M,但在扫描过程中,扫描器会占用超过10G以上内存。因为同一台机器上还有其他的服务,可用物理内存也只有10G左右,所以扫描过程中就没有可用内存了,机器负载(uptime命令查看)也会很高。
排查”怎么导致10G内存占用”也比较简单:因为每次内存占用过高时,都能看到机器上有上百个java -jar ysoserial.jar
进程(扫描器开的),所以可以知道是 shiro-550[1] 这个poc导致的内存消耗。
先说明一下为什么有上百个java -jar ysoserial.jar
进程:从 shiro-550[2] 代码中也可以看到,我在python中使用子进程调用ysoserial
来生成shiro测试payload。因为扫描器使用协程池(gevent.pool
)实现并发,所以扫描器执行到shiro poc时,会产生很多个ysoserial
子进程。
本文不讨论这个问题的解决办法(你可以看Shiro-550 PoC 编写日记[3]),而是分析为什么这里python中的子进程会消耗10G这么多的内存。
我的分析思路:写一个demo复现,然后分析demo
分析过程
-
复现
写个demo复现一下
# coding:utf-8
import subprocess
import gevent.monkey
gevent.monkey.patch_all()
import gevent.pool
def test(_):
popen = subprocess.Popen(['python', "/tmp/big.py"], stdout=subprocess.PIPE) # big.py是一个60M左右的的python文件
print(popen.stdout.read())
if __name__ == "__main__":
pool = gevent.pool.Pool(100) # 100个协程
_ = "x," * 300
pool.map(test, _.split(","))其中
/tmp/big.py
是如下脚本生成的# coding:utf-8
size = 60 * 1024 * 1024 # 60M
fname = "/tmp/big.py"
template = """
a="%s"
while True:
pass
"""
with open(fname, "w") as f:
f.write(template % ('x'*size))下面在机器上观察demo脚本对物理内存占用的影响
-
观察
可以观察到:在执行脚本前,机器还有10G可用的物理内存;执行脚本后,生成了100个big.py进程,内存也从11203M减少到4886M。
内存为什么会减少这么多呢?其实算一算就很容易找到原因:内存减少了约6G,而big.py脚本大小约60M、总共有100个big.py进程,所以应该是每个
python big.py
进程会占用60M物理内存。想一想也很合理:执行
python big.py
时,python应该是将big.py
文件全部读到内存中了。 -
回到最开始的问题
可以推测执行
java -jar ysoserial.jar
命令时,java也会将ysoserial.jar
文件读到内存。又因为
ysoserial.jar
文件接近50M,所以有200个java -jar ysoserial.jar
子进程时,就会消耗至少10G的内存。到这里,我的疑问也解开了。
-
总结
多进程执行
java -jar xxx.jar
或者python xxx.py
时,需要注意xxx.jar
和xxx.py
的大小会对内存占用有影响。看到这里,我不知道你会不会心想这个问题也太简单了吧。
确实,在写出验证demo之后,很容易得到结论。但是在写出demo之前,我把问题想错了导致走了点弯路。下面我来说一下我走弯路时的过程以及学到的东西。
弯路
-
最开始的思路
其实我最开始是怀疑”内存占用10G”以上的原因是:python生成子进程时会占用和父进程一样大小的物理内存,而父进程(也就是扫描器进程)本身因为会读了一些资源文件,所以本身是占用了比较大的物理内存。这样当父进程(扫描器进程)生成200个子进程(
java -jar ysoserial.jar
)时,就会占用200*40M(8G)的内存。似乎这个数字也将近10G,也能差不多对应上问题背景。现在回过头看之前的这个原因猜测,有两点问题:
* python生成子进程时因为有操作系统"写时复制"的机制,所以不会有200个子进程就占用200*40M的内存
* `subprocess.Popen`生成的子进程内存占用和父进程无关,`multiprocessing.Process`生成的子进程内存占用和父进程是一样的关于”写时复制”机制,可能你和我最开始一样不了解,下面我带你来验证一下这个机制是怎么回事。
-
“写时复制”机制
linux上和生成子进程有关的系统调用有fork、clone,这两系统调用都会有”写时复制”机制。
按照我自己的理解,”写时复制”机制就是刚生成子进程时,子进程和父进程 关于”用户态虚拟地址”到”物理地址”的映射关系是一样的。然后,在发生写操作时(无论父进程还是子进程),操作系统都会重新映射。
因为映射到同一个物理页,所以不会导致物理内存变少。
我们可以用crash工具来验证一下这个机制
-
crash工具验证fork时的”写时复制”机制
准备测试代码:
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#define SIZE 4*1024*1024
int main(){
void *addr = mmap(NULL, (size_t)SIZE, PROT_READ|PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); // 申请4M内存
memset(addr, 'A', SIZE);
printf("main: %pn", addr);
while(1){
getchar();
if (fork() == 0){
printf("sub: %pn", addr);
while (1){
sleep(5);
}
}
}
}通过crash工具来验证,可以看到:
* 因为子进程和父进程都没有对addr内存做写操作,所以子进程和父进程中addr映射到同一个物理地址
* 虽然代码中mmap用了`PROT_WRITE`标志,但是子进程和父进程addr内存标志中都没有RW可写标志你也可以修改代码,在子进程中修改addr指向的内存,然后用crash观察”物理地址”和”内存标志”的变化,来体会”写时复制”。
关于crash工具的安装和使用,你可以参考 借助crash工具理解linux系统的内存分配
-
multiprocessing.Process
是否会”写时复制”?multiprocessing.Process
是用clone系统调用而不是fork系统调用(你可以用strace命令验证一下)我们同样可以用crash来验证
multiprocessing.Process
的子进程和父进程是否会映射到同一个物理页。先说结论:multiprocessing.Process
同样有”写时复制”。在得到这个结论前我差点以为multiprocessing.Process
是没有”写时复制”机制的,因为我发现a变量地址对应的物理地址在”父进程”和”子进程”中是不同的。下面我来说一下我是怎么测试的。
先准备测试代码
[root@instance-fj5pftdp tmp]# cat v.py
from multiprocessing import Process
a = b"abc"
print("main", id(a)) # id函数返回变量a的虚拟地址
def t():
while True:
pass
p = Process(target=t, args='')
p.start()然后用crash查看父子进程中a变量对应的物理地址,可以看到:父子进程a变量映射到不同的物理地址。
上面的现象让我一度以为
multiprocessing.Process
是没有”写时复制”机制。但是因为我之前验证了”clone系统调用”有”写时复制”,所以我怀疑multiprocessing.Process
因为啥原因所以没有”写时复制”?为了解决上面的疑问,我在python进程的clone系统调用下断点,在刚调用clone时用crash查看页表
可以看到,在clone刚被调用时,父子进程中a变量映射到同一个物理地址。
到目前为止有两个现象:在clone刚被调用时,父子进程中a变量映射到同一个物理地址;
v.py
运行后,父子进程中a变量映射到不同的物理地址。我猜测:clone被调用后,
v.py
中后面修改了a变量,导致进程中a变量地址被映射到一个新的物理地址上。因为我的猜测也符合”写时复制”机制流程,所以直觉上应该是这样。但是现在还有一个问题:你看我们前面的
v.py
代码,它并没有修改a变量。那么a变量是被谁修改了呢。为了搞清楚这最后一个问题,我用gdb查看a变量在内存中长什么样,然后发现父子进程a变量的”引用计数”不相同,如下
ob_refcnt
字段值就是”引用计数”“引用计数”是CPython用来做”垃圾管理”的一个机制,当对象被创建或者被当作参数传递时,对象的引用计数会加1。
[root@instance-fj5pftdp ~]# ps aux|grep v.py
root 21465 0.0 0.0 144332 8604 pts/1 S+ 12:34 0:00 python3 v.py // 父进程
root 21466 93.0 0.0 144332 6460 pts/1 R+ 12:34 6:19 python3 v.py // 子进程
[root@instance-fj5pftdp ~]# gdb --batch -p 21465 -ex 'print *(PyBytesObject*) 140443328409848' // 140443328409848是a变量地址
...
$1 = {ob_base = {ob_base = {ob_refcnt = 1, ob_type = 0x7fbb8a46dfa0 <PyBytes_Type>}, ob_size = 3}, ob_shash = 4892354780606192576, ob_sval = "a"}
...
[root@instance-fj5pftdp ~]# gdb --batch -p 21466 -ex 'print *(PyBytesObject*) 140443328409848'
...
$1 = {ob_base = {ob_base = {ob_refcnt = 3, ob_type = 0x7fbb8a46dfa0 <PyBytes_Type>}, ob_size = 3}, ob_shash = 4892354780606192576, ob_sval = "a"}
...可以推测CPython在clone之后修改了a变量的”引用计数”,因此发生了”写时复制”,父子进程中a变量地址指向的物理地址也不同。
如果你有兴趣,可以将测试代码的
a = b"abc"
修改成a = b"a" * 1024 * 1024 * 1024
,然后观察一下a变量的1G内存,就能发现父子进程的a变量只有第一个物理页是不同的,其他物理页都是相同的。 -
subprocess.Popen
和multiprocessing.Process
区别做完上面实验我体会的差别:
subprocess.Popen
会调用execve
系统调用,这个系统调用应该会将”页表映射”关系都换掉,所以父进程和子进程的内存没啥关系。
总结
-
多进程执行 java -jar xxx.jar
或者python xxx.py
时,需要注意xxx.jar
和xxx.py
的大小会对内存占用有影响 -
fork、clone系统调用都有”写时复制”机制,可以用crash工具来观察这个机制;”写时复制”可以节约物理内存 -
multiprocessing.Process
生成子进程时,有可能因为”引用计数”被修改,所以子进程存储变量实例的第一个物理页可能和父进程不同 -
subprocess.Popen
会调用execve
系统调用,这个系统调用应该会将”页表”都换掉,所以父进程和子进程的内存没啥关系
在研究这个问题的过程中,我了解了CPython对象的数据结构、写时复制,希望你也有收获。
关于gdb的使用,你可以看文档[4]
上面文章中下面的结论有部分错误:
-
多进程执行 java -jar xxx.jar
或者python xxx.py
时,需要注意xxx.jar
和xxx.py
的大小会对内存占用有影响
实际上java -jar xxx.jar
和python xxx.py
还是有些不同:xxx.jar
是jvm通过mmap系统调用映射”共享文件页”到内存中,所以多个进程会共享同一份内存; xxx.py
并不会被python解释器通过mmap做”文件页”映射。
原文中因为自己想当然地以为”jvm”会和”python解释器”一样,偷了点懒就没有动手验证,所以得出错误的结论。
PS:想问一下有没有读者愿意帮我校对文章内容?可以在公众号聊天框发消息给我
参考资料
shiro-550: https://gist.github.com/leveryd/14ade5985bfc1db1b5ccb3ae4f661178
[2]shiro-550: https://gist.github.com/leveryd/14ade5985bfc1db1b5ccb3ae4f661178
[3]Shiro-550 PoC 编写日记: https://paper.seebug.org/1290/
[4]文档: https://wizardforcel.gitbooks.io/100-gdb-tips/content/set-detach-on-fork.html
原文始发于微信公众号(leveryd):扫描器性能分析案例(修正版)