这一篇主要介绍一些文件描述符的内容。 同样参考了很多,链接都放在 reference 了。

什么是文件

上一篇提到了 *nix 的一个哲学思想 一切皆文件,我们可以与 windows 做一个对比。

  • 在 windows 中是文件的东西,在 *nix 中也是文件;
  • 在 windows 中不是文件的东西,如进程,磁盘等也被抽象成了文件,可以使用访问文件的方法访问它们;
  • 其次,一些更抽象的东西,比如管道(pipe),/dev/zero (一个可以读出无限个 0 的文件),/dev/null(一个黑洞一样丢弃一切写入其中数据的文件),/dev/urandom(一个产生随机数据的文件)也是文件
  • 再其次,类似 socket 通信这些东西也是文件

这样抽象的好处是提供了操作的一致性。几乎所有的读取(读文件,读系统状态,读 socket,读 PIPE),都可以用 read 函数进行;几乎所有的更改(写文件,更改系统参数,写 socket,写 PIPE),都可以使用 write 函数进行。所有的操作都统一,对于程序员而言是一件多么美好的事情。

举一个直观的例子,在较老的 linux 中,使用 cat /dev/urandom > /dev/dsp 可以使扬声器产生随机噪声

什么是文件描述符

在 *nix 系统中,当进程打开现有文件或创建新文件 (open) 时,内核向进程返回一个 文件描述符(file descriptor)。在形式上是一个非负整数,作为一个索引指向被打开的文件,所有执行 I/O 操作的系统调用都会通过文件描述符。

我们用下边的代码解释这个概念

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// gcc file_descriptor.c -o file_decsriptor
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>

int main()
{
	getchar();

	puts("Open a file.");
	int fd = open("/dev/urandom", 0);
	getchar();

	printf("Close the file.\n");
	close(fd);
	getchar();

	return 0;
}

编译运行,一个进程就被建立起来了,我们可以在 /proc/[PID] 目录下查看这个进程的信息(这个机制与 linux 的 procfs 有关,不做过多介绍)。

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

others_reverse_shell [master●●] pgrep file_descriptor
468
others_reverse_shell [master●●] ls /proc/468
attr             cwd       map_files   oom_adj        schedstat     syscall
autogroup        environ   maps        oom_score      sessionid     task
auxv             exe       mem         oom_score_adj  setgroups     timers
cgroup           fd        mountinfo   pagemap        smaps         timerslack_ns
clear_refs       fdinfo    mounts      patch_state    smaps_rollup  uid_map
cmdline          gid_map   mountstats  personality    stack         wchan
comm             io        net         projid_map     stat
coredump_filter  limits    ns          root           statm
cpuset           loginuid  numa_maps   sched          status

比如 /proc/[PID]/maps 存储了该进程的内存布局,/proc/[PID]/environ 存储了该进程的环境变量,还有很多有趣的文件,就不一一介绍了。

本篇主要讲的是 fd(file descriptor),因此我们直奔主题,查看 /proc/[PID]/fd 下有什么

1
2
others_reverse_shell [master●●] ls /proc/468/fd  
0  1  2

可以看到该进程已经有了三个 fd, 至于这三个 fd 是什么我们待会再谈,继续运行程序

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

Open a file.
....

others_reverse_shell [master●●] ls /proc/468/fd
0  1  2  3
others_reverse_shell [master●●] file /proc/468/fd/3
/proc/468/fd/3: symbolic link to /dev/urandom

可以看到,当该进程新打开了一个文件时,/proc/[PID]/fd 中多了一个文件描述符,并且该文件描述符指向我们打开的文件 /dev/urandom。

继续运行

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

Open a file.

Close the file.
....

others_reverse_shell [master●●] ls /proc/468/fd
0  1  2

此时进程关闭了 /dev/urandom 这个文件,3 号文件描述符也就消失了。 如果在程序关闭前,有对该文件进行 IO,那么这些操作都将通过这个文件描述符。

