Skip to content

Commit fc7688c

Browse files
authored
Merge pull request rapid7#20003 from zeroSteiner/feat/cmd/ldap-uris
Add support for RHOSTS using LDAP URIs
2 parents 6eba431 + 23e0ab5 commit fc7688c

File tree

4 files changed

+149
-3
lines changed

4 files changed

+149
-3
lines changed

docs/metasploit-framework.wiki/Metasploit-Guide-LDAP.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ use auxiliary/gather/ldap_query
3434
run rhost=192.168.123.13 [email protected] password=p4$$w0rd action=ENUM_ACCOUNTS
3535
```
3636

37+
Alternatively, the URI syntax can be used:
38+
39+
```
40+
use auxiliary/gather/ldap_query
41+
run ldap://domain.local;Administrator:[email protected]/dc=domain,dc=local action=ENUM_ACCOUNTS
42+
```
43+
3744
Example output:
3845

3946
```msf

docs/metasploit-framework.wiki/Metasploit-Guide-Setting-Module-Options.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ The following protocols are currently supported, and described in more detail be
124124
- file - Load a series of RHOST values separated by newlines from a file. This file can also include URI strings
125125
- http
126126
- https
127+
- ldap
128+
- ldaps
127129
- mysql
128130
- postgres
129131
- smb

lib/msf/core/rhosts_walker.rb

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class RhostsWalker
1515
file
1616
http
1717
https
18+
ldap
19+
ldaps
1820
mysql
1921
postgres
2022
smb
@@ -251,6 +253,45 @@ def parse_http_uri(value, datastore)
251253
end
252254
alias parse_https_uri parse_http_uri
253255

256+
# Parses a uri string such as ldap://user:[email protected] into a hash which can safely be
257+
# merged with a [Msf::DataStore] datastore for setting ldap options.
258+
#
259+
# @see https://datatracker.ietf.org/doc/html/rfc4516
260+
#
261+
# @param value [String] the ldap string
262+
# @return [Hash] A hash where keys match the required datastore options associated with
263+
# the uri value
264+
def parse_ldap_uri(value, datastore)
265+
uri = ::Addressable::URI.parse(value)
266+
result = {}
267+
268+
result['RHOSTS'] = uri.hostname
269+
is_ssl = %w[ssl ldaps].include?(uri.scheme)
270+
result['RPORT'] = uri.port || (is_ssl ? 636 : 389)
271+
result['SSL'] = is_ssl
272+
273+
if uri.path.present?
274+
base_dn = uri.path.delete_prefix('/').split('?', 2).first
275+
result['BASE_DN'] = base_dn if base_dn.present?
276+
end
277+
278+
set_hostname(datastore, result, uri.hostname)
279+
280+
if uri.user && uri.user.include?(';')
281+
domain, user = uri.user.split(';')
282+
result['LDAPDomain'] = domain
283+
set_username(datastore, result, user)
284+
elsif uri.user
285+
result['LDAPDomain'] = ''
286+
set_username(datastore, result, uri.user)
287+
end
288+
289+
set_password(datastore, result, uri.password) if uri.password
290+
291+
result
292+
end
293+
alias parse_ldaps_uri parse_ldap_uri
294+
254295
# Parses a uri string such as mysql://user:[email protected] into a hash
255296
# which can safely be merged with a [Msf::DataStore] datastore for setting mysql options.
256297
#
@@ -353,7 +394,7 @@ def set_hostname(datastore, result, hostname)
353394
def set_username(datastore, result, username)
354395
# Preference setting application specific values first
355396
username_set = false
356-
option_names = %w[SMBUser FtpUser Username user USER USERNAME username]
397+
option_names = %w[SMBUser FtpUser LDAPUsername Username user USER USERNAME username]
357398
option_names.each do |option_name|
358399
if datastore.options.include?(option_name)
359400
result[option_name] = username
@@ -372,7 +413,7 @@ def set_username(datastore, result, username)
372413
def set_password(datastore, result, password)
373414
# Preference setting application specific values first
374415
password_set = false
375-
password_option_names = %w[SMBPass FtpPass Password pass PASSWORD password]
416+
password_option_names = %w[SMBPass FtpPass LDAPPassword Password pass PASSWORD password]
376417
password_option_names.each do |option_name|
377418
if datastore.options.include?(option_name)
378419
result[option_name] = password

