记一次诡异的memcpy问题

泄露秘密 提交于 2019-12-14 23:23:25

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>

当你看到这个标题,心里一定在想肯定是因为内存重叠导致的bug,我承认你是对的,但也不全对,如果你对此感兴趣可以继续往下看,你一定会有所收获。

首先我简单介绍一下背景,最近公司的项目遇到个诡异的问题,当客户端同时发送多个包时,服务端有一定概率解析不了,我们的协议格式是4字节头+消息体,每解析一条完整的包,就使用memcpy将剩余的数据copy到buffer头,类似这样的代码:memcpy(buffer, buffer+offset, len)。就是这样一行简单的代码,却有一定机率出现数据copy错误。【细心的读者可能注意到,其实不需要memcpy,每次都memcpy太浪费了,直接使用指针偏移就可以,没错,正确的姿势确实应该是这样,但是我很庆幸出现了这样的代码,不然可能要错过这么有意思的事,并且如果后续的数据不足一个完整的包,那么还是需要memcpy,所以这个问题最终还是会遇到】。

在详细说明之前,我先简单介绍一下memcpy,搞c/c++的对他肯定很熟悉,其主要用途就是用来copy内存,但需要注意的是在出现内存重叠时,可能会出现bug。在copy内存时源地址和目标地址可能有三种情况:1、源地址与目标地址完全不重叠;2、源地址与目标地址重叠且目标地址小于源地址;3、源地址与目标地址重叠且目标地址大于源地址。对于第一种情况,memcpy完全不会出现问题;对于第三种情况,memcpy一定会出现问题,需要使用memmove替代;但对于第二种情况,大部分人(包括我)和网上的介绍都认为这种情况memcpy没有问题,但现在我可以很负责任的说这是错的,因为这次的bug就因他而起。

服务器是从云上买的,环境是CentOS Linux release 7.4.1708 (Core) ,gcc 4.8.5,glibc 2.17,为了方便测试,我将出现bug的代码剥离出来,同样的逻辑,同样的数据,正常的结果应该是能正确的解析完2个包,但多运行几次会出现body not enough错误,有兴趣的同学可以自行测试,不同的环境可能结果不太一样,代码如下:

#include <stdio.h>
#include <string.h>

int main()
{
        int count = 1;
        char ptr[] = {0,0,0,20,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,0,0,0,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60};
        int len = sizeof(ptr);
        int chunkSize = 0;
        char *ptr2 = malloc(len);

        memcpy(ptr2, ptr, len); //这段代码主要是为了动态连接memcpy,后续使用gdb再调试memcpy时可以直接进到函数实现

        printf("total %d\n", len);

        do{
                if(len < 4){
                        printf("head not enough\n");
                        break;
                }

                chunkSize = (ptr[0] << 24) & 0x7fffffff;
                chunkSize|= ((ptr[1] << 16) & 0xff0000);
                chunkSize|= ((ptr[2] << 8) & 0xff00);
                chunkSize|= (ptr[3] & 0xff);
                if(len - 4 < chunkSize){
                        printf("body not enough, chunkSize=%d\n", chunkSize);
                        break;
                }

                len-= (4 + chunkSize);
                if(len > 0){
                        memcpy(ptr, ptr + 4 + chunkSize, len);
                }

                printf("parse chunk:%d, len=%d\n", chunkSize, len);
        }while(len > 0);

        printf("done\n");
}

上面出现问题的代码是:memcpy(ptr, ptr + 4 + chunkSize, len),为了找到原因,我先找到了glibc2.17的源代码,但是从源代码里没找到答案,可能是源代码没找到对地方吧。于是只能通过汇编来定位问题,上面这段调用memcpy的汇编如下:

0x0000000000401185 <main+987>:       mov    -0x4(%rbp),%eax
0x0000000000401188 <main+990>:       movslq %eax,%rdx
0x000000000040118b <main+993>:       mov    -0xc(%rbp),%eax
0x000000000040118e <main+996>:       cltq   
0x0000000000401190 <main+998>:       lea    0x4(%rax),%rcx
0x0000000000401194 <main+1002>:      mov    -0x18(%rbp),%rax
0x0000000000401198 <main+1006>:      add    %rax,%rcx
0x000000000040119b <main+1009>:      mov    -0x18(%rbp),%rax
0x000000000040119f <main+1013>:      mov    %rcx,%rsi
0x00000000004011a2 <main+1016>:      mov    %rax,%rdi
0x00000000004011a5 <main+1019>:      callq  0x400520 <memcpy@plt>

其中ptr, ptr+4+chunkSize, len 分别放在rdi,rsi,rdx三个寄存器里,我们先查看一下内容

(gdb) x/8xb $rdi
0x7fffffffe3b0: 0x00    0x00    0x00    0x14    0x0b    0x0c    0x0d    0x0e
(gdb) x/8xb $rsi
0x7fffffffe3c8: 0x00    0x00    0x00    0x1e    0x1f    0x20    0x21    0x22
(gdb) p $rdx
$1 = 34

通过单步执行汇编,获得了完整的执行流程

