pentest 1.0.0

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.
@@ -0,0 +1,226 @@
1
+ require 'pentest/checkers/base_checker'
2
+ require 'pentest/payload'
3
+ require 'pentest/sql_proxy'
4
+ require 'term/ansicolor'
5
+ require 'pairwise'
6
+ require 'arproxy'
7
+ require 'callsite'
8
+ require 'gda'
9
+
10
+ class Pentest::SqliChecker < Pentest::BaseChecker
11
+ Pentest::Checkers.add self
12
+
13
+ @description = "Checks for SQL injections"
14
+
15
+ SQLI_PAYLOADS = File.read(File.expand_path('../fuzzers/sqli.txt', File.dirname(__FILE__)), encoding: 'utf-8').lines.map(&:strip).select {|l| l.size > 5 && l =~ /\W/}
16
+ CRACKER_PAYLOAD = %q(<>"'%;()&+)
17
+
18
+ def initialize(endpoint, params)
19
+ super(endpoint, params)
20
+ @queries = []
21
+ @parser = GDA::SQL::Parser.new
22
+ end
23
+
24
+ def generate_preattack_payloads(params, seeds, injection_point)
25
+ values_list = if params.size - 1 <= 0
26
+ [[]]
27
+ elsif params.size - 1 == 1
28
+ seeds.map {|s| [s]}
29
+ else
30
+ Pairwise.combinations(*([seeds] * (params.size - 1)))
31
+ end
32
+
33
+ values_list.map do |values|
34
+ values.insert(injection_point, CRACKER_PAYLOAD)
35
+
36
+ Pentest::Payload.new(
37
+ params: params,
38
+ route: @route,
39
+ values: values,
40
+ injection_point: injection_point,
41
+ injection: CRACKER_PAYLOAD,
42
+ )
43
+ end.take(50)
44
+ end
45
+
46
+ def handle_query(sql)
47
+ @queries << [sql, caller]
48
+ end
49
+
50
+ def attack(param, injection_point, ingredients)
51
+ preattack_payloads = generate_preattack_payloads(@params, ingredients, injection_point)
52
+
53
+ errors = []
54
+
55
+ penetrated_payload = nil
56
+ preattack_payloads.shuffle.each do |payload|
57
+ request, response, err = dispatch(payload)
58
+ status = get_status(err) || response.status
59
+
60
+ Pentest::Logger.put_progress (status / 100).to_s
61
+
62
+ errors << normalize_error(err, payload)
63
+
64
+ if ::ActiveRecord::StatementInvalid === err
65
+ payload.penetration_type = 'SQL Injection Vulnerability'
66
+ payload.penetration_confidence = :preattack
67
+ penetrated_payload = payload
68
+ break
69
+ end
70
+ end
71
+
72
+ # preattack not succeeded. skipping
73
+ return [nil, errors] if penetrated_payload.nil?
74
+
75
+ # attack
76
+ attack_payloads = generate_attack_payloads(params, penetrated_payload.values, injection_point)
77
+
78
+ Pentest::SqlProxy.enable!(self.method(:handle_query))
79
+
80
+ attack_payloads.shuffle.each do |payload|
81
+ request, response, err = dispatch(payload)
82
+ status = get_status(err) || response.status
83
+
84
+ Pentest::Logger.put_progress (status / 100).to_s
85
+
86
+ errors << normalize_error(err, payload)
87
+
88
+ check_attack_result(payload, err);
89
+
90
+ if payload.penetration_confidence == :attack
91
+ penetrated_payload = payload
92
+ break
93
+ end
94
+ end
95
+
96
+ Pentest::SqlProxy.disable!(self.method(:handle_query))
97
+
98
+ [penetrated_payload, errors]
99
+ end
100
+
101
+ def generate_attack_payloads(params, values, injection_point)
102
+ SQLI_PAYLOADS.map do |injection|
103
+ new_values = values.dup
104
+ new_values[injection_point] = injection
105
+
106
+ Pentest::Payload.new(
107
+ params: params,
108
+ route: @route,
109
+ values: new_values,
110
+ injection_point: injection_point,
111
+ injection: injection,
112
+ )
113
+ end
114
+ end
115
+
116
+ def check_attack_result(payload, err)
117
+ @queries.each do |query, trace|
118
+ begin
119
+ stmt = @parser.parse(query.tr('`?', '"0'))
120
+ rescue RuntimeError => e
121
+ next
122
+ end
123
+
124
+ if query.include?(payload.injection) && !(GDA::Nodes::Unknown === stmt.ast)
125
+ tokens = extract_values(stmt.ast)
126
+ if tokens.all? {|token| !token.force_encoding("UTF-8").include? payload.injection}
127
+ callsites = Callsite.parse(trace)
128
+ project_call = callsites.find do |callsite|
129
+ callsite.filename.starts_with?(@app_path)
130
+ end
131
+ unless project_call.nil?
132
+ line = File.read(project_call.filename).lines[project_call.line - 1].rstrip
133
+ end
134
+
135
+ payload.penetration_type = 'SQL Injection Vulnerability'
136
+ payload.penetration_confidence = :attack
137
+ payload.penetration_message = [
138
+ *(line.nil? ? [] : [
139
+ "#{project_call.filename}:#{project_call.line}",
140
+ "> #{line}",
141
+ ]),
142
+ "Issued query: #{query.gsub(payload.injection, Term::ANSIColor.red(payload.injection))}",
143
+ ].join("\n")
144
+ break
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ def extract_values(stmt)
151
+ ret = []
152
+
153
+ if stmt.nil?
154
+ # nop
155
+ elsif stmt.is_a?(String)
156
+ ret << stmt
157
+ elsif stmt.is_a?(Integer)
158
+ ret << stmt.to_s
159
+ elsif stmt.is_a?(Array)
160
+ stmt.each do |s|
161
+ ret += extract_values(s)
162
+ end
163
+ elsif GDA::Nodes::Table === stmt
164
+ ret += extract_values(stmt.table_name)
165
+ elsif GDA::Nodes::Field === stmt
166
+ ret += extract_values(stmt.field_name)
167
+ elsif GDA::Nodes::Expr === stmt
168
+ ret += extract_values(stmt.func)
169
+ ret += extract_values(stmt.cond)
170
+ ret += extract_values(stmt.select)
171
+ ret += extract_values(stmt.case_s)
172
+ ret += extract_values(stmt.param_spec)
173
+ ret += extract_values(stmt.cast_as)
174
+ ret << stmt.value
175
+ elsif GDA::Nodes::Select === stmt
176
+ ret += extract_values(stmt.distinct_expr)
177
+ ret += extract_values(stmt.expr_list)
178
+ ret += extract_values(stmt.from)
179
+ ret += extract_values(stmt.where_cond)
180
+ ret += extract_values(stmt.group_by)
181
+ ret += extract_values(stmt.having_cond)
182
+ ret += extract_values(stmt.order_by)
183
+ ret += extract_values(stmt.limit_count)
184
+ ret += extract_values(stmt.limit_offset)
185
+ elsif GDA::Nodes::SelectField === stmt
186
+ ret += extract_values(stmt.expr)
187
+ ret += extract_values(stmt.field_name)
188
+ ret += extract_values(stmt.table_name)
189
+ ret += extract_values(stmt.as)
190
+ elsif GDA::Nodes::From === stmt
191
+ ret += extract_values(stmt.targets)
192
+ ret += extract_values(stmt.joins)
193
+ elsif GDA::Nodes::Operation === stmt
194
+ ret += extract_values(stmt.operands)
195
+ elsif GDA::Nodes::Target === stmt
196
+ ret += extract_values(stmt.expr)
197
+ ret += extract_values(stmt.table_name)
198
+ ret += extract_values(stmt.as)
199
+ elsif GDA::Nodes::Function === stmt
200
+ ret += extract_values(stmt.args_list)
201
+ ret += extract_values(stmt.function_name)
202
+ elsif GDA::Nodes::Order === stmt
203
+ ret += extract_values(stmt.expr)
204
+ ret += extract_values(stmt.collation_name)
205
+ elsif GDA::Nodes::Insert === stmt
206
+ ret += extract_values(stmt.table)
207
+ ret += extract_values(stmt.fields_list)
208
+ ret += extract_values(stmt.expr_list)
209
+ ret += extract_values(stmt.cond)
210
+ ret += extract_values(stmt.conflict)
211
+ elsif GDA::Nodes::Delete === stmt
212
+ ret += extract_values(stmt.table)
213
+ ret += extract_values(stmt.cond)
214
+ elsif GDA::Nodes::Join === stmt
215
+ ret += extract_values(stmt.expr)
216
+ ret += extract_values(stmt.use)
217
+ ret += extract_values(stmt.position)
218
+ elsif GDA::Nodes::Compound === stmt
219
+ ret += extract_values(stmt.compound_type)
220
+ elsif GDA::Nodes::Unknown === stmt
221
+ ret += extract_values(stmt.expressions)
222
+ end
223
+
224
+ ret
225
+ end
226
+ end
@@ -0,0 +1,87 @@
1
+ require 'pentest/checkers/base_checker'
2
+ require 'pentest/payload'
3
+ require 'term/ansicolor'
4
+ require 'pairwise'
5
+
6
+ class Pentest::XssChecker < Pentest::BaseChecker
7
+ Pentest::Checkers.add self
8
+
9
+ @description = "Checks for Cross-Site Scripting"
10
+
11
+ XSS_PAYLOADS = File.read(File.expand_path('../fuzzers/xss.txt', File.dirname(__FILE__)), encoding: 'utf-8').lines.map(&:strip).select {|l| l.size > 5 && l =~ /\W/}
12
+ CRACKER_PAYLOAD = %q(>>"<>=""'&<<"'&)
13
+
14
+ def initialize(endpoint, params)
15
+ super(endpoint, params)
16
+ end
17
+
18
+ def attack(param, injection_point, ingredients)
19
+ preattack_payloads = generate_preattack_payloads(@params, ingredients, injection_point)
20
+
21
+ errors = []
22
+
23
+ penetrated_payload = nil
24
+ preattack_payloads.shuffle.each do |payload|
25
+ request, response, err = dispatch(payload)
26
+ status = get_status(err) || response.status
27
+
28
+ Pentest::Logger.put_progress (status / 100).to_s
29
+
30
+ errors << normalize_error(err, payload)
31
+ document = Nokogiri::HTML(response.body)
32
+ document_errors = document.errors.reject {|e| is_allowable_error(e)}
33
+
34
+ if document_errors.any?
35
+ payload.penetration_type = 'Cross-Site Scripting Vulnerability'
36
+ payload.penetration_confidence = :preattack
37
+ payload.penetration_message = report_errors(response.body, document_errors)
38
+ penetrated_payload = payload
39
+ break
40
+ end
41
+ end
42
+
43
+ [penetrated_payload, errors]
44
+ end
45
+
46
+ def generate_preattack_payloads(params, seeds, injection_point)
47
+ values_list = if params.size - 1 <= 0
48
+ [[]]
49
+ elsif params.size - 1 == 1
50
+ seeds.map {|s| [s]}
51
+ else
52
+ Pairwise.combinations(*([seeds] * (params.size - 1)))
53
+ end
54
+
55
+ values_list.map do |values|
56
+ values.insert(injection_point, CRACKER_PAYLOAD)
57
+
58
+ Pentest::Payload.new(
59
+ params: params,
60
+ route: @route,
61
+ values: values,
62
+ injection_point: injection_point,
63
+ injection: CRACKER_PAYLOAD,
64
+ )
65
+ end.take(50)
66
+ end
67
+
68
+ private
69
+
70
+ def is_allowable_error(error)
71
+ error.to_s =~ /Tag \w+ invalid/ || error.to_s =~ /already defined/ || error.to_s =~ /Unexpected end tag/
72
+ end
73
+
74
+ def report_errors(body, errors)
75
+ body_lines = body.lines
76
+
77
+ error_strings = errors.map do |error|
78
+ lines = []
79
+ lines << error.to_s
80
+ lines << body_lines[error.line - 1].rstrip
81
+ lines << ' ' * (error.column - 1) + '^'
82
+ lines.join("\n")
83
+ end
84
+
85
+ error_strings.join("\n\n")
86
+ end
87
+ end
@@ -0,0 +1,41 @@
1
+ require 'optparse'
2
+
3
+ module Pentest
4
+ # Implements Command-Line Interface of Pentest
5
+
6
+ class Commandline
7
+ class << self
8
+ # Runs everything:
9
+ def run default_app_path = "."
10
+ options, args = get_options
11
+
12
+ if args.size >= 1
13
+ options[:app_path] = args[0]
14
+ else
15
+ options[:app_path] = default_app_path
16
+ end
17
+
18
+ result = Pentest.run options.merge(:print_report => true)
19
+
20
+ if result.nil?
21
+ exit 0
22
+ else
23
+ exit 1
24
+ end
25
+ end
26
+
27
+ def get_options
28
+ options = {}
29
+ parser = create_option_parser options
30
+ args = parser.parse! ARGV
31
+ [options, args]
32
+ end
33
+
34
+ def create_option_parser options
35
+ OptionParser.new do |opts|
36
+ opts.banner = "Usage: pentest [options] rails/root/path"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,15 @@
1
+ module Pentest
2
+ module DSL
3
+ private
4
+
5
+ def pentest_setup(*args, &block)
6
+ Pentest::add_setup(*args, &block)
7
+ end
8
+
9
+ def pentest_before_attack(*args, &block)
10
+ Pentest::add_before_attack(*args, &block)
11
+ end
12
+ end
13
+ end
14
+
15
+ self.extend Pentest::DSL
@@ -0,0 +1,149 @@
1
+ require 'pentest/checkers'
2
+
3
+ module Pentest
4
+ class Endpoint
5
+ attr_reader :route, :app_path
6
+
7
+ def initialize(route, app_path, hooks)
8
+ @route = route
9
+ @app_path = app_path
10
+ @hooks = hooks
11
+
12
+ @controller = route.defaults[:controller]
13
+ @action = route.defaults[:action]
14
+
15
+ return if @controller.nil? || @action.nil?
16
+
17
+ @controller_name = ::ActiveSupport::Inflector.camelize(@controller) + "Controller"
18
+ @controller_class = ::ActiveSupport::Inflector.constantize(@controller_name)
19
+
20
+ @error_patterns = Set.new
21
+ end
22
+
23
+ def valid?
24
+ !@controller.nil? && !@action.nil? && @controller_class.method_defined?(@action.to_sym)
25
+ end
26
+
27
+ def scan!(ingredients)
28
+ params = get_params
29
+
30
+ Logger.info "#{@route.verb} #{path}"
31
+ Logger.debug "Attacking #{@controller_class.inspect}##{@action}...", timestamp: false
32
+ Logger.debug "Detected Parameters: #{params.to_a.inspect}", timestamp: false
33
+
34
+ error_patterns = Set.new
35
+ penetrated_payloads = []
36
+
37
+ Logger.start_progress
38
+
39
+ Checkers.run_checkers(self, params) do |checker|
40
+ params.each_with_index do |param, injection_point|
41
+ penetrated_payload, errors = checker.attack(param, injection_point, ingredients)
42
+
43
+ accumulate_errors(errors)
44
+
45
+ unless penetrated_payload.nil?
46
+ penetrated_payloads << penetrated_payload
47
+ end
48
+ end
49
+ end
50
+
51
+ Logger.end_progress
52
+
53
+ @error_patterns.each do |error_pattern|
54
+ Logger.warn("Error: #{error_pattern}", timestamp: false)
55
+ end
56
+
57
+ penetrated_payloads
58
+ end
59
+
60
+ def dispatch(payload)
61
+ request = ActionDispatch::TestRequest.create
62
+ request.request_method = @route.verb
63
+ request.path = path(payload.params_hash)
64
+
65
+ @hooks[:before_attacks].each do |before_attack_proc|
66
+ before_attack_proc.call(request)
67
+ end
68
+
69
+ request.path_parameters = {
70
+ controller: @controller,
71
+ action: @action,
72
+ }
73
+
74
+ payload.params_hash.each do |param_parts, value|
75
+ if param_parts.size == 1
76
+ param, = param_parts
77
+ if @route.required_parts.include? param
78
+ request.path_parameters[param] = value
79
+ else
80
+ request.query_parameters[param] = value
81
+ end
82
+ elsif param_parts.size == 2
83
+ request.query_parameters[param_parts[0]] ||= {}
84
+ request.query_parameters[param_parts[0]][param_parts[1]] = value
85
+ end
86
+ end
87
+
88
+ request.path_parameters.each do |param, value|
89
+ request.update_param(param, value)
90
+ end
91
+
92
+ request.query_parameters.each do |param, value|
93
+ request.update_param(param, value)
94
+ end
95
+
96
+ response = ActionDispatch::TestResponse.create
97
+
98
+ err = nil
99
+ begin
100
+ @controller_class.new.dispatch(@action.to_sym, request, response)
101
+ rescue => e
102
+ err = e
103
+ end
104
+
105
+ [request, response, err]
106
+ end
107
+
108
+ private
109
+
110
+ def method
111
+ @controller_class.instance_method(@action.to_sym)
112
+ end
113
+
114
+ def path(options = {})
115
+ @route.required_parts.each do |part|
116
+ options[part] ||= ":#{part}"
117
+ end
118
+
119
+ @route.format(options)
120
+ end
121
+
122
+ def get_params
123
+ exp = RubyParser.get_sexp(method)
124
+ param_usages = AstUtils.search_for_params(exp)
125
+ deep_parameters = Set.new
126
+ non_deep_parameters = Set.new
127
+
128
+ param_usages.each do |param, type, method, arg|
129
+ if type == :callee && method == :[]
130
+ deep_parameters << [ param, arg[1] ]
131
+ else
132
+ non_deep_parameters << param
133
+ end
134
+ end
135
+
136
+ non_deep_parameters += @route.required_parts.map(&:to_sym)
137
+ non_deep_parameters -= deep_parameters.map {|a| a[0]}
138
+ deep_parameters.to_a + non_deep_parameters.map {|param| [param]}
139
+ end
140
+
141
+ def accumulate_errors(errors)
142
+ errors.each do |error|
143
+ unless error.nil?
144
+ @error_patterns << error
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end