pwn 0.5.532 → 0.5.534

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'tty-spinner'
6
+ require 'io/wait'
7
+
8
+ module PWN
9
+ module SDR
10
+ module Decoder
11
+ # Flex Decoder Module for Pagers
12
+ module Flex
13
+ # Supported Method Parameters::
14
+ # pocsag_resp = PWN::SDR::Decoder::Flex.decode(
15
+ # freq_obj: 'required - GQRX socket object returned from #connect method'
16
+ # )
17
+
18
+ public_class_method def self.decode(opts = {})
19
+ freq_obj = opts[:freq_obj]
20
+ gqrx_sock = freq_obj[:gqrx_sock]
21
+ udp_ip = freq_obj[:udp_ip] || '127.0.0.1'
22
+ udp_port = freq_obj[:udp_port] || 7355
23
+
24
+ freq_obj.delete(:gqrx_sock)
25
+
26
+ skip_freq_char = "\n"
27
+
28
+ puts JSON.pretty_generate(freq_obj)
29
+ puts 'Press [ENTER] to Continue to next frequency...'
30
+
31
+ # Spinner setup with dynamic terminal width awareness
32
+ spinner = TTY::Spinner.new(
33
+ '[:spinner] :status',
34
+ format: :arrow_pulse,
35
+ clear: true,
36
+ hide_cursor: true
37
+ )
38
+
39
+ spinner_overhead = 12
40
+ max_title_length = [TTY::Screen.width - spinner_overhead, 50].max
41
+
42
+ initial_title = "INFO: Decoding #{self.to_s.split('::').last} on udp://#{udp_ip}:#{udp_port} ..."
43
+ initial_title = initial_title[0...max_title_length] if initial_title.length > max_title_length
44
+ spinner.update(status: initial_title)
45
+ spinner.auto_spin
46
+
47
+ skip_freq = false
48
+
49
+ # === Replace netcat with PWN::Plugins::Sock.listen ===
50
+ udp_listener = PWN::SDR::GQRX.listen_udp(
51
+ udp_ip: udp_ip,
52
+ udp_port: udp_port
53
+ )
54
+
55
+ # Combined processing pipeline: sox → multimon-ng
56
+ decode_cmd = '
57
+ sox -t raw -e signed-integer -b 16 -r 48000 -c 1 - \
58
+ -t raw -e signed-integer -b 16 -r 22050 -c 1 - | \
59
+ multimon-ng -t raw \
60
+ -a FLEX \
61
+ -a FLEX_NEXT \
62
+ -
63
+ '
64
+
65
+ mm_stdin, mm_stdout, mm_stderr, mm_wait_thr = Open3.popen3(decode_cmd)
66
+
67
+ current_title = 'Waiting for data frames...'
68
+
69
+ # Thread: read from UDP listener and feed to sox|multimon pipeline
70
+ receiver_thread = Thread.new do
71
+ begin
72
+ loop do
73
+ data, _sender = udp_listener.recv(4096)
74
+ next unless data.to_s.bytesize.positive?
75
+
76
+ mm_stdin.write(data)
77
+ mm_stdin.flush rescue nil
78
+ end
79
+ rescue IOError, Errno::EPIPE, EOFError, Errno::ECONNRESET
80
+ # normal exit path when shutting down
81
+ end
82
+ end
83
+
84
+ # Thread: read decoded output from multimon-ng and display it
85
+ decoder_thread = Thread.new do
86
+ # buffer = +''
87
+ buffer = ''
88
+
89
+ valid_types = %w[ALN BIN HEX NUM TON TONE UNK]
90
+ loop do
91
+ begin
92
+ chunk = mm_stdout.readpartial(4096)
93
+ # buffer << chunk
94
+ buffer = "#{buffer}#{chunk}"
95
+
96
+ while (line = buffer.slice!(/^.*\n/))
97
+ line = line.chomp
98
+ next if line.empty? || !line.start_with?('FLEX')
99
+
100
+ decoded_at = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
101
+ dec_msg = { decoded_at: decoded_at }
102
+ dec_msg[:raw_inspected] = line.inspect
103
+
104
+ protocol = line[0..8]
105
+ protocol = 'FLEX' unless protocol == 'FLEX_NEXT'
106
+ dec_msg[:protocol] = protocol
107
+
108
+ # ────────────────────────────── Detect format ──────────────────────────────
109
+ # Sometimes Flex is space delimited, sometimes pipe delimited
110
+ # FLEX_NEXT appears to always be pipe delimited
111
+
112
+ delimiter = '|'
113
+ space_delim = false
114
+ if line.start_with?('FLEX: ')
115
+ delimiter = ' '
116
+ space_delim = true
117
+ end
118
+
119
+ flex_pipe_delim = false
120
+ flex_pipe_delim = true if line.start_with?('FLEX|')
121
+
122
+ parts = line.split(delimiter)
123
+
124
+ # protocol index already used
125
+ idx_already_used = [0]
126
+ target_parts_idx = 1
127
+ target_parts_idx += 1 if flex_pipe_delim
128
+ target_parts_idx += 2 if space_delim
129
+ dec_msg[:speed] = parts[target_parts_idx] if parts[target_parts_idx]
130
+ idx_already_used.push(target_parts_idx)
131
+
132
+ target_parts_idx += 2
133
+ dec_msg[:capcode] = parts[target_parts_idx] if parts[target_parts_idx]
134
+ idx_already_used.push(target_parts_idx)
135
+
136
+ target_parts_idx -= 1
137
+ dec_msg[:capcode_loc] = parts[target_parts_idx] if parts[target_parts_idx]
138
+ idx_already_used.push(target_parts_idx)
139
+
140
+ while target_parts_idx < parts.size
141
+ if idx_already_used.include?(target_parts_idx)
142
+ target_parts_idx += 1
143
+ next
144
+ end
145
+
146
+ key = parts[target_parts_idx]
147
+ key = 'long_sequence_number' if key == 'LS'
148
+
149
+ if key && valid_types.include?(key)
150
+ dec_msg[:type] = key
151
+
152
+ dec_msg[:type_desc] = case key
153
+ when 'ALN'
154
+ 'Human-readable text'
155
+ when 'BIN'
156
+ 'Binary / data payload (typically 32 bit words)'
157
+ when 'HEX'
158
+ 'Raw hex representation of data'
159
+ when 'NUM'
160
+ 'Numbers only'
161
+ when 'TON', 'TONE'
162
+ 'Just alert tone, no message'
163
+ when 'UNK'
164
+ 'Decoded but type unknown / unsupported format'
165
+ end
166
+
167
+ target_parts_idx += 1
168
+ payload_parts = parts[target_parts_idx..]
169
+ dec_msg[:type_payload] = payload_parts.join(delimiter)
170
+
171
+ break
172
+ else
173
+ target_parts_idx += 1
174
+ dec_msg[key] = parts[target_parts_idx] if parts[target_parts_idx]
175
+ end
176
+
177
+ idx_already_used.push(target_parts_idx)
178
+ target_parts_idx += 1
179
+ end
180
+
181
+ final_msg = freq_obj.merge(dec_msg)
182
+ puts JSON.pretty_generate(final_msg)
183
+ # TODO: Append dec_msg to a log file in a better way
184
+ flex_log_file = "/tmp/flex_decoder_#{Time.now.strftime('%Y%m%d')}.log"
185
+ File.open(flex_log_file, 'a') do |f|
186
+ f.puts("#{JSON.generate(final_msg)},")
187
+ end
188
+ end
189
+ rescue EOFError, IOError
190
+ break
191
+ end
192
+ end
193
+ end
194
+
195
+ loop do
196
+ spinner.update(status: current_title)
197
+
198
+ # Non-blocking ENTER check to exit gracefully
199
+ next unless $stdin.wait_readable(0)
200
+
201
+ begin
202
+ char = $stdin.read_nonblock(1)
203
+ next unless char == skip_freq_char
204
+
205
+ skip_freq = true
206
+ puts "\n[!] ENTER pressed → stopping decoder..."
207
+
208
+ break
209
+ rescue IO::WaitReadable, EOFError
210
+ # ignore
211
+ end
212
+
213
+ break if skip_freq
214
+ end
215
+
216
+ spinner.success('Decoding stopped')
217
+ rescue StandardError => e
218
+ spinner.error("Decoding failed: #{e.message}") if defined?(spinner)
219
+ raise
220
+ ensure
221
+ # Cleanup
222
+ [receiver_thread, decoder_thread].each do |thread|
223
+ thread.kill if thread.alive?
224
+ end
225
+
226
+ [mm_stdin, mm_stdout, mm_stderr].each { |io| io.close rescue nil }
227
+
228
+ mm_wait_thr&.value rescue nil
229
+
230
+ PWN::SDR::GQRX.disconnect_udp(udp_listener: udp_listener) if defined?(udp_listener) && udp_listener
231
+
232
+ spinner.stop if defined?(spinner) && spinner
233
+ end
234
+
235
+ # Author(s):: 0day Inc. <support@0dayinc.com>
236
+
237
+ public_class_method def self.authors
238
+ "AUTHOR(S):
239
+ 0day Inc. <support@0dayinc.com>
240
+ "
241
+ end
242
+
243
+ # Display Usage for this Module
244
+
245
+ public_class_method def self.help
246
+ puts "USAGE:
247
+ #{self}.decode(
248
+ freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq method'
249
+ )
250
+
251
+ #{self}.authors
252
+ "
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'open3'
4
5
  require 'tty-spinner'
