pwn 0.5.506 → 0.5.507
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/Gemfile +2 -2
- data/README.md +3 -3
- data/bin/pwn_gqrx_scanner +16 -16
- data/bin/pwn_serial_son_micro_sm132_rfid +11 -11
- data/lib/pwn/ai.rb +1 -1
- data/lib/pwn/aws.rb +1 -1
- data/lib/pwn/banner.rb +1 -1
- data/lib/pwn/blockchain.rb +1 -1
- data/lib/pwn/ffi.rb +1 -1
- data/lib/pwn/plugins/burp_suite.rb +2 -2
- data/lib/pwn/plugins.rb +1 -7
- data/lib/pwn/reports.rb +1 -1
- data/lib/pwn/sast.rb +2 -2
- data/lib/pwn/sdr/decoder/gsm.rb +200 -0
- data/lib/pwn/sdr/decoder.rb +19 -0
- data/lib/pwn/{plugins → sdr}/flipper_zero.rb +5 -5
- data/lib/pwn/sdr/frequency_allocation.rb +372 -0
- data/lib/pwn/sdr/gqrx.rb +656 -0
- data/lib/pwn/{plugins → sdr}/rfidler.rb +2 -2
- data/lib/pwn/{plugins → sdr}/son_micro_rfid.rb +12 -12
- data/lib/pwn/sdr.rb +21 -0
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www.rb +1 -1
- data/lib/pwn.rb +1 -0
- data/spec/lib/pwn/sdr/decoder/gsm_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder_spec.rb +10 -0
- data/spec/lib/pwn/{plugins → sdr}/flipper_zero_spec.rb +3 -3
- data/spec/lib/pwn/sdr/frequency_allocation_spec.rb +15 -0
- data/spec/lib/pwn/{plugins → sdr}/gqrx_spec.rb +3 -3
- data/spec/lib/pwn/{plugins → sdr}/rfidler_spec.rb +3 -3
- data/spec/lib/pwn/{plugins → sdr}/son_micro_rfid_spec.rb +3 -3
- data/spec/lib/pwn/sdr_spec.rb +10 -0
- data/third_party/pwn_rdoc.jsonl +3 -1
- metadata +21 -13
- data/lib/pwn/plugins/gqrx.rb +0 -757
data/lib/pwn/sdr/gqrx.rb
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module PWN
|
|
7
|
+
module SDR
|
|
8
|
+
# This plugin interacts with the remote control interface of GQRX.
|
|
9
|
+
module GQRX
|
|
10
|
+
# Monkey patches for frequency handling
|
|
11
|
+
String.class_eval do
|
|
12
|
+
def raw_hz
|
|
13
|
+
gsub('.', '').to_i
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Integer.class_eval do
|
|
18
|
+
# Should always return format of X.XXX.XXX.XXX
|
|
19
|
+
# So 002_450_000_000 becomes 2.450.000.000
|
|
20
|
+
# So 2_450_000_000 becomes 2.450.000.000
|
|
21
|
+
# So 960_000_000 becomes 960.000.000
|
|
22
|
+
# 1000 should be 1.000
|
|
23
|
+
def pretty_hz
|
|
24
|
+
str_hz = to_s
|
|
25
|
+
# Nuke leading zeros
|
|
26
|
+
# E.g., 002450000000 -> 2450000000
|
|
27
|
+
str_hz = str_hz.sub(/^0+/, '')
|
|
28
|
+
# Insert dots every 3 digits from the right
|
|
29
|
+
str_hz.reverse.scan(/.{1,3}/).join('.').reverse
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Supported Method Parameters::
|
|
34
|
+
# gqrx_sock = PWN::SDR::GQRX.connect(
|
|
35
|
+
# target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
|
|
36
|
+
# port: 'optional - GQRX target port (defaults to 7356)'
|
|
37
|
+
# )
|
|
38
|
+
public_class_method def self.connect(opts = {})
|
|
39
|
+
target = opts[:target] ||= '127.0.0.1'
|
|
40
|
+
port = opts[:port] ||= 7356
|
|
41
|
+
|
|
42
|
+
PWN::Plugins::Sock.connect(target: target, port: port)
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
raise e
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Supported Method Parameters::
|
|
48
|
+
# gqrx_resp = PWN::SDR::GQRX.gqrx_cmd(
|
|
49
|
+
# gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
50
|
+
# cmd: 'required - GQRX command to execute',
|
|
51
|
+
# resp_ok: 'optional - Expected response from GQRX to indicate success'
|
|
52
|
+
# )
|
|
53
|
+
|
|
54
|
+
public_class_method def self.gqrx_cmd(opts = {})
|
|
55
|
+
gqrx_sock = opts[:gqrx_sock]
|
|
56
|
+
cmd = opts[:cmd]
|
|
57
|
+
resp_ok = opts[:resp_ok]
|
|
58
|
+
|
|
59
|
+
# Most Recent GQRX Command Set:
|
|
60
|
+
# https://raw.githubusercontent.com/gqrx-sdr/gqrx/master/resources/remote-control.txt
|
|
61
|
+
# Supported commands:
|
|
62
|
+
# f Get frequency [Hz]
|
|
63
|
+
# F <frequency> Set frequency [Hz]
|
|
64
|
+
# m Get demodulator mode and passband
|
|
65
|
+
# M <mode> [passband]
|
|
66
|
+
# Set demodulator mode and passband [Hz]
|
|
67
|
+
# Passing a '?' as the first argument instead of 'mode' will return
|
|
68
|
+
# a space separated list of radio backend supported modes.
|
|
69
|
+
# l|L ?
|
|
70
|
+
# Get a space separated list of settings available for reading (l) or writing (L).
|
|
71
|
+
# l STRENGTH
|
|
72
|
+
# Get signal strength [dBFS]
|
|
73
|
+
# l SQL
|
|
74
|
+
# Get squelch threshold [dBFS]
|
|
75
|
+
# L SQL <sql>
|
|
76
|
+
# Set squelch threshold to <sql> [dBFS]
|
|
77
|
+
# l AF
|
|
78
|
+
# Get audio gain [dB]
|
|
79
|
+
# L AF <gain>
|
|
80
|
+
# Set audio gain to <gain> [dB]
|
|
81
|
+
# l <gain_name>_GAIN
|
|
82
|
+
# Get the value of the gain setting with the name <gain_name>
|
|
83
|
+
# L <gain_name>_GAIN <value>
|
|
84
|
+
# Set the value of the gain setting with the name <gain_name> to <value>
|
|
85
|
+
# p RDS_PI
|
|
86
|
+
# Get the RDS PI code (in hexadecimal). Returns 0000 if not applicable.
|
|
87
|
+
# u RECORD
|
|
88
|
+
# Get status of audio recorder
|
|
89
|
+
# U RECORD <status>
|
|
90
|
+
# Set status of audio recorder to <status>
|
|
91
|
+
# u DSP
|
|
92
|
+
# Get DSP (SDR receiver) status
|
|
93
|
+
# U DSP <status>
|
|
94
|
+
# Set DSP (SDR receiver) status to <status>
|
|
95
|
+
# u RDS
|
|
96
|
+
# Get RDS decoder to <status>. Only functions in WFM mode.
|
|
97
|
+
# U RDS <status>
|
|
98
|
+
# Set RDS decoder to <status>. Only functions in WFM mode.
|
|
99
|
+
# q|Q
|
|
100
|
+
# Close connection
|
|
101
|
+
# AOS
|
|
102
|
+
# Acquisition of signal (AOS) event, start audio recording
|
|
103
|
+
# LOS
|
|
104
|
+
# Loss of signal (LOS) event, stop audio recording
|
|
105
|
+
# LNB_LO [frequency]
|
|
106
|
+
# If frequency [Hz] is specified set the LNB LO frequency used for
|
|
107
|
+
# display. Otherwise print the current LNB LO frequency [Hz].
|
|
108
|
+
# \chk_vfo
|
|
109
|
+
# Get VFO option status (only usable for hamlib compatibility)
|
|
110
|
+
# \dump_state
|
|
111
|
+
# Dump state (only usable for hamlib compatibility)
|
|
112
|
+
# \get_powerstat
|
|
113
|
+
# Get power status (only usable for hamlib compatibility)
|
|
114
|
+
# v
|
|
115
|
+
# Get 'VFO' (only usable for hamlib compatibility)
|
|
116
|
+
# V
|
|
117
|
+
# Set 'VFO' (only usable for hamlib compatibility)
|
|
118
|
+
# s
|
|
119
|
+
# Get 'Split' mode (only usable for hamlib compatibility)
|
|
120
|
+
# S
|
|
121
|
+
# Set 'Split' mode (only usable for hamlib compatibility)
|
|
122
|
+
# _
|
|
123
|
+
# Get version
|
|
124
|
+
#
|
|
125
|
+
# Reply:
|
|
126
|
+
# RPRT 0
|
|
127
|
+
# Command successful
|
|
128
|
+
# RPRT 1
|
|
129
|
+
# Command failed
|
|
130
|
+
|
|
131
|
+
gqrx_sock.write("#{cmd}\n")
|
|
132
|
+
response = []
|
|
133
|
+
start_time = Time.now
|
|
134
|
+
|
|
135
|
+
# Wait up to 2 seconds for initial response
|
|
136
|
+
if gqrx_sock.wait_readable(2.0)
|
|
137
|
+
response.push(gqrx_sock.readline.chomp)
|
|
138
|
+
# Drain any additional lines quickly
|
|
139
|
+
loop do
|
|
140
|
+
# This is the main contributing factor to this scanner being slow.
|
|
141
|
+
# We're trading speed for accuracy here.
|
|
142
|
+
# break if gqrx_sock.wait_readable(0.0625).nil? && cmd == 'l STRENGTH'
|
|
143
|
+
break if gqrx_sock.wait_readable(0.04).nil? && cmd == 'l STRENGTH'
|
|
144
|
+
break if gqrx_sock.wait_readable(0.001).nil? && cmd != 'l STRENGTH'
|
|
145
|
+
|
|
146
|
+
response.push(gqrx_sock.readline.chomp)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
raise "No response for command: #{cmd}" if response.empty?
|
|
151
|
+
|
|
152
|
+
response_str = response.length == 1 ? response.first : response.join(' ')
|
|
153
|
+
|
|
154
|
+
raise "ERROR!!! Command: #{cmd} Expected Resp: #{resp_ok}, Got: #{response_str}" if resp_ok && response_str != resp_ok
|
|
155
|
+
|
|
156
|
+
# Reformat positive integer frequency responses (e.g., from 'f')
|
|
157
|
+
response_str = response_str.to_i.pretty_hz if response_str.match?(/^\d+$/) && response_str.to_i.positive?
|
|
158
|
+
|
|
159
|
+
response_str
|
|
160
|
+
rescue RuntimeError => e
|
|
161
|
+
puts 'WARNING: RF Gain is not supported by the radio backend.' if e.message.include?('Command: L RF_GAIN')
|
|
162
|
+
puts 'WARNING: Intermediate Gain is not supported by the radio backend.' if e.message.include?('Command: L IF_GAIN')
|
|
163
|
+
puts 'WARNING: Baseband Gain is not supported by the radio backend.' if e.message.include?('Command: L BB_GAIN')
|
|
164
|
+
|
|
165
|
+
raise e unless e.message.include?('Command: L RF_GAIN') ||
|
|
166
|
+
e.message.include?('Command: L IF_GAIN') ||
|
|
167
|
+
e.message.include?('Command: L BB_GAIN')
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
raise e
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Supported Method Parameters::
|
|
173
|
+
# init_freq_hash = PWN::SDR::GQRX.init_freq(
|
|
174
|
+
# gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
175
|
+
# freq: 'required - Frequency to set',
|
|
176
|
+
# demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
|
|
177
|
+
# bandwidth: 'optional - Bandwidth (defaults to 200_000)',
|
|
178
|
+
# squelch: 'optional - Squelch level to set (Defaults to current value)',
|
|
179
|
+
# decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
|
|
180
|
+
# record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to Dir.home)',
|
|
181
|
+
# decoder_opts: 'optional - Hash of additional options for the decoder',
|
|
182
|
+
# suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)'
|
|
183
|
+
# )
|
|
184
|
+
public_class_method def self.init_freq(opts = {})
|
|
185
|
+
gqrx_sock = opts[:gqrx_sock]
|
|
186
|
+
freq = opts[:freq]
|
|
187
|
+
demodulator_mode = opts[:demodulator_mode] ||= 'WFM'
|
|
188
|
+
bandwidth = opts[:bandwidth] ||= 200_000
|
|
189
|
+
squelch = opts[:squelch]
|
|
190
|
+
decoder = opts[:decoder]
|
|
191
|
+
record_dir = opts[:record_dir] ||= Dir.home
|
|
192
|
+
decoder_opts = opts[:decoder_opts] ||= {}
|
|
193
|
+
suppress_details = opts[:suppress_details] || false
|
|
194
|
+
|
|
195
|
+
raise "ERROR: record_dir '#{record_dir}' does not exist. Please create it or provide a valid path." if decoder && !Dir.exist?(record_dir)
|
|
196
|
+
|
|
197
|
+
hz = freq.to_s.raw_hz
|
|
198
|
+
|
|
199
|
+
if squelch.is_a?(Float) && squelch >= -100.0 && squelch <= 0.0
|
|
200
|
+
change_squelch_resp = gqrx_cmd(
|
|
201
|
+
gqrx_sock: gqrx_sock,
|
|
202
|
+
cmd: "L SQL #{squelch}",
|
|
203
|
+
resp_ok: 'RPRT 0'
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
change_freq_resp = gqrx_cmd(
|
|
208
|
+
gqrx_sock: gqrx_sock,
|
|
209
|
+
cmd: "F #{hz}",
|
|
210
|
+
resp_ok: 'RPRT 0'
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Set demod mode and bandwidth (always, using defaults if not provided)
|
|
214
|
+
mode_str = demodulator_mode.to_s.upcase
|
|
215
|
+
passband_hz = bandwidth.to_s.raw_hz
|
|
216
|
+
gqrx_cmd(
|
|
217
|
+
gqrx_sock: gqrx_sock,
|
|
218
|
+
cmd: "M #{mode_str} #{passband_hz}",
|
|
219
|
+
resp_ok: 'RPRT 0'
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Get demodulator mode n passband
|
|
223
|
+
demod_n_passband = gqrx_cmd(
|
|
224
|
+
gqrx_sock: gqrx_sock,
|
|
225
|
+
cmd: 'm'
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Get current frequency
|
|
229
|
+
current_freq = gqrx_cmd(
|
|
230
|
+
gqrx_sock: gqrx_sock,
|
|
231
|
+
cmd: 'f'
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Start recording and decoding if decoder provided
|
|
235
|
+
decoder_module = nil
|
|
236
|
+
decoder_thread = nil
|
|
237
|
+
record_path = nil
|
|
238
|
+
if decoder
|
|
239
|
+
# Resolve decoder module via case statement for extensibility
|
|
240
|
+
case decoder
|
|
241
|
+
when :gsm
|
|
242
|
+
decoder_module = PWN::SDR::Decoder::GSM
|
|
243
|
+
else
|
|
244
|
+
raise "ERROR: Unknown decoder key: #{decoder}. Supported: :gsm"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Ensure recording is off before starting
|
|
248
|
+
record_status = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'u RECORD')
|
|
249
|
+
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 0', resp_ok: 'RPRT 0') if record_status == '1'
|
|
250
|
+
|
|
251
|
+
# Start recording
|
|
252
|
+
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 1', resp_ok: 'RPRT 0')
|
|
253
|
+
|
|
254
|
+
# Prepare for decoder
|
|
255
|
+
start_time = Time.now
|
|
256
|
+
expected_filename = "gqrx_#{start_time.strftime('%Y%m%d_%H%M%S')}_#{current_freq_raw}.wav"
|
|
257
|
+
record_path = File.join(record_dir, expected_filename)
|
|
258
|
+
|
|
259
|
+
# Build partial gqrx_obj for decoder start
|
|
260
|
+
gqrx_obj_partial = {
|
|
261
|
+
gqrx_sock: gqrx_sock,
|
|
262
|
+
record_path: record_path,
|
|
263
|
+
frequency: current_freq,
|
|
264
|
+
bandwidth: bandwidth,
|
|
265
|
+
demodulator_mode: demodulator_mode
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Initialize and start decoder (module style: .start returns thread)
|
|
269
|
+
decoder_thread = decoder_module.start(
|
|
270
|
+
gqrx_obj: gqrx_obj_partial,
|
|
271
|
+
**decoder_opts
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
init_freq_hash = {
|
|
276
|
+
demod_mode_n_passband: demod_n_passband,
|
|
277
|
+
frequency: current_freq,
|
|
278
|
+
bandwidth: bandwidth,
|
|
279
|
+
decoder: decoder,
|
|
280
|
+
decoder_module: decoder_module,
|
|
281
|
+
decoder_thread: decoder_thread,
|
|
282
|
+
record_path: record_path
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
unless suppress_details
|
|
286
|
+
audio_gain_db = gqrx_cmd(
|
|
287
|
+
gqrx_sock: gqrx_sock,
|
|
288
|
+
cmd: 'l AF'
|
|
289
|
+
).to_f
|
|
290
|
+
|
|
291
|
+
strength_db_float = gqrx_cmd(
|
|
292
|
+
gqrx_sock: gqrx_sock,
|
|
293
|
+
cmd: 'l STRENGTH'
|
|
294
|
+
).to_f
|
|
295
|
+
strength_db = strength_db_float.round(1)
|
|
296
|
+
|
|
297
|
+
current_squelch = gqrx_cmd(
|
|
298
|
+
gqrx_sock: gqrx_sock,
|
|
299
|
+
cmd: 'l SQL'
|
|
300
|
+
).to_f
|
|
301
|
+
|
|
302
|
+
rf_gain = gqrx_cmd(
|
|
303
|
+
gqrx_sock: gqrx_sock,
|
|
304
|
+
cmd: 'l RF_GAIN'
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if_gain = gqrx_cmd(
|
|
308
|
+
gqrx_sock: gqrx_sock,
|
|
309
|
+
cmd: 'l IF_GAIN'
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
bb_gain = gqrx_cmd(
|
|
313
|
+
gqrx_sock: gqrx_sock,
|
|
314
|
+
cmd: 'l BB_GAIN'
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
init_freq_hash = {
|
|
318
|
+
demod_mode_n_passband: demod_n_passband,
|
|
319
|
+
frequency: current_freq,
|
|
320
|
+
bandwidth: bandwidth,
|
|
321
|
+
audio_gain_db: audio_gain_db,
|
|
322
|
+
squelch: current_squelch,
|
|
323
|
+
rf_gain: rf_gain,
|
|
324
|
+
if_gain: if_gain,
|
|
325
|
+
bb_gain: bb_gain,
|
|
326
|
+
strength_db: strength_db,
|
|
327
|
+
decoder: decoder,
|
|
328
|
+
decoder_module: decoder_module,
|
|
329
|
+
decoder_thread: decoder_thread,
|
|
330
|
+
record_path: record_path
|
|
331
|
+
}
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
init_freq_hash
|
|
335
|
+
rescue StandardError => e
|
|
336
|
+
raise e
|
|
337
|
+
ensure
|
|
338
|
+
# Ensure recording is stopped and decoder is stopped on error
|
|
339
|
+
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 0', resp_ok: 'RPRT 0') if gqrx_sock && decoder
|
|
340
|
+
decoder_module.stop(thread: decoder_thread, gqrx_obj: init_freq_hash) if decoder_module && decoder_thread
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Supported Method Parameters::
|
|
344
|
+
# scan_resp = PWN::SDR::GQRX.scan_range(
|
|
345
|
+
# gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
346
|
+
# start_freq: 'required - Start frequency of scan range',
|
|
347
|
+
# target_freq: 'required - Target frequency of scan range',
|
|
348
|
+
# demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
|
|
349
|
+
# bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
|
|
350
|
+
# precision: 'optional - Frequency step precision (number of digits; defaults to 1)',
|
|
351
|
+
# lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
|
|
352
|
+
# strength_lock: 'optional - Strength lock in dBFS (defaults to -70.0)',
|
|
353
|
+
# squelch: 'optional - Squelch level in dBFS (defaults to strength_lock - 3.0)',
|
|
354
|
+
# location: 'optional - Location string to include in AI analysis (e.g., "New York, NY", 90210, GPS coords, etc.)'
|
|
355
|
+
# )
|
|
356
|
+
|
|
357
|
+
public_class_method def self.scan_range(opts = {})
|
|
358
|
+
gqrx_sock = opts[:gqrx_sock]
|
|
359
|
+
start_freq = opts[:start_freq]
|
|
360
|
+
target_freq = opts[:target_freq]
|
|
361
|
+
demodulator_mode = opts[:demodulator_mode]
|
|
362
|
+
bandwidth = opts[:bandwidth] ||= 200_000
|
|
363
|
+
precision = opts[:precision] ||= 1
|
|
364
|
+
lock_freq_duration = opts[:lock_freq_duration] ||= 0.04
|
|
365
|
+
strength_lock = opts[:strength_lock] ||= -70.0
|
|
366
|
+
squelch = opts[:squelch] ||= (strength_lock - 3.0)
|
|
367
|
+
location = opts[:location] ||= 'United States'
|
|
368
|
+
|
|
369
|
+
timestamp_start = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
|
|
370
|
+
|
|
371
|
+
hz_start = start_freq.to_s.raw_hz
|
|
372
|
+
hz_target = target_freq.to_s.raw_hz
|
|
373
|
+
# step_hz = 10**(precision - 1)
|
|
374
|
+
step_hz = [10**(precision - 1), (bandwidth.to_i / 4)].max
|
|
375
|
+
step = hz_start > hz_target ? -step_hz : step_hz
|
|
376
|
+
|
|
377
|
+
# Set demodulator mode & passband once
|
|
378
|
+
mode_str = demodulator_mode.to_s.upcase
|
|
379
|
+
passband_hz = bandwidth.to_s.raw_hz
|
|
380
|
+
gqrx_cmd(
|
|
381
|
+
gqrx_sock: gqrx_sock,
|
|
382
|
+
cmd: "M #{mode_str} #{passband_hz}",
|
|
383
|
+
resp_ok: 'RPRT 0'
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Prime radio at starting frequency
|
|
387
|
+
prev_freq_hash = init_freq(
|
|
388
|
+
gqrx_sock: gqrx_sock,
|
|
389
|
+
freq: start_freq,
|
|
390
|
+
demodulator_mode: demodulator_mode,
|
|
391
|
+
bandwidth: bandwidth,
|
|
392
|
+
squelch: squelch,
|
|
393
|
+
suppress_details: true
|
|
394
|
+
)
|
|
395
|
+
prev_freq_hash[:lock_freq_duration] = lock_freq_duration
|
|
396
|
+
prev_freq_hash[:strength_lock] = strength_lock
|
|
397
|
+
|
|
398
|
+
in_signal = false
|
|
399
|
+
candidate_signals = []
|
|
400
|
+
strength_history = []
|
|
401
|
+
|
|
402
|
+
# ──────────────────────────────────────────────────────────────
|
|
403
|
+
# Adaptive peak finder – trims weakest ends after each pass
|
|
404
|
+
# Converges quickly to the true center of the bell curve
|
|
405
|
+
# ──────────────────────────────────────────────────────────────
|
|
406
|
+
find_best_peak = lambda do |opts = {}|
|
|
407
|
+
beg_of_signal_hz = opts[:beg_of_signal_hz].to_s.raw_hz
|
|
408
|
+
top_of_signal_hz = opts[:top_of_signal_hz].to_s.raw_hz
|
|
409
|
+
end_of_signal_hz = top_of_signal_hz + step_hz
|
|
410
|
+
|
|
411
|
+
# current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
|
|
412
|
+
# puts "Current Frequency: #{current_hz.pretty_hz}"
|
|
413
|
+
puts "Signal Began: #{beg_of_signal_hz.pretty_hz}"
|
|
414
|
+
puts "Signal Appeared to Peak at: #{top_of_signal_hz.pretty_hz}"
|
|
415
|
+
puts "Calculated Signal End: #{end_of_signal_hz.pretty_hz}"
|
|
416
|
+
# steps_between_beg_n_end = ((end_of_signal_hz - beg_of_signal_hz) / step_hz).abs
|
|
417
|
+
# puts steps_between_beg_n_end.inspect
|
|
418
|
+
|
|
419
|
+
samples = []
|
|
420
|
+
prev_best_sample = nil
|
|
421
|
+
consecutive_best = 0
|
|
422
|
+
direction_up = true
|
|
423
|
+
|
|
424
|
+
pass_count = 0
|
|
425
|
+
infinite_loop_safeguard = false
|
|
426
|
+
while true
|
|
427
|
+
pass_count += 1
|
|
428
|
+
|
|
429
|
+
# Safeguard against infinite loop
|
|
430
|
+
infinite_loop_safeguard = true if pass_count >= 100
|
|
431
|
+
puts 'WARNING: Infinite loop safeguard triggered in find_best_peak!' if infinite_loop_safeguard
|
|
432
|
+
break if infinite_loop_safeguard
|
|
433
|
+
|
|
434
|
+
direction_up = !direction_up
|
|
435
|
+
start_hz_direction = direction_up ? beg_of_signal_hz : end_of_signal_hz
|
|
436
|
+
end_hz_direction = direction_up ? end_of_signal_hz : beg_of_signal_hz
|
|
437
|
+
step_hz_direction = direction_up ? step_hz : -step_hz
|
|
438
|
+
|
|
439
|
+
start_hz_direction.step(by: step_hz_direction, to: end_hz_direction) do |hz|
|
|
440
|
+
print '>' if direction_up
|
|
441
|
+
print '<' unless direction_up
|
|
442
|
+
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
|
|
443
|
+
sleep lock_freq_duration
|
|
444
|
+
strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
|
|
445
|
+
strength_db = strength_db_float.round(1)
|
|
446
|
+
samples.push({ hz: hz, strength_db: strength_db })
|
|
447
|
+
|
|
448
|
+
# current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
|
|
449
|
+
# puts "Sampled Frequency: #{current_hz.pretty_hz} => Strength: #{strength_db} dBFS"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Compute fresh averaged_samples from all cumulative samples
|
|
453
|
+
averaged_samples = []
|
|
454
|
+
samples.group_by { |s| s[:hz] }.each do |hz, grouped_samples|
|
|
455
|
+
avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(1)
|
|
456
|
+
averaged_samples.push({ hz: hz, strength_db: avg_strength })
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Sort by hz for trimming
|
|
460
|
+
averaged_samples.sort_by! { |s| s[:hz] }
|
|
461
|
+
|
|
462
|
+
# Find current best for trimming threshold
|
|
463
|
+
best_sample = averaged_samples.max_by { |s| s[:strength_db] }
|
|
464
|
+
max_strength = best_sample[:strength_db]
|
|
465
|
+
|
|
466
|
+
# trim_db_threshold should bet average difference between
|
|
467
|
+
# samples near peak, floor to nearest 0.1 dB
|
|
468
|
+
trim_db_threshold = samples.map { |s| (s[:strength_db] - max_strength).abs }.sum / samples.size
|
|
469
|
+
trim_db_threshold = (trim_db_threshold * 10).floor / 10.0
|
|
470
|
+
puts "\nPass #{pass_count}: Calculated trim_db_threshold: #{trim_db_threshold} dB"
|
|
471
|
+
# Adaptive trim: Remove weak ends (implements the comment about trimming weakest ends)
|
|
472
|
+
averaged_samples.shift while !averaged_samples.empty? && averaged_samples.first[:strength_db] < max_strength - trim_db_threshold
|
|
473
|
+
averaged_samples.pop while !averaged_samples.empty? && averaged_samples.last[:strength_db] < max_strength - trim_db_threshold
|
|
474
|
+
|
|
475
|
+
# Update range for next pass if trimmed
|
|
476
|
+
unless averaged_samples.empty?
|
|
477
|
+
beg_of_signal_hz = averaged_samples.first[:hz]
|
|
478
|
+
end_of_signal_hz = averaged_samples.last[:hz]
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Recalculate best_sample after trim
|
|
482
|
+
best_sample = averaged_samples.max_by { |s| s[:strength_db] }
|
|
483
|
+
|
|
484
|
+
# Check for improvement
|
|
485
|
+
if best_sample == prev_best_sample
|
|
486
|
+
consecutive_best += 1
|
|
487
|
+
else
|
|
488
|
+
consecutive_best = 0
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Dup to avoid reference issues
|
|
492
|
+
prev_best_sample = best_sample.dup
|
|
493
|
+
|
|
494
|
+
puts "Pass #{pass_count}: Best #{best_sample[:hz].pretty_hz} => #{best_sample[:strength_db]} dBFS, consecutive best count: #{consecutive_best}"
|
|
495
|
+
|
|
496
|
+
# Break if no improvement in 3 consecutive passes or theres only one sample left
|
|
497
|
+
break if consecutive_best.positive? || averaged_samples.size == 1
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
best_sample
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Begin scanning range
|
|
504
|
+
puts "INFO: Scanning from #{hz_start.pretty_hz} to #{hz_target.pretty_hz} in steps of #{step.abs.pretty_hz} Hz.\nIf scans are slow and/or you're experiencing false positives/negatives, consider adjusting:\n1. The SDR's sample rate in GQRX\n\s\s- Click on `Configure I/O devices`.\n\s\s- A lower `Input rate` value seems counter-intuitive but works well (e.g. ADALM PLUTO ~ 1000000).\n2. Adjust the :strength_lock parameter.\n3. Adjust the :lock_freq_duration parameter.\n4. Adjust the :precision parameter.\n5. Disable AI introspection in PWN::Env\nHappy scanning!\n\n"
|
|
505
|
+
|
|
506
|
+
signals_arr = []
|
|
507
|
+
hz_start.step(by: step, to: hz_target) do |hz|
|
|
508
|
+
gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
|
|
509
|
+
sleep lock_freq_duration
|
|
510
|
+
strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
|
|
511
|
+
strength_db = strength_db_float.round(1)
|
|
512
|
+
prev_strength_db = strength_history.last || -Float::INFINITY
|
|
513
|
+
|
|
514
|
+
if strength_db >= strength_lock && strength_db > prev_strength_db
|
|
515
|
+
in_signal = true
|
|
516
|
+
strength_history.push(strength_db)
|
|
517
|
+
strength_history.shift if strength_history.size > 5
|
|
518
|
+
current_strength = (strength_history.sum / strength_history.size).round(1)
|
|
519
|
+
|
|
520
|
+
print '.'
|
|
521
|
+
# puts "#{hz.pretty_hz} => #{strength_db}"
|
|
522
|
+
|
|
523
|
+
candidate = { hz: hz, freq: hz.pretty_hz, strength: current_strength }
|
|
524
|
+
candidate_signals.push(candidate)
|
|
525
|
+
else
|
|
526
|
+
if in_signal
|
|
527
|
+
beg_of_signal_hz = candidate_signals.map { |s| s[:hz] }.min
|
|
528
|
+
# Previous max step_hz was actually the top of the signal
|
|
529
|
+
top_of_signal_hz = candidate_signals.map { |s| s[:hz] }.max - step_hz
|
|
530
|
+
|
|
531
|
+
distance_from_prev_freq_hz = (beg_of_signal_hz - prev_freq_hash[:frequency].to_s.raw_hz).abs
|
|
532
|
+
next unless distance_from_prev_freq_hz > (bandwidth.to_i / 2)
|
|
533
|
+
|
|
534
|
+
best_peak = find_best_peak.call(
|
|
535
|
+
beg_of_signal_hz: beg_of_signal_hz,
|
|
536
|
+
top_of_signal_hz: top_of_signal_hz
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if best_peak[:hz] && best_peak[:strength_db] > strength_lock
|
|
540
|
+
detailed = init_freq(
|
|
541
|
+
gqrx_sock: gqrx_sock,
|
|
542
|
+
freq: best_peak[:hz],
|
|
543
|
+
demodulator_mode: demodulator_mode,
|
|
544
|
+
bandwidth: bandwidth,
|
|
545
|
+
squelch: squelch,
|
|
546
|
+
suppress_details: true
|
|
547
|
+
)
|
|
548
|
+
detailed[:lock_freq_duration] = lock_freq_duration
|
|
549
|
+
detailed[:strength_lock] = strength_lock
|
|
550
|
+
|
|
551
|
+
system_role_content = "Analyze signal data captured by a software-defined-radio using GQRX at the following location: #{location}. Respond with just FCC information about the transmission if available. If the frequency is unlicensed or not found in FCC records, state that clearly. Be clear and concise in your analysis."
|
|
552
|
+
ai_analysis = PWN::AI::Introspection.reflect_on(
|
|
553
|
+
request: detailed.to_json,
|
|
554
|
+
system_role_content: system_role_content,
|
|
555
|
+
suppress_pii_warning: true
|
|
556
|
+
)
|
|
557
|
+
detailed[:ai_analysis] = ai_analysis unless ai_analysis.nil?
|
|
558
|
+
puts "\n**** Detected Signal ****"
|
|
559
|
+
puts JSON.pretty_generate(detailed)
|
|
560
|
+
signals_arr.push(detailed)
|
|
561
|
+
end
|
|
562
|
+
candidate_signals.clear
|
|
563
|
+
sleep lock_freq_duration
|
|
564
|
+
end
|
|
565
|
+
in_signal = false
|
|
566
|
+
strength_history = []
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
signals = signals_arr.sort_by { |s| s[:frequency].to_s.raw_hz }
|
|
570
|
+
timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
|
|
571
|
+
duration_secs = Time.parse(timestamp_end) - Time.parse(timestamp_start)
|
|
572
|
+
# Convert duration seconds to hours minutes seconds
|
|
573
|
+
hours = (duration_secs / 3600).to_i
|
|
574
|
+
minutes = ((duration_secs % 3600) / 60).to_i
|
|
575
|
+
seconds = (duration_secs % 60).to_i
|
|
576
|
+
duration = format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
|
|
577
|
+
|
|
578
|
+
{
|
|
579
|
+
signals: signals,
|
|
580
|
+
timestamp_start: timestamp_start,
|
|
581
|
+
timestamp_end: timestamp_end,
|
|
582
|
+
duration: duration
|
|
583
|
+
}
|
|
584
|
+
rescue StandardError => e
|
|
585
|
+
raise e
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Supported Method Parameters::
|
|
589
|
+
# PWN::SDR::GQRX.disconnect(
|
|
590
|
+
# gqrx_sock: 'required - GQRX socket object returned from #connect method'
|
|
591
|
+
# )
|
|
592
|
+
public_class_method def self.disconnect(opts = {})
|
|
593
|
+
gqrx_sock = opts[:gqrx_sock]
|
|
594
|
+
|
|
595
|
+
PWN::Plugins::Sock.disconnect(sock_obj: gqrx_sock)
|
|
596
|
+
rescue StandardError => e
|
|
597
|
+
raise e
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
601
|
+
|
|
602
|
+
public_class_method def self.authors
|
|
603
|
+
"AUTHOR(S):
|
|
604
|
+
0day Inc. <support@0dayinc.com>
|
|
605
|
+
"
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# Display Usage for this Module
|
|
609
|
+
|
|
610
|
+
public_class_method def self.help
|
|
611
|
+
puts "USAGE:
|
|
612
|
+
gqrx_sock = #{self}.connect(
|
|
613
|
+
target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
|
|
614
|
+
port: 'optional - GQRX target port (defaults to 7356)'
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
gqrx_resp = #{self}.gqrx_cmd(
|
|
618
|
+
gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
619
|
+
cmd: 'required - GQRX command to execute',
|
|
620
|
+
resp_ok: 'optional - Expected response from GQRX to indicate success'
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
init_freq_hash = #{self}.init_freq(
|
|
624
|
+
gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
625
|
+
freq: 'required - Frequency to set',
|
|
626
|
+
demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
|
|
627
|
+
bandwidth: 'optional - Bandwidth (defaults to 200_000)',
|
|
628
|
+
decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
|
|
629
|
+
record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to ~/gqrx_recordings)',
|
|
630
|
+
decoder_opts: 'optional - Hash of additional options for the decoder',
|
|
631
|
+
suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)'
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
scan_resp = #{self}.scan_range(
|
|
635
|
+
gqrx_sock: 'required - GQRX socket object returned from #connect method',
|
|
636
|
+
start_freq: 'required - Starting frequency',
|
|
637
|
+
target_freq: 'required - Target frequency',
|
|
638
|
+
demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
|
|
639
|
+
bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
|
|
640
|
+
precision: 'optional - Precision (Defaults to 1)',
|
|
641
|
+
lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
|
|
642
|
+
strength_lock: 'optional - Strength lock (defaults to -70.0)',
|
|
643
|
+
squelch: 'optional - Squelch level (defaults to strength_lock - 3.0)',
|
|
644
|
+
location: 'optional - Location string to include in AI analysis (e.g., \"New York, NY\", 90210, GPS coords, etc.)'
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
#{self}.disconnect(
|
|
648
|
+
gqrx_sock: 'required - GQRX socket object returned from #connect method'
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
#{self}.authors
|
|
652
|
+
"
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PWN
|
|
4
|
-
module
|
|
4
|
+
module SDR
|
|
5
5
|
# This plugin is used for interacting with an RFIDler using the
|
|
6
6
|
# the screen command as a terminal emulator.
|
|
7
7
|
module RFIDler
|
|
8
8
|
# Supported Method Parameters::
|
|
9
|
-
# PWN::
|
|
9
|
+
# PWN::SDR::RFIDler.connect_via_screen(
|
|
10
10
|
# block_dev: 'optional - serial block device path (defaults to /dev/ttyUSB0)'
|
|
11
11
|
# )
|
|
12
12
|
|