pwn 0.5.621 → 0.5.626
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/.rubocop.yml +2 -2
- data/Gemfile +1 -1
- data/README.md +7 -1
- data/bin/pwn_gqrx_scanner +78 -9
- data/documentation/Agent-Tool-Registry.md +3 -3
- data/documentation/Configuration.md +301 -47
- data/documentation/Cron.md +5 -3
- data/documentation/Diagrams.md +1 -1
- data/documentation/Extrospection.md +472 -26
- data/documentation/FFI.md +72 -8
- data/documentation/Home.md +4 -4
- data/documentation/How-PWN-Works.md +7 -3
- data/documentation/Mistakes.md +9 -0
- data/documentation/SDR.md +83 -10
- data/documentation/Skills-Memory-Learning.md +101 -11
- data/documentation/diagrams/agent-tool-registry.svg +56 -55
- data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
- data/documentation/diagrams/aws-cloud-security.svg +25 -25
- data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
- data/documentation/diagrams/code-scanning-sast.svg +25 -25
- data/documentation/diagrams/cron-scheduling.svg +23 -23
- data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
- data/documentation/diagrams/dot/extrospection-world-awareness.dot +33 -6
- data/documentation/diagrams/dot/hardware-hacking.dot +3 -1
- data/documentation/diagrams/dot/memory-skills-detailed.dot +4 -2
- data/documentation/diagrams/dot/mistakes-negative-feedback.dot +4 -2
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +3 -3
- data/documentation/diagrams/dot/persistence-filesystem.dot +3 -3
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +10 -4
- data/documentation/diagrams/dot/sdr-radio-flow.dot +39 -16
- data/documentation/diagrams/driver-framework.svg +13 -13
- data/documentation/diagrams/extrospection-world-awareness.svg +383 -177
- data/documentation/diagrams/fuzzing-workflow.svg +24 -24
- data/documentation/diagrams/hardware-hacking.svg +84 -69
- data/documentation/diagrams/history-to-drivers.svg +18 -18
- data/documentation/diagrams/memory-skills-detailed.svg +137 -120
- data/documentation/diagrams/mistakes-negative-feedback.svg +152 -137
- data/documentation/diagrams/network-infra-testing.svg +27 -27
- data/documentation/diagrams/overall-pwn-architecture.svg +97 -97
- data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
- data/documentation/diagrams/persistence-filesystem.svg +100 -94
- data/documentation/diagrams/plugin-ecosystem.svg +35 -35
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +254 -207
- data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
- data/documentation/diagrams/reporting-pipeline.svg +18 -18
- data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
- data/documentation/diagrams/sdr-radio-flow.svg +223 -91
- data/documentation/diagrams/sessions-cron-automation.svg +18 -18
- data/documentation/diagrams/swarm-multi-agent.svg +39 -39
- data/documentation/diagrams/web-application-testing.svg +26 -26
- data/documentation/diagrams/zero-day-research-flow.svg +25 -25
- data/documentation/pwn-ai-Agent.md +16 -10
- data/documentation/pwn_silent_help_learn_demo.gif +0 -0
- data/lib/pwn/ai/agent/assembly.rb +1 -1
- data/lib/pwn/ai/agent/btc.rb +1 -1
- data/lib/pwn/ai/agent/burp_suite.rb +1 -1
- data/lib/pwn/ai/agent/extrospection.rb +2562 -57
- data/lib/pwn/ai/agent/gqrx.rb +2 -2
- data/lib/pwn/ai/agent/hacker_one.rb +1 -1
- data/lib/pwn/ai/agent/learning.rb +16 -16
- data/lib/pwn/ai/agent/loop.rb +1 -1
- data/lib/pwn/ai/agent/{introspection.rb → reflect.rb} +12 -12
- data/lib/pwn/ai/agent/sast.rb +1 -1
- data/lib/pwn/ai/agent/tools/extrospection.rb +311 -8
- data/lib/pwn/ai/agent/tools/learning.rb +7 -7
- data/lib/pwn/ai/agent/transparent_browser.rb +1 -1
- data/lib/pwn/ai/agent/vuln_gen.rb +2 -2
- data/lib/pwn/ai/agent.rb +1 -1
- data/lib/pwn/ai/open_ai.rb +10 -6
- data/lib/pwn/config.rb +5 -4
- data/lib/pwn/ffi/adalm_pluto.rb +498 -0
- data/lib/pwn/ffi/fftw.rb +192 -0
- data/lib/pwn/ffi/hack_rf.rb +193 -0
- data/lib/pwn/ffi/liquid.rb +244 -0
- data/lib/pwn/ffi/rtl_sdr.rb +210 -0
- data/lib/pwn/ffi/soapy_sdr.rb +177 -0
- data/lib/pwn/ffi/stdio.rb +9 -1
- data/lib/pwn/ffi/volk.rb +241 -0
- data/lib/pwn/ffi.rb +45 -2
- data/lib/pwn/plugins/burp_suite.rb +2 -2
- data/lib/pwn/sast/pom_version.rb +2 -2
- data/lib/pwn/sast/test_case_engine.rb +1 -1
- data/lib/pwn/sdr/decoder/adsb.rb +201 -12
- data/lib/pwn/sdr/decoder/apt.rb +19 -5
- data/lib/pwn/sdr/decoder/base.rb +314 -1
- data/lib/pwn/sdr/decoder/bluetooth.rb +91 -18
- data/lib/pwn/sdr/decoder/dect.rb +81 -23
- data/lib/pwn/sdr/decoder/dsp.rb +229 -15
- data/lib/pwn/sdr/decoder/flex.rb +23 -6
- data/lib/pwn/sdr/decoder/gps.rb +80 -18
- data/lib/pwn/sdr/decoder/gsm.rb +83 -28
- data/lib/pwn/sdr/decoder/iridium.rb +80 -18
- data/lib/pwn/sdr/decoder/lora.rb +84 -28
- data/lib/pwn/sdr/decoder/lte.rb +80 -18
- data/lib/pwn/sdr/decoder/morse.rb +23 -6
- data/lib/pwn/sdr/decoder/p25.rb +90 -31
- data/lib/pwn/sdr/decoder/pager.rb +23 -6
- data/lib/pwn/sdr/decoder/pocsag.rb +23 -6
- data/lib/pwn/sdr/decoder/rds.rb +219 -52
- data/lib/pwn/sdr/decoder/rfid.rb +81 -27
- data/lib/pwn/sdr/decoder/rtl433.rb +85 -27
- data/lib/pwn/sdr/decoder/rtty.rb +23 -6
- data/lib/pwn/sdr/decoder/wifi.rb +86 -32
- data/lib/pwn/sdr/decoder/zigbee.rb +88 -35
- data/lib/pwn/sdr/decoder.rb +6 -5
- data/lib/pwn/sdr/frequency_allocation.rb +124 -81
- data/lib/pwn/sdr/gqrx.rb +1513 -165
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/extrospection_spec.rb +96 -0
- data/spec/lib/pwn/ai/agent/{introspection_spec.rb → reflect_spec.rb} +3 -3
- data/spec/lib/pwn/ai/agent/tools/extrospection_spec.rb +8 -24
- data/spec/lib/pwn/ffi/adalm_pluto_spec.rb +37 -0
- data/spec/lib/pwn/ffi/fftw_spec.rb +26 -0
- data/spec/lib/pwn/ffi/hack_rf_spec.rb +25 -0
- data/spec/lib/pwn/ffi/liquid_spec.rb +42 -0
- data/spec/lib/pwn/ffi/rtl_sdr_spec.rb +24 -0
- data/spec/lib/pwn/ffi/soapy_sdr_spec.rb +25 -0
- data/spec/lib/pwn/ffi/volk_spec.rb +36 -0
- data/spec/lib/pwn/sdr/decoder/base_iq_spec.rb +65 -0
- data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +50 -4
- data/spec/lib/pwn/sdr/decoder/rds_spec.rb +45 -4
- data/third_party/pwn_rdoc.jsonl +279 -30
- metadata +21 -5
|
@@ -187,11 +187,28 @@ module PWN
|
|
|
187
187
|
|
|
188
188
|
public_class_method def self.decode(opts = {})
|
|
189
189
|
freq_obj = opts[:freq_obj]
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
190
|
+
# Prefer true-air I/Q (FM-demod → existing audio demod) when the
|
|
191
|
+
# operator asks for a source/file or sets freq_obj[:iq_source].
|
|
192
|
+
# Otherwise keep the GQRX 48 kHz UDP audio path (run_native).
|
|
193
|
+
want_iq = opts[:source] || opts[:file] || freq_obj[:iq_source] || freq_obj[:iq_file]
|
|
194
|
+
if want_iq
|
|
195
|
+
PWN::SDR::Decoder::Base.run_iq(
|
|
196
|
+
freq_obj: freq_obj,
|
|
197
|
+
protocol: 'POCSAG',
|
|
198
|
+
demod: Demod.new,
|
|
199
|
+
sample_rate: (opts[:sample_rate] || freq_obj[:iq_rate] || 240_000).to_i,
|
|
200
|
+
source: opts[:source],
|
|
201
|
+
file: opts[:file],
|
|
202
|
+
fm_demod: true,
|
|
203
|
+
note: 'POCSAG true-air: FM-demod I/Q then native bit recovery; falls back to detector without SDR hardware.'
|
|
204
|
+
)
|
|
205
|
+
else
|
|
206
|
+
PWN::SDR::Decoder::Base.run_native(
|
|
207
|
+
freq_obj: freq_obj,
|
|
208
|
+
protocol: 'POCSAG',
|
|
209
|
+
demod: Demod.new
|
|
210
|
+
)
|
|
211
|
+
end
|
|
195
212
|
end
|
|
196
213
|
|
|
197
214
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
@@ -203,7 +220,7 @@ module PWN
|
|
|
203
220
|
# Display Usage for this Module
|
|
204
221
|
|
|
205
222
|
public_class_method def self.help
|
|
206
|
-
puts "USAGE (
|
|
223
|
+
puts "USAGE (true-air I/Q + GQRX-audio native paths, no external binaries):
|
|
207
224
|
#{self}.decode(
|
|
208
225
|
freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
|
|
209
226
|
)
|
data/lib/pwn/sdr/decoder/rds.rb
CHANGED
|
@@ -6,38 +6,124 @@ require 'tty-spinner'
|
|
|
6
6
|
module PWN
|
|
7
7
|
module SDR
|
|
8
8
|
module Decoder
|
|
9
|
-
# RDS Decoder Module for FM Radio Signals
|
|
9
|
+
# RDS Decoder Module for FM Radio Signals.
|
|
10
|
+
#
|
|
11
|
+
# Two entry points:
|
|
12
|
+
# .sample — non-interactive structured Hash (agents / cron / tools)
|
|
13
|
+
# .decode — interactive TTY spinner (human REPL via GQRX.init_freq)
|
|
14
|
+
#
|
|
15
|
+
# Both share the same GQRX RDS protocol path (U RDS, p RDS_PI / PS_NAME /
|
|
16
|
+
# RADIOTEXT). .sample is the canonical mid-layer API that Extrospection
|
|
17
|
+
# and any other automation should call.
|
|
10
18
|
module RDS
|
|
19
|
+
DEFAULT_SETTLE_SECS = 8.0
|
|
20
|
+
DEFAULT_INTERVAL = 0.75
|
|
21
|
+
CALLSIGN_RX = /\A[A-Z]{1,2}[A-Z0-9]{2,4}\z/
|
|
22
|
+
CALLSIGN_RT_RX = /\A([A-Z]{1,2}[A-Z0-9]{2,4})\b/
|
|
23
|
+
|
|
24
|
+
# Supported Method Parameters::
|
|
25
|
+
# rds_hash = PWN::SDR::Decoder::RDS.sample(
|
|
26
|
+
# gqrx_sock: 'required unless freq_obj - TCPSocket from GQRX.connect',
|
|
27
|
+
# freq_obj: 'required unless gqrx_sock - Hash from GQRX.init_freq',
|
|
28
|
+
# settle_secs: 'optional - seconds to sample (default 8, max 30)',
|
|
29
|
+
# interval: 'optional - poll interval seconds (default 0.75)',
|
|
30
|
+
# leave_enabled: 'optional - leave RDS decoder ON after sample (default false)'
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# Returns::
|
|
34
|
+
# {
|
|
35
|
+
# pi:, ps_name:, radiotext:, station:,
|
|
36
|
+
# samples: Integer, settle_secs: Float,
|
|
37
|
+
# error: String? # present when RDS backend is unavailable
|
|
38
|
+
# }
|
|
39
|
+
|
|
40
|
+
public_class_method def self.sample(opts = {})
|
|
41
|
+
sock = resolve_sock(opts)
|
|
42
|
+
raise ArgumentError, 'gqrx_sock: or freq_obj: with :gqrx_sock required' unless sock
|
|
43
|
+
|
|
44
|
+
settle = (opts[:settle_secs] || DEFAULT_SETTLE_SECS).to_f.clamp(0.5, 30.0)
|
|
45
|
+
interval = [(opts[:interval] || DEFAULT_INTERVAL).to_f, 0.1].max
|
|
46
|
+
leave_on = opts[:leave_enabled] ? true : false
|
|
47
|
+
samples = []
|
|
48
|
+
|
|
49
|
+
unless enable_rds!(sock: sock)
|
|
50
|
+
return {
|
|
51
|
+
pi: nil,
|
|
52
|
+
ps_name: nil,
|
|
53
|
+
radiotext: nil,
|
|
54
|
+
station: nil,
|
|
55
|
+
samples: 0,
|
|
56
|
+
settle_secs: settle,
|
|
57
|
+
error: 'RDS not supported by this radio backend'
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
deadline = Time.now + settle
|
|
62
|
+
while Time.now < deadline
|
|
63
|
+
snap = poll_once(sock: sock)
|
|
64
|
+
samples << snap unless snap[:pi].empty? && snap[:ps].empty? && snap[:rt].empty?
|
|
65
|
+
|
|
66
|
+
# Early exit once we have a non-zero PI and a non-trivial RT —
|
|
67
|
+
# give one more interval for RadioText to finish filling.
|
|
68
|
+
pi = snap[:pi]
|
|
69
|
+
rt = snap[:rt]
|
|
70
|
+
if pi =~ /\A[0-9A-F]{4}\z/ && pi != '0000' && rt.length >= 8
|
|
71
|
+
sleep interval
|
|
72
|
+
snap2 = poll_once(sock: sock)
|
|
73
|
+
samples << snap2
|
|
74
|
+
break if snap2[:rt].length >= rt.length
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sleep interval
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
disable_rds!(sock: sock) unless leave_on
|
|
81
|
+
|
|
82
|
+
aggregate(samples: samples, settle_secs: settle)
|
|
83
|
+
rescue ArgumentError
|
|
84
|
+
raise
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
disable_rds!(sock: sock) if sock && !leave_on
|
|
87
|
+
{
|
|
88
|
+
pi: nil,
|
|
89
|
+
ps_name: nil,
|
|
90
|
+
radiotext: nil,
|
|
91
|
+
station: nil,
|
|
92
|
+
samples: samples&.length.to_i,
|
|
93
|
+
settle_secs: settle,
|
|
94
|
+
error: "#{e.class}: #{e.message}"
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
11
98
|
# Supported Method Parameters::
|
|
12
|
-
#
|
|
13
|
-
# freq_obj: 'required -
|
|
99
|
+
# PWN::SDR::Decoder::RDS.decode(
|
|
100
|
+
# freq_obj: 'required - Hash returned from PWN::SDR::GQRX.init_freq'
|
|
14
101
|
# )
|
|
102
|
+
#
|
|
103
|
+
# Interactive TTY UX: enables RDS, spins a live status line until the
|
|
104
|
+
# operator presses ENTER, then disables RDS. Does not return a Hash —
|
|
105
|
+
# callers that need structured data should use .sample instead.
|
|
15
106
|
|
|
16
107
|
public_class_method def self.decode(opts = {})
|
|
17
108
|
freq_obj = opts[:freq_obj]
|
|
109
|
+
raise ArgumentError, 'freq_obj: required' unless freq_obj.is_a?(Hash)
|
|
110
|
+
|
|
18
111
|
gqrx_sock = freq_obj[:gqrx_sock]
|
|
112
|
+
raise ArgumentError, 'freq_obj[:gqrx_sock] required' unless gqrx_sock
|
|
19
113
|
|
|
20
|
-
#
|
|
21
|
-
freq_obj.
|
|
114
|
+
# Pretty-print the tuned frequency context without leaking the socket.
|
|
115
|
+
display = freq_obj.dup
|
|
116
|
+
display.delete(:gqrx_sock)
|
|
22
117
|
skip_freq_char = "\n"
|
|
23
|
-
puts JSON.pretty_generate(
|
|
118
|
+
puts JSON.pretty_generate(display)
|
|
24
119
|
puts "\n*** FM Radio RDS Decoder ***"
|
|
25
120
|
puts 'Press [ENTER] to continue to next frequency...'
|
|
26
121
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
resp_ok: 'RPRT 0'
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
PWN::SDR::GQRX.cmd(
|
|
35
|
-
gqrx_sock: gqrx_sock,
|
|
36
|
-
cmd: 'U RDS 1',
|
|
37
|
-
resp_ok: 'RPRT 0'
|
|
38
|
-
)
|
|
122
|
+
unless enable_rds!(sock: gqrx_sock)
|
|
123
|
+
puts 'ERROR: RDS not supported by this radio backend'
|
|
124
|
+
return nil
|
|
125
|
+
end
|
|
39
126
|
|
|
40
|
-
# Spinner setup with dynamic terminal width awareness
|
|
41
127
|
spinner = TTY::Spinner.new(
|
|
42
128
|
'[:spinner] :status',
|
|
43
129
|
format: :arrow_pulse,
|
|
@@ -45,54 +131,38 @@ module PWN
|
|
|
45
131
|
hide_cursor: true
|
|
46
132
|
)
|
|
47
133
|
|
|
48
|
-
# Conservative overhead for spinner animation, colors, and spacing
|
|
49
134
|
spinner_overhead = 12
|
|
50
135
|
max_title_length = [TTY::Screen.width - spinner_overhead, 50].max
|
|
51
136
|
|
|
52
137
|
initial_title = 'INFO: Decoding FM radio RDS data...'
|
|
53
138
|
initial_title = initial_title[0...max_title_length] if initial_title.length > max_title_length
|
|
54
|
-
spinner.update(
|
|
139
|
+
spinner.update(status: initial_title)
|
|
55
140
|
spinner.auto_spin
|
|
56
141
|
|
|
57
142
|
last_resp = {}
|
|
58
143
|
|
|
59
144
|
loop do
|
|
145
|
+
snap = poll_once(sock: gqrx_sock)
|
|
60
146
|
rds_resp = {
|
|
61
|
-
rds_pi:
|
|
62
|
-
rds_ps_name:
|
|
63
|
-
rds_radiotext:
|
|
147
|
+
rds_pi: snap[:pi],
|
|
148
|
+
rds_ps_name: snap[:ps],
|
|
149
|
+
rds_radiotext: snap[:rt]
|
|
64
150
|
}
|
|
65
151
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# --- Enforce RDS specification bounds and clean formatting ---
|
|
69
|
-
# PI: 16-bit code >>> exactly 4 uppercase hex digits, zero-padded
|
|
70
|
-
rds_pi = rds_resp[:rds_pi].upcase
|
|
71
|
-
rds_pi = rds_pi.rjust(4, '0')[0, 4]
|
|
72
|
-
|
|
73
|
-
# PS: exactly 8 ASCII characters (pad short with spaces, truncate long)
|
|
152
|
+
if rds_resp[:rds_pi] != '0000' && !rds_resp[:rds_pi].empty? && rds_resp != last_resp
|
|
153
|
+
rds_pi = rds_resp[:rds_pi].upcase.rjust(4, '0')[0, 4]
|
|
74
154
|
rds_ps = "#{rds_resp[:rds_ps_name]} "[0, 8]
|
|
75
|
-
|
|
76
|
-
# RadioText: strip trailing spaces (stations often pad to clear)
|
|
77
155
|
rds_rt = rds_resp[:rds_radiotext].rstrip
|
|
78
156
|
|
|
79
|
-
# Fixed prefix: always exactly 28 characters for predictable layout
|
|
80
|
-
# Breakdown: "PI: " (4) + 4 hex (4) + " | PS: " (7) + 8 chars (8) + " | RT: " (7) = 28
|
|
81
157
|
prefix = "Program ID: #{rds_pi} | Station Name: #{rds_ps} | Radio Txt: "
|
|
82
|
-
|
|
83
|
-
# minimum visibility
|
|
84
|
-
available_for_term = max_title_length - prefix.length
|
|
85
|
-
available_for_term = [available_for_term, 10].max
|
|
86
|
-
|
|
158
|
+
available_for_term = [max_title_length - prefix.length, 10].max
|
|
87
159
|
rt_display = rds_rt
|
|
88
160
|
rt_display = "#{rt_display[0...available_for_term]}..." if rt_display.length > available_for_term
|
|
89
161
|
|
|
90
|
-
|
|
91
|
-
spinner.update(status: msg)
|
|
162
|
+
spinner.update(status: "#{prefix}#{rt_display}")
|
|
92
163
|
last_resp = rds_resp.dup
|
|
93
164
|
end
|
|
94
165
|
|
|
95
|
-
# Non-blocking check for ENTER key to exit
|
|
96
166
|
if $stdin.wait_readable(0)
|
|
97
167
|
begin
|
|
98
168
|
char = $stdin.read_nonblock(1)
|
|
@@ -105,16 +175,10 @@ module PWN
|
|
|
105
175
|
sleep 0.01
|
|
106
176
|
end
|
|
107
177
|
rescue StandardError => e
|
|
108
|
-
spinner.error('Decoding failed') if defined?(spinner)
|
|
178
|
+
spinner.error('Decoding failed') if defined?(spinner) && spinner
|
|
109
179
|
raise e
|
|
110
180
|
ensure
|
|
111
|
-
|
|
112
|
-
PWN::SDR::GQRX.cmd(
|
|
113
|
-
gqrx_sock: gqrx_sock,
|
|
114
|
-
cmd: 'U RDS 0',
|
|
115
|
-
resp_ok: 'RPRT 0'
|
|
116
|
-
)
|
|
117
|
-
|
|
181
|
+
disable_rds!(sock: gqrx_sock) if defined?(gqrx_sock) && gqrx_sock
|
|
118
182
|
spinner.stop if defined?(spinner) && spinner
|
|
119
183
|
end
|
|
120
184
|
|
|
@@ -130,6 +194,15 @@ module PWN
|
|
|
130
194
|
|
|
131
195
|
public_class_method def self.help
|
|
132
196
|
puts "USAGE:
|
|
197
|
+
# Non-interactive (agents / automation) — returns a Hash:
|
|
198
|
+
rds = #{self}.sample(
|
|
199
|
+
gqrx_sock: sock, # or freq_obj: from init_freq
|
|
200
|
+
settle_secs: 8, # default 8, max 30
|
|
201
|
+
leave_enabled: false
|
|
202
|
+
)
|
|
203
|
+
# => { pi:, ps_name:, radiotext:, station:, samples:, settle_secs: }
|
|
204
|
+
|
|
205
|
+
# Interactive TTY spinner (human REPL via GQRX.init_freq decoder: :rds):
|
|
133
206
|
#{self}.decode(
|
|
134
207
|
freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq method'
|
|
135
208
|
)
|
|
@@ -137,6 +210,100 @@ module PWN
|
|
|
137
210
|
#{self}.authors
|
|
138
211
|
"
|
|
139
212
|
end
|
|
213
|
+
|
|
214
|
+
# ---- internals -------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
private_class_method def self.resolve_sock(opts = {})
|
|
217
|
+
return opts[:gqrx_sock] if opts[:gqrx_sock]
|
|
218
|
+
|
|
219
|
+
fo = opts[:freq_obj]
|
|
220
|
+
return fo[:gqrx_sock] if fo.is_a?(Hash)
|
|
221
|
+
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private_class_method def self.enable_rds!(opts = {})
|
|
226
|
+
sock = opts[:sock]
|
|
227
|
+
begin
|
|
228
|
+
PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'U RDS 0', resp_ok: 'RPRT 0')
|
|
229
|
+
rescue StandardError
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
begin
|
|
233
|
+
PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'U RDS 1', resp_ok: 'RPRT 0')
|
|
234
|
+
true
|
|
235
|
+
rescue StandardError
|
|
236
|
+
false
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private_class_method def self.disable_rds!(opts = {})
|
|
241
|
+
sock = opts[:sock]
|
|
242
|
+
PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'U RDS 0')
|
|
243
|
+
rescue StandardError
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private_class_method def self.poll_once(opts = {})
|
|
248
|
+
sock = opts[:sock]
|
|
249
|
+
pi = begin
|
|
250
|
+
PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'p RDS_PI').to_s.strip.chomp.delete('.').upcase
|
|
251
|
+
rescue StandardError
|
|
252
|
+
''
|
|
253
|
+
end
|
|
254
|
+
ps = begin
|
|
255
|
+
PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'p RDS_PS_NAME').to_s.strip.chomp
|
|
256
|
+
rescue StandardError
|
|
257
|
+
''
|
|
258
|
+
end
|
|
259
|
+
rt = begin
|
|
260
|
+
PWN::SDR::GQRX.cmd(gqrx_sock: sock, cmd: 'p RDS_RADIOTEXT').to_s.strip.chomp
|
|
261
|
+
rescue StandardError
|
|
262
|
+
''
|
|
263
|
+
end
|
|
264
|
+
{ pi: pi, ps: ps, rt: rt }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Fold raw poll samples into the public Hash shape expected by
|
|
268
|
+
# Extrospection.rf_tune / agents (pi / ps_name / radiotext / station).
|
|
269
|
+
private_class_method def self.aggregate(opts = {})
|
|
270
|
+
samples = opts[:samples] || []
|
|
271
|
+
settle = opts[:settle_secs]
|
|
272
|
+
|
|
273
|
+
best_pi = samples.map { |s| s[:pi] }.find { |p| p =~ /\A[0-9A-F]{4}\z/ && p != '0000' }
|
|
274
|
+
best_pi = best_pi.to_s.rjust(4, '0')[0, 4] if best_pi
|
|
275
|
+
|
|
276
|
+
# PS often scrolls (artist / title / callsign cycle) — collect every
|
|
277
|
+
# non-empty sample, prefer a short all-caps callsign-like token, and
|
|
278
|
+
# fall back to the longest only when no callsign was seen.
|
|
279
|
+
ps_candidates = samples.map { |s| s[:ps].to_s.strip }.reject(&:empty?)
|
|
280
|
+
callsign_like = ps_candidates.find { |p| p =~ CALLSIGN_RX }
|
|
281
|
+
best_ps = callsign_like || ps_candidates.max_by(&:length)
|
|
282
|
+
best_ps = "#{best_ps} "[0, 8].rstrip if best_ps
|
|
283
|
+
best_rt = samples.map { |s| s[:rt].to_s.rstrip }.reject(&:empty?).max_by(&:length)
|
|
284
|
+
|
|
285
|
+
station = nil
|
|
286
|
+
if best_rt && best_rt =~ CALLSIGN_RT_RX
|
|
287
|
+
station = Regexp.last_match(1)
|
|
288
|
+
elsif callsign_like
|
|
289
|
+
station = callsign_like
|
|
290
|
+
elsif best_ps && best_ps =~ CALLSIGN_RX
|
|
291
|
+
station = best_ps
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# If station callsign is known and best_ps is just a mid-scroll
|
|
295
|
+
# fragment of RadioText, prefer station so callers use station + RT.
|
|
296
|
+
best_ps = station if station && best_ps && best_rt && best_ps != station && best_rt.include?(best_ps)
|
|
297
|
+
|
|
298
|
+
{
|
|
299
|
+
pi: best_pi,
|
|
300
|
+
ps_name: best_ps,
|
|
301
|
+
radiotext: best_rt,
|
|
302
|
+
station: station,
|
|
303
|
+
samples: samples.length,
|
|
304
|
+
settle_secs: settle
|
|
305
|
+
}
|
|
306
|
+
end
|
|
140
307
|
end
|
|
141
308
|
end
|
|
142
309
|
end
|
data/lib/pwn/sdr/decoder/rfid.rb
CHANGED
|
@@ -3,14 +3,69 @@
|
|
|
3
3
|
module PWN
|
|
4
4
|
module SDR
|
|
5
5
|
module Decoder
|
|
6
|
-
#
|
|
7
|
-
# (
|
|
8
|
-
#
|
|
9
|
-
# Near-field ASK/load-modulation on LF/HF requires an inductive
|
|
10
|
-
# coupler (not an SDR antenna); UHF backscatter is 40–640 kbps ASK.
|
|
11
|
-
# Native mode reports reader-carrier presence and tag-response
|
|
12
|
-
# bursts by band. `parse_line` retained for offline text analysis.
|
|
6
|
+
# True-air + detector-fallback decoder for RFID.
|
|
7
|
+
# Prefers PWN::FFI I/Q (RTL-SDR / ADALM-Pluto / HackRF / capture file)
|
|
8
|
+
# via Base.run_iq; degrades to Base.run_detector with no hardware.
|
|
13
9
|
module RFID
|
|
10
|
+
# Streaming I/Q energy/burst demod for Base.run_iq.
|
|
11
|
+
class DemodIQ
|
|
12
|
+
def initialize(rate:, protocol:, modulation:, extra: {})
|
|
13
|
+
@rate = rate.to_f
|
|
14
|
+
@protocol = protocol
|
|
15
|
+
@modulation = modulation
|
|
16
|
+
@extra = extra
|
|
17
|
+
@floor = nil
|
|
18
|
+
@in_burst = false
|
|
19
|
+
@burst_t0 = nil
|
|
20
|
+
@peak = -200.0
|
|
21
|
+
@burst_n = 0
|
|
22
|
+
@threshold = (extra[:threshold] || 8.0).to_f
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def feed_iq(samples, rate: nil, &)
|
|
26
|
+
@rate = rate.to_f if rate
|
|
27
|
+
m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
|
|
28
|
+
hop = [(@rate / 1000.0).round, 1].max
|
|
29
|
+
i = 0
|
|
30
|
+
while i < m2.length
|
|
31
|
+
win = m2[i, hop]
|
|
32
|
+
break if win.nil? || win.empty?
|
|
33
|
+
|
|
34
|
+
ms = win.sum / win.length
|
|
35
|
+
lvl = ms.positive? ? (10.0 * Math.log10(ms)) : -120.0
|
|
36
|
+
@floor = @floor.nil? ? lvl : ((@floor * 0.98) + (lvl * 0.02))
|
|
37
|
+
delta = lvl - @floor
|
|
38
|
+
if delta >= @threshold
|
|
39
|
+
unless @in_burst
|
|
40
|
+
@in_burst = true
|
|
41
|
+
@burst_t0 = Time.now
|
|
42
|
+
@peak = lvl
|
|
43
|
+
end
|
|
44
|
+
@peak = lvl if lvl > @peak
|
|
45
|
+
elsif @in_burst
|
|
46
|
+
@in_burst = false
|
|
47
|
+
@burst_n += 1
|
|
48
|
+
dur_ms = ((Time.now - @burst_t0) * 1000).round
|
|
49
|
+
msg = {
|
|
50
|
+
protocol: @protocol, event: 'burst', source: 'iq',
|
|
51
|
+
burst_no: @burst_n, peak_dbfs: @peak.round(1),
|
|
52
|
+
floor_dbfs: @floor.round(1), delta_db: (@peak - @floor).round(1),
|
|
53
|
+
duration_ms: dur_ms, modulation: @modulation,
|
|
54
|
+
sample_rate: @rate.to_i
|
|
55
|
+
}.merge(@extra.except(:threshold))
|
|
56
|
+
# protocol-specific enrichment
|
|
57
|
+
msg.merge!(self.class.enrich(msg)) if self.class.respond_to?(:enrich)
|
|
58
|
+
msg[:summary] = format(
|
|
59
|
+
'%<p>s IQ-burst #%<n>d peak=%<pk>+.1f dBFS Δ=%<d>.1f dB dur=%<ms>d ms',
|
|
60
|
+
p: @protocol, n: @burst_n, pk: @peak, d: @peak - @floor, ms: dur_ms
|
|
61
|
+
)
|
|
62
|
+
yield msg if block_given?
|
|
63
|
+
end
|
|
64
|
+
i += hop
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
14
69
|
# Supported Method Parameters::
|
|
15
70
|
# PWN::SDR::Decoder::RFID.decode(
|
|
16
71
|
# freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
|
|
@@ -23,25 +78,26 @@ module PWN
|
|
|
23
78
|
elsif hz.between?(13_000_000, 14_000_000) then 'HF'
|
|
24
79
|
else 'UHF'
|
|
25
80
|
end
|
|
26
|
-
|
|
81
|
+
|
|
82
|
+
rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_000_000).to_i
|
|
83
|
+
proto = "RFID-#{band}"
|
|
84
|
+
demod = DemodIQ.new(
|
|
85
|
+
rate: rate, protocol: proto, modulation: 'ASK/load-mod',
|
|
86
|
+
extra: { threshold: 6.0 }
|
|
87
|
+
)
|
|
88
|
+
PWN::SDR::Decoder::Base.run_iq(
|
|
27
89
|
freq_obj: freq_obj,
|
|
28
|
-
protocol:
|
|
29
|
-
|
|
90
|
+
protocol: proto,
|
|
91
|
+
sample_rate: rate,
|
|
92
|
+
source: opts[:source],
|
|
93
|
+
file: opts[:file],
|
|
94
|
+
demod: demod,
|
|
30
95
|
threshold: 6.0,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
when 'LF' then b[:duration_ms] > 100 ? 'reader-CW' : 'EM4x/HID-response'
|
|
34
|
-
when 'HF' then b[:duration_ms] > 5 ? 'ISO14443-REQA/frame' : 'ISO15693-slot'
|
|
35
|
-
else b[:duration_ms] > 20 ? 'reader-Query' : 'EPC-backscatter'
|
|
36
|
-
end
|
|
37
|
-
{ band: band, modulation: 'ASK/load-mod', classification: kind }
|
|
38
|
-
}
|
|
96
|
+
note: 'True-air I/Q path reports reader-carrier and tag-backscatter bursts by band.',
|
|
97
|
+
describe: proc { |b| { band: b[:band], modulation: 'ASK/load-mod', classification: b[:classification] } }
|
|
39
98
|
)
|
|
40
99
|
end
|
|
41
100
|
|
|
42
|
-
# Supported Method Parameters::
|
|
43
|
-
# PWN::SDR::Decoder::RFID.parse_line(line: 'UID: 04 A1 B2 C3 D4 E5 F6')
|
|
44
|
-
|
|
45
101
|
public_class_method def self.parse_line(opts = {})
|
|
46
102
|
line = opts[:line].to_s
|
|
47
103
|
out = { protocol: 'RFID' }
|
|
@@ -60,16 +116,14 @@ module PWN
|
|
|
60
116
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
61
117
|
end
|
|
62
118
|
|
|
63
|
-
# Display Usage for this Module
|
|
64
|
-
|
|
65
119
|
public_class_method def self.help
|
|
66
|
-
puts "USAGE (
|
|
120
|
+
puts "USAGE (true-air I/Q via PWN::FFI + detector fallback):
|
|
67
121
|
#{self}.decode(
|
|
68
|
-
freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
|
|
122
|
+
freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
|
|
123
|
+
source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
|
|
124
|
+
file: 'optional - .cu8/.cs16 capture'
|
|
69
125
|
)
|
|
70
126
|
|
|
71
|
-
#{self}.parse_line(line: 'UID: 04 A1 B2 C3 D4 E5 F6 SAK: 08 Mifare Classic 1K')
|
|
72
|
-
|
|
73
127
|
#{self}.authors
|
|
74
128
|
"
|
|
75
129
|
end
|
|
@@ -5,17 +5,69 @@ require 'json'
|
|
|
5
5
|
module PWN
|
|
6
6
|
module SDR
|
|
7
7
|
module Decoder
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# The upstream `rtl_433` binary carries ~250 device-specific protocol
|
|
12
|
-
# dissectors; re-implementing that library is out of scope. This
|
|
13
|
-
# module instead characterises OOK/ASK/FSK bursts natively (count,
|
|
14
|
-
# duration, gap, peak dBFS) — enough to fingerprint a keyfob press,
|
|
15
|
-
# a periodic weather-station beacon, or a TPMS chirp — without
|
|
16
|
-
# invoking any external binary. `parse_line` still accepts rtl_433
|
|
17
|
-
# `-F json` output for offline analysis.
|
|
8
|
+
# True-air + detector-fallback decoder for RTL433.
|
|
9
|
+
# Prefers PWN::FFI I/Q (RTL-SDR / ADALM-Pluto / HackRF / capture file)
|
|
10
|
+
# via Base.run_iq; degrades to Base.run_detector with no hardware.
|
|
18
11
|
module RTL433
|
|
12
|
+
# Streaming I/Q energy/burst demod for Base.run_iq.
|
|
13
|
+
class DemodIQ
|
|
14
|
+
def initialize(rate:, protocol:, modulation:, extra: {})
|
|
15
|
+
@rate = rate.to_f
|
|
16
|
+
@protocol = protocol
|
|
17
|
+
@modulation = modulation
|
|
18
|
+
@extra = extra
|
|
19
|
+
@floor = nil
|
|
20
|
+
@in_burst = false
|
|
21
|
+
@burst_t0 = nil
|
|
22
|
+
@peak = -200.0
|
|
23
|
+
@burst_n = 0
|
|
24
|
+
@threshold = (extra[:threshold] || 8.0).to_f
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def feed_iq(samples, rate: nil, &)
|
|
28
|
+
@rate = rate.to_f if rate
|
|
29
|
+
m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
|
|
30
|
+
hop = [(@rate / 1000.0).round, 1].max
|
|
31
|
+
i = 0
|
|
32
|
+
while i < m2.length
|
|
33
|
+
win = m2[i, hop]
|
|
34
|
+
break if win.nil? || win.empty?
|
|
35
|
+
|
|
36
|
+
ms = win.sum / win.length
|
|
37
|
+
lvl = ms.positive? ? (10.0 * Math.log10(ms)) : -120.0
|
|
38
|
+
@floor = @floor.nil? ? lvl : ((@floor * 0.98) + (lvl * 0.02))
|
|
39
|
+
delta = lvl - @floor
|
|
40
|
+
if delta >= @threshold
|
|
41
|
+
unless @in_burst
|
|
42
|
+
@in_burst = true
|
|
43
|
+
@burst_t0 = Time.now
|
|
44
|
+
@peak = lvl
|
|
45
|
+
end
|
|
46
|
+
@peak = lvl if lvl > @peak
|
|
47
|
+
elsif @in_burst
|
|
48
|
+
@in_burst = false
|
|
49
|
+
@burst_n += 1
|
|
50
|
+
dur_ms = ((Time.now - @burst_t0) * 1000).round
|
|
51
|
+
msg = {
|
|
52
|
+
protocol: @protocol, event: 'burst', source: 'iq',
|
|
53
|
+
burst_no: @burst_n, peak_dbfs: @peak.round(1),
|
|
54
|
+
floor_dbfs: @floor.round(1), delta_db: (@peak - @floor).round(1),
|
|
55
|
+
duration_ms: dur_ms, modulation: @modulation,
|
|
56
|
+
sample_rate: @rate.to_i
|
|
57
|
+
}.merge(@extra.except(:threshold))
|
|
58
|
+
# protocol-specific enrichment
|
|
59
|
+
msg.merge!(self.class.enrich(msg)) if self.class.respond_to?(:enrich)
|
|
60
|
+
msg[:summary] = format(
|
|
61
|
+
'%<p>s IQ-burst #%<n>d peak=%<pk>+.1f dBFS Δ=%<d>.1f dB dur=%<ms>d ms',
|
|
62
|
+
p: @protocol, n: @burst_n, pk: @peak, d: @peak - @floor, ms: dur_ms
|
|
63
|
+
)
|
|
64
|
+
yield msg if block_given?
|
|
65
|
+
end
|
|
66
|
+
i += hop
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
19
71
|
# Supported Method Parameters::
|
|
20
72
|
# PWN::SDR::Decoder::RTL433.decode(
|
|
21
73
|
# freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
|
|
@@ -23,24 +75,32 @@ module PWN
|
|
|
23
75
|
|
|
24
76
|
public_class_method def self.decode(opts = {})
|
|
25
77
|
freq_obj = opts[:freq_obj]
|
|
26
|
-
|
|
78
|
+
|
|
79
|
+
rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 250_000).to_i
|
|
80
|
+
proto = 'ISM-433'
|
|
81
|
+
demod = DemodIQ.new(
|
|
82
|
+
rate: rate, protocol: proto, modulation: 'OOK/ASK/FSK',
|
|
83
|
+
extra: { threshold: 10.0 }
|
|
84
|
+
)
|
|
85
|
+
PWN::SDR::Decoder::Base.run_iq(
|
|
27
86
|
freq_obj: freq_obj,
|
|
28
|
-
protocol:
|
|
29
|
-
|
|
87
|
+
protocol: proto,
|
|
88
|
+
sample_rate: rate,
|
|
89
|
+
source: opts[:source],
|
|
90
|
+
file: opts[:file],
|
|
91
|
+
demod: demod,
|
|
30
92
|
threshold: 10.0,
|
|
93
|
+
note: 'True-air I/Q path characterises OOK/FSK bursts; offline rtl_433 JSON via .parse_line.',
|
|
31
94
|
describe: proc { |b|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
95
|
+
{ modulation: 'OOK/ASK/FSK', classification: (if b[:duration_ms] < 20
|
|
96
|
+
'keyfob/OOK-short'
|
|
97
|
+
else
|
|
98
|
+
(b[:duration_ms] < 120 ? 'sensor/OOK-packet' : 'FSK-continuous')
|
|
99
|
+
end) }
|
|
37
100
|
}
|
|
38
101
|
)
|
|
39
102
|
end
|
|
40
103
|
|
|
41
|
-
# Supported Method Parameters::
|
|
42
|
-
# PWN::SDR::Decoder::RTL433.parse_line(line: '{"time":"...","model":"..."}')
|
|
43
|
-
|
|
44
104
|
public_class_method def self.parse_line(opts = {})
|
|
45
105
|
line = opts[:line].to_s
|
|
46
106
|
h = begin
|
|
@@ -67,16 +127,14 @@ module PWN
|
|
|
67
127
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
68
128
|
end
|
|
69
129
|
|
|
70
|
-
# Display Usage for this Module
|
|
71
|
-
|
|
72
130
|
public_class_method def self.help
|
|
73
|
-
puts "USAGE (
|
|
131
|
+
puts "USAGE (true-air I/Q via PWN::FFI + detector fallback):
|
|
74
132
|
#{self}.decode(
|
|
75
|
-
freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
|
|
133
|
+
freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
|
|
134
|
+
source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
|
|
135
|
+
file: 'optional - .cu8/.cs16 capture'
|
|
76
136
|
)
|
|
77
137
|
|
|
78
|
-
#{self}.parse_line(line: '{\"model\":\"Acurite-Tower\",\"id\":1234,...}')
|
|
79
|
-
|
|
80
138
|
#{self}.authors
|
|
81
139
|
"
|
|
82
140
|
end
|