spec/lib/msf/core/rhosts_walker_spec.rb

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ def http_options_for(datastores)
2222
mysql_keys = %w[RHOSTS RPORT USERNAME PASSWORD]
2323
postgres_keys = %w[RHOSTS RPORT USERNAME PASSWORD DATABASE]
2424
ssh_keys = %w[RHOSTS RPORT USERNAME PASSWORD]
25-
required_keys = dynamic_keys + http_keys + smb_keys + mysql_keys + postgres_keys + ssh_keys
25+
ldap_keys = %w[RHOSTS RPORT SSL LDAPDomain LDAPUsername LDAPPassword BASE_DN]
26+
required_keys = dynamic_keys + http_keys + smb_keys + mysql_keys + postgres_keys + ssh_keys + ldap_keys
2627
datastores.map do |datastore|
2728
# Workaround: Manually convert the datastore to a hash ourselves as `datastore.to_h` coerces all datatypes into strings
2829
# which prevents this test suite from validating types correctly. i.e. The tests need to ensure that RPORT is correctly
@@ -154,6 +155,33 @@ def initialize
154155
mod
155156
end
156157

158+
let(:ldap_mod) do
159+
mod_klass = Class.new(Msf::Auxiliary) do
160+
include Msf::Exploit::Remote::LDAP
161+
include Msf::Exploit::Remote::LDAP::Queries
162+
163+
def initialize
164+
super(
165+
'Name' => 'mock ldap module',
166+
'Description' => 'mock ldap module',
167+
'Author' => ['Unknown'],
168+
'License' => MSF_LICENSE
169+
)
170+
171+
register_options([
172+
Msf::OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it'])
173+
])
174+
end
175+
end
176+
177+
mod = mod_klass.new
178+
datastore = Msf::ModuleDataStore.new(mod)
179+
allow(mod).to receive(:framework).and_return(nil)
180+
mod.send(:datastore=, datastore)
181+
datastore.import_options(mod.options)
182+
mod
183+
end
184+
157185
let(:mysql_mod) do
158186
mod_klass = Class.new(Msf::Auxiliary) do
159187
include Msf::Exploit::Remote::MYSQL
@@ -908,6 +936,74 @@ def create_tempfile(content)
908936
expect(each_host_for(ssh_mod)).to have_datastore_values(expected)
909937
end
910938
end
939+
940+
context 'when using the ldap scheme' do
941+
it 'enumerates ldap schemes for scanners when no user or password are specified' do
942+
ldap_mod.datastore['RHOSTS'] = 'ldap://example.com/ ldaps://example.com/'
943+
expected = [
944+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 389, 'SSL' => false, 'LDAPDomain' => nil, 'LDAPUsername' => nil, 'LDAPPassword' => nil, 'BASE_DN' => nil },
945+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 636, 'SSL' => true, 'LDAPDomain' => nil, 'LDAPUsername' => nil, 'LDAPPassword' => nil, 'BASE_DN' => nil }
946+
]
947+
expect(each_host_for(ldap_mod)).to have_datastore_values(expected)
948+
end
949+
950+
it 'enumerates ldap schemes for scanners when a port is specified' do
951+
ldap_mod.datastore['RHOSTS'] = 'ldap://example.com:1389/ ldaps://example.com:1636/'
952+
expected = [
953+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 1389, 'SSL' => false, 'LDAPDomain' => nil, 'LDAPUsername' => nil, 'LDAPPassword' => nil, 'BASE_DN' => nil },
954+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 1636, 'SSL' => true, 'LDAPDomain' => nil, 'LDAPUsername' => nil, 'LDAPPassword' => nil, 'BASE_DN' => nil }
955+
]
956+
expect(each_host_for(ldap_mod)).to have_datastore_values(expected)
957+
end
958+
959+
it 'enumerates ldap schemes for scanners when no user or password are specified and uses the default option values instead' do
960+
ldap_mod.datastore.import_options(
961+
Msf::OptionContainer.new(
962+
[
963+
Msf::OptString.new('LDAPUsername', [true, 'The username to authenticate as', 'db2admin'], fallbacks: ['USERNAME']),
964+
Msf::OptString.new('LDAPPassword', [true, 'The password for the specified username', 'db2admin'], fallbacks: ['PASSWORD']),
965+
]
966+
),
967+
ldap_mod.class,
968+
true
969+
)
970+
ldap_mod.datastore['RHOSTS'] = 'ldap://example.com/ ldap://[email protected]/ ldap://user:[email protected] ldap://:@example.com'
971+
expected = [
972+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 389, 'SSL' => false, 'LDAPDomain' => nil, 'LDAPUsername' => 'db2admin', 'LDAPPassword' => 'db2admin', 'BASE_DN' => nil },
973+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 389, 'SSL' => false, 'LDAPDomain' => '', 'LDAPUsername' => 'user', 'LDAPPassword' => 'db2admin', 'BASE_DN' => nil },
974+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 389, 'SSL' => false, 'LDAPDomain' => '', 'LDAPUsername' => 'user', 'LDAPPassword' => 'password', 'BASE_DN' => nil },
975+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 389, 'SSL' => false, 'LDAPDomain' => '', 'LDAPUsername' => '', 'LDAPPassword' => '', 'BASE_DN' => nil }
976+
]
977+
expect(each_host_for(ldap_mod)).to have_datastore_values(expected)
978+
end
979+
980+
it 'enumerates ldap schemes for scanners when a user and password are specified' do
981+
ldap_mod.datastore['RHOSTS'] = 'ldap://user:[email protected]/'
982+
expected = [
983+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 389, 'SSL' => false, 'LDAPDomain' => '', 'LDAPUsername' => 'user', 'LDAPPassword' => 'pass', 'BASE_DN' => nil },
984+
]
985+
expect(each_host_for(ldap_mod)).to have_datastore_values(expected)
986+
end
987+
988+
it 'enumerates ldap schemes for scanners when a domain, user and password are specified' do
989+
ldap_mod.datastore['RHOSTS'] = 'ldap://domain;user:[email protected]/'
990+
expected = [
991+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 389, 'SSL' => false, 'LDAPDomain' => 'domain', 'LDAPUsername' => 'user', 'LDAPPassword' => 'pass', 'BASE_DN' => nil },
992+
]
993+
expect(each_host_for(ldap_mod)).to have_datastore_values(expected)
994+
end
995+
996+
it 'enumerates ldap schemes for when the module has BASE_DN available' do
997+
ldap_mod.datastore['RHOSTS'] = 'ldap://[email protected] ldap://[email protected]/ ldap://[email protected]/dc=msflab,dc=local'
998+
expected = [
999+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 389, 'SSL' => false, 'LDAPDomain' => '', 'LDAPUsername' => 'user', 'LDAPPassword' => nil, 'BASE_DN' => nil },
1000+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 389, 'SSL' => false, 'LDAPDomain' => '', 'LDAPUsername' => 'user', 'LDAPPassword' => nil, 'BASE_DN' => nil },
1001+
{ 'RHOSTNAME' => 'example.com', 'RHOSTS' => '192.0.2.2', 'RPORT' => 389, 'SSL' => false, 'LDAPDomain' => '', 'LDAPUsername' => 'user', 'LDAPPassword' => nil, 'BASE_DN' => 'dc=msflab,dc=local' }
1002+
]
1003+
expect(each_host_for(ldap_mod)).to have_datastore_values(expected)
1004+
end
1005+
end
1006+
9111007
# TODO: Discuss adding a test for the datastore containing an existing TARGETURI,and running with a HTTP url without a path. Should the TARGETURI be overridden to '/', '', or unaffected, and the default value is used instead?
9121008

9131009
it 'enumerates a combination of all syntaxes' do

0 commit comments

Comments
 (0)