6
+ require 'io/wait'
5
7
 
6
8
  module PWN
7
9
  module SDR
@@ -14,96 +16,219 @@ module PWN
14
16
  # )
15
17
 
16
18
  public_class_method def self.decode(opts = {})
17
- puts 'IM HERE'
18
19
  freq_obj = opts[:freq_obj]
19
20
  gqrx_sock = freq_obj[:gqrx_sock]
20
- record_path = freq_obj[:record_path]
21
- freq_obj = freq_obj.dup
21
+ udp_ip = freq_obj[:udp_ip] || '127.0.0.1'
22
+ udp_port = freq_obj[:udp_port] || 7355
23
+
22
24
  freq_obj.delete(:gqrx_sock)
25
+
23
26
  skip_freq_char = "\n"
24
- puts JSON.pretty_generate(freq_obj)
25
- puts "\n*** Pager POCSAG Decoder ***"
26
- puts 'Press [ENTER] to continue...'
27
-
28
- # Toggle POCSAG off and on to reset the decoder
29
- PWN::SDR::GQRX.cmd(
30
- gqrx_sock: gqrx_sock,
31
- cmd: 'U RECORD 0',
32
- resp_ok: 'RPRT 0'
33
- )
34
27
 
35
- PWN::SDR::GQRX.cmd(
36
- gqrx_sock: gqrx_sock,
37
- cmd: 'U RECORD 1',
38
- resp_ok: 'RPRT 0'
39
- )
28
+ puts JSON.pretty_generate(freq_obj)
29
+ puts 'Press [ENTER] to Continue to next frequency...'
40
30
 
