管道与重定向


Linux 启明道场.

你可能已经注意到,运行某些命令时,它们会把数据输出到终端上。 到目前为止,你已经通过这种方式看到了不少 flag,但这背后的技术远不止如此。 命令行处理输入和输出的机制,是它如此强大的原因之一。

本模块将介绍输入和输出重定向。 简单来说,Linux 中的每个进程最初都有三个标准通信通道:

  • 标准输入是进程接收输入的通道。例如,shell 会通过标准输入读取你输入的命令。
  • 标准输出是进程输出正常数据的通道,例如前面各关输出给你的 flag,或 ls 等工具的输出。
  • 标准错误是进程输出错误详情的通道。例如,如果你输错了命令,shell 就会通过标准错误告诉你这条命令不存在。

这三个通道在 Linux 中使用得非常频繁,因此通常用更短的名称来称呼它们:stdin、stdout、stderr。 本模块将教你如何重定向、串联、屏蔽这些通道,以及如何对它们进行其他操作。 祝你好运!


首先,我们来看如何把 stdout 重定向到文件。 使用 > 字符就可以做到,如下所示:

hacker@dojo:~$ echo hi > asdf

这会把 echo hi 的输出(也就是 hi)重定向到文件 asdf。 随后,你可以使用 cat 等程序输出这个文件的内容:

hacker@dojo:~$ cat asdf
hi

本关要求你使用输出重定向,把单词 PWN(全部大写)写入名为 COLLEGE(全部大写)的文件。

除了重定向 echo 的输出,你当然也可以重定向任何命令的输出。 本关中,/challenge/run 会再次给你一个 flag,但只有把它的输出重定向到文件 myflag 才能拿到。 flag 自然就会出现在 myflag 文件中!

你会发现,即使重定向了 stdout,/challenge/run 仍然会在终端上显示信息。 这是因为它通过标准错误发送说明和反馈,而只通过标准输出打印 flag!


警告:如果之前的尝试在当前目录中留下了名为 myflag 的文件、目录或符号链接,请先删除它,以便 shell 在这里创建一个普通的可写文件。

输出重定向的一种常见用途是保存命令结果,以便稍后分析。 很多时候,你希望把结果汇总起来:运行一系列命令,保存它们的输出,之后再用 grep 搜索。 这种情况下,你可能希望把所有输出不断追加到同一个文件中,但 > 每次都会重新创建输出文件,清除原有内容。

要以追加模式重定向输出,可以用 >> 代替 >,如下所示:

hacker@dojo:~$ echo pwn > outfile
hacker@dojo:~$ echo college >> outfile
hacker@dojo:~$ cat outfile
pwn
college
hacker@dojo:$

练习一下:运行 /challenge/run,以追加模式把输出重定向到文件 /home/hacker/the-flag。 本关程序会将 flag 的前半部分写入该文件,并将后半部分输出到 stdout,前提是你把 stdout 重定向到了这个文件。 如果你正确使用了追加模式,后半部分就会接在前半部分后面;但如果使用的是截断模式(>),后半部分就会覆盖前半部分,你也就无法获得完整的 flag!

现在开始吧!

和标准输出一样,你也可以重定向命令的错误通道。 这里我们要学习文件描述符编号。 文件描述符(File Descriptor,简称 FD)是一个用来标识 Linux 通信通道的数字。 虽然你可能还没意识到,但你已经在使用它们了。 我们已经熟悉其中三个:

  • FD 0:标准输入
  • FD 1:标准输出
  • FD 2:标准错误

重定向进程的通信通道时,实际使用的是 FD 编号,只不过有些编号可以省略。 例如,不带数字的 > 就等同于 1>,用于重定向 FD 1(标准输出)。 因此,下面两条命令是等价的:

hacker@dojo:~$ echo hi > asdf
hacker@dojo:~$ echo hi 1> asdf

理解了这一点,重定向错误就很容易了。 如果某条命令可能通过标准错误输出数据(例如 /challenge/run),你可以这样做:

hacker@dojo:~$ /challenge/run 2> errors.log

