Class: WPScan::Controller::Core

Inherits:
Base
  • Object
show all
Defined in:
app/controllers/core.rb,
app/controllers/core/cli_options.rb

Overview

CLI Options for the Core Controller

Constant Summary

Constants included from OptParseValidator

OptParseValidator::VERSION

Instance Method Summary collapse

Methods inherited from Base

#==, #datastore, #formatter, #option_parser, option_parser=, #output, #render, reset, #target, #tmp_directory, #user_interaction?

Instance Method Details

#after_scanObject



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'app/controllers/core.rb', line 266

def after_scan
  @stop_time   = Time.now
  @elapsed     = @stop_time - @start_time
  @used_memory = GetProcessMem.new.bytes - @start_memory

  warnings = WPScan.error_warning_messages

  output('finished',
         cached_requests: WPScan.cached_requests,
         requests_done: WPScan.total_requests,
         data_sent: WPScan.total_data_sent,
         data_received: WPScan.total_data_received,
         response_status_codes: WPScan.format_status_codes(WPScan.top_status_codes),
         response_status_codes_warning: warnings.any?,
         response_status_codes_warnings: warnings)
end

#base_cli_optionsObject



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'app/controllers/core/cli_options.rb', line 7

def base_cli_options
  formats = WPScan::Formatter.availables

  [
    OptURL.new(['-u', '--url URL', 'The URL to scan'],
               required_unless: %i[help hh version],
               default_protocol: 'http'),
    OptBoolean.new(['--force', 'Do not check if target returns a 403'])
  ] + mixed_cli_options + [
    OptFilePath.new(['-o', '--output FILE', 'Output to FILE'], writable: true, exists: false),
    OptChoice.new(['-f', '--format FORMAT',
                   'Output results in the format supplied'], choices: formats),
    OptBoolean.new(['--[no-]stream',
                    'Emit enumeration findings (plugins/themes/users) as they are discovered, ' \
                    'instead of waiting until each enumeration step completes. ' \
                    'Has no effect on the json or sarif output formats, which always batch.'],
                   default: true),
    OptChoice.new(['--detection-mode MODE'],
                  choices: %w[mixed passive aggressive],
                  normalize: :to_sym,
                  default: :mixed),
    OptArray.new(['--scope DOMAINS',
                  'Comma separated (sub-)domains to consider in scope. ',
                  'Wildcard(s) allowed in the trd of valid domains, e.g: *.target.tld'], advanced: true),
    OptArray.new(['--exclude-vulns UUIDs',
                  'Comma separated list of vulnerability UUIDs to exclude. ',
                  'Format: UUID (e.g: c099c1da-3750-4e63-8af9-929e773bbe57)'], advanced: true)
  ] + cli_browser_options
end

#before_scanObject



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'app/controllers/core.rb', line 34

def before_scan
  @last_update = local_db.last_update
  @saml_authenticated = false

  maybe_output_banner_help_and_version

  update_db if update_db_required?
  setup_cache
  check_target_availability
  load_server_module
  check_wordpress_state
rescue Error::NotWordPress => e
  target.maybe_add_cookies
  raise e unless target.wordpress?(ParsedCli.detection_mode)
end

#check_target_availabilityVoid

Checks that the target is accessible, raises related errors otherwise.

Returns:

  • (Void)


62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'app/controllers/core.rb', line 62

def check_target_availability
  res = WPScan::Browser.get(target.url)

  case res.code
  when 0
    raise Error::TargetDown, res
  when 401
    raise Error::HTTPAuthRequired
  when 403
    raise Error::AccessForbidden, WPScan::ParsedCli.random_user_agent unless WPScan::ParsedCli.force
  when 407
    raise Error::ProxyAuthRequired
  end

  handle_redirection(res)
end

#check_wordpress_stateObject

Raises errors if the target is hosted on wordpress.com or is not running WordPress. Also checks if the homepage_url is still the install URL.



217
218
219
220
221
222
223
224
225
226
227
228
# File 'app/controllers/core.rb', line 217

def check_wordpress_state
  raise Error::WordPressHosted if target.wordpress_hosted?

  if %r{/wp-admin/install.php$}i.match?(Addressable::URI.parse(target.homepage_url).path)

    output('not_fully_configured', url: target.homepage_url)

    exit(WPScan::ExitCode::VULNERABLE)
  end

  raise Error::NotWordPress unless target.wordpress?(ParsedCli.detection_mode) || ParsedCli.force
end

#cli_browser_cache_optionsArray<OptParseValidator::OptBase>

Returns:



136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'app/controllers/core/cli_options.rb', line 136