41
31
  # Spinner setup with dynamic terminal width awareness
42
32
  spinner = TTY::Spinner.new(
43
- '[:spinner] :decoding',
33
+ '[:spinner] :status',
44
34
  format: :arrow_pulse,
45
35
  clear: true,
46
36
  hide_cursor: true
47
37
  )
48
38
 
49
- record_header_size = 44
50
- wav_header = File.binread(record_path, record_header_size)
51
- raise 'ERROR: WAV file header is invalid!' unless wav_header[0, 4] == 'RIFF' && wav_header[8, 4] == 'WAVE'
52
-
53
- bytes_read = wav_header_size
54
-
55
- # Conservative overhead for spinner animation, colors, and spacing
56
39
  spinner_overhead = 12
57
40
  max_title_length = [TTY::Screen.width - spinner_overhead, 50].max
58
41
 
59
- initial_title = 'INFO: Decoding Pager POCSAG data...'
42
+ initial_title = "INFO: Decoding #{self.to_s.split('::').last} on udp://#{udp_ip}:#{udp_port} ..."
60
43
  initial_title = initial_title[0...max_title_length] if initial_title.length > max_title_length
61
- spinner.update(title: initial_title)
44
+ spinner.update(status: initial_title)
62
45
  spinner.auto_spin
63
46
 