这会把标准错误(FD 2)重定向到 errors.log 文件。 此外,你还可以同时重定向多个文件描述符! 例如:

hacker@dojo:~$ some_command > output.log 2> errors.log

这条命令会把输出重定向到 output.log,把错误重定向到 errors.log。

来实际操作一下! 本关中,你需要和之前一样,把 /challenge/run 的输出重定向到 myflag,并把“错误”(在这里其实是操作说明)重定向到 instructions。 你会发现终端上没有显示任何内容,因为所有输出都被重定向了! 操作成功后,你可以在 instructions 中找到说明和反馈,在 myflag 中找到 flag!


警告:如果之前的尝试在当前目录中留下了名为 myflag 或 instructions 的文件、目录或符号链接,请先删除它们,以便 shell 在这里创建普通的可写文件。

既然可以重定向程序的输出,当然也可以把输入重定向给程序! 使用 < 就能做到,如下所示:

hacker@dojo:~$ echo yo > message
hacker@dojo:~$ cat message
yo
hacker@dojo:~$ rev < message
oy

借助输入重定向,你可以用各种程序完成很多有趣的操作! 本关将用 /challenge/run 进行练习:你需要把 PWN 文件重定向给它,并确保 PWN 文件中包含 COLLEGE! 至于如何把这个值写入 PWN 文件,回想一下之前使用 echo 进行输出重定向的关卡吧!

你已经知道如何运行命令、重定向它们的输出(例如 >),以及在生成的文件中搜索内容(例如 grep)。 现在把这些操作结合起来吧!

为了给更复杂的关卡做好准备,请完成以下步骤:

  1. 把 /challenge/run 的输出重定向到 /tmp/data.txt。
  2. 这样,/tmp/data.txt 中就会有十万行文本,其中一行是 flag。
  3. 使用 grep 从中找出 flag!

其实,你可以省去“中间环节”,不必像上一关那样先把结果存进文件。 使用 |(管道)运算符就可以做到。 管道左侧命令的标准输出会连接到管道右侧命令的标准输入,也就是通过管道传入右侧命令。 例如:

hacker@dojo:~$ echo no-no | grep yes
hacker@dojo:~$ echo yes-yes | grep yes
yes-yes
hacker@dojo:~$ echo yes-yes | grep no
hacker@dojo:~$ echo no-no | grep no
no-no

现在自己试一试!/challenge/run 会输出十万行文本,其中包含 flag。 用 grep 找出 flag!

你已经知道如何把错误重定向到文件,也知道如何通过管道把输出传给 grep 等程序。 但如果你想直接用 grep 搜索错误输出,该怎么办?

> 运算符会把指定的文件描述符重定向到文件。你已经用过 2> 来重定向 fd 2,也就是标准错误。 | 运算符则只会把标准输出传给另一个程序,而且它并没有 2| 这种形式! 它只能重定向标准输出(文件描述符 1)。

好在,只要有 shell,就有办法!

shell 提供了 >& 运算符,可以把一个文件描述符重定向到另一个文件描述符。 这样,我们就可以分两步用 grep 搜索错误输出:先把标准错误重定向到标准输出(2>& 1),再照常用管道传递合并后的 stderr 和 stdout(|)!

现在试试看! 和上一关一样,这一关也会产生海量输出,不过这次是通过标准错误输出。 用 grep 搜索这些内容,找出 flag!

grep 命令有一个非常实用的选项:-v(反向匹配)。 普通的 grep 会显示匹配某个模式的行,而 grep -v 会显示不匹配该模式的行:

hacker@dojo:~$ cat data.txt
hello hackers!
hello world!
hacker@dojo:~$ cat data.txt | grep -v world
hello hackers!
hacker@dojo:~$

有时,要筛选出你想要的数据,唯一的办法就是排除你不想要的数据。 本关中,/challenge/run 会把 flag 输出到 stdout,但还会混入 1000 多个诱饵 flag(这些 flag 的某个位置包含单词 DECOY)。 你需要过滤掉这些诱饵,同时保留真正的 flag!