def cli_browser_cache_options
  [
    OptInteger.new(['--cache-ttl TIME_TO_LIVE', 'The cache time to live in seconds'],
                   default: 600, advanced: true),
    OptBoolean.new(['--clear-cache', 'Clear the cache before the scan'], advanced: true),
    OptDirectoryPath.new(['--cache-dir PATH'],
                         readable: true,
                         writable: true,
                         create: true,
                         default: tmp_directory.join('cache'),
                         advanced: true)
  ]
end

#cli_browser_cookies_optionsArray<OptParseValidator::OptBase>

Returns:



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'app/controllers/core/cli_options.rb', line 116

def cli_browser_cookies_options
  [
    OptString.new(['--cookie-string COOKIE',
                   'Cookie string to use in requests, ' \
                   'format: cookie1=value1[; cookie2=value2]']),
    OptFilePath.new(['--cookie-jar FILE-PATH', 'File to read and write cookies'],
                    writable: true,
                    readable: true,
                    create: true,
                    default: tmp_directory.join('cookie_jar.txt')),
    OptBoolean.new(['--expect-saml',
                    'Expect SAML authentication to be required. ' \
                    'When the target redirects to a SAML IdP, an interactive browser ' \
                    'is launched for login and the resulting session cookies are reused ' \
                    'for the rest of the scan.'],
                   advanced: true)
  ]
end

#cli_browser_headers_optionsArray<OptParseValidator::OptBase>

Returns:



95
96
97
98
99
100
101
# File 'app/controllers/core/cli_options.rb', line 95

def cli_browser_headers_options
  [
    OptString.new(['--user-agent VALUE', '--ua']),
    OptHeaders.new(['--headers HEADERS', 'Additional headers to append in requests'], advanced: true),
    OptString.new(['--vhost VALUE', 'The virtual host (Host header) to use in requests'], advanced: true)
  ]
end

#cli_browser_optionsArray<OptParseValidator::OptBase>

Returns:



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/controllers/core/cli_options.rb', line 62

def cli_browser_options
  cli_browser_headers_options + [
    OptBoolean.new(['--random-user-agent', '--rua',
                    'Use a random user-agent for each scan']),
    OptFilePath.new(['--user-agents-list FILE-PATH',
                     'List of agents to use with --random-user-agent'],
                    exists: true,
                    advanced: true,
                    default: APP_DIR.join('user_agents.txt')),
    OptCredentials.new(['--http-auth login:password',
                        'Basic HTTP authentication, beware that the $ character must be properly escaped.']),
    OptCredentials.new(['--wp-auth login:password',
                        'WordPress admin credentials used to query the REST API for an authoritative ' \
                        'inventory of installed plugins and themes (/wp-json/wp/v2/plugins and /themes). ' \
                        'When provided, plugin/theme enumeration via -e is bypassed. ' \
                        'The password MUST be a WordPress Application Password (WP >= 5.6, ' \
                        'created at Users -> Profile -> Application Passwords). ' \
                        'Real account passwords are rejected by WordPress core over Basic Auth.']),
    OptPositiveInteger.new(['-t', '--max-threads VALUE', 'The max threads to use'],
                           default: 5),
    OptPositiveInteger.new(['--throttle MilliSeconds', 'Milliseconds to wait before doing another web request. ' \
                                                       'If used, the max threads will be set to 1.']),
    OptPositiveInteger.new(['--request-timeout SECONDS', 'The request timeout in seconds'],
                           default: 60),
    OptPositiveInteger.new(['--connect-timeout SECONDS', 'The connection timeout in seconds'],
                           default: 30),
    OptBoolean.new(['--disable-tls-checks',
                    'Disables SSL/TLS certificate verification, and downgrade to TLS1.0+ ' \
                    '(requires cURL 7.66 for the latter)'])
  ] + cli_browser_proxy_options + cli_browser_cookies_options + cli_browser_cache_options
end

#cli_browser_proxy_optionsArray<OptParseValidator::OptBase>

Returns:



104
105
106
107
108
109
110
111
112
113
# File 'app/controllers/core/cli_options.rb', line 104

def cli_browser_proxy_options
  [
    OptProxy.new(['--proxy protocol://IP:port',
                  'Supported protocols depend on the cURL installed. ' \
                  'Note: with socks5://, hostnames are resolved locally before being ' \
                  'sent to the proxy; use socks5h:// to have the proxy resolve them ' \
                  '(required for .onion addresses when proxying through Tor).']),
    OptCredentials.new(['--proxy-auth login:password'])
  ]
end

#cli_optionsArray<OptParseValidator::Opt>

Returns:

  • (Array<OptParseValidator::Opt>)


11
12
13
14
15
16
17
18
19
20
21
22
23
# File 'app/controllers/core.rb', line 11

