pwn 0.5.613 → 0.5.617
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 +10 -10
- data/README.md +117 -17
- data/bin/pwn +47 -2
- data/bin/pwn_gqrx_scanner +75 -18
- data/documentation/AI-Integration.md +36 -0
- data/documentation/BurpSuite.md +26 -0
- data/documentation/Contributing.md +31 -0
- data/documentation/Diagrams.md +140 -0
- data/documentation/Drivers.md +25 -0
- data/documentation/General-PWN-Usage.md +60 -0
- data/documentation/Home.md +78 -0
- data/documentation/How-PWN-Works.md +62 -0
- data/documentation/Installation.md +75 -0
- data/documentation/NmapIt.md +18 -0
- data/documentation/Plugins.md +59 -0
- data/documentation/Reporting.md +28 -0
- data/documentation/SAST.md +32 -0
- data/documentation/Skills-Memory-Learning.md +44 -0
- data/documentation/Transparent-Browser.md +32 -0
- data/documentation/Troubleshooting.md +38 -0
- data/documentation/What-is-PWN.md +53 -0
- data/documentation/Why-PWN.md +48 -0
- data/documentation/diagrams/ai-integration-tool-calling.svg +172 -0
- data/documentation/diagrams/build.sh +20 -0
- data/documentation/diagrams/burp-vs-zap-preference.svg +69 -0
- data/documentation/diagrams/code-scanning-sast.svg +172 -0
- data/documentation/diagrams/dot/_THEME.md +27 -0
- data/documentation/diagrams/dot/agent-tool-registry.dot +59 -0
- data/documentation/diagrams/dot/ai-integration-tool-calling.dot +50 -0
- data/documentation/diagrams/dot/aws-cloud-security.dot +48 -0
- data/documentation/diagrams/dot/burp-vs-zap-preference.dot +26 -0
- data/documentation/diagrams/dot/code-scanning-sast.dot +49 -0
- data/documentation/diagrams/dot/cron-scheduling.dot +40 -0
- data/documentation/diagrams/dot/driver-framework.dot +39 -0
- data/documentation/diagrams/dot/extrospection-world-awareness.dot +55 -0
- data/documentation/diagrams/dot/fuzzing-workflow.dot +47 -0
- data/documentation/diagrams/dot/history-to-drivers.dot +34 -0
- data/documentation/diagrams/dot/memory-skills-detailed.dot +60 -0
- data/documentation/diagrams/dot/network-infra-testing.dot +54 -0
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +115 -0
- data/documentation/diagrams/dot/penetration-testing-workflow.dot +60 -0
- data/documentation/diagrams/dot/plugin-ecosystem.dot +45 -0
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +98 -0
- data/documentation/diagrams/dot/pwn-repl-prototyping.dot +56 -0
- data/documentation/diagrams/dot/reporting-pipeline.dot +46 -0
- data/documentation/diagrams/dot/reverse-engineering-flow.dot +43 -0
- data/documentation/diagrams/dot/sdr-radio-flow.dot +45 -0
- data/documentation/diagrams/dot/sessions-cron-automation.dot +44 -0
- data/documentation/diagrams/dot/swarm-multi-agent.dot +70 -0
- data/documentation/diagrams/dot/web-application-testing.dot +49 -0
- data/documentation/diagrams/dot/zero-day-research-flow.dot +49 -0
- data/documentation/diagrams/driver-framework.svg +111 -0
- data/documentation/diagrams/fuzzing-workflow.svg +160 -0
- data/documentation/diagrams/history-to-drivers.svg +119 -0
- data/documentation/diagrams/memory-skills-detailed.svg +202 -0
- data/documentation/diagrams/network-infra-testing.svg +183 -0
- data/documentation/diagrams/overall-pwn-architecture.svg +405 -0
- data/documentation/diagrams/penetration-testing-workflow.svg +201 -0
- data/documentation/diagrams/plugin-ecosystem.svg +276 -0
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +306 -0
- data/documentation/diagrams/pwn-repl-prototyping.svg +168 -0
- data/documentation/diagrams/reporting-pipeline.svg +151 -0
- data/documentation/diagrams/reverse-engineering-flow.svg +134 -0
- data/documentation/diagrams/sessions-cron-automation.svg +136 -0
- data/documentation/diagrams/web-application-testing.svg +173 -0
- data/documentation/diagrams/zero-day-research-flow.svg +153 -0
- data/documentation/pwn-REPL.md +33 -0
- data/documentation/pwn-ai-Agent.md +43 -0
- data/git_commit.sh +1 -1
- 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 +618 -0
- data/lib/pwn/ai/agent/gqrx.rb +2 -2
- data/lib/pwn/ai/agent/hacker_one.rb +1 -1
- data/lib/pwn/ai/agent/introspection.rb +91 -0
- data/lib/pwn/ai/agent/learning.rb +379 -0
- data/lib/pwn/ai/agent/loop.rb +33 -3
- data/lib/pwn/ai/agent/metrics.rb +155 -0
- data/lib/pwn/ai/agent/prompt_builder.rb +32 -3
- data/lib/pwn/ai/agent/sast.rb +1 -1
- data/lib/pwn/ai/agent/swarm.rb +437 -0
- data/lib/pwn/ai/agent/tools/cron.rb +163 -0
- data/lib/pwn/ai/agent/tools/extrospection.rb +280 -0
- data/lib/pwn/ai/agent/tools/learning.rb +221 -0
- data/lib/pwn/ai/agent/tools/memory.rb +27 -0
- data/lib/pwn/ai/agent/tools/metrics.rb +61 -0
- data/lib/pwn/ai/agent/tools/sessions.rb +139 -0
- data/lib/pwn/ai/agent/tools/skills.rb +124 -8
- data/lib/pwn/ai/agent/tools/swarm.rb +229 -0
- 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 +5 -0
- data/lib/pwn/ai/anthropic.rb +19 -4
- data/lib/pwn/ai/grok.rb +249 -106
- data/lib/pwn/ai.rb +0 -1
- data/lib/pwn/config.rb +96 -36
- data/lib/pwn/cron.rb +16 -7
- data/lib/pwn/plugins/monkey_patch.rb +2 -2
- data/lib/pwn/plugins/repl.rb +158 -284
- data/lib/pwn/sdr/decoder/base.rb +251 -0
- data/lib/pwn/sdr/decoder/gsm.rb +84 -185
- data/lib/pwn/sdr/decoder/pocsag.rb +62 -217
- data/lib/pwn/sdr/decoder.rb +1 -0
- data/lib/pwn/sdr/gqrx.rb +446 -65
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/extrospection_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/introspection_spec.rb +15 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +68 -0
- data/spec/lib/pwn/ai/agent/metrics_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +34 -0
- data/spec/lib/pwn/ai/agent/tools/cron_spec.rb +40 -0
- data/spec/lib/pwn/ai/agent/tools/extrospection_spec.rb +40 -0
- data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +30 -0
- data/spec/lib/pwn/ai/agent/tools/metrics_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/tools/sessions_spec.rb +35 -0
- data/spec/lib/pwn/ai/agent/tools/swarm_spec.rb +17 -0
- data/spec/lib/pwn/{ai/introspection_spec.rb → sdr/decoder/base_spec.rb} +3 -3
- data/third_party/pwn_rdoc.jsonl +178 -33
- metadata +110 -36
- data/documentation/PWN.png +0 -0
- data/documentation/PWN_Contributors_and_Users.png +0 -0
- data/documentation/fax-spectrogram.png +0 -0
- data/documentation/fax-waveform.png +0 -0
- data/documentation/frozen_string_literal_files_changed_from_false_to_true.txt +0 -54
- data/documentation/lifecycle_authz_replay.example.yaml +0 -27
- data/documentation/pwn_android_war_dialer_session.png +0 -0
- data/documentation/pwn_install.png +0 -0
- data/documentation/pwn_wallpaper.jpg +0 -0
- data/documentation/ringing-spectrogram.png +0 -0
- data/documentation/ringing-waveform.png +0 -0
- data/documentation/vulnerability_report_template.md +0 -37
- data/lib/pwn/ai/introspection.rb +0 -76
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'tty-spinner'
|
|
6
|
+
require 'tty-screen'
|
|
7
|
+
require 'io/wait'
|
|
8
|
+
|
|
9
|
+
module PWN
|
|
10
|
+
module SDR
|
|
11
|
+
module Decoder
|
|
12
|
+
# Shared pipeline plumbing for every PWN::SDR::Decoder::* module.
|
|
13
|
+
#
|
|
14
|
+
# Encapsulates the pattern established by PWN::SDR::Decoder::Flex and
|
|
15
|
+
# PWN::SDR::Decoder::RDS:
|
|
16
|
+
#
|
|
17
|
+
# 1. Bind to the GQRX UDP audio stream (48 kHz s16le mono).
|
|
18
|
+
# 2. Feed it through `sox` (resample to 22 050 Hz) into an external
|
|
19
|
+
# demodulator (multimon-ng, rtl_433, acarsdec, dsd, ...).
|
|
20
|
+
# 3. Read decoded lines back on a background thread, hand each to a
|
|
21
|
+
# caller-supplied parser Proc, merge with freq_obj, JSON-log it.
|
|
22
|
+
# 4. Show a TTY::Spinner status line and exit cleanly on [ENTER].
|
|
23
|
+
#
|
|
24
|
+
# Decoders whose external tool needs raw I/Q direct from the SDR (ADSB,
|
|
25
|
+
# GSM, LoRa, WiFi, ZigBee, ...) can instead pass `direct_cmd:` — Base
|
|
26
|
+
# will spawn it stand-alone (no UDP → sox bridge) and still handle the
|
|
27
|
+
# spinner / stdout parser / logging / [ENTER]-to-skip loop uniformly.
|
|
28
|
+
module Base
|
|
29
|
+
# Supported Method Parameters::
|
|
30
|
+
# PWN::SDR::Decoder::Base.run_pipeline(
|
|
31
|
+
# freq_obj: 'required - freq_obj Hash from PWN::SDR::GQRX.init_freq',
|
|
32
|
+
# protocol: 'required - short name used in banner / log filename',
|
|
33
|
+
# decode_cmd: 'optional - shell pipeline reading 22 050 Hz s16le on stdin',
|
|
34
|
+
# direct_cmd: 'optional - stand-alone shell cmd (owns the SDR itself)',
|
|
35
|
+
# line_match: 'optional - Regexp/String a stdout line must match',
|
|
36
|
+
# parser: 'optional - Proc.new { |line| Hash } to structure a line',
|
|
37
|
+
# required_bins: 'optional - Array of executables that must exist on PATH',
|
|
38
|
+
# resample_hz: 'optional - sox output rate for decode_cmd (default 22050)'
|
|
39
|
+
# )
|
|
40
|
+
|
|
41
|
+
public_class_method def self.run_pipeline(opts = {})
|
|
42
|
+
freq_obj = opts[:freq_obj]
|
|
43
|
+
protocol = opts[:protocol] || 'SIGNAL'
|
|
44
|
+
decode_cmd = opts[:decode_cmd]
|
|
45
|
+
direct_cmd = opts[:direct_cmd]
|
|
46
|
+
line_match = opts[:line_match]
|
|
47
|
+
parser = opts[:parser]
|
|
48
|
+
required_bins = Array(opts[:required_bins])
|
|
49
|
+
resample_hz = opts[:resample_hz] || 22_050
|
|
50
|
+
|
|
51
|
+
raise 'ERROR: :freq_obj is required' unless freq_obj.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
gqrx_sock = freq_obj[:gqrx_sock]
|
|
54
|
+
udp_ip = freq_obj[:udp_ip] || '127.0.0.1'
|
|
55
|
+
udp_port = freq_obj[:udp_port] || 7355
|
|
56
|
+
freq_obj = freq_obj.dup
|
|
57
|
+
freq_obj.delete(:gqrx_sock)
|
|
58
|
+
freq_obj.delete(:decoder_module)
|
|
59
|
+
|
|
60
|
+
skip_freq_char = "\n"
|
|
61
|
+
|
|
62
|
+
puts JSON.pretty_generate(freq_obj)
|
|
63
|
+
puts "\n*** #{protocol} Decoder ***"
|
|
64
|
+
puts 'Press [ENTER] to continue to next frequency...'
|
|
65
|
+
|
|
66
|
+
missing = required_bins.reject { |b| bin_available?(bin: b) }
|
|
67
|
+
unless missing.empty?
|
|
68
|
+
puts "[!] Missing required executable(s): #{missing.join(', ')}"
|
|
69
|
+
puts ' Install them and re-run, or press [ENTER] to skip.'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
spinner = TTY::Spinner.new(
|
|
73
|
+
'[:spinner] :status',
|
|
74
|
+
format: :arrow_pulse,
|
|
75
|
+
clear: true,
|
|
76
|
+
hide_cursor: true
|
|
77
|
+
)
|
|
78
|
+
spinner_overhead = 12
|
|
79
|
+
max_title_length = [TTY::Screen.width - spinner_overhead, 50].max
|
|
80
|
+
banner = "INFO: Decoding #{protocol} on udp://#{udp_ip}:#{udp_port} ..."
|
|
81
|
+
banner = "INFO: Decoding #{protocol} via `#{direct_cmd.to_s.split.first}` ..." if direct_cmd
|
|
82
|
+
banner = banner[0...max_title_length] if banner.length > max_title_length
|
|
83
|
+
spinner.update(status: banner)
|
|
84
|
+
spinner.auto_spin
|
|
85
|
+
|
|
86
|
+
log_file = "/tmp/#{protocol.downcase.gsub(/[^a-z0-9]+/, '_')}_decoder_#{Time.now.strftime('%Y%m%d')}.log"
|
|
87
|
+
|
|
88
|
+
udp_listener = nil
|
|
89
|
+
receiver_thread = nil
|
|
90
|
+
mm_stdin = mm_stdout = mm_stderr = mm_wait_thr = nil
|
|
91
|
+
|
|
92
|
+
if direct_cmd && missing.empty?
|
|
93
|
+
mm_stdin, mm_stdout, mm_stderr, mm_wait_thr = Open3.popen3(direct_cmd)
|
|
94
|
+
elsif decode_cmd && missing.empty?
|
|
95
|
+
udp_listener = PWN::SDR::GQRX.listen_udp(udp_ip: udp_ip, udp_port: udp_port)
|
|
96
|
+
|
|
97
|
+
full_cmd = 'sox -t raw -e signed-integer -b 16 -r 48000 -c 1 - ' \
|
|
98
|
+
"-t raw -e signed-integer -b 16 -r #{resample_hz} -c 1 - | #{decode_cmd}"
|
|
99
|
+
mm_stdin, mm_stdout, mm_stderr, mm_wait_thr = Open3.popen3(full_cmd)
|
|
100
|
+
|
|
101
|
+
receiver_thread = Thread.new do
|
|
102
|
+
loop do
|
|
103
|
+
data, = udp_listener.recv(4096)
|
|
104
|
+
next unless data.to_s.bytesize.positive?
|
|
105
|
+
|
|
106
|
+
mm_stdin.write(data)
|
|
107
|
+
begin
|
|
108
|
+
mm_stdin.flush
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
current_title = 'Waiting for data frames...'
|
|
119
|
+
decoder_thread = nil
|
|
120
|
+
if mm_stdout
|
|
121
|
+
decoder_thread = Thread.new do
|
|
122
|
+
buffer = ''
|
|
123
|
+
loop do
|
|
124
|
+
chunk = mm_stdout.readpartial(4096)
|
|
125
|
+
buffer = "#{buffer}#{chunk}"
|
|
126
|
+
while (line = buffer.slice!(/^.*\n/))
|
|
127
|
+
line = line.chomp
|
|
128
|
+
next if line.empty?
|
|
129
|
+
next if line_match && !match_line?(line: line, matcher: line_match)
|
|
130
|
+
|
|
131
|
+
dec_msg = { decoded_at: Time.now.strftime('%Y-%m-%d %H:%M:%S%z'), raw: line }
|
|
132
|
+
dec_msg.merge!(parser.call(line)) if parser.respond_to?(:call)
|
|
133
|
+
final_msg = freq_obj.merge(dec_msg)
|
|
134
|
+
|
|
135
|
+
spinner.stop
|
|
136
|
+
puts JSON.pretty_generate(final_msg)
|
|
137
|
+
spinner.auto_spin
|
|
138
|
+
|
|
139
|
+
File.open(log_file, 'a') { |f| f.puts("#{JSON.generate(final_msg)},") }
|
|
140
|
+
disp = dec_msg[:summary] || line
|
|
141
|
+
current_title = disp[0...max_title_length]
|
|
142
|
+
end
|
|
143
|
+
rescue IOError
|
|
144
|
+
break
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
elsif gqrx_sock
|
|
148
|
+
# No external decoder — fall back to live signal-strength telemetry
|
|
149
|
+
decoder_thread = Thread.new do
|
|
150
|
+
loop do
|
|
151
|
+
lvl = PWN::SDR::GQRX.cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
|
|
152
|
+
current_title = "signal #{format('%+.1f', lvl)} dBFS (analog / no external demod)"
|
|
153
|
+
sleep 0.3
|
|
154
|
+
end
|
|
155
|
+
rescue StandardError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
loop do
|
|
161
|
+
spinner.update(status: current_title)
|
|
162
|
+
next unless $stdin.wait_readable(0)
|
|
163
|
+
|
|
164
|
+
begin
|
|
165
|
+
char = $stdin.read_nonblock(1)
|
|
166
|
+
next unless char == skip_freq_char
|
|
167
|
+
|
|
168
|
+
puts "\n[!] ENTER pressed → stopping #{protocol} decoder..."
|
|
169
|
+
break
|
|
170
|
+
rescue IO::WaitReadable, EOFError
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
spinner.success('Decoding stopped')
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
spinner.error("Decoding failed: #{e.message}") if defined?(spinner) && spinner
|
|
178
|
+
raise
|
|
179
|
+
ensure
|
|
180
|
+
[receiver_thread, decoder_thread].compact.each { |t| t.kill if t.alive? }
|
|
181
|
+
[mm_stdin, mm_stdout, mm_stderr].compact.each do |io|
|
|
182
|
+
io.close
|
|
183
|
+
rescue StandardError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
begin
|
|
187
|
+
mm_wait_thr&.value
|
|
188
|
+
rescue StandardError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
PWN::SDR::GQRX.disconnect_udp(udp_listener: udp_listener) if udp_listener
|
|
192
|
+
spinner.stop if defined?(spinner) && spinner
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Supported Method Parameters::
|
|
196
|
+
# PWN::SDR::Decoder::Base.bin_available?(bin: 'multimon-ng')
|
|
197
|
+
|
|
198
|
+
public_class_method def self.bin_available?(opts = {})
|
|
199
|
+
bin = opts[:bin].to_s
|
|
200
|
+
return false if bin.empty?
|
|
201
|
+
|
|
202
|
+
ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
|
|
203
|
+
File.executable?(File.join(dir, bin))
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Supported Method Parameters::
|
|
208
|
+
# PWN::SDR::Decoder::Base.match_line?(line: str, matcher: Regexp|String|Array)
|
|
209
|
+
|
|
210
|
+
public_class_method def self.match_line?(opts = {})
|
|
211
|
+
line = opts[:line].to_s
|
|
212
|
+
matcher = opts[:matcher]
|
|
213
|
+
case matcher
|
|
214
|
+
when Regexp then line.match?(matcher)
|
|
215
|
+
when String then line.start_with?(matcher)
|
|
216
|
+
when Array then matcher.any? { |m| match_line?(line: line, matcher: m) }
|
|
217
|
+
else true
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
222
|
+
|
|
223
|
+
public_class_method def self.authors
|
|
224
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Display Usage for this Module
|
|
228
|
+
|
|
229
|
+
public_class_method def self.help
|
|
230
|
+
puts "USAGE:
|
|
231
|
+
#{self}.run_pipeline(
|
|
232
|
+
freq_obj: 'required - freq_obj from PWN::SDR::GQRX.init_freq',
|
|
233
|
+
protocol: 'required - short protocol name',
|
|
234
|
+
decode_cmd: 'optional - stdin pipeline (sox-resampled 22 050 Hz s16le)',
|
|
235
|
+
direct_cmd: 'optional - stand-alone cmd that owns the SDR',
|
|
236
|
+
line_match: 'optional - Regexp/String/Array line filter',
|
|
237
|
+
parser: 'optional - Proc { |line| Hash } structured extractor',
|
|
238
|
+
required_bins: 'optional - Array of executables that must be on PATH',
|
|
239
|
+
resample_hz: 'optional - sox output rate (default 22050)'
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
#{self}.bin_available?(bin: 'multimon-ng')
|
|
243
|
+
#{self}.match_line?(line: str, matcher: Regexp|String|Array)
|
|
244
|
+
|
|
245
|
+
#{self}.authors
|
|
246
|
+
"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
data/lib/pwn/sdr/decoder/gsm.rb
CHANGED
|
@@ -3,213 +3,112 @@
|
|
|
3
3
|
module PWN
|
|
4
4
|
module SDR
|
|
5
5
|
module Decoder
|
|
6
|
-
#
|
|
6
|
+
# GSM (2G) broadcast-channel decoder.
|
|
7
|
+
#
|
|
8
|
+
# GSM is 270.833 kbit/s GMSK — it CANNOT be recovered from GQRX's 48 kHz
|
|
9
|
+
# demodulated-audio UDP tap. This module therefore drives the SDR
|
|
10
|
+
# directly via `grgsm_livemon_headless` (from gr-gsm) which publishes
|
|
11
|
+
# decoded Um bursts as GSMTAP on udp/4729, then reads them back with
|
|
12
|
+
# `tshark` for structured field extraction (MCC/MNC/LAC/CI/ARFCN, paging
|
|
13
|
+
# IMSIs/TMSIs, System Information messages, etc.).
|
|
14
|
+
#
|
|
15
|
+
# Interface matches PWN::SDR::Decoder::Flex / ::RDS so the GQRX
|
|
16
|
+
# dispatcher (`decoder: :gsm`) works uniformly.
|
|
7
17
|
module GSM
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
18
|
+
# Supported Method Parameters::
|
|
19
|
+
# PWN::SDR::Decoder::GSM.decode(
|
|
20
|
+
# freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
|
|
21
|
+
# )
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
TSC_0 = [0, 0, 1, 0, 0, 1, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 1, 1].freeze
|
|
15
|
-
|
|
16
|
-
# Starts the live decoding thread.
|
|
17
|
-
public_class_method def self.start(opts = {})
|
|
18
|
-
freq_obj = opts[:freq_obj]
|
|
19
|
-
raise ':ERROR: :freq_obj is required' unless freq_obj.is_a?(Hash)
|
|
20
|
-
|
|
21
|
-
gqrx_sock = freq_obj[:gqrx_sock]
|
|
22
|
-
freq = freq_obj[:freq]
|
|
23
|
-
bandwidth = freq_obj[:bandwidth].to_i
|
|
24
|
-
record_path = freq_obj[:record_path]
|
|
25
|
-
|
|
26
|
-
sleep 0.1 until File.exist?(record_path)
|
|
27
|
-
|
|
28
|
-
header = File.binread(record_path, HEADER_SIZE)
|
|
29
|
-
raise 'Invalid WAV header' unless header.start_with?('RIFF') && header.include?('WAVE')
|
|
30
|
-
|
|
31
|
-
bytes_read = HEADER_SIZE
|
|
32
|
-
|
|
33
|
-
puts "GSM Decoder started for freq: #{freq}, bandwidth: #{bandwidth}"
|
|
34
|
-
|
|
35
|
-
Thread.new do
|
|
36
|
-
loop do
|
|
37
|
-
current_size = File.size(record_path)
|
|
38
|
-
if current_size > bytes_read
|
|
39
|
-
new_bytes = current_size - bytes_read
|
|
40
|
-
# Ensure full I/Q pairs (8 bytes)
|
|
41
|
-
new_bytes -= new_bytes % 8
|
|
42
|
-
data = File.binread(record_path, new_bytes, bytes_read)
|
|
43
|
-
process_chunk(
|
|
44
|
-
data: data,
|
|
45
|
-
bandwidth: bandwidth,
|
|
46
|
-
freq: freq
|
|
47
|
-
)
|
|
48
|
-
bytes_read = current_size
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
sleep SLEEP_INTERVAL
|
|
52
|
-
end
|
|
53
|
-
rescue StandardError => e
|
|
54
|
-
puts "Decoder error: #{e.message}"
|
|
55
|
-
ensure
|
|
56
|
-
cleanup(record_path: record_path)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Stops the decoding thread.
|
|
61
|
-
public_class_method def self.stop(opts = {})
|
|
23
|
+
public_class_method def self.decode(opts = {})
|
|
62
24
|
freq_obj = opts[:freq_obj]
|
|
63
25
|
raise 'ERROR: :freq_obj is required' unless freq_obj.is_a?(Hash)
|
|
64
26
|
|
|
65
|
-
|
|
66
|
-
|
|
27
|
+
hz = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
|
|
28
|
+
gain = (freq_obj[:rf_gain] || 40).to_s.to_f
|
|
29
|
+
|
|
30
|
+
tshark_fields = %w[
|
|
31
|
+
frame.time
|
|
32
|
+
gsmtap.arfcn
|
|
33
|
+
gsmtap.chan_type
|
|
34
|
+
gsm_a.imsi
|
|
35
|
+
gsm_a.tmsi
|
|
36
|
+
e212.mcc
|
|
37
|
+
e212.mnc
|
|
38
|
+
gsm_a.lac
|
|
39
|
+
gsm_a.bssmap.cell_ci
|
|
40
|
+
gsm_a.dtap.msg_rr_type
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
direct_cmd = "sh -c 'grgsm_livemon_headless -f #{hz} -g #{gain} >/dev/null 2>&1 & " \
|
|
44
|
+
'LMPID=$!; trap "kill $LMPID 2>/dev/null" EXIT; sleep 2; ' \
|
|
45
|
+
'tshark -i lo -l -Y gsmtap -T fields -E separator=\| ' \
|
|
46
|
+
"#{tshark_fields.map { |f| "-e #{f}" }.join(' ')}'"
|
|
47
|
+
|
|
48
|
+
PWN::SDR::Decoder::Base.run_pipeline(
|
|
49
|
+
freq_obj: freq_obj,
|
|
50
|
+
protocol: 'GSM',
|
|
51
|
+
required_bins: %w[grgsm_livemon_headless tshark],
|
|
52
|
+
direct_cmd: direct_cmd,
|
|
53
|
+
line_match: /\S/,
|
|
54
|
+
parser: proc { |line| parse_line(line: line) }
|
|
55
|
+
)
|
|
67
56
|
end
|
|
68
57
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
power = window.map { |c| c.abs**2 }.sum / window_size
|
|
93
|
-
powers << power
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
max_power = powers.max
|
|
97
|
-
return unless max_power > POWER_THRESHOLD
|
|
98
|
-
|
|
99
|
-
# Demod the entire chunk (assume burst-aligned roughly)
|
|
100
|
-
bits = demod_gmsk(complex_samples)
|
|
101
|
-
# Synchronize via TSC correlation
|
|
102
|
-
sync_offset = find_tsc_offset(bits, TSC_0)
|
|
103
|
-
return unless sync_offset >= 0
|
|
104
|
-
|
|
105
|
-
# Extract data bits from normal burst structure
|
|
106
|
-
burst_start = sync_offset - 58 # TSC starts at symbol 58 (0-index)
|
|
107
|
-
return unless burst_start >= 0 && burst_start + 148 <= bits.length
|
|
108
|
-
|
|
109
|
-
data_bits = extract_data_bits(bits, burst_start)
|
|
110
|
-
puts "Burst synchronized at offset #{sync_offset} for #{freq} Hz (power: #{max_power.round(4)})"
|
|
111
|
-
decode_imsi(
|
|
112
|
-
data_bits: data_bits,
|
|
113
|
-
freq: freq
|
|
114
|
-
)
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def demod_gmsk(complex_samples)
|
|
118
|
-
return [] if complex_samples.length < 2
|
|
119
|
-
|
|
120
|
-
bits = []
|
|
121
|
-
(1...complex_samples.length).each do |i|
|
|
122
|
-
prod = complex_samples[i] * complex_samples[i - 1].conj
|
|
123
|
-
# Sign of imaginary part for quadrature differential
|
|
124
|
-
bit = (prod.imag >= 0 ? 0 : 1) # Or adjust polarity
|
|
125
|
-
bits << bit
|
|
126
|
-
end
|
|
127
|
-
bits
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def find_tsc_offset(bits, tsc)
|
|
131
|
-
max_corr = -1
|
|
132
|
-
best_offset = -1
|
|
133
|
-
tsc_length = tsc.length # 26
|
|
134
|
-
(0...(bits.length - tsc_length + 1)).each do |offset|
|
|
135
|
-
window = bits[offset, tsc_length]
|
|
136
|
-
corr = window.zip(tsc).count { |b1, b2| b1 == b2 }
|
|
137
|
-
if corr > max_corr
|
|
138
|
-
max_corr = corr
|
|
139
|
-
best_offset = offset
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
# Threshold: e.g., >20 matches for good sync
|
|
143
|
-
max_corr > 20 ? best_offset : -1
|
|
58
|
+
# Supported Method Parameters::
|
|
59
|
+
# PWN::SDR::Decoder::GSM.parse_line(line: 'ts|arfcn|chan|imsi|tmsi|mcc|mnc|lac|ci|rr')
|
|
60
|
+
|
|
61
|
+
public_class_method def self.parse_line(opts = {})
|
|
62
|
+
line = opts[:line].to_s
|
|
63
|
+
f = line.split('|', -1)
|
|
64
|
+
out = {
|
|
65
|
+
protocol: 'GSM',
|
|
66
|
+
arfcn: f[1],
|
|
67
|
+
chan_type: f[2],
|
|
68
|
+
imsi: f[3],
|
|
69
|
+
tmsi: f[4],
|
|
70
|
+
mcc: f[5],
|
|
71
|
+
mnc: f[6],
|
|
72
|
+
lac: f[7],
|
|
73
|
+
cell_id: f[8],
|
|
74
|
+
rr_msg_type: f[9]
|
|
75
|
+
}.reject { |_, v| v.to_s.empty? }
|
|
76
|
+
|
|
77
|
+
if out[:imsi].to_s.length.between?(14, 16)
|
|
78
|
+
out[:imsi_mcc] = out[:imsi][0, 3]
|
|
79
|
+
out[:imsi_mnc] = out[:imsi][3, 3]
|
|
80
|
+
out[:imsi_msin] = out[:imsi][6..]
|
|
144
81
|
end
|
|
145
82
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def decode_imsi(opts = {})
|
|
156
|
-
data_bits = opts[:data_bits]
|
|
157
|
-
freq = opts[:freq]
|
|
158
|
-
raise ':ERROR: :data_bits and :freq are required' unless data_bits && freq
|
|
159
|
-
|
|
160
|
-
# Simplified "IMSI extraction": Interpret first ~60 bits as packed digits (4 bits per digit, BCD-like).
|
|
161
|
-
# In reality: Deinterleave (over bursts), Viterbi decode convolutional code (polys G0=10011b, G1=11011b),
|
|
162
|
-
# CRC check, parse L3 message (e.g., Paging Req Type 1 has IMSI IE at specific offset, packed BCD).
|
|
163
|
-
# Here: Raw data bits to 15-digit IMSI (first 60 bits -> 15 nibbles).
|
|
164
|
-
return unless data_bits.length >= 60
|
|
165
|
-
|
|
166
|
-
imsi_digits = []
|
|
167
|
-
data_bits[0, 60].each_slice(4) do |nibble|
|
|
168
|
-
digit = nibble.join.to_i(2)
|
|
169
|
-
imsi_digits << (digit % 10) # Mod 10 for digit-like, or keep as is for hex
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# Format as 3(MCC)+3(MNC)+9(MSIN)
|
|
173
|
-
mcc = imsi_digits[0, 3].join
|
|
174
|
-
mnc = imsi_digits[3, 3].join
|
|
175
|
-
msin = imsi_digits[6, 9].join
|
|
176
|
-
imsi = "#{mcc.ljust(3, '0')}#{mnc.ljust(3, '0')}#{msin.ljust(9, '0')}"
|
|
177
|
-
|
|
178
|
-
puts "Decoded IMSI: #{imsi} at #{freq} Hz"
|
|
179
|
-
# TODO: Integrate full L3 parser (e.g., from ruby-gsm gem or custom).
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def cleanup(opts = {})
|
|
183
|
-
record_path = opts[:record_path]
|
|
184
|
-
raise ':ERROR: :record_path is required' unless record_path
|
|
185
|
-
|
|
186
|
-
return unless File.exist?(record_path)
|
|
187
|
-
|
|
188
|
-
File.delete(record_path)
|
|
189
|
-
puts "Cleaned up recording: #{record_path}"
|
|
190
|
-
end
|
|
83
|
+
summary_bits = []
|
|
84
|
+
summary_bits << "ARFCN=#{out[:arfcn]}" if out[:arfcn]
|
|
85
|
+
summary_bits << "MCC/MNC=#{out[:mcc]}/#{out[:mnc]}" if out[:mcc]
|
|
86
|
+
summary_bits << "LAC=#{out[:lac]} CI=#{out[:cell_id]}" if out[:lac]
|
|
87
|
+
summary_bits << "IMSI=#{out[:imsi]}" if out[:imsi]
|
|
88
|
+
summary_bits << "TMSI=#{out[:tmsi]}" if out[:tmsi]
|
|
89
|
+
out[:summary] = "GSM #{summary_bits.join(' ')}".strip
|
|
90
|
+
out
|
|
191
91
|
end
|
|
192
92
|
|
|
193
93
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
194
94
|
|
|
195
95
|
public_class_method def self.authors
|
|
196
|
-
"AUTHOR(S)
|
|
197
|
-
0day Inc. <support@0dayinc.com>
|
|
198
|
-
"
|
|
96
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
199
97
|
end
|
|
200
98
|
|
|
201
99
|
# Display Usage for this Module
|
|
202
100
|
|
|
203
101
|
public_class_method def self.help
|
|
204
102
|
puts "USAGE:
|
|
205
|
-
|
|
206
|
-
freq_obj: 'required - freq_obj returned from PWN::SDR::
|
|
103
|
+
#{self}.decode(
|
|
104
|
+
freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
|
|
207
105
|
)
|
|
208
106
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
107
|
+
NOTE: Requires `grgsm_livemon_headless` (gr-gsm) and `tshark`.
|
|
108
|
+
GSM cannot be decoded from GQRX's 48 kHz audio tap; this
|
|
109
|
+
module drives the SDR directly and reads GSMTAP on lo:4729.
|
|
110
|
+
|
|
111
|
+
#{self}.parse_line(line: 'ts|arfcn|chan|imsi|tmsi|mcc|mnc|lac|ci|rr')
|
|
213
112
|
|
|
214
113
|
#{self}.authors
|
|
215
114
|
"
|