HoleyBeep:原理解析及利用方法

 

一、前言

在很久以前,人们经常使用a字符让扬声器发出非常刺耳的蜂鸣声(beep)。

这有点烦人,特别是当我们想精心设计8bit之类的音乐时更讨厌出现这种情况。这也是为什么Johnathan Nightingale会去研发beep这款软件,这款软件非常短小精悍,我们可以根据自己的需求来微调电脑的蜂鸣声。

随着X server的到来,事情逐渐变得复杂起来。

为了让beep正常工作,用户必须是超级用户(superuser)或者是当前tty的所有者。也就是说,对于root用户或者本地用户,beep都能正常工作,但如果是非root的远程用户则不行。此外,连接到X server的任何终端(比如xterm)都会被系统认为是远程身份,因此beep无法正常工作。

当然还是有办法的,比如大多数人(或者发行版)会设置SUID位来解决这个问题。SUID位是比较特殊的一个位,如果二进制程序设置了这个位,那么运行该程序时就能拥有程序所有者的权限(这里为root),而不是普通用户的权限(我们自己)。

现在这个特殊位的应用场景非常广泛,主要是为了方便起见。以poweroff为例,该程序需要root权限才能工作(只有root用户才能关闭计算机),但对个人计算机来说不是特别方便。如果你是公司的系统管理员,每个用户都需要请你来关闭他们的计算机,这是非常烦人的一件事情。另一方面,如果许多用户共享一台服务器,某个可疑用户具备关闭整个系统的能力也是非常严重的一个安全问题。

当然,所有SUID程序都是潜在的安全漏洞。如果将其应用在bash上,那么任何人都能拿到免费的root权限shell,这也是整个社区为什么会花大力气审查这类程序的原因所在。

所以,人们可能会认为像beep这样只有375行代码并且经过一群人审查过的程序应该足够安全,即使设置了SUID位也可以安装,对吧?

然而事实并非如此!

 

二、理解代码

让我们来看一下beep的源码,下载链接参考此处

程序在主函数中设置了一些signal(信号)处理函数,然后解析参数,对于每次beep请求都会调用play_beep()函数。

int main(int argc, char **argv) {

  /* ... */

  signal(SIGINT, handle_signal);
  signal(SIGTERM, handle_signal);
  parse_command_line(argc, argv, parms);

  while(parms) {
    beep_parms_t *next = parms->next;

    if(parms->stdin_beep) {
      /* ... */
    } else {
      play_beep(*parms);
    }

    /* Junk each parms struct after playing it */
    free(parms);
    parms = next;
  }

  if(console_device)
    free(console_device);

  return EXIT_SUCCESS;
}

另一方面,play_beep()会打开目标设备,查找设备类型,然后在循环里面调用do_beep()函数。

void play_beep(beep_parms_t parms) {

  /* ... */

  /* try to snag the console */
  if(console_device)
    console_fd = open(console_device, O_WRONLY);
  else
    if((console_fd = open("/dev/tty0", O_WRONLY)) == -1)
      console_fd = open("/dev/vc/0", O_WRONLY);

  if(console_fd == -1) {
    /* ... */
  }

  if (ioctl(console_fd, EVIOCGSND(0)) != -1)
    console_type = BEEP_TYPE_EVDEV;
  else
    console_type = BEEP_TYPE_CONSOLE;

  /* Beep */
  for (i = 0; i < parms.reps; i++) {                    /* start beep */
    do_beep(parms.freq);
    usleep(1000*parms.length);                          /* wait...    */
    do_beep(0);                                         /* stop beep  */
    if(parms.end_delay || (i+1 < parms.reps))
       usleep(1000*parms.delay);                        /* wait...    */
  }                                                     /* repeat.    */

  close(console_fd);
}

do_beep()本身会根据目标设备的具体类型,简单地调用正确的函数来发出声音:

void do_beep(int freq) {
  int period = (freq != 0 ? (int)(CLOCK_TICK_RATE/freq) : freq);

  if(console_type == BEEP_TYPE_CONSOLE) {
    if(ioctl(console_fd, KIOCSOUND, period) < 0) {
      putchar('a');  
      perror("ioctl");
    }
  } else {
     /* BEEP_TYPE_EVDEV */
     struct input_event e;

     e.type = EV_SND;
     e.code = SND_TONE;
     e.value = freq;

     if(write(console_fd, &e, sizeof(struct input_event)) < 0) {
       putchar('a'); /* See above */
       perror("write");
     }
  }
}

signal处理函数非常简单:先free目标设备(一个char *指针),如果该设备处于打开状态,则调用do_beep(0)来停止发出声音。

/* If we get interrupted, it would be nice to not leave the speaker beeping in
   perpetuity. */
void handle_signal(int signum) {

  if(console_device)
    free(console_device);

  switch(signum) {
  case SIGINT:
  case SIGTERM:
    if(console_fd >= 0) {
      /* Kill the sound, quit gracefully */
      do_beep(0);
      close(console_fd);
      exit(signum);
    } else {
      /* Just quit gracefully */
      exit(signum);
    }
  }
}

那么,了解这些背景后我们掌握了什么信息呢?

首先吸引我眼球的是,如果SIGINT以及SIGTERM信号同一时间发送,那么有可能存在多次free()的风险。但这种方法除了能导致程序崩溃以外,我找不到更好的方法来利用这一点,因为随后我们再也不会去使用console_device