64
- last_resp = {}
47
+ skip_freq = false
65
48
 
66
- loop do
67
- current_wav_size = File.size?(record_path) || 0
68
- pocsag_resp = {}
49
+ # === Replace netcat with PWN::Plugins::Sock.listen ===
50
+ udp_listener = PWN::SDR::GQRX.listen_udp(
51
+ udp_ip: udp_ip,
52
+ udp_port: udp_port
53
+ )
54
+
55
+ # Combined processing pipeline: sox → multimon-ng
56
+ decode_cmd = '
57
+ sox -t raw -e signed-integer -b 16 -r 48000 -c 1 - \
58
+ -t raw -e signed-integer -b 16 -r 22050 -c 1 - | \
59
+ multimon-ng -t raw \
60
+ -a POCSAG512 \
61
+ -a POCSAG1200 \
62
+ -a POCSAG2400 \
63
+ -
64
+ '
65
+
66
+ mm_stdin, mm_stdout, mm_stderr, mm_wait_thr = Open3.popen3(decode_cmd)
69
67
 
70
- # Only update when we have valid new data
71
- if current_wav_size > bytes_read
72
- new_bytes = current_wav_size - bytes_read
73
- # Ensure full I/Q pairs (8 bytes each)
74
- new_bytes -= new_bytes % 8
75
- data = File.binread(record_path, new_bytes, bytes_read)
68
+ current_title = 'Waiting for data frames...'
76
69
 
77
- msg = 'DECODED DATA'
70
+ # Thread: read from UDP listener and feed to sox|multimon pipeline
71
+ receiver_thread = Thread.new do
72
+ begin
73
+ loop do
74
+ data, _sender = udp_listener.recv(4096)
75
+ next unless data.to_s.bytesize.positive?
78
76
 
79
- spinner.update(decoding: msg)
80
- last_resp = pocsag_resp.dup
77
+ mm_stdin.write(data)
78
+ mm_stdin.flush rescue nil
79
+ end
80
+ rescue IOError, Errno::EPIPE, EOFError, Errno::ECONNRESET
81
+ # normal exit path when shutting down
81
82
  end
83
+ end
84
+
85
+ # Thread: read decoded output from multimon-ng and display it
86
+ decoder_thread = Thread.new do
87
+ # buffer = +''
88
+ buffer = ''
82
89
 
83
- # Non-blocking check for ENTER key to exit
84
- if $stdin.wait_readable(0)
90
+ valid_types = %w[ALN BIN HEX NUM TON TONE UNK]
91
+ loop do
85
92
  begin
