引用Shell

由于各种星象的排列,今年我在不同的环境和项目中遇到了相同的问题。当外部命令的参数包含空格或其他特殊字符时会发生什么?
你是否曾好奇为什么网页表单对空白字符有奇怪的限制?可能是因为后端无法处理包含空白字符或其他特殊字符的值。或者,在某个时候,程序员处理过这样的系统,它给他们留下了永久的阴影;他们对空白有恐惧症。某些底层机制的机理泄漏并感染了应用层体验。
我们倾向于假设可以将字符串插入到命令行中,一切都会顺利进行,即使我们实际上知道这样做可能会很危险。我在《掌握Perl》一书中解释了一些这些危险,当时我正在写关于Perl的污染检查。你也可以在perlsec中了解一些内容。在这篇简短的文章中,我将忽略所有这些。
我的示例使用了一个我一直在玩的macOS命令,但这适用于几乎所有Unix-like的外部命令。在Windows上,你还有额外的顾虑,因为你必须知道cmd
会做什么,以及特定的程序将如何处理其自己的参数字符串。
错误的做法
考虑这个稍微有些牵强的片段。我使用James Berry的tag。这是一个命令行工具,可以可靠地设置和检索文件标签的名称。使用文件名运行它,它会返回文件名和标签列表
$ tag vicunas.txt
vicunas.txt Orange
以下是该目录在Path Finder中的样子,这是我最喜欢的Finder替代品。
我的任务涉及到大量文件。像大多数人一样,我希望从命令行工具捕获文本是毫不费力的。我经常求助于反引号和简单的命令构造
foreach my $file ( @ARGV ) {
my $result = `tag $file`;
print $result;
}
尽管我理智上知道这并不总是有效,但我最初那样写是因为它很简单。我采取了捷径,结果却适得其反。当我运行我的程序时,一些调用出现了问题
$ perl shellwords.pl *
alpaca.pl
butterfly.p6
camel.txt Green
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `tag has (parens).txt'
tag: The file “has” couldn’t be opened because there is no such file.
llama.pl
shellwords.pl
vicunas.txt Orange
我们倾向于先写最简单的事情,即使我们知道以后会遇到问题。一些人称之为技术债务;我叫它懒惰。我们都有这样的习惯。
考虑那些失败命令的样子。“奇怪”的文件名看起来并不是命令的单个参数。其中之一甚至令人怀疑。我认为我在文件名中使用的括号比任何人想象的都多
$ tag has spaces.txt
$ has (parens).txt
简单的修复方法
有一个简单的修复方法;我只需要在它周围加上引号。这暂时有效,因为我只是冒险认为边缘情况很少见
foreach my $file ( @ARGV ) {
my $result = `tag "$file"`;
print $result;
}
但当文件名中有一个引号时,它又失败了。这比人们想象的要常见得多。例如,我倾向于以网页标题成为文件名的方式保存网页。我还要修复多少次这个问题?
alpaca.pl
butterfly.p6
camel.txt Green
sh: -c: line 0: unexpected EOF while looking for matching `"'
sh: -c: line 1: syntax error: unexpected end of file
has (parens).txt
has spaces.txt Blue
llama.pl
shellwords.pl
vicunas.txt Orange
在某个时候,我想到我会简单地quotemeta整个字符串,尽管我知道它是为了保护正则表达式中的字符串而设计的
foreach my $file ( @ARGV ) {
my $result = `tag "\Q$file\E"`;
print $result;
}
这也不管用。现在没有任何文件匹配
tag: The file “alpaca\.pl” couldn’t be opened because there is no such file.
tag: The file “butterfly\.p6” couldn’t be opened because there is no such file.
tag: The file “camel\.txt” couldn’t be opened because there is no such file.
...
更好的修复方法是仅转义分隔符。这使用单独的语句来完成
foreach my $file ( @ARGV ) {
my $quoted_file = $file =~ s/"/\\"/gr;
my $result = `tag "$quoted_file"`;
print $result;
}
看起来它有效(尽管基于我迄今为止的绩效,我不会基于这个任务来赌我的生命)
alpaca.pl
butterfly.p6
camel.txt Green
has " quote.txt
has (parens).txt
has spaces.txt Blue
llama.pl
shellwords.pl
vicunas.txt Orange
我可以在命令中将其内联,虽然看起来有点丑。我得到修改后的字符串在一个匿名数组引用(方括号中)中,并在字符串中立即解引用它。
foreach my $file ( @ARGV ) {
my $result = `tag "@{[ $file =~ s/"/\\"/gr ]}"`;
print $result;
}
呃。这种情况可行,但为了节省键盘敲击次数而牺牲了美感(但我用了多少键盘敲击才得到最终结果呢?)。它可能还遗漏了一些其他特殊情况,比如用于shell插值的$
和shell反引号。在Unix中单引号可能能解决这个问题,但在Windows中则不行。我稍后会展示String::ShellQuote。
我可以打开到命令的管道,并将命令及其参数作为列表指定。这不需要引号或转义任何内容,因为Perl中的每个参数都是命令中的一个参数(类似于system的列表形式)。
foreach my $file ( @ARGV ) {
open my $fh, '-|', 'tag', $file;
my $result = <$fh>;
print $result;
}
要正确实现它需要多少工作量?几乎不需要。做这些小事情很烦人,但比起一大堆支持工单或愤怒的人群在桌子旁边要好得多。
如果我不需要输出,我可以用列表形式的system
(或exec
)。在这种情况下,system
完全绕过了shell。
foreach my $file ( @ARGV ) {
system 'tag', $file;
}
但是要注意数组!只有一个元素的数组不是列表形式!有一个稍微奇怪的语法可以解决这个问题。但我会在Mastering Perl的“安全编程技术”章节中更详细地解释这个问题,但exec文档也解释了这个问题。
my @array = ( "tag $file" );
system @array; # not list form!
my @array = ( 'tag', $file );
system @array; # now it's the list form!
system { $array[0] } @array
记住,边缘情况出现的频率并不重要;重要的是它的破坏性。有些事情我无法控制,但这种情况并不属于那些事情。在这里花几分钟可以节省以后大量的时间和金钱。
使用模块
有一些模块可以帮你做这类事情(但会增加额外的依赖风险)。Dan Book建议使用String::ShellQuote作为例子,它处理Bourne shell问题(抱歉zsh)。
use String::ShellQuote;
foreach my $file ( @ARGV ) {
my $quoted_file = shell_quote $file;
my $result = `tag $quoted_file`;
print $result;
}
他还建议使用IPC::ReadpipeX。查看内部结构,你会看到管道再次打开。
use IPC::ReadpipeX;
foreach my $file ( @ARGV ) {
my $result = readpipex 'tag', $file;
print $result;
}
完全委托给shell
一个现在已经删除的GitHub用户建议了一种方法,该方法将一切委托给shell,让shell来处理。我几乎原封不动地引用了这一部分。
我想提一下,通过控制环境,可以轻易绕过shell引号问题。这里有一个例子。
#!/usr/bin/env perl
sub test {
local $ENV{MY_VAR} = 'No "problem here", sir!';
system 'touch -- "$MY_VAR" && ls -l -- "$MY_VAR"';
}
test();
这会产生类似以下内容,证明了其有效性。
-rw-r--r-- 1 kerframil kerframil 0 2019-08-18 03:13 No "problem here", sir!
确实,这种方法非常可靠,并且对shell代码注入也有抵抗力。唯一的限制是导出的变量不应包含空字节,否则会导致扩展截断。这是sh的内禀限制,与C使用\0作为字符串终止符有关。注意,在类Unix操作系统中,路径可能根本不包含空字节。
但是等等,你可能要问!为什么麻烦,我们可以通过系统函数将参数向量传递给特定的可执行文件来绕过shell,不是吗?好吧,诚然,展示的例子只是一个学术性的例子。然而,这种方法在实践中有时确实很有用。以下是一些原因。
有时,开发者可能希望故意利用shell特性,但又不想费心使字符串安全地注入到shell代码中。
这项技术无需猜测shell认为的元字符,也不需要相应地进行转义或引号处理。通过将变量扩展直接委托给shell,这根本不再重要。编辑:这在zsh等环境中也适用。
这项技术消除了代码注入的可能性,除非在shell中使用eval执行“有趣”的事情。
这项技术不仅适用于Perl,而且对于像PHP这样的“邪恶语言”非常有用,这些语言在创建子进程时相对难以避免调用/bin/sh。例如,在Puppet等应用程序中,它甚至可以发挥良好的效果。
使用模块捕获输出
我可以使用核心模块IPC::Open3运行带有参数的外部命令
use IPC::Open3;
foreach my $file ( @ARGV ) {
my $pid = open3(
undef, my $out, my $err,
'tag', $file
);
my $result = <$out>;
waitpid( $pid, 0 );
print $result;
}
CPAN模块Capture::Tiny可以以稍微令人愉悦的界面完成同样的工作(以外部依赖为代价)
use Capture::Tiny qw(capture_stdout);
foreach my $file ( @ARGV ) {
my $result = capture_stdout { system 'tag', $file };
print $result;
}
一个梦想
我一直想要一个更简单的方法来构建这些字符串。我非常希望有类似sprintf的语法来以各种特殊方式插值字符串。尽管我还没有做什么,但我仍然是String::Sprintf的维护者
# some fictional world
my $command = sprintf '%C @a', $command, @args;
封面图像© psyberartist
标签
brian d foy
brian d foy是Perl培训师和作家,也是Perl.com的高级编辑。他是Mastering Perl、《Mojolicious Web Clients》的作者、《Learning Perl Exercises》的作者,以及《Programming Perl》、《Learning Perl》、《Intermediate Perl》和《Effective Perl Programming》的合著者。
浏览他们的文章
反馈
这篇文章有问题?请通过在GitHub上打开问题或拉取请求来帮助我们