stdin, stdout 和 stderr

我们再写一个简单的程序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// gcc echo.c -o echo
#include <stdio.h>

int main()
{
	char buf[0x10];
	gets(buf);
	puts(buf);
	return 0;
}

编译生成可执行文件,使用 strace 观察它在运行过程中的系统调用

1
2
3
4
others_reverse_shell [master●●] strace ./echo 
execve("./echo", ["./echo"], 0x7fff39984720 /* 52 vars */) = 0
......
read(0, 

忽略程序装载时的一些系统调用,程序停在了 read(0, 这里等待我们的输入,也就是说 gets 这个库函数实际上使用了read 这个系统调用,传入一些输入继续观察

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
others_reverse_shell [master●●] strace ./echo 
execve("./echo", ["./echo"], 0x7ffdc5b21cb0 /* 52 vars */) = 0
......
read(0, 12345678
"12345678\n", 1024)             = 9
......
write(1, "12345678\n", 912345678
)               = 9
exit_group(0)                           = ?
+++ exited with 0 +++

这说明 puts 实际上使用了 write 这个系统调用,使用 man 3 readman 3 write 查看 read 和 write 的用户手册,可以发现如下的描述

 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
NAME
       pread, read — read from a file

SYNOPSIS
       #include <unistd.h>

       ssize_t pread(int fildes, void *buf, size_t nbyte, off_t offset);
       ssize_t read(int fildes, void *buf, size_t nbyte);

DESCRIPTION
       The read() function shall attempt to read nbyte bytes from the file  associated
       with  the open file descriptor, fildes, into the buffer pointed to by buf.  The
       behavior of multiple concurrent reads on  the  same  pipe,  FIFO,  or  terminal
       device is unspecified.

......
NAME
       pwrite, write — write on a file

SYNOPSIS
       #include <unistd.h>

       ssize_t pwrite(int fildes, const void *buf, size_t nbyte,
           off_t offset);
       ssize_t write(int fildes, const void *buf, size_t nbyte);

DESCRIPTION
       The write() function shall attempt to write nbyte bytes from the buffer pointed
       to by buf to the file associated with the open file descriptor, fildes.

圈一下重点

read attempt to read bytes from the file associated with the open file descriptor;

write attempt to write bytes to the file associated with the open file descriptor

read 和 write 这两个系统调用一个从文件读取内容,一个把内容写到文件里。 更具体的,这个进程运行时,read 是从 0 这个文件读取内容,然后把内容写到了 1 这个文件里。 那么 0 和 1 ,以及刚刚提到的 2 这个文件又是什么?

这个答案很容易搜索到:0,1 和 2 分别为 stdin,stdout 和 stderr。man stdin 查看一下对应的用户手册,圈出重点

1
2
3
4
5
6
7
 Under  normal  circumstances every UNIX program has three streams opened for it when it starts up, 
 one for input, one for output, and one for printing diagnostic  or  error  messages.

The input stream is referred to as  "standard  input";  
the  output  stream  is referred to as "standard output"; 
and the error stream is referred to as "standard error".  
These terms are abbreviated to form the symbols used to refer  to these files, namely stdin, stdout, and stderr.

每个程序在运行时会自动打开三个文件 stdin,stdout 和 stderr,文件描述符为0,1,2,分别代表标准输入,标准输出和标准错误;

之后打开的文件会从 3 继续向后递增,新打开文件 (open) 时,将返回所能允许的最小文件描述符。

同时从 stdin 的用户手册里还可以发现 stderr 默认是无缓冲,stdout 默认是行缓冲,stdin 默认是全缓冲,可以通过 setbuf, setvbuf 等函数改变这些属性(关于 io 缓冲的更多知识可以参考这篇文章

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
NOTES
       The  stream  stderr  is unbuffered.  The stream stdout is line-buffered when it
       points to a terminal.  Partial lines will not appear until fflush(3) or exit(3)
       is called, or a newline is printed.  This can produce unexpected results, espe‐
       cially with debugging output.  The buffering mode of the standard  streams  (or
       any  other stream) can be changed using the setbuf(3) or setvbuf(3) call.  Note
       that in case stdin is associated with a  terminal,  there  may  also  be  input
       buffering  in  the  terminal  driver,  entirely  unrelated  to stdio buffering.
       (Indeed, normally terminal input is line buffered in the kernel.)  This  kernel
       input handling can be modified using calls like tcsetattr(3); see also stty(1),
       and termios(3).

Play with file decsriptors

有了以上的概念,我们来看一下下边的代码

 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
others_reverse_shell [master●●] bat play_with_fd.c
───────┬─────────────────────────────────────────────────────────────────────────────────
        File: play_with_fd.c
───────┼─────────────────────────────────────────────────────────────────────────────────
   1    // gcc play_with_fd.c -o play_with_fd
   2    #include <stdio.h>
   3    #include <fcntl.h>
   4    #include <unistd.h>
   5    #include <sys/stat.h>
   6   
   7    int main()
   8    {
   9           puts("Now stdout is on.");
  10           puts("And then we close stdout.");
  11   
  12           close(1);
  13           puts("dumb output because stdout is closed.");
  14   
  15           int fd = open("output.txt", O_RDWR | O_CREAT, 0777);
  16           puts("Since open() returns 1 and puts() triggers write(1,....), this message wi
        ll be written to the file output.txt");
  17           close(fd); // don't forget to close(fd)!!!
  18   
  19           return 0;
  20    }
───────┴─────────────────────────────────────────────────────────────────────────────────

编译运行,得到如下结果

1
2
3
4
5
others_reverse_shell [master●●] ./play_with_fd 
Now stdout is on.
And then we close stdout.
others_reverse_shell [master●●] cat output.txt 
Since open() returns 1 and puts() triggers write(1,....), this message will be written to the file output.txt
  • 13 行的输出并没有向往常一样显示在显示屏上,因为 stdout 在 12 行已经被关闭了;

  • 16 行的信息输出到了 output.txt 中,这是因为 15 行执行时,这个进程有 0 和 2 两个 fd,因此 open 返回所能允许的最小文件描述符 1,而 puts 实际是系统调用 write(1, ….),因此这条消息就被写到了 1 (此时为打开的 output.txt)这个文件中;

  • 同理,我们还可以通过 close(0),close(1) 等操作控制进程从其他文件(包括其他进程,socket,pipe 等)进行 io。

值得一提的记得 17 行的 close(fd) 的操作,这是因为每个用户和每个进程所能承受的 fd 是有限的,进程的可以通过 /proc/[PID]/limits 查看,用户可以通过 ulimit -a 查看

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
post ulimit -a
-t: cpu time (seconds)              unlimited
-f: file size (blocks)              unlimited
-d: data seg size (kbytes)          unlimited
-s: stack size (kbytes)             8192
-c: core file size (blocks)         0
-m: resident set size (kbytes)      unlimited
-u: processes                       30645
-n: file descriptors                1024      <- max
-l: locked-in-memory size (kbytes)  16384
-v: address space (kbytes)          unlimited
-x: file locks                      unlimited
-i: pending signals                 30645
-q: bytes in POSIX msg queues       819200
-e: max nice                        0
-r: max rt priority                 0
-N 15:                              unlimited

比如我的环境下我最多能同时打开 1024 个文件,如果大于这个数值,会造成文件打开失败,后续的从文件 io 等操作全部失效。

至于更多的 fd tricks,在下一篇我将会从几道 pwnable 题目入手逐步介绍

References

Linux Programmer’s Manual

http://www.learnlinux.org.za/courses/build/shell-scripting/ch01s04.html

https://zh.wikipedia.org/wiki/文件描述符 https://www.zhihu.com/question/25696682 https://www.zhihu.com/question/30420304