用 grep -v 过滤掉所有包含 "DECOY" 的行,找出真正的 flag!

匹配模式并不只有 grep 这一种方式。有时,真实数据和无用数据混在同一行中, 我们需要把无用的部分过滤掉。这时就可以使用 sed。sed 能够方便地把文本中符合某个模式的内容 替换成其他内容。匹配和替换的语法很简单:

sed "s/oldword/newword/g"

s/ —— 替换
oldword —— 要替换的词
newword —— 用来替换 oldword 的内容
/g —— 全局替换(查找模式的所有匹配项)

本关中,/challenge/run 会打印 flag,但每两个字符之间都插入了字符串 "FAKEFLAG"。你的任务是从 flag 中过滤掉这些无用数据。祝你好运!

通过管道把数据从一个命令传给另一个命令后,你自然就无法在屏幕上看到这些数据了。 这并不总是你想要的结果:例如,为了排查意料之外的情况,你可能希望看看数据在命令之间传递时是什么样的(比如:“为什么第二条命令没起作用???”)。

好在有解决办法! tee 命令的名字来自水管中的“T 形三通”,它会把流经管道的数据复制到命令行上指定的任意数量的文件中。 例如:

hacker@dojo:~$ echo hi | tee pwn college
hi
hacker@dojo:~$ cat pwn
hi
hacker@dojo:~$ cat college
hi
hacker@dojo:~$

可以看到,给 tee 指定两个文件后,传入管道的数据就有了三份:一份输出到 stdout,一份写入 pwn 文件,一份写入 college 文件。 你可以想象如何用它排查异常情况:

hacker@dojo:~$ command_1 | command_2
Command 2 failed!
hacker@dojo:~$ command_1 | tee cmd1_output | command_2
Command 2 failed!
hacker@dojo:~$ cat cmd1_output
Command 1 failed: must pass --succeed!
hacker@dojo:~$ command_1 --succeed | command_2
Commands succeeded!

现在轮到你了! 这个进程的 /challenge/pwn 必须通过管道传给 /challenge/college,但你需要先查看传递的数据,才能知道 pwn 要求你做什么! 用 tee 把第一次尝试的输出复制到文件中,查看保存的内容,再根据获得的信息重新运行这组管道命令。

有时,你需要比较两个命令的输出,而不是两个文件。 你可能会想到,先把各自的输出保存到文件中:

hacker@dojo:~$ command1 > file1
hacker@dojo:~$ command2 > file2
hacker@dojo:~$ diff file1 file2

但还有一种更优雅的办法!Linux 遵循“一切皆文件”的理念。 也就是说,系统尽可能让大多数资源都能像文件一样被访问,包括运行中程序的输入和输出! shell 也遵循这一理念。例如,正如前几关所学,你可以让接收文件参数的工具与程序的输出连接起来。

有意思的是,我们还可以更进一步,把程序的输入和输出连接到命令的参数上。 这需要用到进程替换。 要读取某个命令的输出(输入进程替换),可以使用 <(command)。 当你写下 <(command) 时,bash 会运行该命令,并将它的输出连接到一个临时创建的文件。 当然,这并不是一个真正存储数据的文件,而是所谓的命名管道,因为它有一个文件名:

hacker@dojo:~$ echo <(echo hi)
/dev/fd/63
hacker@dojo:~$

/dev/fd/63 是从哪里来的? bash 把 <(echo hi) 替换成了连接到该命令输出的命名管道文件的路径! 命令运行时,读取这个文件就会读取该命令标准输出中的数据。 通常,我们会让接收输入文件参数的命令来读取它:

hacker@dojo:~$ cat <(echo hi)
hi
hacker@dojo:~$

当然,你也可以在同一条命令中多次使用这种写法:

hacker@dojo:~$ echo <(echo pwn) <(echo college)
/dev/fd/63 /dev/fd/64
hacker@dojo:~$ cat <(echo pwn) <(echo college)
pwn
college
hacker@dojo:~$