86
- char = $stdin.read_nonblock(1)
87
- break if char == skip_freq_char
88
- rescue IO::WaitReadable, EOFError
89
- # No-op
93
+ chunk = mm_stdout.readpartial(4096)
94
+ # buffer << chunk
95
+ buffer = "#{buffer}#{chunk}"
96
+
97
+ while (line = buffer.slice!(/^.*\n/))
98
+ line = line.chomp
99
+ next if line.empty? || !line.start_with?('POCSAG')
100
+
101
+ decoded_at = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
102
+ dec_msg = { decoded_at: decoded_at }
103
+ dec_msg[:raw_inspected] = line.inspect
104
+
105
+ protocol = line[0..8]
106
+ protocol = line[0..9] unless protocol == 'POCSAG512'
107
+ dec_msg[:protocol] = protocol
108
+
109
+ # ────────────────────────────── Detect format ──────────────────────────────
110
+ # Sometimes POCSAG is space delimited, sometimes pipe delimited
111
+ # FLEX_NEXT appears to always be pipe delimited
112
+
113
+ delimiter = '|'
114
+ space_delim = false
115
+ if line.start_with?('FLEX: ')
116
+ delimiter = ' '
117
+ space_delim = true
118
+ end
119
+
120
+ flex_pipe_delim = false
121
+ flex_pipe_delim = true if line.start_with?('FLEX|')
122
+
123
+ parts = line.split(delimiter)
124
+
125
+ # protocol index already used
126
+ idx_already_used = [0]
127
+ target_parts_idx = 1
128
+ target_parts_idx += 1 if flex_pipe_delim
129
+ target_parts_idx += 2 if space_delim
130
+ dec_msg[:speed] = parts[target_parts_idx] if parts[target_parts_idx]
131
+ idx_already_used.push(target_parts_idx)
132
+
133
+ target_parts_idx += 2
134
+ dec_msg[:capcode] = parts[target_parts_idx] if parts[target_parts_idx]
135
+ idx_already_used.push(target_parts_idx)
136
+
137
+ target_parts_idx -= 1
138
+ dec_msg[:capcode_loc] = parts[target_parts_idx] if parts[target_parts_idx]
139
+ idx_already_used.push(target_parts_idx)
140
+
141
+ while target_parts_idx < parts.size
142
+ if idx_already_used.include?(target_parts_idx)
143
+ target_parts_idx += 1
144
+ next
145
+ end
146
+
147
+ key = parts[target_parts_idx]
148
+ key = 'long_sequence_number' if key == 'LS'
149
+
150
+ if key && valid_types.include?(key)
151
+ dec_msg[:type] = key
152
+
153
+ dec_msg[:type_desc] = case key
154
+ when 'ALN'
155
+ 'Human-readable text'
156
+ when 'BIN'
157
+ 'Binary / data payload (typically 32 bit words)'
158
+ when 'HEX'
159
+ 'Raw hex representation of data'
160
+ when 'NUM'
161
+ 'Numbers only'
162
+ when 'TON', 'TONE'
163
+ 'Just alert tone, no message'
164
+ when 'UNK'
165
+ 'Decoded but type unknown / unsupported format'
166
+ end
167
+
168
+ target_parts_idx += 1
169
+ payload_parts = parts[target_parts_idx..]
170
+ dec_msg[:type_payload] = payload_parts.join(delimiter)
171
+
172
+ break
173
+ else
174
+ target_parts_idx += 1
175
+ dec_msg[key] = parts[target_parts_idx] if parts[target_parts_idx]
176
+ end
177
+
178
+ idx_already_used.push(target_parts_idx)
179
+ target_parts_idx += 1
180
+ end
181
+
182
+ final_msg = freq_obj.merge(dec_msg)
183
+ puts JSON.pretty_generate(final_msg)
184
+ # TODO: Append dec_msg to a log file in a better way
185
+ flex_log_file = "/tmp/flex_decoder_#{Time.now.strftime('%Y%m%d')}.log"
186
+ File.open(flex_log_file, 'a') do |f|
187
+ f.puts("#{JSON.generate(final_msg)},")
188
+ end
189
+ end
190
+ rescue EOFError, IOError
191
+ break
90
192
  end
91
193
  end
194
+ end
92
195
 
93
- sleep 0.01
196
+ loop do
197
+ spinner.update(status: current_title)
198
+
199
+ # Non-blocking ENTER check to exit gracefully
200
+ next unless $stdin.wait_readable(0)
201
+
202
+ begin
203
+ char = $stdin.read_nonblock(1)
204
+ next unless char == skip_freq_char
205
+
206
+ skip_freq = true
207
+ puts "\n[!] ENTER pressed → stopping decoder..."
208
+
209
+ break
210
+ rescue IO::WaitReadable, EOFError
211
+ # ignore
212
+ end
213
+
214
+ break if skip_freq
94
215
  end
216
+
217
+ spinner.success('Decoding stopped')
95
218
  rescue StandardError => e
96
- spinner.error('Decoding failed') if defined?(spinner)
97
- raise e
219
+ spinner.error("Decoding failed: #{e.message}") if defined?(spinner)
220
+ raise
98
221
  ensure
99
- # Toggle POCSAG off and on to reset the decoder
100
- PWN::SDR::GQRX.cmd(
101
- gqrx_sock: gqrx_sock,
102
- cmd: 'U RECORD 0',
103
- resp_ok: 'RPRT 0'
104
- )
222
+ # Cleanup
223
+ [receiver_thread, decoder_thread].each do |thread|
224
+ thread.kill if thread.alive?
225
+ end
226
+
227
+ [mm_stdin, mm_stdout, mm_stderr].each { |io| io.close rescue nil }
228
+
229
+ mm_wait_thr&.value rescue nil
105
230
 
