扫描器性能分析案例(修正版)

admin 2022年1月16日20:09:24评论68 views字数 6002阅读20分0秒阅读模式

之前文章结论有问题,修正后形成本文。原文如下


问题背景

之前线上的漏洞扫描器遇到一个问题:扫描刚开始时,内存占用不超过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脚本对物理内存占用的影响

  • 观察

    扫描器性能分析案例(修正版)
    image

    可以观察到:在执行脚本前,机器还有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文件读到内存。

    扫描器性能分析案例(修正版)
    image

    又因为ysoserial.jar文件接近50M,所以有200个java -jar ysoserial.jar子进程时,就会消耗至少10G的内存。

    到这里,我的疑问也解开了。

  • 总结

    多进程执行java -jar xxx.jar或者python xxx.py时,需要注意xxx.jarxxx.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可写标志
    扫描器性能分析案例(修正版)
    image

    你也可以修改代码,在子进程中修改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变量映射到不同的物理地址。扫描器性能分析案例(修正版)

    image

    上面的现象让我一度以为multiprocessing.Process是没有"写时复制"机制。但是因为我之前验证了"clone系统调用"有"写时复制",所以我怀疑multiprocessing.Process因为啥原因所以没有"写时复制"?

    为了解决上面的疑问,我在python进程的clone系统调用下断点,在刚调用clone时用crash查看页表扫描器性能分析案例(修正版)

    image

    可以看到,在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.Popenmultiprocessing.Process区别

    做完上面实验我体会的差别:subprocess.Popen会调用execve系统调用,这个系统调用应该会将"页表映射"关系都换掉,所以父进程和子进程的内存没啥关系。

总结

  • 多进程执行java -jar xxx.jar或者python xxx.py时,需要注意xxx.jarxxx.py的大小会对内存占用有影响
  • fork、clone系统调用都有"写时复制"机制,可以用crash工具来观察这个机制;"写时复制"可以节约物理内存
  • multiprocessing.Process生成子进程时,有可能因为"引用计数"被修改,所以子进程存储变量实例的第一个物理页可能和父进程不同
  • subprocess.Popen会调用execve系统调用,这个系统调用应该会将"页表"都换掉,所以父进程和子进程的内存没啥关系

在研究这个问题的过程中,我了解了CPython对象的数据结构、写时复制,希望你也有收获。

关于gdb的使用,你可以看文档[4]


上面文章中下面的结论有部分错误:

  • 多进程执行java -jar xxx.jar或者python xxx.py时,需要注意xxx.jarxxx.py的大小会对内存占用有影响

实际上java -jar xxx.jarpython xxx.py还是有些不同:xxx.jar是jvm通过mmap系统调用映射"共享文件页"到内存中,所以多个进程会共享同一份内存; xxx.py并不会被python解释器通过mmap做"文件页"映射。

扫描器性能分析案例(修正版)

image

扫描器性能分析案例(修正版)

image

原文中因为自己想当然地以为"jvm"会和"python解释器"一样,偷了点懒就没有动手验证,所以得出错误的结论。

PS:想问一下有没有读者愿意帮我校对文章内容?可以在公众号聊天框发消息给我

参考资料

[1]

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):扫描器性能分析案例(修正版)

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年1月16日20:09:24
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   扫描器性能分析案例(修正版)http://cn-sec.com/archives/739707.html

发表评论

匿名网友 填写信息