那么我们最想得到什么效果呢?

比如do_beep()中的write()看起来是不是非常诱人,如果可以利用它来写入任意文件将是非常酷的一件事情!

然而这种写操作受console_type保护,这个值必须为BEEP_TYPE_EVDEV

console_type的值在play_beep()中设置,具体取决于ioctl()的返回值,必须满足ioctl()的条件才能设置为BEEP_TYPE_EVDEV

道理就是这样,我们无法让ioctl()说谎帮我们发出蜂鸣声。如果文件不是一个设备文件,ioctl()就会失败,device_type也无法设置为BEEP_TYPE_EVDEVdo_beep()就不能调用write()(它会使用ioctl(),而据我所知,在上下文环境中这是一种人畜无害的行为)。

但我们别忘了还有一个signal处理函数,并且信号可以随时随地发生!这种情况非常适合构造竞争条件(race conditions)。

 

三、竞争条件

signal处理函数会调用do_beep()。如果我们恰好拥有合适的console_fd以及console_type值,那么就能具备目标文件的写入能力。

由于signal在任何时候都可以被调用,因此我们需要找到确切的位置,让这些变量不能得到应有的正确值。

还记得play_beep()函数吗?代码如下:

void play_beep(beep_parms_t parms) {

  /* ... */

  /* try to snag the console */
  if(console_device)
    console_fd = open(console_device, O_WRONLY);
  else
    if((console_fd = open("/dev/tty0", O_WRONLY)) == -1)
      console_fd = open("/dev/vc/0", O_WRONLY);

  if(console_fd == -1) {
    /* ... */
  }

  if (ioctl(console_fd, EVIOCGSND(0)) != -1)
    console_type = BEEP_TYPE_EVDEV;
  else
    console_type = BEEP_TYPE_CONSOLE;

  /* Beep */
  for (i = 0; i < parms.reps; i++) {                    /* start beep */
    do_beep(parms.freq);
    usleep(1000*parms.length);                          /* wait...    */
    do_beep(0);                                         /* stop beep  */
    if(parms.end_delay || (i+1 < parms.reps))
       usleep(1000*parms.delay);                        /* wait...    */
  }                                                     /* repeat.    */

  close(console_fd);
}

每次请求蜂鸣声时都会调用这个函数。如果上一次调用成功了,那么console_fd以及console_type仍将保留之前的值。

也就是说,在一小段代码中(第285行到293行),console_fd取的是新的值,而console_type保留了之前的值。

就是这样,我们发现了竞争条件漏洞,此时此刻正是我们希望触发signal处理函数的时候。

 

四、利用代码

编写利用代码并没有那么简单,想寻找合适的时机是非常痛苦的一件事情。

beep开始运行后我们无法更改目标设备(console_device)的路径。这里可以使用的小技巧就是创建一个符号链接(symlink),先指向一个有效的设备,然后再指向目标文件。

现在我们已经具备目标文件的写入权限,我们应该知道具体要写入什么数据。

调用write的代码如下:

struct input_event e;

e.type = EV_SND;
e.code = SND_TONE;
e.value = freq;

if(write(console_fd, &e, sizeof(struct input_event)) < 0) {
  putchar('a'); /* See above */
  perror("write");
}

struct input_event这个结构体的定义位于linux/input.h中,如下所示:

struct input_event {
        struct timeval time;
        __u16 type;
        __u16 code;
        __s32 value;
};

struct timeval {
        __kernel_time_t         tv_sec;         /* seconds */
        __kernel_suseconds_t    tv_usec;        /* microseconds */
};

// On my system, sizeof(struct timeval) is 16.

结构体中的time成员并没有在beep源代码中分配,并且该成员也是该结构体的第一个元素,因此其值将成为攻击发起后目标文件的第一个字节。

也许我们可以欺骗栈布局,让栈保留我们希望设置的值?

在一定运气成分的帮助下,经过若干次重复尝试后,我发现-l参数会被放入栈中,后面跟着一个。这是一个int类型参数,可以给我们4字节的空间。

也就是说,我们可以向任何文件中写入4个字节。

我决定写入/*/x。在shell脚本中,这个字符串会执行一个程序:/tmp/x(我们需要事先准备这样一个程序)。

如果我们的目标文件为/etc/profile或者/etc/bash/bashrc,我们就能获取每个登录用户的完整访问权限。

为了自动化执行攻击,我编写了一个简单的python脚本(具体链接参考此处)。脚本会设置指向/dev/input/event0的符号链接,运行beep,稍等片刻后重新设置符号链接,再稍等片刻,然后向beep发送信号。

$ echo 'echo PWND $(whoami)' > /tmp/x 
$ ./exploit.py /etc/bash/bashrc # Or any shell script
Backup made at '/etc/bash/bashrc.bak'
Done!
$ su
PWND root

我们也可以使用cron计划任务,这种方法看起来更好,并且不需要root用户登录,但由于时间原因我并没有采用这种方法。

 

五、总结

这是我第一次利用0day漏洞。

刚开始时,想发现并理解这个漏洞对我来说有点困难。我需要反复查看补丁,直到理解其具体含义。

我发现signal的处理比我想象中的要复杂得多,尤其是我们应当避免使用不可重入(re-entrant)函数,禁用C库中的大部分函数。

希望本文对大家有所帮助,欢迎大家关注我的推特

(完)