def cli_options
  [OptURL.new(['--url URL', 'The URL of the blog to scan'],
              required_unless: %i[update help hh version], default_protocol: 'http')] +
    base_cli_options.drop(2) + # drop the base --url and --force
    [
      OptChoice.new(['--server SERVER', 'Force the supplied server module to be loaded'],
                    choices: %w[apache iis nginx],
                    normalize: %i[downcase to_sym],
                    advanced: true),
      OptBoolean.new(['--force', 'Do not check if the target is running WordPress or returns a 403']),
      OptBoolean.new(['--[no-]update', 'Whether or not to update the Database'])
    ]
end

#handle_follow_redirect(effective_url, effective_uri) ⇒ Object

Handles –follow-redirect option

Parameters:

  • effective_url (String)
  • effective_uri (Addressable::URI)


168
169
170
171
172
173
# File 'app/controllers/core.rb', line 168

def handle_follow_redirect(effective_url, effective_uri)
  return unless WPScan::ParsedCli.follow_redirect && target.url != effective_url

  target.url = effective_url
  target.scope << effective_uri.host
end

#handle_redirection(res) ⇒ Object

Checks for redirects; an out-of-scope redirect raises Error::HTTPRedirect.

Parameters:

Raises:



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'app/controllers/core.rb', line 123

def handle_redirection(res)
  effective_url = target.homepage_res.effective_url # get and follow location of target.url
  effective_uri = Addressable::URI.parse(effective_url)
  is_saml = saml_request?(effective_uri, target.homepage_res)

  if WPScan::ParsedCli.expect_saml && !is_saml && !@saml_authenticated
    puts 'SAML authentication was expected but not required.'
    puts # New line to serve as buffer before the scan results start
  end

  if is_saml
    raise Error::SAMLAuthenticationFailed if @saml_authenticated

    handle_saml_authentication(effective_uri)
    return check_target_availability
  end

  handle_scheme_redirect(effective_url, effective_uri)
  handle_follow_redirect(effective_url, effective_uri)

  return if target.in_scope?(effective_url)

  raise Error::HTTPRedirect, effective_url unless WPScan::ParsedCli.ignore_main_redirect

  # Sets homepage_res back to unfollowed response when ignore_main_redirect is used
  target.homepage_res = res
end

#handle_saml_authentication(effective_uri) ⇒ Void

Drives an interactive SAML login via a headless browser, injects the resulting session cookies into the shared Browser, and clears the target’s cached homepage so the rest of the scan runs against the authenticated session.

Parameters:

  • effective_uri (Addressable::URI)

    URL that triggered the SAML redirect

Returns:

  • (Void)

Raises:



105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'app/controllers/core.rb', line 105

def handle_saml_authentication(effective_uri)
  raise Error::SAMLAuthenticationFailed if WPScan::ParsedCli.cookie_string && !WPScan::ParsedCli.expect_saml
  raise Error::SAMLAuthenticationRequired unless WPScan::ParsedCli.expect_saml

  new_cookies = BrowserAuthenticator.authenticate(effective_uri.to_s)

  browser = WPScan::Browser.instance
  browser.cookie_string = [browser.cookie_string, new_cookies].compact.reject(&:empty?).join('; ')

  # Discard the pre-auth homepage so subsequent finders refetch with the new cookies.
  target.reset_homepage_cache!

  @saml_authenticated = true
end

#handle_scheme_redirect(effective_url, effective_uri) ⇒ Object

Handles scheme-only redirects (http => https or vice versa)

Parameters:

  • effective_url (String)
  • effective_uri (Addressable::URI)


155
156
157
158
159
160
161
162
# File 'app/controllers/core.rb', line 155

def handle_scheme_redirect(effective_url, effective_uri)
  # http://a.com => https://a.com (or the opposite)
  if !WPScan::ParsedCli.ignore_main_redirect && target.uri.domain == effective_uri.domain &&
     target.uri.path == effective_uri.path && target.uri.scheme != effective_uri.scheme

    target.url = effective_url
  end
end

#load_server_moduleSymbol

Loads the related server module into the target and includes it on WpItem (needed to check if directory listing is enabled etc.).

Returns:

  • (Symbol)

    The server module loaded



234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'app/controllers/core.rb', line 234

def load_server_module
  server = target.server || :Apache # auto-detect

  case ParsedCli.server
  when :apache
    server = :Apache
  when :iis
    server = :IIS
  when :nginx
    server = :Nginx
  end

  mod = WPScan::Target::Server.const_get(server)

  target.extend mod
  Model::WpItem.include mod

  server
end

#local_dbDB::Updater

Returns:



176
177
178
# File 'app/controllers/core.rb', line 176

def local_db
  @local_db ||= DB::Updater.new(DB_DIR)
end

#maybe_output_banner_help_and_versionObject



50
51
52
53
54
55
56
57
# File 'app/controllers/core.rb', line 50

