aikido-zen 0.1.1-x86_64-linux → 0.2.0-x86_64-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 +4 -4
- data/.simplecov +1 -0
- data/CHANGELOG.md +4 -0
- data/README.md +11 -2
- data/benchmarks/rails7.1_sql_injection.js +30 -34
- data/docs/banner.svg +128 -129
- data/docs/config.md +8 -6
- data/docs/rails.md +2 -2
- data/lib/aikido/zen/agent.rb +3 -1
- data/lib/aikido/zen/api_client.rb +3 -3
- data/lib/aikido/zen/attack.rb +105 -36
- data/lib/aikido/zen/collector/routes.rb +2 -0
- data/lib/aikido/zen/collector.rb +19 -3
- data/lib/aikido/zen/config.rb +44 -20
- data/lib/aikido/zen/errors.rb +10 -1
- data/lib/aikido/zen/event.rb +4 -2
- data/lib/aikido/zen/libzen-v0.1.37.x86_64.so +0 -0
- data/lib/aikido/zen/middleware/check_allowed_addresses.rb +2 -14
- data/lib/aikido/zen/middleware/middleware.rb +11 -0
- data/lib/aikido/zen/middleware/{throttler.rb → rack_throttler.rb} +3 -11
- data/lib/aikido/zen/middleware/request_tracker.rb +190 -0
- data/lib/aikido/zen/middleware/set_context.rb +1 -4
- data/lib/aikido/zen/payload.rb +2 -0
- data/lib/aikido/zen/rails_engine.rb +8 -0
- data/lib/aikido/zen/rate_limiter.rb +1 -1
- data/lib/aikido/zen/request/schema/builder.rb +0 -2
- data/lib/aikido/zen/request/schema/definition.rb +0 -5
- data/lib/aikido/zen/request/schema.rb +0 -3
- data/lib/aikido/zen/scanners/path_traversal/helpers.rb +65 -0
- data/lib/aikido/zen/scanners/path_traversal_scanner.rb +61 -0
- data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
- data/lib/aikido/zen/scanners/shell_injection_scanner.rb +62 -0
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +0 -4
- data/lib/aikido/zen/scanners/ssrf_scanner.rb +9 -6
- data/lib/aikido/zen/scanners.rb +2 -0
- data/lib/aikido/zen/sinks/action_controller.rb +26 -12
- data/lib/aikido/zen/sinks/file.rb +120 -0
- data/lib/aikido/zen/sinks/kernel.rb +73 -0
- data/lib/aikido/zen/sinks.rb +8 -0
- data/lib/aikido/zen/system_info.rb +1 -1
- data/lib/aikido/zen/version.rb +2 -2
- data/lib/aikido/zen.rb +14 -1
- data/tasklib/bench.rake +3 -2
- metadata +16 -8
- data/lib/aikido/zen/libzen-v0.1.31.x86_64.so +0 -0
@@ -0,0 +1,190 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
module Middleware
|
5
|
+
# Rack middleware used to track request
|
6
|
+
# It implements the logic under that which is considered worthy of being tracked.
|
7
|
+
class RequestTracker
|
8
|
+
def initialize(app)
|
9
|
+
@app = app
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(env)
|
13
|
+
request = Aikido::Zen::Middleware.request_from(env)
|
14
|
+
response = @app.call(env)
|
15
|
+
|
16
|
+
Aikido::Zen.track_request request
|
17
|
+
|
18
|
+
if Aikido::Zen.config.collect_api_schema? && request.route && track?(
|
19
|
+
status_code: response[0],
|
20
|
+
route: request.route.path,
|
21
|
+
http_method: request.request_method
|
22
|
+
)
|
23
|
+
Aikido::Zen.track_discovered_route(request)
|
24
|
+
end
|
25
|
+
|
26
|
+
response
|
27
|
+
end
|
28
|
+
|
29
|
+
IGNORED_METHODS = %w[OPTIONS HEAD]
|
30
|
+
IGNORED_EXTENSIONS = %w[properties config webmanifest]
|
31
|
+
IGNORED_SEGMENTS = ["cgi-bin"]
|
32
|
+
WELL_KNOWN_URIS = %w[
|
33
|
+
/.well-known/acme-challenge
|
34
|
+
/.well-known/amphtml
|
35
|
+
/.well-known/api-catalog
|
36
|
+
/.well-known/appspecific
|
37
|
+
/.well-known/ashrae
|
38
|
+
/.well-known/assetlinks.json
|
39
|
+
/.well-known/broadband-labels
|
40
|
+
/.well-known/brski
|
41
|
+
/.well-known/caldav
|
42
|
+
/.well-known/carddav
|
43
|
+
/.well-known/change-password
|
44
|
+
/.well-known/cmp
|
45
|
+
/.well-known/coap
|
46
|
+
/.well-known/coap-eap
|
47
|
+
/.well-known/core
|
48
|
+
/.well-known/csaf
|
49
|
+
/.well-known/csaf-aggregator
|
50
|
+
/.well-known/csvm
|
51
|
+
/.well-known/did.json
|
52
|
+
/.well-known/did-configuration.json
|
53
|
+
/.well-known/dnt
|
54
|
+
/.well-known/dnt-policy.txt
|
55
|
+
/.well-known/dots
|
56
|
+
/.well-known/ecips
|
57
|
+
/.well-known/edhoc
|
58
|
+
/.well-known/enterprise-network-security
|
59
|
+
/.well-known/enterprise-transport-security
|
60
|
+
/.well-known/est
|
61
|
+
/.well-known/genid
|
62
|
+
/.well-known/gnap-as-rs
|
63
|
+
/.well-known/gpc.json
|
64
|
+
/.well-known/gs1resolver
|
65
|
+
/.well-known/hoba
|
66
|
+
/.well-known/host-meta
|
67
|
+
/.well-known/host-meta.json
|
68
|
+
/.well-known/hosting-provider
|
69
|
+
/.well-known/http-opportunistic
|
70
|
+
/.well-known/idp-proxy
|
71
|
+
/.well-known/jmap
|
72
|
+
/.well-known/keybase.txt
|
73
|
+
/.well-known/knx
|
74
|
+
/.well-known/looking-glass
|
75
|
+
/.well-known/masque
|
76
|
+
/.well-known/matrix
|
77
|
+
/.well-known/mercure
|
78
|
+
/.well-known/mta-sts.txt
|
79
|
+
/.well-known/mud
|
80
|
+
/.well-known/nfv-oauth-server-configuration
|
81
|
+
/.well-known/ni
|
82
|
+
/.well-known/nodeinfo
|
83
|
+
/.well-known/nostr.json
|
84
|
+
/.well-known/oauth-authorization-server
|
85
|
+
/.well-known/oauth-protected-resource
|
86
|
+
/.well-known/ohttp-gateway
|
87
|
+
/.well-known/openid-federation
|
88
|
+
/.well-known/open-resource-discovery
|
89
|
+
/.well-known/openid-configuration
|
90
|
+
/.well-known/openorg
|
91
|
+
/.well-known/oslc
|
92
|
+
/.well-known/pki-validation
|
93
|
+
/.well-known/posh
|
94
|
+
/.well-known/privacy-sandbox-attestations.json
|
95
|
+
/.well-known/private-token-issuer-directory
|
96
|
+
/.well-known/probing.txt
|
97
|
+
/.well-known/pvd
|
98
|
+
/.well-known/rd
|
99
|
+
/.well-known/related-website-set.json
|
100
|
+
/.well-known/reload-config
|
101
|
+
/.well-known/repute-template
|
102
|
+
/.well-known/resourcesync
|
103
|
+
/.well-known/sbom
|
104
|
+
/.well-known/security.txt
|
105
|
+
/.well-known/ssf-configuration
|
106
|
+
/.well-known/sshfp
|
107
|
+
/.well-known/stun-key
|
108
|
+
/.well-known/terraform.json
|
109
|
+
/.well-known/thread
|
110
|
+
/.well-known/time
|
111
|
+
/.well-known/timezone
|
112
|
+
/.well-known/tdmrep.json
|
113
|
+
/.well-known/tor-relay
|
114
|
+
/.well-known/tpcd
|
115
|
+
/.well-known/traffic-advice
|
116
|
+
/.well-known/trust.txt
|
117
|
+
/.well-known/uma2-configuration
|
118
|
+
/.well-known/void
|
119
|
+
/.well-known/webfinger
|
120
|
+
/.well-known/webweaver.json
|
121
|
+
/.well-known/wot
|
122
|
+
]
|
123
|
+
|
124
|
+
# @param status_code [Integer]
|
125
|
+
# @param route [String]
|
126
|
+
# @param http_method [String]
|
127
|
+
def track?(status_code:, route:, http_method:)
|
128
|
+
# In the UI we want to show only successful (2xx) or redirect (3xx) responses
|
129
|
+
# anything else is discarded.
|
130
|
+
return false unless status_code >= 200 && status_code <= 399
|
131
|
+
|
132
|
+
return false if IGNORED_METHODS.include?(http_method)
|
133
|
+
|
134
|
+
segments = route.split "/"
|
135
|
+
|
136
|
+
# Do not discover routes with dot files like `/path/to/.file` or `/.directory/file`
|
137
|
+
# We want to allow discovery of well-known URIs like `/.well-known/acme-challenge`
|
138
|
+
return false if segments.any? { |s| is_dot_file s } && !is_well_known_uri(route)
|
139
|
+
|
140
|
+
return false if segments.any? { |s| contains_ignored_string s }
|
141
|
+
|
142
|
+
# Check for every file segment if it contains a file extension and if it
|
143
|
+
# should be discovered or ignored
|
144
|
+
segments.all? { |s| should_track_extension s }
|
145
|
+
end
|
146
|
+
|
147
|
+
private
|
148
|
+
|
149
|
+
# Check if a path is a well-known URI
|
150
|
+
# e.g. /.well-known/acme-challenge
|
151
|
+
# https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml
|
152
|
+
def is_well_known_uri(route)
|
153
|
+
WELL_KNOWN_URIS.include?(route)
|
154
|
+
end
|
155
|
+
|
156
|
+
def is_dot_file(segment)
|
157
|
+
segment.start_with?(".") && segment.size > 1
|
158
|
+
end
|
159
|
+
|
160
|
+
def contains_ignored_string(segment)
|
161
|
+
IGNORED_SEGMENTS.any? { |ignored| segment.include?(ignored) }
|
162
|
+
end
|
163
|
+
|
164
|
+
# Ignore routes which contain file extensions
|
165
|
+
def should_track_extension(segment)
|
166
|
+
extension = get_file_extension(segment)
|
167
|
+
|
168
|
+
return true unless extension
|
169
|
+
|
170
|
+
# Do not discover files with extensions of 1 to 5 characters,
|
171
|
+
# e.g. file.css, file.js, file.woff2
|
172
|
+
return false if extension.size > 1 && extension.size < 6
|
173
|
+
|
174
|
+
# Ignore some file extensions that are longer than 5 characters or shorter than 2 chars
|
175
|
+
return false if IGNORED_EXTENSIONS.include?(extension)
|
176
|
+
|
177
|
+
true
|
178
|
+
end
|
179
|
+
|
180
|
+
def get_file_extension(segment)
|
181
|
+
extension = File.extname(segment)
|
182
|
+
if extension&.start_with?(".")
|
183
|
+
# Remove the dot from the extension
|
184
|
+
return extension[1..]
|
185
|
+
end
|
186
|
+
extension
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
@@ -15,10 +15,7 @@ module Aikido::Zen
|
|
15
15
|
end
|
16
16
|
|
17
17
|
def call(env)
|
18
|
-
|
19
|
-
|
20
|
-
Aikido::Zen.current_context = env[ENV_KEY] = context
|
21
|
-
Aikido::Zen.track_request(context.request)
|
18
|
+
Aikido::Zen.current_context = env[ENV_KEY] = Context.from_rack_env(env)
|
22
19
|
|
23
20
|
@app.call(env)
|
24
21
|
ensure
|
data/lib/aikido/zen/payload.rb
CHANGED
@@ -14,6 +14,9 @@ module Aikido::Zen
|
|
14
14
|
|
15
15
|
app.middleware.use Aikido::Zen::Middleware::SetContext
|
16
16
|
app.middleware.use Aikido::Zen::Middleware::CheckAllowedAddresses
|
17
|
+
# Request Tracker stats do not consider failed request or 40x, so the middleware
|
18
|
+
# must be the last one wrapping the request.
|
19
|
+
app.middleware.use Aikido::Zen::Middleware::RequestTracker
|
17
20
|
|
18
21
|
ActiveSupport.on_load(:action_controller) do
|
19
22
|
# Due to how Rails sets up its middleware chain, the routing is evaluated
|
@@ -57,6 +60,11 @@ module Aikido::Zen
|
|
57
60
|
# that any gems required after aikido-zen are detected and patched
|
58
61
|
# accordingly.
|
59
62
|
Aikido::Zen.load_sinks!
|
63
|
+
|
64
|
+
# Agent's bootstrap process has finished —Controllers are patched to block
|
65
|
+
# unwanted requests, sinks are loaded, scanners are running—, so we mark
|
66
|
+
# the agent as installed.
|
67
|
+
Aikido::Zen.middleware_installed!
|
60
68
|
end
|
61
69
|
end
|
62
70
|
end
|
@@ -65,11 +65,6 @@ module Aikido::Zen
|
|
65
65
|
in {type: _}, {type: "null"}
|
66
66
|
new(definition.merge(optional: true))
|
67
67
|
|
68
|
-
# number | integer => number
|
69
|
-
in [{type: "integer"}, {type: "number"}] |
|
70
|
-
[{type: "number"}, {type: "integer"}]
|
71
|
-
new(definition.merge(other.definition).merge(type: "number"))
|
72
|
-
|
73
68
|
# x | y => [x, y] if x != y
|
74
69
|
else
|
75
70
|
left_type, right_type = definition[:type], other.definition[:type]
|
@@ -56,9 +56,6 @@ module Aikido::Zen
|
|
56
56
|
return self if other.nil?
|
57
57
|
|
58
58
|
self.class.new(
|
59
|
-
# TODO: this is currently overriding the content type with the new
|
60
|
-
# value, but we should support APIs that accept input in many types
|
61
|
-
# (e.g. JSON and XML)
|
62
59
|
content_type: other.content_type,
|
63
60
|
body_schema: body_schema.merge(other.body_schema),
|
64
61
|
query_schema: query_schema.merge(other.query_schema),
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
module Scanners
|
5
|
+
module PathTraversal
|
6
|
+
DANGEROUS_PATH_PARTS = ["../", "..\\"]
|
7
|
+
LINUX_ROOT_FOLDERS = [
|
8
|
+
"/bin/",
|
9
|
+
"/boot/",
|
10
|
+
"/dev/",
|
11
|
+
"/etc/",
|
12
|
+
"/home/",
|
13
|
+
"/init/",
|
14
|
+
"/lib/",
|
15
|
+
"/media/",
|
16
|
+
"/mnt/",
|
17
|
+
"/opt/",
|
18
|
+
"/proc/",
|
19
|
+
"/root/",
|
20
|
+
"/run/",
|
21
|
+
"/sbin/",
|
22
|
+
"/srv/",
|
23
|
+
"/sys/",
|
24
|
+
"/tmp/",
|
25
|
+
"/usr/",
|
26
|
+
"/var/"
|
27
|
+
]
|
28
|
+
|
29
|
+
DANGEROUS_PATH_STARTS = LINUX_ROOT_FOLDERS + ["c:/", "c:\\"]
|
30
|
+
|
31
|
+
module Helpers
|
32
|
+
def self.contains_unsafe_path_parts(filepath)
|
33
|
+
DANGEROUS_PATH_PARTS.each do |dangerous_part|
|
34
|
+
return true if filepath.include?(dangerous_part)
|
35
|
+
end
|
36
|
+
|
37
|
+
false
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.starts_with_unsafe_path(filepath, user_input)
|
41
|
+
# Check if path is relative (not absolute or drive letter path)
|
42
|
+
# Required because `expand_path` will build absolute paths from relative paths
|
43
|
+
return false if Pathname.new(filepath).relative? || Pathname.new(user_input).relative?
|
44
|
+
|
45
|
+
normalized_path = File.expand_path__internal_for_aikido_zen(filepath).downcase
|
46
|
+
normalized_user_input = File.expand_path__internal_for_aikido_zen(user_input).downcase
|
47
|
+
|
48
|
+
DANGEROUS_PATH_STARTS.each do |dangerous_start|
|
49
|
+
if normalized_path.start_with?(dangerous_start) && normalized_path.start_with?(normalized_user_input)
|
50
|
+
# If the user input is the same as the dangerous start, we don't want to flag it
|
51
|
+
# to prevent false positives.
|
52
|
+
# e.g., if user input is /etc/ and the path is /etc/passwd, we don't want to flag it,
|
53
|
+
# as long as the user input does not contain a subdirectory or filename
|
54
|
+
if user_input == dangerous_start || user_input == dangerous_start.chomp("/")
|
55
|
+
return false
|
56
|
+
end
|
57
|
+
return true
|
58
|
+
end
|
59
|
+
end
|
60
|
+
false
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "path_traversal/helpers"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
module Scanners
|
7
|
+
class PathTraversalScanner
|
8
|
+
# Checks if the user introduced input is trying to access other path using
|
9
|
+
# Path Traversal kind of attacks.
|
10
|
+
#
|
11
|
+
# @param filepath [String] the expanded path that is tried to be read
|
12
|
+
# @param context [Aikido::Zen::Context]
|
13
|
+
# @param sink [Aikido::Zen::Sink] the Sink that is running the scan.
|
14
|
+
# @param operation [Symbol, String] name of the method being scanned.
|
15
|
+
#
|
16
|
+
# @return [Aikido::Zen::Attacks::PathTraversalAttack, nil] an Attack if any
|
17
|
+
# user input is detected to be attempting a Path Traversal Attack, or +nil+ if not.
|
18
|
+
def self.call(filepath:, sink:, context:, operation:)
|
19
|
+
return unless context
|
20
|
+
|
21
|
+
context.payloads.each do |payload|
|
22
|
+
next unless new(filepath, payload.value).attack?
|
23
|
+
|
24
|
+
return Attacks::PathTraversalAttack.new(
|
25
|
+
sink: sink,
|
26
|
+
input: payload,
|
27
|
+
filepath: filepath,
|
28
|
+
context: context,
|
29
|
+
operation: "#{sink.operation}.#{operation}"
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def initialize(filepath, input)
|
37
|
+
@filepath = filepath.downcase
|
38
|
+
@input = input.downcase
|
39
|
+
end
|
40
|
+
|
41
|
+
def attack?
|
42
|
+
# Single character are ignored because they don't pose a big threat
|
43
|
+
return false if @input.length <= 1
|
44
|
+
|
45
|
+
# We ignore cases where the user input is longer than the file path.
|
46
|
+
# Because the user input can't be part of the file path.
|
47
|
+
return false if @input.length > @filepath.length
|
48
|
+
|
49
|
+
# We ignore cases where the user input is not part of the file path.
|
50
|
+
return false unless @filepath.include?(@input)
|
51
|
+
|
52
|
+
if PathTraversal::Helpers.contains_unsafe_path_parts(@filepath) && PathTraversal::Helpers.contains_unsafe_path_parts(@input)
|
53
|
+
return true
|
54
|
+
end
|
55
|
+
|
56
|
+
# Check for absolute path traversal
|
57
|
+
PathTraversal::Helpers.starts_with_unsafe_path(@filepath, @input)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen::Scanners::ShellInjection
|
4
|
+
module Helpers
|
5
|
+
ESCAPE_CHARS = %W[' "]
|
6
|
+
DANGEROUS_CHARS_INSIDE_DOUBLE_QUOTES = %W[$ ` \\ !]
|
7
|
+
DANGEROUS_CHARS = [
|
8
|
+
"#", "!", '"', "$", "&", "'", "(", ")", "*", ";", "<", "=", ">", "?",
|
9
|
+
"[", "\\", "]", "^", "`", "{", "|", "}", " ", "\n", "\t", "~"
|
10
|
+
]
|
11
|
+
|
12
|
+
COMMANDS = %w[sleep shutdown reboot poweroff halt ifconfig chmod chown ping
|
13
|
+
ssh scp curl wget telnet kill killall rm mv cp touch echo cat head
|
14
|
+
tail grep find awk sed sort uniq wc ls env ps who whoami id w df du
|
15
|
+
pwd uname hostname netstat passwd arch printenv logname pstree hostnamectl
|
16
|
+
set lsattr killall5 dmesg history free uptime finger top shopt :]
|
17
|
+
|
18
|
+
PATH_PREFIXES = %w[/bin/ /sbin/ /usr/bin/ /usr/sbin/ /usr/local/bin/ /usr/local/sbin/]
|
19
|
+
|
20
|
+
SEPARATORS = [" ", "\t", "\n", ";", "&", "|", "(", ")", "<", ">"]
|
21
|
+
|
22
|
+
# @param command [string]
|
23
|
+
# @param user_input [string]
|
24
|
+
def self.is_safely_encapsulated(command, user_input)
|
25
|
+
segments = command.split(user_input)
|
26
|
+
|
27
|
+
# The next condition is merely here to be compliant with what javascript does when splitting strings:
|
28
|
+
# From js doc https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split
|
29
|
+
# > If separator appears at the beginning (or end) of the string, it still has the effect of splitting,
|
30
|
+
# > resulting in an empty (i.e. zero length) string appearing at the first (or last) position of
|
31
|
+
# > the returned array.
|
32
|
+
# This is necessary because this code is ported form the firewall-node code.
|
33
|
+
if user_input.length > 1
|
34
|
+
if command.start_with? user_input
|
35
|
+
segments.unshift ""
|
36
|
+
end
|
37
|
+
|
38
|
+
if command.end_with? user_input
|
39
|
+
segments << ""
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Call the helper function to get current and next segments
|
44
|
+
get_current_and_next_segments(segments).all? do |segments_pair|
|
45
|
+
char_before_user_input = segments_pair[:current_segment][-1]
|
46
|
+
char_after_user_input = segments_pair[:next_segment][0]
|
47
|
+
|
48
|
+
# Check if the character before is an escape character
|
49
|
+
is_escape_char = ESCAPE_CHARS.include?(char_before_user_input)
|
50
|
+
|
51
|
+
unless is_escape_char
|
52
|
+
next false
|
53
|
+
end
|
54
|
+
|
55
|
+
# If characters before and after the user input do not match, return false
|
56
|
+
next false if char_before_user_input != char_after_user_input
|
57
|
+
|
58
|
+
# If user input contains the escape character, return false
|
59
|
+
next false if user_input.include?(char_before_user_input)
|
60
|
+
|
61
|
+
# Handle dangerous characters inside double quotes
|
62
|
+
if char_before_user_input == '"' && DANGEROUS_CHARS_INSIDE_DOUBLE_QUOTES.any? { |char| user_input.include?(char) }
|
63
|
+
next false
|
64
|
+
end
|
65
|
+
|
66
|
+
next true
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.get_current_and_next_segments(segments)
|
71
|
+
segments.each_cons(2).map { |current_segment, next_segment| {current_segment: current_segment, next_segment: next_segment} }
|
72
|
+
end
|
73
|
+
|
74
|
+
# Helper function for sorting commands by length (longer commands first)
|
75
|
+
def self.by_length(a, b)
|
76
|
+
b.length - a.length
|
77
|
+
end
|
78
|
+
|
79
|
+
# Escape characters with special meaning either inside or outside character sets.
|
80
|
+
# Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler
|
81
|
+
# form would be disallowed by Unicode patterns’ stricter grammar.
|
82
|
+
#
|
83
|
+
# Inspired by https://github.com/sindresorhus/escape-string-regexp/
|
84
|
+
def self.escape_string_regexp(string)
|
85
|
+
string.gsub(/[|\\{}()\[\]^$+*?.]/) { "\\#{$&}" }.gsub("-", '\\x2d')
|
86
|
+
end
|
87
|
+
|
88
|
+
# Construct the regex for commands
|
89
|
+
COMMANDS_REGEX = Regexp.new(
|
90
|
+
"([/.]*((#{PATH_PREFIXES.map { |p| Helpers.escape_string_regexp(p) }.join("|")})?((#{COMMANDS.sort(&method(:by_length)).join("|")}))))",
|
91
|
+
Regexp::IGNORECASE
|
92
|
+
)
|
93
|
+
|
94
|
+
def self.contains_shell_syntax(command, user_input)
|
95
|
+
# Check if input is only whitespace
|
96
|
+
return false if user_input.strip.empty?
|
97
|
+
|
98
|
+
# Check if the user input contains any dangerous characters
|
99
|
+
if DANGEROUS_CHARS.any? { |c| user_input.include?(c) }
|
100
|
+
return true
|
101
|
+
end
|
102
|
+
|
103
|
+
# If the command is exactly the same as the user input, check if it matches the regex
|
104
|
+
if command == user_input
|
105
|
+
return match_all(command, COMMANDS_REGEX).any? do |match|
|
106
|
+
match[:match].length == command.length && match[:match] == command
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# Check if the command contains a commonly used command
|
111
|
+
match_all(command, COMMANDS_REGEX).each do |match|
|
112
|
+
# We found a command like `rm` or `/sbin/shutdown` in the command
|
113
|
+
# Check if the command is the same as the user input
|
114
|
+
# If it's not the same, continue searching
|
115
|
+
next if user_input != match[:match]
|
116
|
+
|
117
|
+
# Otherwise, we'll check if the command is surrounded by separators
|
118
|
+
# These separators are used to separate commands and arguments
|
119
|
+
# e.g. `rm<space>-rf`
|
120
|
+
# e.g. `ls<newline>whoami`
|
121
|
+
# e.g. `echo<tab>hello` Check if the command is surrounded by separators
|
122
|
+
char_before = if match[:index] - 1 < 0
|
123
|
+
nil
|
124
|
+
else
|
125
|
+
command[match[:index] - 1]
|
126
|
+
end
|
127
|
+
|
128
|
+
char_after = if match[:index] + match[:match].length >= command.length
|
129
|
+
nil
|
130
|
+
else
|
131
|
+
command[match[:index] + match[:match].length]
|
132
|
+
end
|
133
|
+
|
134
|
+
# e.g. `<separator>rm<separator>`
|
135
|
+
if SEPARATORS.include?(char_before) && SEPARATORS.include?(char_after)
|
136
|
+
return true
|
137
|
+
end
|
138
|
+
|
139
|
+
# e.g. `<separator>rm`
|
140
|
+
if SEPARATORS.include?(char_before) && char_after.nil?
|
141
|
+
return true
|
142
|
+
end
|
143
|
+
|
144
|
+
# e.g. `rm<separator>`
|
145
|
+
if char_before.nil? && SEPARATORS.include?(char_after)
|
146
|
+
return true
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
false
|
151
|
+
end
|
152
|
+
|
153
|
+
def self.match_all(string, regex)
|
154
|
+
string.enum_for(:scan, regex).map do |match|
|
155
|
+
{match: match[0], index: $~.begin(0)}
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "shell_injection/helpers"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
module Scanners
|
7
|
+
class ShellInjectionScanner
|
8
|
+
# @param command [String]
|
9
|
+
# @param sink [Aikido::Zen::Sink]
|
10
|
+
# @param context [Aikido::Zen::Context]
|
11
|
+
# @param operation [Symbol, String]
|
12
|
+
#
|
13
|
+
def self.call(command:, sink:, context:, operation:)
|
14
|
+
return unless context
|
15
|
+
|
16
|
+
context.payloads.each do |payload|
|
17
|
+
next unless new(command, payload.value).attack?
|
18
|
+
|
19
|
+
return Attacks::ShellInjectionAttack.new(
|
20
|
+
sink: sink,
|
21
|
+
input: payload,
|
22
|
+
command: command,
|
23
|
+
context: context,
|
24
|
+
operation: "#{sink.operation}.#{operation}"
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
nil
|
29
|
+
end
|
30
|
+
|
31
|
+
# @param command [String]
|
32
|
+
# @param input [String]
|
33
|
+
def initialize(command, input)
|
34
|
+
@command = command
|
35
|
+
@input = input
|
36
|
+
end
|
37
|
+
|
38
|
+
def attack?
|
39
|
+
# Block single ~ character. For example `echo ~`
|
40
|
+
if @input == "~"
|
41
|
+
if @command.size > 1 && @command.include?("~")
|
42
|
+
return true
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# we ignore single character since they don't pose a big threat.
|
47
|
+
# They are only able to crash the shell, not execute arbitraty commands.
|
48
|
+
return false if @input.size <= 1
|
49
|
+
|
50
|
+
# We ignore cases where the user input is longer than the command because
|
51
|
+
# the user input can't be part of the command
|
52
|
+
return false if @input.size > @command.size
|
53
|
+
|
54
|
+
return false unless @command.include?(@input)
|
55
|
+
|
56
|
+
return false if ShellInjection::Helpers.is_safely_encapsulated @command, @input
|
57
|
+
|
58
|
+
ShellInjection::Helpers.contains_shell_syntax @command, @input
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -22,10 +22,6 @@ module Aikido::Zen
|
|
22
22
|
# @raise [Aikido::Zen::InternalsError] if an error occurs when loading or
|
23
23
|
# calling zenlib. See Sink#scan.
|
24
24
|
def self.call(query:, dialect:, sink:, context:, operation:)
|
25
|
-
# FIXME: This assumes queries executed outside of an HTTP request are
|
26
|
-
# safe, but this is not the case. For example, if an HTTP request
|
27
|
-
# enqueues a background job, passing user input verbatim, the job might
|
28
|
-
# pass that input to a query without having a current request in scope.
|
29
25
|
return if context.nil?
|
30
26
|
|
31
27
|
dialect = DIALECTS.fetch(dialect) do
|
@@ -228,11 +228,11 @@ module Aikido::Zen
|
|
228
228
|
# @api private
|
229
229
|
class RedirectChains
|
230
230
|
def initialize
|
231
|
-
@redirects = {}
|
231
|
+
@redirects = Hash.new { |h, k| h[k] = [] }
|
232
232
|
end
|
233
233
|
|
234
234
|
def add(source:, destination:)
|
235
|
-
@redirects[destination]
|
235
|
+
@redirects[destination].push(source)
|
236
236
|
self
|
237
237
|
end
|
238
238
|
|
@@ -242,11 +242,14 @@ module Aikido::Zen
|
|
242
242
|
#
|
243
243
|
# @param uri [URI]
|
244
244
|
# @return [URI, nil]
|
245
|
-
def origin(uri)
|
246
|
-
source = @redirects[uri]
|
245
|
+
def origin(uri, visited = Set.new)
|
246
|
+
source = @redirects[uri].first
|
247
247
|
|
248
|
-
if
|
249
|
-
|
248
|
+
return source if visited.include?(source)
|
249
|
+
visited << source
|
250
|
+
|
251
|
+
if !@redirects[source].empty?
|
252
|
+
origin(source, visited)
|
250
253
|
else
|
251
254
|
source
|
252
255
|
end
|
data/lib/aikido/zen/scanners.rb
CHANGED