现在来完成挑战! 回想一下 diff 关卡所学的内容,它来自理解命令模块。 那一关中,你比较了两个文件。 现在,你要比较两组命令输出:/challenge/print_decoys 会打印许多诱饵 flag,/challenge/print_decoys_and_flag 则会打印同样的诱饵,再加上真正的 flag。

结合进程替换与 diff,比较这两个程序的输出,找到你的 flag!

你已经学会了:用 <(command) 进行进程替换,可以让命令输出以文件的形式供其他程序读取。 不过,进程替换也可以用来向命令写入数据!

你可以用 tee 把数据复制到两个文件:

hacker@dojo:~$ echo HACK | tee THE > PLANET
hacker@dojo:~$ cat THE
HACK
hacker@dojo:~$ cat PLANET
HACK
hacker@dojo:~$

你也用过 tee,把数据同时复制到一个文件和一个命令:

hacker@dojo:~$ echo HACK | tee THE | cat
HACK
hacker@dojo:~$ cat THE
HACK
hacker@dojo:~$

那么,要把数据同时复制给两个命令呢? 正如 tee 的手册所说,它的作用是将数据写入文件和标准输出:

TEE(1)                           User Commands                          TEE(1)

NAME
       tee - read from standard input and write to standard output and files

等等!你刚刚学过,bash 可以利用进程替换,让命令看起来像文件! 要向命令写入数据(输出进程替换),可以使用 >(command)。 如果你把 >(rev) 写成一个参数,bash 就会运行 rev 命令(这个命令从标准输入读取数据,将每行的字符顺序反转,再写入标准输出!),并将它的输入连接到一个临时的命名管道文件。 当其他命令写入这个文件时,数据就会进入该命令的标准输入:

hacker@dojo:~$ echo HACK | rev
KCAH
hacker@dojo:~$ echo HACK | tee >(rev)
HACK
KCAH

上面的例子中,依次发生了以下事情:

  1. bash 启动 rev 命令,把一个命名管道(假设是 /dev/fd/63)连接到 rev 的标准输入。
  2. bash 启动 tee 命令,把一个管道连接到它的标准输入,并将传给 tee 的第一个参数替换成 /dev/fd/63。tee 甚至从未见过 >(rev) 这个参数;shell 已经替换了这个参数,然后才启动 tee。
  3. bash 使用内建命令 echo,把 HACK 输出到 tee 的标准输入。
  4. tee 读入 HACK,将它写入标准输出,再写入 /dev/fd/63(它连接着 rev 的 stdin)。
  5. rev 从标准输入读入 HACK,将它反转,然后把 KCAH 写入标准输出。

现在轮到你了! 本关提供了 /challenge/hack、/challenge/the 和 /challenge/planet。 运行 /challenge/hack 命令,并将它的输出复制为 /challenge/the 和 /challenge/planet 两条命令的输入! 记住,tee 可以接收多个输出目标,>(command) 就是其中一种目标。 如果需要复习这种方法,可以回顾前面的“用 tee 复制管道数据”和“用进程替换提供输入”两关。


小知识!

细心的同学会发现,下面两种写法是等价的:

hacker@dojo:~$ echo hi | rev
ih
hacker@dojo:~$ echo hi > >(rev)
ih
hacker@dojo:~$

通过管道传递数据不止一种方法! 当然,第二种写法难读得多,也更难扩展。 例如:

hacker@dojo:~$ echo hi | rev | rev
hi
hacker@dojo:~$ echo hi > >(rev | rev)
hi
hacker@dojo:~$

这样写就太绕了! 这里要记住的是:虽然进程替换是一个强大的工具,但它的用途相当专门,不要什么情况都用它!

现在,把你学到的知识结合起来吧。 你需要掌握这项管道操作:将 stdout 重定向给一个程序,将 stderr 重定向给另一个程序。

难点在于,| 运算符会把左侧命令的 stdout 连接到右侧命令的 stdin。 你当然已经用过 2>&1,把 stderr 重定向到 stdout,再通过管道传走;但这样会把 stderr 和 stdout 混在一起。 怎样才能让它们保持分离呢?