def maybe_output_banner_help_and_version
  output('banner') if WPScan::ParsedCli.banner
  output('help', help: option_parser.simple_help, simple: true) if WPScan::ParsedCli.help
  output('help', help: option_parser.full_help, simple: false) if WPScan::ParsedCli.hh
  output('version') if WPScan::ParsedCli.version

  exit(WPScan::ExitCode::OK) if WPScan::ParsedCli.help || WPScan::ParsedCli.hh || WPScan::ParsedCli.version
end

#mixed_cli_optionsObject



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'app/controllers/core/cli_options.rb', line 37

def mixed_cli_options
  [
    OptBoolean.new(['-h', '--help', 'Display the simple help and exit']),
    OptBoolean.new(['--hh', 'Display the full help and exit']),
    OptBoolean.new(['--version', 'Display the version and exit']),
    OptBoolean.new(['--ignore-main-redirect',
                    'Ignore the main redirect (if any) and scan the target url. ' \
                    'Has no effect if --follow-redirect is set.'],
                   advanced: true),
    OptBoolean.new(['--follow-redirect', 'Automatically update the URL to the destination of the redirect'],
                   advanced: true),
    OptBoolean.new(['-v', '--verbose', 'Verbose mode']),
    OptBoolean.new(['--[no-]banner', 'Whether or not to display the banner'], default: true),
    OptPositiveInteger.new(['--max-scan-duration SECONDS',
                            'Abort the scan if it exceeds the time provided in seconds'],
                           advanced: true),
    OptPositiveInteger.new(['--max-log-file-size MiB',
                            'Skip inspection of PHP log files (debug.log, error_log, ...) ' \
                            'when their advertised or transferred size exceeds this value, in MiB. ' \
                            'Guards against memory exhaustion on sites serving huge log files.'],
                           default: 20, advanced: true)
  ]
end

#runObject



254
255
256
257
258
259
260
261
262
263
264
# File 'app/controllers/core.rb', line 254

def run
  @start_time   = Time.now
  @start_memory = WPScan.start_memory

  output('started',
         url: target.url,
         ip: target.ip,
         effective_url: target.homepage_url,
         command_line: WPScan.command_line,
         hostname: Socket.gethostname)
end

#saml_request?(effective_uri, homepage_res = nil) ⇒ Boolean

Checks whether the response or its redirect chain contains a SAMLRequest, indicating that the target requires SAML authentication.

Parameters:

  • effective_uri (Addressable::URI)

    Final URL after following redirects

  • homepage_res (Typhoeus::Response) (defaults to: nil)

    Response whose redirect chain to inspect

Returns:

  • (Boolean)


86
87
88
89
90
91
92
93
94
95
96
# File 'app/controllers/core.rb', line 86

def saml_request?(effective_uri, homepage_res = nil)
  return false unless effective_uri

  return true if effective_uri.to_s.match?(/[?&]SAMLRequest/i)

  # SAML flows often bounce through intermediate pages before the IdP;
  # walk the redirect chain to catch a SAMLRequest in any Location header.
  !!homepage_res&.redirections&.any? do |redirect_response|
    redirect_response.headers['Location']&.match?(/SAMLRequest/i)
  end
end

#setup_cacheObject



25
26
27
28
29
30
31
32
# File 'app/controllers/core.rb', line 25

def setup_cache
  return unless WPScan::ParsedCli.cache_dir

  storage_path = File.join(WPScan::ParsedCli.cache_dir, Digest::MD5.hexdigest(target.url))

  Typhoeus::Config.cache = Cache::Typhoeus.new(storage_path)
  Typhoeus::Config.cache.clean if WPScan::ParsedCli.clear_cache
end

#update_dbObject



201
202
203
204
205
206
207
208
# File 'app/controllers/core.rb', line 201

def update_db
  @updating_db = true
  output('db_update_started')
  output('db_update_finished', updated: local_db.update, verbose: ParsedCli.verbose)
  @updating_db = false

  exit(0) unless ParsedCli.url
end

#update_db_required?Boolean

Returns:

  • (Boolean)


181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'app/controllers/core.rb', line 181

def update_db_required?
  if local_db.missing_files?
    raise Error::MissingDatabaseFile if ParsedCli.update == false

    return true
  end

  return ParsedCli.update unless ParsedCli.update.nil?

  return false unless user_interaction? && local_db.outdated?

  output('@notice', msg: 'It seems like you have not updated the database for some time.')
  print '[?] Do you want to update now? [Y]es [N]o, default: [N]'
  $stdout.flush

  response = $stdin.gets.to_s.strip

  !!/^y/i.match?(response)
end

#updating_db?Boolean

Returns Whether the DB update is currently in progress.

Returns:

  • (Boolean)

    Whether the DB update is currently in progress



211
212
213
# File 'app/controllers/core.rb', line 211

def updating_db?
  @updating_db
end