rack-libinjection 0.1.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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +55 -0
- data/CHANGELOG.md +112 -0
- data/GET_STARTED.md +418 -0
- data/LICENSE-libinjection.txt +33 -0
- data/LICENSE.txt +21 -0
- data/README.md +68 -0
- data/SECURITY.md +65 -0
- data/ext/libinjection/extconf.rb +113 -0
- data/ext/libinjection/libinjection_ext.c +1132 -0
- data/ext/libinjection/vendor/libinjection/.vendored +5 -0
- data/ext/libinjection/vendor/libinjection/COPYING +33 -0
- data/ext/libinjection/vendor/libinjection/MIGRATION.md +393 -0
- data/ext/libinjection/vendor/libinjection/README.md +251 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection.h +70 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_error.h +26 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_html5.c +830 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_html5.h +56 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_sqli.c +2342 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_sqli.h +297 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_sqli_data.h +9651 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_xss.c +1203 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_xss.h +23 -0
- data/lib/libinjection/version.rb +6 -0
- data/lib/libinjection.rb +31 -0
- data/lib/rack/libinjection.rb +586 -0
- data/lib/rack-libinjection.rb +3 -0
- data/samples/README.md +67 -0
- data/samples/libinjection_detect_raw_hot_path.rb +161 -0
- data/samples/rack_all_surfaces_hot_path.rb +198 -0
- data/samples/rack_params_hot_path.rb +166 -0
- data/samples/rack_query_hot_path.rb +176 -0
- data/samples/results/.gitkeep +0 -0
- data/script/fuzz_smoke.rb +39 -0
- data/script/vendor_libs.rb +227 -0
- data/test/test_helper.rb +7 -0
- data/test/test_libinjection.rb +223 -0
- data/test/test_middleware.rb +404 -0
- metadata +148 -0
data/samples/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Hot-path samples
|
|
2
|
+
|
|
3
|
+
These files are diagnostic profiling loops, not release benchmarks. They are
|
|
4
|
+
meant to be run while capturing a macOS `sample` profile and then narrowing the
|
|
5
|
+
call tree with `filtercalltree`, following the same workflow as the other native
|
|
6
|
+
hot-path samples in this workspace.
|
|
7
|
+
|
|
8
|
+
Run from the repository root after compiling the extension:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
bundle exec rake compile
|
|
12
|
+
bundle exec ruby samples/libinjection_detect_raw_hot_path.rb
|
|
13
|
+
bundle exec ruby samples/rack_query_hot_path.rb
|
|
14
|
+
bundle exec ruby samples/rack_params_hot_path.rb
|
|
15
|
+
bundle exec ruby samples/rack_all_surfaces_hot_path.rb
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Each script prints:
|
|
19
|
+
|
|
20
|
+
- current PID;
|
|
21
|
+
- exact hot call being looped;
|
|
22
|
+
- payload/request shape;
|
|
23
|
+
- one-line `sample + filtercalltree` capture command;
|
|
24
|
+
- expected native/Ruby symbols;
|
|
25
|
+
- ops/sec or requests/sec;
|
|
26
|
+
- GC allocation delta.
|
|
27
|
+
|
|
28
|
+
## Scripts
|
|
29
|
+
|
|
30
|
+
| Script | Hot path |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `libinjection_detect_raw_hot_path.rb` | raw native `LibInjection.detect_raw(payload)` primitive used by middleware |
|
|
33
|
+
| `rack_query_hot_path.rb` | `scan: [:query]`, raw query string plus native decoded variants, no Rack nested params parser and no `Rack::Request` allocation |
|
|
34
|
+
| `rack_params_hot_path.rb` | `scan: [:params]`, Rack query parsing, nested param walk, native scan per string/key |
|
|
35
|
+
| `rack_all_surfaces_hot_path.rb` | path decode, headers, cookies, params, recursive walk, native scan |
|
|
36
|
+
|
|
37
|
+
Useful env knobs:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
PAYLOAD=clean|sqli|xss
|
|
41
|
+
DURATION=20
|
|
42
|
+
SLEEP_BEFORE_HOT_LOOP=7
|
|
43
|
+
PREHEAT_ITERATIONS=100
|
|
44
|
+
DISABLE_GC=0|1
|
|
45
|
+
THREATS=sqli,xss|sqli|xss
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Native-only knobs:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
INPUT_BYTES=128 # small input, GVL path
|
|
52
|
+
INPUT_BYTES=2048 # large input, no-GVL path
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Query/path native decoding knobs:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
PATH_DECODE_DEPTH=2
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
All-surfaces knobs:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
SCAN_COOKIE_NAMES=0|1
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Results captured by the printed command are written under `samples/results/`.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Native hot-path sample for:
|
|
4
|
+
# LibInjection.detect_raw(payload)
|
|
5
|
+
#
|
|
6
|
+
# This is the primitive used by Rack::LibInjection#scan_string_into.
|
|
7
|
+
# It is intentionally not a README benchmark: run it while capturing a macOS
|
|
8
|
+
# `sample` profile to inspect native/Ruby hot symbols.
|
|
9
|
+
#
|
|
10
|
+
# Run:
|
|
11
|
+
# bundle exec ruby samples/libinjection_detect_raw_hot_path.rb
|
|
12
|
+
#
|
|
13
|
+
# Optional env:
|
|
14
|
+
# PAYLOAD=clean|sqli|xss INPUT_BYTES=2048 DURATION=25 PREHEAT_ITERATIONS=10 \
|
|
15
|
+
# bundle exec ruby samples/libinjection_detect_raw_hot_path.rb
|
|
16
|
+
|
|
17
|
+
$stdout.sync = true
|
|
18
|
+
$stderr.sync = true
|
|
19
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
20
|
+
|
|
21
|
+
require "libinjection"
|
|
22
|
+
|
|
23
|
+
payload_mode = ENV.fetch("PAYLOAD", "clean")
|
|
24
|
+
input_bytes = Integer(ENV.fetch("INPUT_BYTES", "2048"))
|
|
25
|
+
sleep_before_hot_loop = Float(ENV.fetch("SLEEP_BEFORE_HOT_LOOP", "7.0"))
|
|
26
|
+
duration = Float(ENV.fetch("DURATION", "25.0"))
|
|
27
|
+
preheat_iterations = Integer(ENV.fetch("PREHEAT_ITERATIONS", "10"))
|
|
28
|
+
disable_gc = ENV.fetch("DISABLE_GC", "1") != "0"
|
|
29
|
+
sample_name = "rack_libinjection_detect_raw_hot_path"
|
|
30
|
+
native_grep = "LibInjection|libinjection|libinjection_native|rb_li_|li_input_|li_scan_|li_sqli_|li_xss_|libinjection_sqli|libinjection_xss|sqli|xss"
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, "INPUT_BYTES must be positive" unless input_bytes.positive?
|
|
33
|
+
|
|
34
|
+
def monotonic
|
|
35
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def gc_snapshot
|
|
39
|
+
stat = GC.stat
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
total_allocated_objects: stat.fetch(:total_allocated_objects),
|
|
43
|
+
minor_gc_count: stat.fetch(:minor_gc_count),
|
|
44
|
+
major_gc_count: stat.fetch(:major_gc_count)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def gc_delta(before, after)
|
|
49
|
+
before.each_with_object({}) do |(key, value), out|
|
|
50
|
+
out[key] = after.fetch(key) - value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def padded_payload(seed, input_bytes)
|
|
55
|
+
bytes = seed.b
|
|
56
|
+
return bytes.byteslice(0, input_bytes).b.freeze if bytes.bytesize >= input_bytes
|
|
57
|
+
|
|
58
|
+
filler = " AAAAA normal_token_12345".b
|
|
59
|
+
out = String.new(capacity: input_bytes, encoding: Encoding::BINARY)
|
|
60
|
+
out << bytes
|
|
61
|
+
out << filler while out.bytesize < input_bytes
|
|
62
|
+
out.byteslice(0, input_bytes).b.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def payload_for(mode, input_bytes)
|
|
66
|
+
case mode
|
|
67
|
+
when "clean"
|
|
68
|
+
padded_payload("normal search term with harmless words", input_bytes)
|
|
69
|
+
when "sqli"
|
|
70
|
+
padded_payload("1 OR 1=1--", input_bytes)
|
|
71
|
+
when "xss"
|
|
72
|
+
padded_payload("<script>alert(1)</script>", input_bytes)
|
|
73
|
+
else
|
|
74
|
+
raise ArgumentError, "PAYLOAD must be one of: clean, sqli, xss"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def expected_type_for(mode)
|
|
79
|
+
case mode
|
|
80
|
+
when "clean" then nil
|
|
81
|
+
when "sqli" then :sqli
|
|
82
|
+
when "xss" then :xss
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
payload = payload_for(payload_mode, input_bytes)
|
|
87
|
+
expected_type = expected_type_for(payload_mode)
|
|
88
|
+
|
|
89
|
+
preheat_iterations.times do
|
|
90
|
+
result = LibInjection.detect_raw(payload)
|
|
91
|
+
actual_type = result && result[0]
|
|
92
|
+
raise "preheat: expected #{expected_type.inspect}, got #{result.inspect}" unless actual_type == expected_type
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
sample_seconds = (sleep_before_hot_loop + duration + 15).ceil
|
|
96
|
+
sample_file = "/tmp/#{sample_name}.sample"
|
|
97
|
+
txt_file = File.expand_path("results/#{sample_name}.txt", __dir__)
|
|
98
|
+
txt_dir = File.dirname(txt_file)
|
|
99
|
+
|
|
100
|
+
puts "pid=#{Process.pid}"
|
|
101
|
+
puts "ruby=#{RUBY_DESCRIPTION}"
|
|
102
|
+
puts "platform=#{RUBY_PLATFORM}"
|
|
103
|
+
puts "mode=#{sample_name}"
|
|
104
|
+
puts "call=LibInjection.detect_raw(payload)"
|
|
105
|
+
puts "payload=#{payload_mode}"
|
|
106
|
+
puts "input_bytes=#{payload.bytesize}"
|
|
107
|
+
puts "disable_gc=#{disable_gc}"
|
|
108
|
+
puts "duration=#{duration}"
|
|
109
|
+
puts "preheat_iterations=#{preheat_iterations}"
|
|
110
|
+
puts "sample_seconds=#{sample_seconds}"
|
|
111
|
+
puts "sample_file=#{sample_file}"
|
|
112
|
+
puts "txt_file=#{txt_file}"
|
|
113
|
+
puts
|
|
114
|
+
puts "Copy this one-line capture command:"
|
|
115
|
+
puts %(mkdir -p "#{txt_dir}"; OUT="#{txt_file}"; SAMPLE="#{sample_file}"; { sample #{Process.pid} #{sample_seconds} -f "$SAMPLE"; echo; echo "===== focused native/Ruby symbols ====="; filtercalltree "$SAMPLE" | grep -E "#{native_grep}" | head -320; echo; echo "===== filtercalltree head -320 ====="; filtercalltree "$SAMPLE" | head -320; } 2>&1 | tee "$OUT")
|
|
116
|
+
puts
|
|
117
|
+
puts "Expected hot native symbols:"
|
|
118
|
+
puts " LibInjection.detect_raw -> rb_li_detect_raw"
|
|
119
|
+
puts " li_input_prepare / li_input_release"
|
|
120
|
+
puts " li_scan_run / li_scan_perform"
|
|
121
|
+
puts " li_scan_release_gvl / rb_nogvl path when INPUT_BYTES >= native threshold"
|
|
122
|
+
puts " libinjection_sqli / libinjection_xss"
|
|
123
|
+
puts " libinjection_sqli_init / libinjection_sqli_fingerprint / libinjection_sqli_check_fingerprint"
|
|
124
|
+
puts
|
|
125
|
+
puts "sleep=#{sleep_before_hot_loop} seconds before hot loop"
|
|
126
|
+
puts
|
|
127
|
+
|
|
128
|
+
begin
|
|
129
|
+
GC.start
|
|
130
|
+
GC.disable if disable_gc
|
|
131
|
+
before_gc = gc_snapshot
|
|
132
|
+
sleep sleep_before_hot_loop
|
|
133
|
+
|
|
134
|
+
count = 0
|
|
135
|
+
detected = 0
|
|
136
|
+
last_result = nil
|
|
137
|
+
started = monotonic
|
|
138
|
+
deadline = started + duration
|
|
139
|
+
|
|
140
|
+
while monotonic < deadline
|
|
141
|
+
last_result = LibInjection.detect_raw(payload)
|
|
142
|
+
detected += 1 if last_result
|
|
143
|
+
count += 1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
actual_type = last_result && last_result[0]
|
|
147
|
+
raise "last_hot_loop: expected #{expected_type.inspect}, got #{last_result.inspect}" unless actual_type == expected_type
|
|
148
|
+
|
|
149
|
+
elapsed = monotonic - started
|
|
150
|
+
after_gc = gc_snapshot
|
|
151
|
+
|
|
152
|
+
puts "count=#{count}"
|
|
153
|
+
puts "detected=#{detected}"
|
|
154
|
+
puts "last_result=#{last_result.inspect}"
|
|
155
|
+
puts "elapsed=#{format('%.6f', elapsed)}"
|
|
156
|
+
puts "ops_per_sec=#{format('%.6f', count / elapsed)}"
|
|
157
|
+
puts "sec_per_op=#{format('%.9f', elapsed / [count, 1].max)}"
|
|
158
|
+
puts "gc_delta=#{gc_delta(before_gc, after_gc)}"
|
|
159
|
+
ensure
|
|
160
|
+
GC.enable
|
|
161
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rack middleware hot-path sample for all currently supported scan surfaces:
|
|
4
|
+
# Rack::LibInjection.new(app, scan: [:params, :path, :headers, :cookies]).call(env)
|
|
5
|
+
#
|
|
6
|
+
# This path exercises path decoding, header filtering, cookie parsing, Rack params,
|
|
7
|
+
# recursive param walking, notifier-free report mode, and the native detect_raw primitive.
|
|
8
|
+
#
|
|
9
|
+
# Run:
|
|
10
|
+
# bundle exec ruby samples/rack_all_surfaces_hot_path.rb
|
|
11
|
+
#
|
|
12
|
+
# Optional env:
|
|
13
|
+
# PAYLOAD=clean|sqli|xss PATH_DECODE_DEPTH=2 SCAN_COOKIE_NAMES=0 DURATION=20 \
|
|
14
|
+
# bundle exec ruby samples/rack_all_surfaces_hot_path.rb
|
|
15
|
+
|
|
16
|
+
$stdout.sync = true
|
|
17
|
+
$stderr.sync = true
|
|
18
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
19
|
+
|
|
20
|
+
require "rack/mock"
|
|
21
|
+
require "rack/libinjection"
|
|
22
|
+
|
|
23
|
+
payload_mode = ENV.fetch("PAYLOAD", "sqli")
|
|
24
|
+
path_decode_depth = Integer(ENV.fetch("PATH_DECODE_DEPTH", "2"))
|
|
25
|
+
scan_cookie_names = ENV.fetch("SCAN_COOKIE_NAMES", "0") != "0"
|
|
26
|
+
sleep_before_hot_loop = Float(ENV.fetch("SLEEP_BEFORE_HOT_LOOP", "7.0"))
|
|
27
|
+
duration = Float(ENV.fetch("DURATION", "20.0"))
|
|
28
|
+
preheat_iterations = Integer(ENV.fetch("PREHEAT_ITERATIONS", "100"))
|
|
29
|
+
disable_gc = ENV.fetch("DISABLE_GC", "0") != "0"
|
|
30
|
+
sample_name = "rack_libinjection_all_surfaces_hot_path"
|
|
31
|
+
native_grep = "Rack::LibInjection|rack/libinjection|LibInjection|libinjection|rb_li_|li_input_|li_scan_|libinjection_sqli|libinjection_xss|scan_path|scan_headers|scan_cookies|scan_params|scan_url_encoded_string_into|scan_string_into|detect_url_encoded_raw|li_url_decode|header_name|walk_into|Rack::QueryParser|parse_nested_query|cookies"
|
|
32
|
+
|
|
33
|
+
ATTACK_ENV_KEY = Rack::LibInjection::ATTACK_ENV_KEY
|
|
34
|
+
|
|
35
|
+
def monotonic
|
|
36
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def gc_snapshot
|
|
40
|
+
stat = GC.stat
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
total_allocated_objects: stat.fetch(:total_allocated_objects),
|
|
44
|
+
minor_gc_count: stat.fetch(:minor_gc_count),
|
|
45
|
+
major_gc_count: stat.fetch(:major_gc_count)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def gc_delta(before, after)
|
|
50
|
+
before.each_with_object({}) do |(key, value), out|
|
|
51
|
+
out[key] = after.fetch(key) - value
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def payload_for(mode)
|
|
56
|
+
case mode
|
|
57
|
+
when "clean" then "normal-search-term"
|
|
58
|
+
when "sqli" then "1 OR 1=1--"
|
|
59
|
+
when "xss" then "<script>alert(1)</script>"
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError, "PAYLOAD must be one of: clean, sqli, xss"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def path_for(mode)
|
|
66
|
+
case mode
|
|
67
|
+
when "clean"
|
|
68
|
+
"/items/catalog/search"
|
|
69
|
+
when "sqli"
|
|
70
|
+
# Double-encoded SQLi path segment. With PATH_DECODE_DEPTH=2 it reaches:
|
|
71
|
+
# /items/1 OR 1=1--
|
|
72
|
+
"/items/1%2520OR%25201%253D1--"
|
|
73
|
+
when "xss"
|
|
74
|
+
"/items/%253Cscript%253Ealert(1)%253C%252Fscript%253E"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_query(payload)
|
|
79
|
+
Rack::Utils.build_nested_query(
|
|
80
|
+
"q" => payload,
|
|
81
|
+
"profile" => {
|
|
82
|
+
"name" => "Roman",
|
|
83
|
+
"filters" => ["recent", payload, "safe"]
|
|
84
|
+
},
|
|
85
|
+
"page" => "1"
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def expected_min_attacks(mode, scan_cookie_names)
|
|
90
|
+
return 0 if mode == "clean"
|
|
91
|
+
|
|
92
|
+
# At least: path, param q, nested param, custom header, cookie value.
|
|
93
|
+
# More may be reported because path is scanned as full path + segment and XSS/SQLi
|
|
94
|
+
# signatures can hit both raw and decoded values.
|
|
95
|
+
scan_cookie_names ? 5 : 4
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
payload = payload_for(payload_mode)
|
|
99
|
+
query = build_query(payload)
|
|
100
|
+
path = "#{path_for(payload_mode)}?#{query}"
|
|
101
|
+
expected_min = expected_min_attacks(payload_mode, scan_cookie_names)
|
|
102
|
+
base_env = Rack::MockRequest.env_for(
|
|
103
|
+
path,
|
|
104
|
+
"HTTP_X_ATTACK_PROBE" => payload,
|
|
105
|
+
"HTTP_USER_AGENT" => "rack-libinjection-hot-path-sample/1.0",
|
|
106
|
+
"HTTP_COOKIE" => "probe=#{Rack::Utils.escape(payload)}; session_id=abcdef123456"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
app = ->(_env) { [204, {}, []] }
|
|
110
|
+
middleware = Rack::LibInjection.new(
|
|
111
|
+
app,
|
|
112
|
+
mode: :report,
|
|
113
|
+
scan: %i[params path headers cookies],
|
|
114
|
+
path_decode_depth: path_decode_depth,
|
|
115
|
+
scan_cookie_names: scan_cookie_names,
|
|
116
|
+
notify_skipped: false
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
preheat_iterations.times do
|
|
120
|
+
env = base_env.dup
|
|
121
|
+
status, = middleware.call(env)
|
|
122
|
+
attacks = env.fetch(ATTACK_ENV_KEY)
|
|
123
|
+
raise "preheat: unexpected status #{status}" unless status == 204
|
|
124
|
+
raise "preheat: expected >= #{expected_min} attacks, got #{attacks.inspect}" if attacks.length < expected_min
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sample_seconds = (sleep_before_hot_loop + duration + 15).ceil
|
|
128
|
+
sample_file = "/tmp/#{sample_name}.sample"
|
|
129
|
+
txt_file = File.expand_path("results/#{sample_name}.txt", __dir__)
|
|
130
|
+
txt_dir = File.dirname(txt_file)
|
|
131
|
+
|
|
132
|
+
puts "pid=#{Process.pid}"
|
|
133
|
+
puts "ruby=#{RUBY_DESCRIPTION}"
|
|
134
|
+
puts "platform=#{RUBY_PLATFORM}"
|
|
135
|
+
puts "mode=#{sample_name}"
|
|
136
|
+
puts "call=Rack::LibInjection.new(app, scan: %i[params path headers cookies]).call(env)"
|
|
137
|
+
puts "payload=#{payload_mode}"
|
|
138
|
+
puts "path_decode_depth=#{path_decode_depth}"
|
|
139
|
+
puts "scan_cookie_names=#{scan_cookie_names}"
|
|
140
|
+
puts "query_bytes=#{query.bytesize}"
|
|
141
|
+
puts "path=#{path_for(payload_mode)}"
|
|
142
|
+
puts "disable_gc=#{disable_gc}"
|
|
143
|
+
puts "duration=#{duration}"
|
|
144
|
+
puts "preheat_iterations=#{preheat_iterations}"
|
|
145
|
+
puts "sample_seconds=#{sample_seconds}"
|
|
146
|
+
puts "sample_file=#{sample_file}"
|
|
147
|
+
puts "txt_file=#{txt_file}"
|
|
148
|
+
puts
|
|
149
|
+
puts "Copy this one-line capture command:"
|
|
150
|
+
puts %(mkdir -p "#{txt_dir}"; OUT="#{txt_file}"; SAMPLE="#{sample_file}"; { sample #{Process.pid} #{sample_seconds} -f "$SAMPLE"; echo; echo "===== focused Rack/native symbols ====="; filtercalltree "$SAMPLE" | grep -E "#{native_grep}" | head -420; echo; echo "===== filtercalltree head -420 ====="; filtercalltree "$SAMPLE" | head -420; } 2>&1 | tee "$OUT")
|
|
151
|
+
puts
|
|
152
|
+
puts "Expected hot symbols:"
|
|
153
|
+
puts " Rack::LibInjection#call / #collect_attacks"
|
|
154
|
+
puts " #scan_path_into / #scan_path_value_into / #scan_url_encoded_string_into"
|
|
155
|
+
puts " #scan_headers_into / #header_name"
|
|
156
|
+
puts " #scan_cookies_into"
|
|
157
|
+
puts " #scan_params_into / #walk_into"
|
|
158
|
+
puts " #scan_url_encoded_string_into -> LibInjection.detect_url_encoded_raw -> rb_li_detect_url_encoded_raw"
|
|
159
|
+
puts " li_input_prepare / li_scan_run / li_scan_perform"
|
|
160
|
+
puts " libinjection_sqli / libinjection_xss"
|
|
161
|
+
puts
|
|
162
|
+
puts "sleep=#{sleep_before_hot_loop} seconds before hot loop"
|
|
163
|
+
puts
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
GC.start
|
|
167
|
+
GC.disable if disable_gc
|
|
168
|
+
before_gc = gc_snapshot
|
|
169
|
+
sleep sleep_before_hot_loop
|
|
170
|
+
|
|
171
|
+
count = 0
|
|
172
|
+
total_attacks = 0
|
|
173
|
+
started = monotonic
|
|
174
|
+
deadline = started + duration
|
|
175
|
+
|
|
176
|
+
while monotonic < deadline
|
|
177
|
+
env = base_env.dup
|
|
178
|
+
status, = middleware.call(env)
|
|
179
|
+
raise "hot_loop: unexpected status #{status}" unless status == 204
|
|
180
|
+
|
|
181
|
+
attacks = env.fetch(ATTACK_ENV_KEY)
|
|
182
|
+
total_attacks += attacks.length
|
|
183
|
+
count += 1
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
elapsed = monotonic - started
|
|
187
|
+
after_gc = gc_snapshot
|
|
188
|
+
|
|
189
|
+
puts "count=#{count}"
|
|
190
|
+
puts "total_attacks=#{total_attacks}"
|
|
191
|
+
puts "attacks_per_request=#{format('%.6f', total_attacks.to_f / [count, 1].max)}"
|
|
192
|
+
puts "elapsed=#{format('%.6f', elapsed)}"
|
|
193
|
+
puts "requests_per_sec=#{format('%.6f', count / elapsed)}"
|
|
194
|
+
puts "sec_per_request=#{format('%.9f', elapsed / [count, 1].max)}"
|
|
195
|
+
puts "gc_delta=#{gc_delta(before_gc, after_gc)}"
|
|
196
|
+
ensure
|
|
197
|
+
GC.enable
|
|
198
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rack middleware hot-path sample for parsed params only:
|
|
4
|
+
# Rack::LibInjection.new(app, scan: [:params]).call(env)
|
|
5
|
+
#
|
|
6
|
+
# This path exercises Rack param parsing + recursive param walking +
|
|
7
|
+
# LibInjection.detect_raw for each string/key.
|
|
8
|
+
#
|
|
9
|
+
# Run:
|
|
10
|
+
# bundle exec ruby samples/rack_params_hot_path.rb
|
|
11
|
+
#
|
|
12
|
+
# Optional env:
|
|
13
|
+
# PAYLOAD=clean|sqli|xss DURATION=20 PREHEAT_ITERATIONS=100 DISABLE_GC=0 \
|
|
14
|
+
# bundle exec ruby samples/rack_params_hot_path.rb
|
|
15
|
+
|
|
16
|
+
$stdout.sync = true
|
|
17
|
+
$stderr.sync = true
|
|
18
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
19
|
+
|
|
20
|
+
require "rack/mock"
|
|
21
|
+
require "rack/libinjection"
|
|
22
|
+
|
|
23
|
+
payload_mode = ENV.fetch("PAYLOAD", "clean")
|
|
24
|
+
sleep_before_hot_loop = Float(ENV.fetch("SLEEP_BEFORE_HOT_LOOP", "7.0"))
|
|
25
|
+
duration = Float(ENV.fetch("DURATION", "20.0"))
|
|
26
|
+
preheat_iterations = Integer(ENV.fetch("PREHEAT_ITERATIONS", "100"))
|
|
27
|
+
disable_gc = ENV.fetch("DISABLE_GC", "0") != "0"
|
|
28
|
+
sample_name = "rack_libinjection_params_hot_path"
|
|
29
|
+
native_grep = "Rack::LibInjection|rack/libinjection|LibInjection|libinjection|rb_li_|li_input_|li_scan_|libinjection_sqli|libinjection_xss|scan_params_into|walk_into|scan_string_into|Rack::QueryParser|parse_nested_query"
|
|
30
|
+
|
|
31
|
+
ATTACK_ENV_KEY = Rack::LibInjection::ATTACK_ENV_KEY
|
|
32
|
+
|
|
33
|
+
def monotonic
|
|
34
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def gc_snapshot
|
|
38
|
+
stat = GC.stat
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
total_allocated_objects: stat.fetch(:total_allocated_objects),
|
|
42
|
+
minor_gc_count: stat.fetch(:minor_gc_count),
|
|
43
|
+
major_gc_count: stat.fetch(:major_gc_count)
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def gc_delta(before, after)
|
|
48
|
+
before.each_with_object({}) do |(key, value), out|
|
|
49
|
+
out[key] = after.fetch(key) - value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def payload_for(mode)
|
|
54
|
+
case mode
|
|
55
|
+
when "clean" then "normal search term"
|
|
56
|
+
when "sqli" then "1 OR 1=1--"
|
|
57
|
+
when "xss" then "<script>alert(1)</script>"
|
|
58
|
+
else
|
|
59
|
+
raise ArgumentError, "PAYLOAD must be one of: clean, sqli, xss"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def expected_min_attacks(mode)
|
|
64
|
+
mode == "clean" ? 0 : 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_query(payload)
|
|
68
|
+
Rack::Utils.build_nested_query(
|
|
69
|
+
"q" => payload,
|
|
70
|
+
"profile" => {
|
|
71
|
+
"name" => "Roman",
|
|
72
|
+
"filters" => ["recent", payload, "safe"]
|
|
73
|
+
},
|
|
74
|
+
"page" => "1"
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
payload = payload_for(payload_mode)
|
|
79
|
+
expected_min = expected_min_attacks(payload_mode)
|
|
80
|
+
query = build_query(payload)
|
|
81
|
+
path = "/search?#{query}"
|
|
82
|
+
base_env = Rack::MockRequest.env_for(path)
|
|
83
|
+
|
|
84
|
+
app = ->(_env) { [204, {}, []] }
|
|
85
|
+
middleware = Rack::LibInjection.new(
|
|
86
|
+
app,
|
|
87
|
+
mode: :report,
|
|
88
|
+
scan: [:params],
|
|
89
|
+
notify_skipped: false
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
preheat_iterations.times do
|
|
93
|
+
env = base_env.dup
|
|
94
|
+
status, = middleware.call(env)
|
|
95
|
+
attacks = env.fetch(ATTACK_ENV_KEY)
|
|
96
|
+
raise "preheat: unexpected status #{status}" unless status == 204
|
|
97
|
+
raise "preheat: expected >= #{expected_min} attacks, got #{attacks.inspect}" if attacks.length < expected_min
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
sample_seconds = (sleep_before_hot_loop + duration + 15).ceil
|
|
101
|
+
sample_file = "/tmp/#{sample_name}.sample"
|
|
102
|
+
txt_file = File.expand_path("results/#{sample_name}.txt", __dir__)
|
|
103
|
+
txt_dir = File.dirname(txt_file)
|
|
104
|
+
|
|
105
|
+
puts "pid=#{Process.pid}"
|
|
106
|
+
puts "ruby=#{RUBY_DESCRIPTION}"
|
|
107
|
+
puts "platform=#{RUBY_PLATFORM}"
|
|
108
|
+
puts "mode=#{sample_name}"
|
|
109
|
+
puts "call=Rack::LibInjection.new(app, scan: [:params]).call(env)"
|
|
110
|
+
puts "payload=#{payload_mode}"
|
|
111
|
+
puts "query_bytes=#{query.bytesize}"
|
|
112
|
+
puts "disable_gc=#{disable_gc}"
|
|
113
|
+
puts "duration=#{duration}"
|
|
114
|
+
puts "preheat_iterations=#{preheat_iterations}"
|
|
115
|
+
puts "sample_seconds=#{sample_seconds}"
|
|
116
|
+
puts "sample_file=#{sample_file}"
|
|
117
|
+
puts "txt_file=#{txt_file}"
|
|
118
|
+
puts
|
|
119
|
+
puts "Copy this one-line capture command:"
|
|
120
|
+
puts %(mkdir -p "#{txt_dir}"; OUT="#{txt_file}"; SAMPLE="#{sample_file}"; { sample #{Process.pid} #{sample_seconds} -f "$SAMPLE"; echo; echo "===== focused Rack/native symbols ====="; filtercalltree "$SAMPLE" | grep -E "#{native_grep}" | head -360; echo; echo "===== filtercalltree head -360 ====="; filtercalltree "$SAMPLE" | head -360; } 2>&1 | tee "$OUT")
|
|
121
|
+
puts
|
|
122
|
+
puts "Expected hot symbols:"
|
|
123
|
+
puts " Rack::LibInjection#call / #collect_attacks"
|
|
124
|
+
puts " Rack::LibInjection#scan_params_into / #walk_into / #scan_string_into"
|
|
125
|
+
puts " Rack query parsing frames for nested params"
|
|
126
|
+
puts " LibInjection.detect_raw -> rb_li_detect_raw"
|
|
127
|
+
puts " li_input_prepare / li_scan_run / li_scan_perform"
|
|
128
|
+
puts " libinjection_sqli / libinjection_xss"
|
|
129
|
+
puts
|
|
130
|
+
puts "sleep=#{sleep_before_hot_loop} seconds before hot loop"
|
|
131
|
+
puts
|
|
132
|
+
|
|
133
|
+
begin
|
|
134
|
+
GC.start
|
|
135
|
+
GC.disable if disable_gc
|
|
136
|
+
before_gc = gc_snapshot
|
|
137
|
+
sleep sleep_before_hot_loop
|
|
138
|
+
|
|
139
|
+
count = 0
|
|
140
|
+
total_attacks = 0
|
|
141
|
+
started = monotonic
|
|
142
|
+
deadline = started + duration
|
|
143
|
+
|
|
144
|
+
while monotonic < deadline
|
|
145
|
+
env = base_env.dup
|
|
146
|
+
status, = middleware.call(env)
|
|
147
|
+
raise "hot_loop: unexpected status #{status}" unless status == 204
|
|
148
|
+
|
|
149
|
+
attacks = env.fetch(ATTACK_ENV_KEY)
|
|
150
|
+
total_attacks += attacks.length
|
|
151
|
+
count += 1
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
elapsed = monotonic - started
|
|
155
|
+
after_gc = gc_snapshot
|
|
156
|
+
|
|
157
|
+
puts "count=#{count}"
|
|
158
|
+
puts "total_attacks=#{total_attacks}"
|
|
159
|
+
puts "attacks_per_request=#{format('%.6f', total_attacks.to_f / [count, 1].max)}"
|
|
160
|
+
puts "elapsed=#{format('%.6f', elapsed)}"
|
|
161
|
+
puts "requests_per_sec=#{format('%.6f', count / elapsed)}"
|
|
162
|
+
puts "sec_per_request=#{format('%.9f', elapsed / [count, 1].max)}"
|
|
163
|
+
puts "gc_delta=#{gc_delta(before_gc, after_gc)}"
|
|
164
|
+
ensure
|
|
165
|
+
GC.enable
|
|
166
|
+
end
|