=> 0x0000000000400520 <memcpy@plt+0>:               jmpq   *0x201b12(%rip)        # 0x602038 <memcpy@got.plt>
=> 0x00007ffff7b619f0 <__memcpy_ssse3_back+0>:      mov    %rdi,%rax
=> 0x00007ffff7b619f3 <__memcpy_ssse3_back+3>:      cmp    $0x90,%rdx
=> 0x00007ffff7b619fa <__memcpy_ssse3_back+10>:     jae    0x7ffff7b61a30 <__memcpy_ssse3_back+64>
=> 0x00007ffff7b619fc <__memcpy_ssse3_back+12>:     cmp    %dil,%sil
=> 0x00007ffff7b619ff <__memcpy_ssse3_back+15>:     jbe    0x7ffff7b61a1a <__memcpy_ssse3_back+42>
=> 0x00007ffff7b61a01 <__memcpy_ssse3_back+17>:     add    %rdx,%rsi
=> 0x00007ffff7b61a04 <__memcpy_ssse3_back+20>:     add    %rdx,%rdi
=> 0x00007ffff7b61a07 <__memcpy_ssse3_back+23>:     lea    0x39202(%rip),%r11        # 0x7ffff7b9ac10
=> 0x00007ffff7b61a0e <__memcpy_ssse3_back+30>:     movslq (%r11,%rdx,4),%rdx
=> 0x00007ffff7b61a12 <__memcpy_ssse3_back+34>:     lea    (%r11,%rdx,1),%rdx
=> 0x00007ffff7b61a16 <__memcpy_ssse3_back+38>:     jmpq   *%rdx
=> 0x00007ffff7b63d12 <__memcpy_ssse3_back+8994>:   lddqu  -0x22(%rsi),%xmm0
=> 0x00007ffff7b63d17 <__memcpy_ssse3_back+8999>:   movdqu %xmm0,-0x22(%rdi)
=> 0x00007ffff7b63d1c <__memcpy_ssse3_back+9004>:   lddqu  -0x12(%rsi),%xmm0
=> 0x00007ffff7b63d21 <__memcpy_ssse3_back+9009>:   lddqu  -0x10(%rsi),%xmm1
=> 0x00007ffff7b63d26 <__memcpy_ssse3_back+9014>:   movdqu %xmm0,-0x12(%rdi)
=> 0x00007ffff7b63d2b <__memcpy_ssse3_back+9019>:   movdqu %xmm1,-0x10(%rdi)
=> 0x00007ffff7b63d30 <__memcpy_ssse3_back+9024>:   retq

上面这段代码关键部分转换成C大概是这样

dst+= 34
src+= 34
memcpy(dst-34, src-34, 16)
memcpy(dst-18, src-18, 16)
memcpy(dst-16, src-16, 16)

通过上面的代码明确了一件事,那就是这种方法是从src的起始位置开始copy,执行后dst的内容如下

(gdb) x/8xb 0x7fffffffe3b0
0x7fffffffe3b0: 0x00    0x00    0x00    0x1e    0x1f    0x20    0x21    0x22

可以看到,memcpy执行完后,完成了从src到dst的copy,没有异常。到这里,一切顺利,但接下来发生的事,就是本文最重要的部分,睁大眼睛继续看。通过在char ptr[]前面增加一行char tmp[],内容和ptr一模一样(其实这一步可以不做的,但是加了这一行,复现机率比较高),即:

char tmp[] = {0,0,0,20,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,0,0,0,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60};

再次通过gdb执行,从查看内存、汇编跟踪到查看结果一气呵成,得到如下结果

(gdb) x/8xb $rdi
0x7fffffffe3f0: 0x00    0x00    0x00    0x14    0x0b    0x0c    0x0d    0x0e
(gdb) x/8xb $rsi
0x7fffffffe408: 0x00    0x00    0x00    0x1e    0x1f    0x20    0x21    0x22
(gdb) p $rdx
$1 = 34


