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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +55 -0
  3. data/CHANGELOG.md +112 -0
  4. data/GET_STARTED.md +418 -0
  5. data/LICENSE-libinjection.txt +33 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +68 -0
  8. data/SECURITY.md +65 -0
  9. data/ext/libinjection/extconf.rb +113 -0
  10. data/ext/libinjection/libinjection_ext.c +1132 -0
  11. data/ext/libinjection/vendor/libinjection/.vendored +5 -0
  12. data/ext/libinjection/vendor/libinjection/COPYING +33 -0
  13. data/ext/libinjection/vendor/libinjection/MIGRATION.md +393 -0
  14. data/ext/libinjection/vendor/libinjection/README.md +251 -0
  15. data/ext/libinjection/vendor/libinjection/src/libinjection.h +70 -0
  16. data/ext/libinjection/vendor/libinjection/src/libinjection_error.h +26 -0
  17. data/ext/libinjection/vendor/libinjection/src/libinjection_html5.c +830 -0
  18. data/ext/libinjection/vendor/libinjection/src/libinjection_html5.h +56 -0
  19. data/ext/libinjection/vendor/libinjection/src/libinjection_sqli.c +2342 -0
  20. data/ext/libinjection/vendor/libinjection/src/libinjection_sqli.h +297 -0
  21. data/ext/libinjection/vendor/libinjection/src/libinjection_sqli_data.h +9651 -0
  22. data/ext/libinjection/vendor/libinjection/src/libinjection_xss.c +1203 -0
  23. data/ext/libinjection/vendor/libinjection/src/libinjection_xss.h +23 -0
  24. data/lib/libinjection/version.rb +6 -0
  25. data/lib/libinjection.rb +31 -0
  26. data/lib/rack/libinjection.rb +586 -0
  27. data/lib/rack-libinjection.rb +3 -0
  28. data/samples/README.md +67 -0
  29. data/samples/libinjection_detect_raw_hot_path.rb +161 -0
  30. data/samples/rack_all_surfaces_hot_path.rb +198 -0
  31. data/samples/rack_params_hot_path.rb +166 -0
  32. data/samples/rack_query_hot_path.rb +176 -0
  33. data/samples/results/.gitkeep +0 -0
  34. data/script/fuzz_smoke.rb +39 -0
  35. data/script/vendor_libs.rb +227 -0
  36. data/test/test_helper.rb +7 -0
  37. data/test/test_libinjection.rb +223 -0
  38. data/test/test_middleware.rb +404 -0
  39. 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