aikido-zen 1.0.2.beta.2-aarch64-linux
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.aikido +6 -0
- data/.ruby-version +1 -0
- data/.simplecov +26 -0
- data/.standard.yml +3 -0
- data/LICENSE +674 -0
- data/README.md +146 -0
- data/Rakefile +67 -0
- data/benchmarks/README.md +23 -0
- data/benchmarks/rails7.1_sql_injection.js +70 -0
- data/docs/banner.svg +202 -0
- data/docs/config.md +125 -0
- data/docs/proxy.md +10 -0
- data/docs/rails.md +114 -0
- data/lib/aikido/zen/actor.rb +116 -0
- data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
- data/lib/aikido/zen/agent.rb +179 -0
- data/lib/aikido/zen/api_client.rb +145 -0
- data/lib/aikido/zen/attack.rb +207 -0
- data/lib/aikido/zen/background_worker.rb +52 -0
- data/lib/aikido/zen/capped_collections.rb +68 -0
- data/lib/aikido/zen/collector/hosts.rb +15 -0
- data/lib/aikido/zen/collector/routes.rb +66 -0
- data/lib/aikido/zen/collector/sink_stats.rb +95 -0
- data/lib/aikido/zen/collector/stats.rb +111 -0
- data/lib/aikido/zen/collector/users.rb +30 -0
- data/lib/aikido/zen/collector.rb +144 -0
- data/lib/aikido/zen/config.rb +282 -0
- data/lib/aikido/zen/context/rack_request.rb +24 -0
- data/lib/aikido/zen/context/rails_request.rb +44 -0
- data/lib/aikido/zen/context.rb +112 -0
- data/lib/aikido/zen/detached_agent/agent.rb +78 -0
- data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
- data/lib/aikido/zen/detached_agent/server.rb +78 -0
- data/lib/aikido/zen/detached_agent.rb +2 -0
- data/lib/aikido/zen/errors.rb +107 -0
- data/lib/aikido/zen/event.rb +71 -0
- data/lib/aikido/zen/internals.rb +103 -0
- data/lib/aikido/zen/libzen-v0.1.39-aarch64-linux.so +0 -0
- data/lib/aikido/zen/middleware/check_allowed_addresses.rb +26 -0
- data/lib/aikido/zen/middleware/middleware.rb +11 -0
- data/lib/aikido/zen/middleware/rack_throttler.rb +48 -0
- data/lib/aikido/zen/middleware/request_tracker.rb +192 -0
- data/lib/aikido/zen/middleware/set_context.rb +26 -0
- data/lib/aikido/zen/outbound_connection.rb +45 -0
- data/lib/aikido/zen/outbound_connection_monitor.rb +23 -0
- data/lib/aikido/zen/package.rb +22 -0
- data/lib/aikido/zen/payload.rb +50 -0
- data/lib/aikido/zen/rails_engine.rb +56 -0
- data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
- data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
- data/lib/aikido/zen/rate_limiter/result.rb +31 -0
- data/lib/aikido/zen/rate_limiter.rb +50 -0
- data/lib/aikido/zen/request/heuristic_router.rb +115 -0
- data/lib/aikido/zen/request/rails_router.rb +77 -0
- data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
- data/lib/aikido/zen/request/schema/auth_schemas.rb +54 -0
- data/lib/aikido/zen/request/schema/builder.rb +121 -0
- data/lib/aikido/zen/request/schema/definition.rb +107 -0
- data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
- data/lib/aikido/zen/request/schema.rb +87 -0
- data/lib/aikido/zen/request.rb +122 -0
- data/lib/aikido/zen/route.rb +39 -0
- data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
- data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
- data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
- data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
- data/lib/aikido/zen/runtime_settings.rb +65 -0
- data/lib/aikido/zen/scan.rb +75 -0
- data/lib/aikido/zen/scanners/path_traversal/helpers.rb +65 -0
- data/lib/aikido/zen/scanners/path_traversal_scanner.rb +63 -0
- data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
- data/lib/aikido/zen/scanners/shell_injection_scanner.rb +64 -0
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +93 -0
- data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
- data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +97 -0
- data/lib/aikido/zen/scanners/ssrf_scanner.rb +265 -0
- data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +49 -0
- data/lib/aikido/zen/scanners.rb +7 -0
- data/lib/aikido/zen/sink.rb +118 -0
- data/lib/aikido/zen/sinks/action_controller.rb +83 -0
- data/lib/aikido/zen/sinks/async_http.rb +80 -0
- data/lib/aikido/zen/sinks/curb.rb +113 -0
- data/lib/aikido/zen/sinks/em_http.rb +83 -0
- data/lib/aikido/zen/sinks/excon.rb +118 -0
- data/lib/aikido/zen/sinks/file.rb +112 -0
- data/lib/aikido/zen/sinks/http.rb +93 -0
- data/lib/aikido/zen/sinks/httpclient.rb +95 -0
- data/lib/aikido/zen/sinks/httpx.rb +78 -0
- data/lib/aikido/zen/sinks/kernel.rb +33 -0
- data/lib/aikido/zen/sinks/mysql2.rb +31 -0
- data/lib/aikido/zen/sinks/net_http.rb +101 -0
- data/lib/aikido/zen/sinks/patron.rb +103 -0
- data/lib/aikido/zen/sinks/pg.rb +72 -0
- data/lib/aikido/zen/sinks/resolv.rb +62 -0
- data/lib/aikido/zen/sinks/socket.rb +78 -0
- data/lib/aikido/zen/sinks/sqlite3.rb +46 -0
- data/lib/aikido/zen/sinks/trilogy.rb +31 -0
- data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
- data/lib/aikido/zen/sinks.rb +36 -0
- data/lib/aikido/zen/sinks_dsl.rb +250 -0
- data/lib/aikido/zen/synchronizable.rb +24 -0
- data/lib/aikido/zen/system_info.rb +84 -0
- data/lib/aikido/zen/version.rb +10 -0
- data/lib/aikido/zen/worker.rb +87 -0
- data/lib/aikido/zen.rb +246 -0
- data/lib/aikido-zen.rb +3 -0
- data/placeholder/.gitignore +4 -0
- data/placeholder/README.md +11 -0
- data/placeholder/Rakefile +75 -0
- data/placeholder/lib/placeholder.rb.template +3 -0
- data/placeholder/placeholder.gemspec.template +20 -0
- data/tasklib/bench.rake +94 -0
- data/tasklib/libzen.rake +133 -0
- data/tasklib/wrk.rb +88 -0
- metadata +205 -0
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../attack"
|
4
|
+
require_relative "../internals"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Scanners
|
8
|
+
class SQLInjectionScanner
|
9
|
+
def self.skips_on_nil_context?
|
10
|
+
true
|
11
|
+
end
|
12
|
+
|
13
|
+
# Checks if the given SQL query may have dangerous user input injected,
|
14
|
+
# and returns an Attack if so, based on the current request.
|
15
|
+
#
|
16
|
+
# @param query [String]
|
17
|
+
# @param context [Aikido::Zen::Context]
|
18
|
+
# @param sink [Aikido::Zen::Sink] the Sink that is running the scan.
|
19
|
+
# @param dialect [Symbol] one of +:mysql+, +:postgesql+, or +:sqlite+.
|
20
|
+
# @param operation [Symbol, String] name of the method being scanned.
|
21
|
+
# Expects +sink.operation+ being set to get the full module/name combo.
|
22
|
+
#
|
23
|
+
# @return [Aikido::Zen::Attack, nil] an Attack if any user input is
|
24
|
+
# detected to be attempting a SQL injection, or nil if this is safe.
|
25
|
+
#
|
26
|
+
# @raise [Aikido::Zen::InternalsError] if an error occurs when loading or
|
27
|
+
# calling zenlib. See Sink#scan.
|
28
|
+
def self.call(query:, dialect:, sink:, context:, operation:)
|
29
|
+
dialect = DIALECTS.fetch(dialect) do
|
30
|
+
Aikido::Zen.config.logger.warn "Unknown SQL dialect #{dialect.inspect}"
|
31
|
+
DIALECTS[:common]
|
32
|
+
end
|
33
|
+
|
34
|
+
context.payloads.each do |payload|
|
35
|
+
next unless new(query, payload.value, dialect).attack?
|
36
|
+
|
37
|
+
return Attacks::SQLInjectionAttack.new(
|
38
|
+
sink: sink,
|
39
|
+
query: query,
|
40
|
+
input: payload,
|
41
|
+
dialect: dialect,
|
42
|
+
context: context,
|
43
|
+
operation: "#{sink.operation}.#{operation}"
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
nil
|
48
|
+
end
|
49
|
+
|
50
|
+
def initialize(query, input, dialect)
|
51
|
+
@query = query.downcase
|
52
|
+
@input = input.downcase
|
53
|
+
@dialect = dialect
|
54
|
+
end
|
55
|
+
|
56
|
+
def attack?
|
57
|
+
# Ignore single char inputs since they shouldn't be able to do much harm
|
58
|
+
return false if @input.length <= 1
|
59
|
+
|
60
|
+
# If the input is longer than the query, then it is not part of it
|
61
|
+
return false if @input.length > @query.length
|
62
|
+
|
63
|
+
# If the input is not included in the query at all, then we are safe
|
64
|
+
return false unless @query.include?(@input)
|
65
|
+
|
66
|
+
# If the input is solely alphanumeric, we can ignore it
|
67
|
+
return false if /\A[[:alnum:]_]+\z/i.match?(@input)
|
68
|
+
|
69
|
+
# If the input is a comma-separated list of numbers, ignore it.
|
70
|
+
return false if /\A(?:\d+(?:,\s*)?)+\z/i.match?(@input)
|
71
|
+
|
72
|
+
Internals.detect_sql_injection(@query, @input, @dialect)
|
73
|
+
end
|
74
|
+
|
75
|
+
# @api private
|
76
|
+
Dialect = Struct.new(:name, :internals_key, keyword_init: true) do
|
77
|
+
alias_method :to_s, :name
|
78
|
+
alias_method :to_int, :internals_key
|
79
|
+
end
|
80
|
+
|
81
|
+
# Maps easy-to-use Symbols to a struct that keeps both the name and the
|
82
|
+
# internal identifier used by libzen.
|
83
|
+
#
|
84
|
+
# @see https://github.com/AikidoSec/zen-internals/blob/main/src/sql_injection/helpers/select_dialect_based_on_enum.rs
|
85
|
+
DIALECTS = {
|
86
|
+
common: Dialect.new(name: "SQL", internals_key: 0),
|
87
|
+
mysql: Dialect.new(name: "MySQL", internals_key: 8),
|
88
|
+
postgresql: Dialect.new(name: "PostgreSQL", internals_key: 9),
|
89
|
+
sqlite: Dialect.new(name: "SQLite", internals_key: 12)
|
90
|
+
}
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "delegate"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
module Scanners
|
7
|
+
module SSRF
|
8
|
+
# Simple per-request cache of all DNS lookups performed for a given host.
|
9
|
+
# We can store this in the context after performing a lookup, and have the
|
10
|
+
# SSRF scanner make sure the hostname being inspected doesn't actually
|
11
|
+
# resolve to an internal/dangerous IP.
|
12
|
+
class DNSLookups < SimpleDelegator
|
13
|
+
def initialize
|
14
|
+
super(Hash.new { |h, k| h[k] = [] })
|
15
|
+
end
|
16
|
+
|
17
|
+
def add(hostname, addresses)
|
18
|
+
self[hostname].concat(Array(addresses))
|
19
|
+
end
|
20
|
+
|
21
|
+
def ===(hostname)
|
22
|
+
key?(hostname)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "resolv"
|
4
|
+
require "ipaddr"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Scanners
|
8
|
+
module SSRF
|
9
|
+
# Little helper to check if a given hostname or address is to be
|
10
|
+
# considered "dangerous" when used for an outbound HTTP request.
|
11
|
+
#
|
12
|
+
# When given a hostname:
|
13
|
+
#
|
14
|
+
# * If any DNS lookups have been performed and stored in the current Zen
|
15
|
+
# context (under the "dns.lookups" metadata key), we will map it to the
|
16
|
+
# list of IPs that we've resolved it to.
|
17
|
+
#
|
18
|
+
# * If not, we'll still try to map it to any statically defined address in
|
19
|
+
# the system hosts file (e.g. /etc/hosts).
|
20
|
+
#
|
21
|
+
# Once we mapped the hostname to an IP address (or, if given an IP
|
22
|
+
# address), this will check that it's not a loopback address, a private IP
|
23
|
+
# address (as defined by RFCs 1918 and 4193), or in one of the
|
24
|
+
# "special-use" IP ranges defined in RFC 5735.
|
25
|
+
class PrivateIPChecker
|
26
|
+
def initialize(resolver = Resolv::Hosts.new)
|
27
|
+
@resolver = resolver
|
28
|
+
end
|
29
|
+
|
30
|
+
# @param hostname_or_address [String]
|
31
|
+
# @return [Boolean]
|
32
|
+
def private?(hostname_or_address)
|
33
|
+
resolve(hostname_or_address).any? do |ip|
|
34
|
+
PRIVATE_RANGES.any? { |range| range === ip }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
# Source: https://github.com/AikidoSec/firewall-node/blob/main/library/vulnerabilities/ssrf/isPrivateIP.ts
|
41
|
+
PRIVATE_IPV4_RANGES = [
|
42
|
+
IPAddr.new("0.0.0.0/8"), # "This" network (RFC 1122)
|
43
|
+
IPAddr.new("10.0.0.0/8"), # Private-Use Networks (RFC 1918)
|
44
|
+
IPAddr.new("100.64.0.0/10"), # Shared Address Space (RFC 6598)
|
45
|
+
IPAddr.new("127.0.0.0/8"), # Loopback (RFC 1122)
|
46
|
+
IPAddr.new("169.254.0.0/16"), # Link Local (RFC 3927)
|
47
|
+
IPAddr.new("172.16.0.0/12"), # Private-Use Networks (RFC 1918)
|
48
|
+
IPAddr.new("192.0.0.0/24"), # IETF Protocol Assignments (RFC 5736)
|
49
|
+
IPAddr.new("192.0.2.0/24"), # TEST-NET-1 (RFC 5737)
|
50
|
+
IPAddr.new("192.31.196.0/24"), # AS112 Redirection Anycast (RFC 7535)
|
51
|
+
IPAddr.new("192.52.193.0/24"), # Automatic Multicast Tunneling (RFC 7450)
|
52
|
+
IPAddr.new("192.88.99.0/24"), # 6to4 Relay Anycast (RFC 3068)
|
53
|
+
IPAddr.new("192.168.0.0/16"), # Private-Use Networks (RFC 1918)
|
54
|
+
IPAddr.new("192.175.48.0/24"), # AS112 Redirection Anycast (RFC 7535)
|
55
|
+
IPAddr.new("198.18.0.0/15"), # Network Interconnect Device Benchmark Testing (RFC 2544)
|
56
|
+
IPAddr.new("198.51.100.0/24"), # TEST-NET-2 (RFC 5737)
|
57
|
+
IPAddr.new("203.0.113.0/24"), # TEST-NET-3 (RFC 5737)
|
58
|
+
IPAddr.new("224.0.0.0/4"), # Multicast (RFC 3171)
|
59
|
+
IPAddr.new("240.0.0.0/4"), # Reserved for Future Use (RFC 1112)
|
60
|
+
IPAddr.new("255.255.255.255/32") # Limited Broadcast (RFC 919)
|
61
|
+
]
|
62
|
+
|
63
|
+
PRIVATE_IPV6_RANGES = [
|
64
|
+
IPAddr.new("::/128"), # Unspecified address (RFC 4291)
|
65
|
+
IPAddr.new("::1/128"), # Loopback address (RFC 4291)
|
66
|
+
IPAddr.new("fc00::/7"), # Unique local address (ULA) (RFC 4193
|
67
|
+
IPAddr.new("fe80::/10"), # Link-local address (LLA) (RFC 4291)
|
68
|
+
IPAddr.new("100::/64"), # Discard prefix (RFC 6666)
|
69
|
+
IPAddr.new("2001:db8::/32"), # Documentation prefix (RFC 3849)
|
70
|
+
IPAddr.new("3fff::/20") # Documentation prefix (RFC 9637)
|
71
|
+
]
|
72
|
+
|
73
|
+
PRIVATE_RANGES = PRIVATE_IPV4_RANGES + PRIVATE_IPV6_RANGES + PRIVATE_IPV4_RANGES.map(&:ipv4_mapped)
|
74
|
+
|
75
|
+
def resolved_in_current_context
|
76
|
+
context = Aikido::Zen.current_context
|
77
|
+
context && context["dns.lookups"]
|
78
|
+
end
|
79
|
+
|
80
|
+
def resolve(hostname_or_address)
|
81
|
+
return [] if hostname_or_address.nil?
|
82
|
+
|
83
|
+
case hostname_or_address
|
84
|
+
when Resolv::AddressRegex
|
85
|
+
[IPAddr.new(hostname_or_address)]
|
86
|
+
when resolved_in_current_context
|
87
|
+
resolved_in_current_context[hostname_or_address]
|
88
|
+
.map { |address| IPAddr.new(address) }
|
89
|
+
else
|
90
|
+
@resolver.getaddresses(hostname_or_address.to_s)
|
91
|
+
.map { |address| IPAddr.new(address) }
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,265 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "ssrf/private_ip_checker"
|
4
|
+
require_relative "ssrf/dns_lookups"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Scanners
|
8
|
+
class SSRFScanner
|
9
|
+
# SSRF attacks can be triggered through external inputs, so it is essential
|
10
|
+
# to have a valid context to safeguard against these attacks.
|
11
|
+
def self.skips_on_nil_context?
|
12
|
+
true
|
13
|
+
end
|
14
|
+
|
15
|
+
# Checks if an outbound HTTP request is to a hostname supplied from user
|
16
|
+
# input that resolves to a "dangerous" address. This is called from two
|
17
|
+
# different places:
|
18
|
+
#
|
19
|
+
# * HTTP library sinks, before we make a request. In these cases we can
|
20
|
+
# detect very obvious attempts such as a request that attempts to access
|
21
|
+
# localhost or an internal IP.
|
22
|
+
# * DNS lookup sinks, after we resolve a hostname. For HTTP requests that
|
23
|
+
# are not obviously an attack, we let the DNS resolution happen, and
|
24
|
+
# then check again, now knowing if the domain name provided actually
|
25
|
+
# resolves to an internal IP or not.
|
26
|
+
#
|
27
|
+
# NOTE: Because not all DNS resolutions might be happening in the context
|
28
|
+
# of a protected HTTP request, the +request+ argument below *might* be nil
|
29
|
+
# and we can then skip this scan.
|
30
|
+
#
|
31
|
+
# @param request [Aikido::Zen::Scanners::SSRFScanner::Request, nil]
|
32
|
+
# the ongoing outbound HTTP request.
|
33
|
+
# @param context [Aikido::Zen::Context]
|
34
|
+
# @param sink [Aikido::Zen::Sink] the Sink that is running the scan.
|
35
|
+
# @param operation [Symbol, String] name of the method being scanned.
|
36
|
+
# Expects +sink.operation+ being set to get the full module/name combo.
|
37
|
+
#
|
38
|
+
# @return [Aikido::Zen::Attacks::SSRFAttack, nil] an Attack if any user
|
39
|
+
# input is detected to be attempting SSRF, or +nil+ if not.
|
40
|
+
def self.call(request:, sink:, context:, operation:, **)
|
41
|
+
return if request.nil? # See NOTE above.
|
42
|
+
|
43
|
+
context["ssrf.redirects"] ||= RedirectChains.new
|
44
|
+
|
45
|
+
context.payloads.each do |payload|
|
46
|
+
scanner = new(request.uri, payload.value, context["ssrf.redirects"])
|
47
|
+
next unless scanner.attack?
|
48
|
+
|
49
|
+
attack = Attacks::SSRFAttack.new(
|
50
|
+
sink: sink,
|
51
|
+
request: request,
|
52
|
+
input: payload,
|
53
|
+
context: context,
|
54
|
+
operation: "#{sink.operation}.#{operation}"
|
55
|
+
)
|
56
|
+
|
57
|
+
return attack
|
58
|
+
end
|
59
|
+
|
60
|
+
nil
|
61
|
+
end
|
62
|
+
|
63
|
+
# Track the origin of a redirection so we know if an attacker is using
|
64
|
+
# redirect chains to mask their use of a (seemingly) safe domain.
|
65
|
+
#
|
66
|
+
# @param request [Aikido::Zen::Scanners::SSRFScanner::Request]
|
67
|
+
# @param response [Aikido::Zen::Scanners::SSRFScanner::Response]
|
68
|
+
# @param context [Aikido::Zen::Context]
|
69
|
+
#
|
70
|
+
# @return [void]
|
71
|
+
def self.track_redirects(request:, response:, context: Aikido::Zen.current_context)
|
72
|
+
return unless response.redirect?
|
73
|
+
|
74
|
+
context["ssrf.redirects"] ||= RedirectChains.new
|
75
|
+
context["ssrf.redirects"].add(
|
76
|
+
source: request.uri,
|
77
|
+
destination: response.redirect_to
|
78
|
+
)
|
79
|
+
end
|
80
|
+
|
81
|
+
# @api private
|
82
|
+
def initialize(request_uri, input, redirects)
|
83
|
+
@request_uri = request_uri
|
84
|
+
@input = input
|
85
|
+
@redirects = redirects
|
86
|
+
end
|
87
|
+
|
88
|
+
# @api private
|
89
|
+
def attack?
|
90
|
+
return false if @input.nil? || @input.to_s.empty?
|
91
|
+
|
92
|
+
# If the request is not aimed at an internal IP, we can ignore it. (It
|
93
|
+
# might still be an SSRF if defined strictly, but it's unlikely to be
|
94
|
+
# exfiltrating data from the app's servers, and the risk for false
|
95
|
+
# positives is too high.)
|
96
|
+
return false unless private_ip?(@request_uri.hostname)
|
97
|
+
|
98
|
+
origins_for_request
|
99
|
+
.product(uris_from_input)
|
100
|
+
.any? { |(conn_uri, candidate)| match?(conn_uri, candidate) }
|
101
|
+
end
|
102
|
+
|
103
|
+
# @!visibility private
|
104
|
+
def self.private_ip_checker
|
105
|
+
@private_ip_checker ||= SSRF::PrivateIPChecker.new
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def match?(conn_uri, input_uri)
|
111
|
+
return false if conn_uri.hostname.nil? || conn_uri.hostname.empty?
|
112
|
+
return false if input_uri.hostname.nil? || input_uri.hostname.empty?
|
113
|
+
|
114
|
+
# The URI library will automatically set the port to the default port
|
115
|
+
# for the current scheme if not provided, which means we can't just
|
116
|
+
# check if the port is present, as it always will be.
|
117
|
+
is_port_relevant = input_uri.port != input_uri.default_port
|
118
|
+
return false if is_port_relevant && input_uri.port != conn_uri.port
|
119
|
+
|
120
|
+
conn_uri.hostname == input_uri.hostname &&
|
121
|
+
conn_uri.port == input_uri.port
|
122
|
+
end
|
123
|
+
|
124
|
+
def private_ip?(hostname)
|
125
|
+
self.class.private_ip_checker.private?(hostname)
|
126
|
+
end
|
127
|
+
|
128
|
+
def origins_for_request
|
129
|
+
[@request_uri, @redirects.origin(@request_uri)].compact
|
130
|
+
end
|
131
|
+
|
132
|
+
# Maps the current user input into a Set of URIs we can check against:
|
133
|
+
#
|
134
|
+
# * The input itself, if it already looks like a URI.
|
135
|
+
# * The input prefixed with http://
|
136
|
+
# * The input prefixed with https://
|
137
|
+
# * The input prefixed with the scheme of the request's URI, to consider
|
138
|
+
# things like an FTP request (to "ftp://localhost") with a plain host
|
139
|
+
# as a user-input ("localhost").
|
140
|
+
#
|
141
|
+
# @return [Array<URI>] a list of unique URIs based on the above criteria.
|
142
|
+
def uris_from_input
|
143
|
+
input = @input.to_s
|
144
|
+
|
145
|
+
# If you build a URI manually and set the hostname to an IPv6 string,
|
146
|
+
# the URI library will be helpful to wrap it in brackets so it's a
|
147
|
+
# valid hostname. We should do the same for the input.
|
148
|
+
input = format("[%s]", input) if unescaped_ipv6?(input)
|
149
|
+
|
150
|
+
[
|
151
|
+
input,
|
152
|
+
"http://#{input}",
|
153
|
+
"https://#{input}",
|
154
|
+
"#{@request_uri.scheme}://#{input}"
|
155
|
+
].map { |candidate| as_uri(candidate) }.compact.uniq
|
156
|
+
end
|
157
|
+
|
158
|
+
def as_uri(string)
|
159
|
+
URI(string)
|
160
|
+
rescue URI::InvalidURIError
|
161
|
+
nil
|
162
|
+
end
|
163
|
+
|
164
|
+
# Check if the input is an IPv6 that is not surrounded by square brackets.
|
165
|
+
def unescaped_ipv6?(input)
|
166
|
+
(
|
167
|
+
IPAddr::RE_IPV6ADDRLIKE_FULL.match?(input) ||
|
168
|
+
IPAddr::RE_IPV6ADDRLIKE_COMPRESSED.match?(input)
|
169
|
+
) && !(input.start_with?("[") && input.end_with?("]"))
|
170
|
+
end
|
171
|
+
|
172
|
+
# @api private
|
173
|
+
module Headers
|
174
|
+
# @param headers [Hash<String, Object>]
|
175
|
+
# @param header_normalizer [Proc{Object => String}]
|
176
|
+
def initialize(headers:, header_normalizer: :to_s.to_proc)
|
177
|
+
@headers = headers.to_h
|
178
|
+
@header_normalizer = header_normalizer
|
179
|
+
@normalized_headers = false
|
180
|
+
end
|
181
|
+
|
182
|
+
# @return [Hash<String, String>]
|
183
|
+
def headers
|
184
|
+
return @headers if @normalized_headers
|
185
|
+
|
186
|
+
@headers
|
187
|
+
.transform_keys!(&:downcase)
|
188
|
+
.transform_values!(&@header_normalizer)
|
189
|
+
.tap { @normalized_headers = true }
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
# @api private
|
194
|
+
class Request
|
195
|
+
include Headers
|
196
|
+
|
197
|
+
attr_reader :verb
|
198
|
+
attr_reader :uri
|
199
|
+
|
200
|
+
def initialize(verb:, uri:, **header_options)
|
201
|
+
super(**header_options)
|
202
|
+
@verb = verb.to_s.upcase
|
203
|
+
@uri = URI(uri)
|
204
|
+
end
|
205
|
+
|
206
|
+
def to_s
|
207
|
+
[@verb, @uri.to_s].join(" ").strip
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
# @api private
|
212
|
+
class Response
|
213
|
+
include Headers
|
214
|
+
|
215
|
+
attr_reader :status
|
216
|
+
|
217
|
+
def initialize(status:, **header_options)
|
218
|
+
super(**header_options)
|
219
|
+
@status = status.to_s
|
220
|
+
end
|
221
|
+
|
222
|
+
def redirect?
|
223
|
+
@status.start_with?("3") && headers["location"]
|
224
|
+
end
|
225
|
+
|
226
|
+
def redirect_to
|
227
|
+
URI(headers["location"]) if redirect?
|
228
|
+
rescue URI::BadURIError
|
229
|
+
nil
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# @api private
|
234
|
+
class RedirectChains
|
235
|
+
def initialize
|
236
|
+
@redirects = Hash.new { |h, k| h[k] = [] }
|
237
|
+
end
|
238
|
+
|
239
|
+
def add(source:, destination:)
|
240
|
+
@redirects[destination].push(source)
|
241
|
+
self
|
242
|
+
end
|
243
|
+
|
244
|
+
# Recursively looks for the original URI that triggered the current
|
245
|
+
# chain. If given a URI that was not the result of a redirect chain, it
|
246
|
+
# returns +nil+
|
247
|
+
#
|
248
|
+
# @param uri [URI]
|
249
|
+
# @return [URI, nil]
|
250
|
+
def origin(uri, visited = Set.new)
|
251
|
+
source = @redirects[uri].first
|
252
|
+
|
253
|
+
return source if visited.include?(source)
|
254
|
+
visited << source
|
255
|
+
|
256
|
+
if !@redirects[source].empty?
|
257
|
+
origin(source, visited)
|
258
|
+
else
|
259
|
+
source
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
module Scanners
|
5
|
+
# Inspects the result of DNS lookups, to determine if we're being the target
|
6
|
+
# of a stored SSRF targeting IMDS addresses (169.254.169.254).
|
7
|
+
class StoredSSRFScanner
|
8
|
+
# Stored-SSRF can occur without external input, so we do not require a
|
9
|
+
# context to determine if an attack is happening.
|
10
|
+
def self.skips_on_nil_context?
|
11
|
+
false
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.call(hostname:, addresses:, operation:, sink:, context:, **opts)
|
15
|
+
offending_address = new(hostname, addresses).attack?
|
16
|
+
return if offending_address.nil?
|
17
|
+
|
18
|
+
Attacks::StoredSSRFAttack.new(
|
19
|
+
hostname: hostname,
|
20
|
+
address: offending_address,
|
21
|
+
sink: sink,
|
22
|
+
context: context,
|
23
|
+
operation: "#{sink.operation}.#{operation}"
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
def initialize(hostname, addresses, config: Aikido::Zen.config)
|
28
|
+
@hostname = hostname
|
29
|
+
@addresses = addresses
|
30
|
+
@config = config
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [String, nil] either the offending address, or +nil+ if no
|
34
|
+
# address is deemed dangerous.
|
35
|
+
def attack?
|
36
|
+
return false if @config.imds_allowed_hosts.include?(@hostname)
|
37
|
+
|
38
|
+
@addresses.find do |candidate|
|
39
|
+
DANGEROUS_ADDRESSES.any? { |address| address === candidate }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
DANGEROUS_ADDRESSES = [
|
44
|
+
IPAddr.new("169.254.169.254"),
|
45
|
+
IPAddr.new("fd00:ec2::254")
|
46
|
+
]
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "scanners/sql_injection_scanner"
|
4
|
+
require_relative "scanners/stored_ssrf_scanner"
|
5
|
+
require_relative "scanners/ssrf_scanner"
|
6
|
+
require_relative "scanners/path_traversal_scanner"
|
7
|
+
require_relative "scanners/shell_injection_scanner"
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "scan"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
module Sinks
|
7
|
+
# @api internal
|
8
|
+
# @return [Hash<String, Sink>]
|
9
|
+
def self.registry
|
10
|
+
@registry ||= {}
|
11
|
+
end
|
12
|
+
|
13
|
+
# Primary interface to set up a sink with a list of given scanners.
|
14
|
+
#
|
15
|
+
# @param name [String] name of the library being patched. (This must
|
16
|
+
# match the name of the gem, or we won't report that gem as
|
17
|
+
# supported.)
|
18
|
+
# @param scanners [Array<#call>] a list of objects that respond to
|
19
|
+
# #call with a Hash and return an Attack or nil.
|
20
|
+
# @param opts [Hash<Symbol, Object>] any other options to pass to
|
21
|
+
# the Sink initializer.
|
22
|
+
#
|
23
|
+
# @return [void]
|
24
|
+
# @raise [ArgumentError] if a Sink with this name has already been
|
25
|
+
# registered.
|
26
|
+
def self.add(name, scanners:, **opts)
|
27
|
+
raise ArgumentError, "Sink #{name} already registered" if registry.key?(name.to_s)
|
28
|
+
registry[name.to_s] = Sink.new(name.to_s, scanners: scanners, **opts)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Sinks serve as the proxies between a given library that we protect
|
33
|
+
# (such as a database adapter that we patch to prevent SQL injections)
|
34
|
+
# and the reporting agent.
|
35
|
+
#
|
36
|
+
# When a library is patched to track and potentially block attacks, we
|
37
|
+
# rely on a sink to run any scans required, and report any attacks to
|
38
|
+
# our agent.
|
39
|
+
#
|
40
|
+
# @see ./sinks/trilogy.rb for a reference implementation.
|
41
|
+
class Sink
|
42
|
+
# @return [String] name of the patched library (e.g. "mysql2").
|
43
|
+
attr_reader :name
|
44
|
+
|
45
|
+
# @return [Array<#call>] list of registered scanners for this sink.
|
46
|
+
attr_reader :scanners
|
47
|
+
|
48
|
+
# @return [String] descriptor of the module / method being scanned
|
49
|
+
# for attacks. This is fed into Attacks when instantiated. In
|
50
|
+
# certain cases, some scanners allow you to specialize this by
|
51
|
+
# using an +operation+ param of their own.
|
52
|
+
attr_reader :operation
|
53
|
+
|
54
|
+
DEFAULT_REPORTER = ->(scan) { Aikido::Zen.track_scan(scan) }
|
55
|
+
|
56
|
+
def initialize(name, scanners:, operation: name, reporter: DEFAULT_REPORTER)
|
57
|
+
raise ArgumentError, "scanners cannot be empty" if scanners.empty?
|
58
|
+
|
59
|
+
@name = name
|
60
|
+
@operation = operation
|
61
|
+
@scanners = scanners
|
62
|
+
@reporter = reporter
|
63
|
+
end
|
64
|
+
|
65
|
+
# Run the given arguments through all the registered scanners, until
|
66
|
+
# one of them returns an Attack or all return +nil+, and report the
|
67
|
+
# findings back to the Sink's +reporter+ to track statistics and
|
68
|
+
# potentially handle the +Attack+, if anything.
|
69
|
+
#
|
70
|
+
# This checks if runtime protection has been turned off for the current
|
71
|
+
# route first, and if so skips the scanning altogether, returning nil.
|
72
|
+
#
|
73
|
+
# @param scan_params [Hash] data to pass to all registered scanners.
|
74
|
+
# @option scan_params [Aikido::Zen::Context, nil] :context
|
75
|
+
# The current Context, including the HTTP request being inspected, or
|
76
|
+
# +nil+ if we're scanning outside of an HTTP request.
|
77
|
+
#
|
78
|
+
# @return [Aikido::Zen::Scan, nil] the result of the scan, or +nil+ if the
|
79
|
+
# scan was skipped due to protection being disabled for the current route.
|
80
|
+
# @raise [Aikido::UnderAttackError] if an attack is detected and
|
81
|
+
# blocking_mode is enabled.
|
82
|
+
def scan(context: Aikido::Zen.current_context, **scan_params)
|
83
|
+
return if context&.scanning
|
84
|
+
context&.scanning = true
|
85
|
+
|
86
|
+
return if context&.protection_disabled?
|
87
|
+
|
88
|
+
scan = Scan.new(sink: self, context: context)
|
89
|
+
|
90
|
+
scans_performed = 0
|
91
|
+
scan.perform do
|
92
|
+
result = nil
|
93
|
+
|
94
|
+
scanners.each do |scanner|
|
95
|
+
next if scanner.skips_on_nil_context? && context.nil?
|
96
|
+
|
97
|
+
result = scanner.call(sink: self, context: context, **scan_params)
|
98
|
+
scans_performed += 1
|
99
|
+
|
100
|
+
break result if result
|
101
|
+
rescue Aikido::Zen::InternalsError => error
|
102
|
+
Aikido::Zen.config.logger.warn(error.message)
|
103
|
+
scan.track_error(error, scanner)
|
104
|
+
rescue => error
|
105
|
+
scan.track_error(error, scanner)
|
106
|
+
end
|
107
|
+
|
108
|
+
result
|
109
|
+
end
|
110
|
+
|
111
|
+
@reporter.call(scan) if scans_performed > 0
|
112
|
+
|
113
|
+
scan
|
114
|
+
ensure
|
115
|
+
context&.scanning = false
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|