106
- File.unlink(record_path)
231
+ PWN::SDR::GQRX.disconnect_udp(udp_listener: udp_listener) if defined?(udp_listener) && udp_listener
107
232
 
108
233
  spinner.stop if defined?(spinner) && spinner
109
234
  end
@@ -17,12 +17,12 @@ module PWN
17
17
  freq_obj = opts[:freq_obj]
18
18
  gqrx_sock = freq_obj[:gqrx_sock]
19
19
 
20
- freq_obj = freq_obj.dup
20
+ # freq_obj = freq_obj.dup
21
21
  freq_obj.delete(:gqrx_sock)
22
22
  skip_freq_char = "\n"
23
23
  puts JSON.pretty_generate(freq_obj)
24
24
  puts "\n*** FM Radio RDS Decoder ***"
25
- puts 'Press [ENTER] to continue...'
25
+ puts 'Press [ENTER] to continue to next frequency...'
26
26
 
27
27
  # Toggle RDS off and on to reset the decoder
28
28
  PWN::SDR::GQRX.cmd(
@@ -39,7 +39,7 @@ module PWN
39
39
 
40
40
  # Spinner setup with dynamic terminal width awareness
41
41
  spinner = TTY::Spinner.new(
42
- '[:spinner] :decoding',
42
+ '[:spinner] :status',
43
43
  format: :arrow_pulse,
44
44
  clear: true,
45
45
  hide_cursor: true
@@ -88,7 +88,7 @@ module PWN
88
88
  rt_display = "#{rt_display[0...available_for_term]}..." if rt_display.length > available_for_term
89
89
 
90
90
  msg = "#{prefix}#{rt_display}"
91
- spinner.update(decoding: msg)
91
+ spinner.update(status: msg)
92
92
  last_resp = rds_resp.dup
93
93
  end
94
94
 
@@ -7,6 +7,7 @@ module PWN
7
7
  module SDR
8
8
  # Deocder Module for SDR signals.
9
9
  module Decoder
10
+ autoload :Flex, 'pwn/sdr/decoder/flex'
10
11
  autoload :GSM, 'pwn/sdr/decoder/gsm'
11
12
  autoload :POCSAG, 'pwn/sdr/decoder/pocsag'
12
13
  autoload :RDS, 'pwn/sdr/decoder/rds'
@@ -451,6 +451,17 @@ module PWN
451
451
  bandwidth: '25.000',
452
452
  precision: 4
453
453
  },
454
+ pager_flex: {
455
+ ranges: [
456
+ # 900 MHz exclusive paging band (mostly FLEX, but some legacy POCSAG remains)
457
+ # Primary nationwide commercial paging band
458
+ { start_freq: '929.000.000', target_freq: '932.000.000' }
459
+ ],
460
+ demodulator_mode: :FM,
461
+ bandwidth: '20.000',
462
+ precision: 4,
463
+ decoder: :flex
464
+ },
454
465
  pager_pocsag: {
455
466
  ranges: [
456
467
  # High-band VHF paging — most common for public-safety POCSAG today
@@ -463,14 +474,10 @@ module PWN
463
474
  # UHF high — very common for local POCSAG (incl. 450–470 & 467.xxx for on-site)
464
475
  { start_freq: '440.000.000', target_freq: '470.000.000' },
465
476
  # Classic UHF paging allocation (454/459 MHz pairs — many reallocated)
466
- { start_freq: '454.000.000', target_freq: '460.000.000' },
467
-
468
- # 900 MHz exclusive paging band (mostly FLEX, but some legacy POCSAG remains)
469
- # Primary nationwide commercial paging band
470
- { start_freq: '929.000.000', target_freq: '932.000.000' }
477
+ { start_freq: '454.000.000', target_freq: '460.000.000' }
471
478
  ],
472
479
  demodulator_mode: :FM,
473
- bandwidth: '25.000',
480
+ bandwidth: '12.500',
474
481
  precision: 4,
475
482
  decoder: :pocsag
476
483
  },