=> 0x0000000000400520 <memcpy@plt+0>:               jmpq   *0x201b12(%rip)        # 0x602038 <memcpy@got.plt>
=> 0x00007ffff7b619f0 <__memcpy_ssse3_back+0>:      mov    %rdi,%rax
=> 0x00007ffff7b619f3 <__memcpy_ssse3_back+3>:      cmp    $0x90,%rdx
=> 0x00007ffff7b619fa <__memcpy_ssse3_back+10>:     jae    0x7ffff7b61a30 <__memcpy_ssse3_back+64>
=> 0x00007ffff7b619fc <__memcpy_ssse3_back+12>:     cmp    %dil,%sil
=> 0x00007ffff7b619ff <__memcpy_ssse3_back+15>:     jbe    0x7ffff7b61a1a <__memcpy_ssse3_back+42>
=> 0x00007ffff7b61a1a <__memcpy_ssse3_back+42>:     lea    0x38faf(%rip),%r11        # 0x7ffff7b9a9d0
=> 0x00007ffff7b61a21 <__memcpy_ssse3_back+49>:     movslq (%r11,%rdx,4),%rdx
=> 0x00007ffff7b61a25 <__memcpy_ssse3_back+53>:     lea    (%r11,%rdx,1),%rdx
=> 0x00007ffff7b61a29 <__memcpy_ssse3_back+57>:     jmpq   *%rdx
=> 0x00007ffff7b6440c <__memcpy_ssse3_back+10780>:  lddqu  0x12(%rsi),%xmm0
=> 0x00007ffff7b64411 <__memcpy_ssse3_back+10785>:  movdqu %xmm0,0x12(%rdi)
=> 0x00007ffff7b64416 <__memcpy_ssse3_back+10790>:  lddqu  0x2(%rsi),%xmm0
=> 0x00007ffff7b6441b <__memcpy_ssse3_back+10795>:  lddqu  (%rsi),%xmm1
=> 0x00007ffff7b6441f <__memcpy_ssse3_back+10799>:  movdqu %xmm0,0x2(%rdi)
=> 0x00007ffff7b64424 <__memcpy_ssse3_back+10804>:  movdqu %xmm1,(%rdi)
=> 0x00007ffff7b64428 <__memcpy_ssse3_back+10808>:  retq 


(gdb) x/8xb 0x7fffffffe3f0
0x7fffffffe3f0: 0x33    0x34    0x35    0x36    0x37    0x38    0x39    0x3a

可以看出,memcpy执行之后,内存copy失败了,dst的内容和src内容完全不一样了。那么是什么导致的呢?通过上面的汇编代码,我们转换成C代码,如下:

memcpy(dst+18, src+18, 16)
memcpy(dst+2, src+2, 16)
memcpy(dst, src, 16)

问题就出在这代码上,从上面的代码我们可以看到,他的实现是从src的尾部开始copy,那从尾部开始copy怎么就导致数据错乱呢?我们来看一下流程

//初始状态
//注意:src的起始地址紧跟在dst之后
dst = 00,00,00,20,11,12,13,14,15,16,17,18,19,20,21,22,23,24,  25,26,27,28,29,30
src = 00,00,00,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,  45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60

//执行memcpy(dst+18, src+18, 16)
//src+18的位置(空格之后的部分)copy到src+18的位置,一共16字节,由于dst+24就等于src,因此src+24的数据其实copy到了src,注意观察src已经被修改了
dst = 00,00,00,20,11,12,13,14,15,16,17,18,19,20,21,22,23,24,  45,46,47,48,49,50
src = 51,52,53,54,55,56,57,58,59,60,37,38,39,40,41,42,43,44,  45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60

//执行memcpy(dst+2, src+2, 16)
//这两段内存没有重叠,正常copy,
dst = 00,00,  53,54,55,56,57,58,59,60,37,38,39,40,41,42,43,44,  45,46,47,48,49,50
src = 51,52,  53,54,55,56,57,58,59,60,37,38,39,40,41,42,43,44,  45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60


//执行memcpy(dst, src, 16)
//两段内存没有重叠,正常copy
dst = 51,52,53,54,55,56,57,58,59,60,37,38,39,40,41,42,  43,44,45,46,47,48,49,50
src = 51,52,53,54,55,56,57,58,59,60,37,38,39,40,41,42,  43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60

这段分析之后,我们知道问题的根源在于memcpy的copy方式上,如果从前往后,是没有问题的,但如果从后往前,一定会出问题,那么同样的平台,同样的代码,同样的数据,为什么执行结果完全不同呢?细心的你会发现,两次memcpy所执行的代码在第6行之后完全不一样了,问题关键就在第5和6行:

=> 0x00007ffff7b619fc <__memcpy_ssse3_back+12>:     cmp    %dil,%sil
=> 0x00007ffff7b619ff <__memcpy_ssse3_back+15>:     jbe    0x7ffff7b61a1a <__memcpy_ssse3_back+42>

上面这两行代码是比较dst的src地址的最低1字节,即:如果dst的最低1字节小于等于src的最低1字节,就使用从前往后的方式,反之使用从后往前的方式。我们再回头比较两次执行时dst和src的地址

//正常的地址
dst = 0x7fffffffe3b0
dil = 0xb0

src = 0x7fffffffe3c8
sil = 0xc8

//异常的地址
dst = 0x7fffffffe3f0
dil = 0xf0
src = 0x7fffffffe408
sil = 0x08

果然,因为内存地址不一样,得到的结果也不一样了。到此,一切都真相大白了,就是因为memcpy的实现通过比较内存地址最低1字节来决定copy方式。不禁仰天长啸,这操作真是骚。目前我还理解不了为何这么干,可能又是因为性能吧。其实官方手册明确的说了,当memcpy出现内存重叠的时应该用memmove代替,因为可能在某些实现可能是从后往前copy,应该使用memmove代替。其实在问题开始大家都应该猜到,应该使用memmove来替换就可以解决问题,但是如果不追查到问题的根源,怎么能发现这么有意思事情呢?

最后提醒大家,如果你不了解具体的实现,当内存重叠时(无论第2或第3种情况),最好都使用memmove。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!