你需要结合 >()、2> 和 | 的知识。 具体怎么做,就留给你来思考了。

本关提供以下程序:

  • /challenge/hack:它会通过 stdout 和 stderr 输出数据。
  • /challenge/the:你必须把 hack 的 stderr 重定向给这个程序。
  • /challenge/planet:你必须把 hack 的 stdout 重定向给这个程序。

去拿到 flag 吧!


额外挑战:想增加难度的话,找出一种不使用 | 的解法。

你已经学过如何使用 | 创建管道,也见过进程替换创建的临时命名管道(例如 /dev/fd/63)。 你还可以自己创建持久存在于文件系统中的命名管道! 它们叫作 FIFO,意思是 First (byte) In, First (byte) Out,即先进入的字节先出来。

使用 mkfifo 命令就可以创建 FIFO:

hacker@dojo:~$ mkfifo my_pipe
hacker@dojo:~$ ls -l my_pipe
prw-r--r-- 1 hacker hacker 0 Jan 1 12:00 my_pipe
hacker@dojo:~$ ls -l some_file
-rw-r--r-- 1 hacker hacker 0 Jan 1 12:00 some_file
hacker@dojo:~$

注意权限字段开头的 p,它表示这是一个管道! 这与普通文件开头的 - 明显不同,比如上面示例中的 some_file。

与进程替换自动创建的命名管道相比,FIFO 有以下特点:

  • 你可以决定 FIFO 的创建位置。
  • 它们会一直存在,直到你将其删除。
  • 任何进程都可以通过路径向它们写入数据(例如 echo hi > my_pipe)。
  • 你可以用 ls 查看它们,也可以像检查文件一样检查它们。

FIFO 有一个需要注意的地方:在管道的读取端和写入端都就绪之前,对它的操作会“阻塞”。 例如,考虑下面的情况:

hacker@dojo:~$ mkfifo myfifo
hacker@dojo:~$ echo pwn > myfifo

为了执行 echo pwn > myfifo,bash 会以写入模式打开 myfifo 文件。 不过,这个操作会一直等待,直到有另一个程序同时以读取模式打开这个文件,使管道两端都连接起来。 你可以在另一个终端中这样做:

hacker@dojo:~$ cat myfifo
pwn
hacker@dojo:~$

这里发生了什么? 运行 cat myfifo 后,管道两端的连接都已就绪,因而解除了阻塞,echo pwn > myfifo 得以执行,将 pwn 写入管道,再由 cat 读取。

当然,普通文件也能实现类似的效果:你已经学过如何用 echo 将内容写入文件,再用 cat 读取。 那为什么还要使用 FIFO 呢? 主要区别如下:

  1. 无需磁盘存储:FIFO 直接在内存中传递进程间的数据,不会把内容保存到磁盘。
  2. 数据读后即消失:数据一旦从 FIFO 中被读走,就不再保留(普通文件中的数据则会一直存在)。
  3. 自动同步:写入方会阻塞,等待读取方就绪,反过来也一样。这其实很有用!它提供了自动同步。以上面的例子来说,使用 FIFO 时,无论先执行 cat myfifo 还是 echo pwn > myfifo 都没关系,它们会互相等待。使用普通文件时,则必须保证先执行写入操作,再执行读取操作。
  4. 支持复杂的数据流:FIFO 适合组织复杂的数据流,可以灵活地合并、拆分数据等。例如,FIFO 支持多个读取方和写入方。

本关将带你简单认识 FIFO。 你需要创建 /tmp/flag_fifo 文件,并将 /challenge/run 的 stdout 重定向到它。 如果操作成功,/challenge/run 就会把 flag 写入这个 FIFO! 去试试吧!


提示: FIFO 的阻塞行为会让你很难在单个终端中完成这一关。 你可以使用 Desktop 或 VSCode 模式,以便同时打开两个终端。


30天排行榜:

此排行榜反映本模块在道场上线后各挑战的解出情况。

排名 黑客 徽章 得分
暂无解题排行数据 —— 注册账号并完成挑战即可登上榜单!