Skip to content

Commit ffafd4c

Browse files
author
Tod Beardsley
committed
Add NTP fuzzer from @jhart-r7
Looks good to me!
2 parents aa27af9 + 06fd1ea commit ffafd4c

File tree

6 files changed

+462
-3
lines changed

6 files changed

+462
-3
lines changed

lib/rex/proto/ntp.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# -*- coding: binary -*-
2+
require 'rex/proto/ntp/constants'
3+
require 'rex/proto/ntp/modes'

lib/rex/proto/ntp/constants.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# -*- coding: binary -*-
2+
module Rex
3+
module Proto
4+
module NTP
5+
VERSIONS = (0..7).to_a
6+
MODES = (0..7).to_a
7+
MODE_6_OPERATIONS = (0..31).to_a
8+
MODE_7_IMPLEMENTATIONS = (0..255).to_a
9+
MODE_7_REQUEST_CODES = (0..255).to_a
10+
end
11+
end
12+
end

lib/rex/proto/ntp/modes.rb

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# -*- coding: binary -*-
2+
3+
require 'bit-struct'
4+
5+
module Rex
6+
module Proto
7+
module NTP
8+
9+
# A very generic NTP message
10+
#
11+
# Uses the common/similar parts from versions 1-4 and considers everything
12+
# after to be just one big field. For the particulars on the different versions,
13+
# see:
14+
# http://tools.ietf.org/html/rfc958#appendix-B
15+
# http://tools.ietf.org/html/rfc1059#appendix-B
16+
# pages 45/48 of http://tools.ietf.org/pdf/rfc1119.pdf
17+
# http://tools.ietf.org/html/rfc1305#appendix-D
18+
# http://tools.ietf.org/html/rfc5905#page-19
19+
class NTPGeneric < BitStruct
20+
# 0 1 2 3
21+
# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
22+
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
23+
# |LI | VN | mode| Stratum | Poll | Precision |
24+
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
25+
unsigned :li, 2, default: 0
26+
unsigned :version, 3, default: 0
27+
unsigned :mode, 3, default: 0
28+
unsigned :stratum, 8, default: 0
29+
unsigned :poll, 8, default: 0
30+
unsigned :precision, 8, default: 0
31+
rest :payload
32+
end
33+
34+
# An NTP control message. Control messages are only specified for NTP
35+
# versions 2-4, but this is a fuzzer so why not try them all...
36+
class NTPControl < BitStruct
37+
# 0 1 2 3
38+
# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
39+
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
40+
# |00 | VN | 6 |R E M| op | Sequence |
41+
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
42+
# | status | association id |
43+
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
44+
# | offset | count |
45+
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
46+
unsigned :reserved, 2, default: 0
47+
unsigned :version, 3, default: 0
48+
unsigned :mode, 3, default: 6
49+
unsigned :response, 1, default: 0
50+
unsigned :error, 1, default: 0
51+
unsigned :more, 1, default: 0
52+
unsigned :operation, 5, default: 0
53+
unsigned :sequence, 16, default: 0
54+
unsigned :status, 16, default: 0
55+
unsigned :association_id, 16, default: 0
56+
# TODO: there *must* be bugs in the handling of these next two fields!
57+
unsigned :payload_offset, 16, default: 0
58+
unsigned :payload_size, 16, default: 0
59+
rest :payload
60+
end
61+
62+
# An NTP "private" message. Private messages are only specified for NTP
63+
# versions 2-4, but this is a fuzzer so why not try them all...
64+
class NTPPrivate < BitStruct
65+
# 0 1 2 3
66+
# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
67+
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
68+
# |R M| VN | 7 |A| Sequence | Implementation| Req code |
69+
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
70+
# | err | Number of data items | MBZ | Size of data item |
71+
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
72+
unsigned :response, 1, default: 0
73+
unsigned :more, 1, default: 0
74+
unsigned :version, 3, default: 0
75+
unsigned :mode, 3, default: 7
76+
unsigned :auth, 1, default: 0
77+
unsigned :sequence, 7, default: 0
78+
unsigned :implementation, 8, default: 0
79+
unsigned :request_code, 8, default: 0
80+
unsigned :error, 4, default: 0
81+
unsigned :record_count, 12, default: 0
82+
unsigned :mbz, 4, default: 0
83+
unsigned :record_size, 12, default: 0
84+
rest :payload
85+
86+
def records
87+
records = []
88+
1.upto(record_count) do |record_num|
89+
records << payload[record_size*(record_num-1), record_size]
90+
end
91+
records
92+
end
93+
end
94+
95+
def self.ntp_control(version, operation, payload = nil)
96+
n = NTPControl.new
97+
n.version = version
98+
n.operation = operation
99+
if payload
100+
n.payload_offset = 0
101+
n.payload_size = payload.size
102+
n.payload = payload
103+
end
104+
n.to_s
105+
end
106+
107+
def self.ntp_private(version, implementation, request_code, payload = nil)
108+
n = NTPPrivate.new
109+
n.version = version
110+
n.implementation = implementation
111+
n.request_code = request_code
112+
n.payload = payload if payload
113+
n.to_s
114+
end
115+
116+
def self.ntp_generic(version, mode)
117+
n = NTPGeneric.new
118+
n.version = version
119+
n.mode = mode
120+
n.to_s
121+
end
122+
123+
# Parses the given message and provides a description about the NTP message inside
124+
def self.describe(message)
125+
ntp = NTPGeneric.new(message)
126+
"#{message.size}-byte version #{ntp.version} mode #{ntp.mode} reply"
127+
end
128+
end
129+
end
130+
end
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# encoding: UTF-8
2+
##
3+
# This module requires Metasploit: http//metasploit.com/download
4+
# Current source: https://github.com/rapid7/metasploit-framework
5+
##
6+
7+
require 'msf/core'
8+
require 'rex/proto/ntp'
9+
require 'securerandom'
10+
11+
class Metasploit3 < Msf::Auxiliary
12+
include Msf::Auxiliary::Fuzzer
13+
include Msf::Exploit::Remote::Udp
14+
include Msf::Auxiliary::Scanner
15+
16+
def initialize
17+
super(
18+
'Name' => 'NTP Protocol Fuzzer',
19+
'Description' => %q(
20+
A simplistic fuzzer for the Network Time Protocol that sends the
21+
following probes to understand NTP and look for anomalous NTP behavior:
22+
23+
* All possible combinations of NTP versions and modes, even if not
24+
allowed or specified in the RFCs
25+
* Short versions of the above
26+
* Short, invalid datagrams
27+
* Full-size, random datagrams
28+
* All possible NTP control messages
29+
* All possible NTP private messages
30+
31+
This findings of this fuzzer are not necessarily indicative of bugs,
32+
let alone vulnerabilities, rather they point out interesting things
33+
that might deserve more attention. Furthermore, this module is not
34+
particularly intelligent and there are many more areas of NTP that
35+
could be explored, including:
36+
37+
* Warn if the response is 100% identical to the request
38+
* Warn if the "mode" (if applicable) doesn't align with what we expect,
39+
* Filter out the 12-byte mode 6 unsupported opcode errors.
40+
* Fuzz the control message payload offset/size/etc. There be bugs
41+
),
42+
'Author' => 'Jon Hart <jon_hart[at]rapid7.com>',
43+
'License' => MSF_LICENSE
44+
)
45+
46+
register_options(
47+
[
48+
Opt::RPORT(123),
49+
OptInt.new('SLEEP', [true, 'Sleep for this many ms between requests', 0]),
50+
OptInt.new('WAIT', [true, 'Wait this many ms for responses', 250])
51+
], self.class)
52+
53+
register_advanced_options(
54+
[
55+
OptString.new('VERSIONS', [false, 'Specific versions to fuzz (csv)', '2,3,4']),
56+
OptString.new('MODES', [false, 'Modes to fuzz (csv)', nil]),
57+
OptString.new('MODE_6_OPERATIONS', [false, 'Mode 6 operations to fuzz (csv)', nil]),
58+
OptString.new('MODE_7_IMPLEMENTATIONS', [false, 'Mode 7 implementations to fuzz (csv)', nil]),
59+
OptString.new('MODE_7_REQUEST_CODES', [false, 'Mode 7 request codes to fuzz (csv)', nil])
60+
], self.class)
61+
end
62+
63+
def sleep_time
64+
datastore['SLEEP'] / 1000.0
65+
end
66+
67+
def check_and_set(setting)
68+
thing = setting.upcase
69+
const_name = thing.to_sym
70+
var_name = thing.downcase
71+
if datastore.key?(thing)
72+
instance_variable_set("@#{var_name}", datastore[thing].split(/[^\d]/).select { |v| !v.empty? }.map { |v| v.to_i })
73+
unsupported_things = instance_variable_get("@#{var_name}") - Rex::Proto::NTP.const_get(const_name)
74+
fail "Unsupported #{thing}: #{unsupported_things}" unless unsupported_things.empty?
75+
else
76+
instance_variable_set("@#{var_name}", Rex::Proto::NTP.const_get(const_name))
77+
end
78+
end
79+
80+
def run_host(ip)
81+
# check and set the optional advanced options
82+
check_and_set('VERSIONS')
83+
check_and_set('MODES')
84+
check_and_set('MODE_6_OPERATIONS')
85+
check_and_set('MODE_7_IMPLEMENTATIONS')
86+
check_and_set('MODE_7_REQUEST_CODES')
87+
88+
connect_udp
89+
fuzz_version_mode(ip, true)
90+
fuzz_version_mode(ip, false)
91+
fuzz_short(ip)
92+
fuzz_random(ip)
93+
fuzz_control(ip) if @modes.include?(6)
94+
fuzz_private(ip) if @modes.include?(7)
95+
disconnect_udp
96+
end
97+
98+
# Sends a series of NTP control messages
99+
def fuzz_control(host)
100+
@versions.each do |version|
101+
print_status("#{host}:#{rport} fuzzing version #{version} control messages (mode 6)")
102+
@mode_6_operations.each do |op|
103+
request = Rex::Proto::NTP.ntp_control(version, op)
104+
what = "#{request.size}-byte version #{version} mode 6 op #{op} message"
105+
vprint_status("#{host}:#{rport} probing with #{request.size}-byte #{what}")
106+
responses = probe(host, datastore['RPORT'].to_i, request)
107+
handle_responses(host, request, responses, what)
108+
Rex.sleep(sleep_time)
109+
end
110+
end
111+
end
112+
113+
# Sends a series of NTP private messages
114+
def fuzz_private(host)
115+
@versions.each do |version|
116+
print_status("#{host}:#{rport} fuzzing version #{version} private messages (mode 7)")
117+
@mode_7_implementations.each do |implementation|
118+
@mode_7_request_codes.each do |request_code|
119+
request = Rex::Proto::NTP.ntp_private(version, implementation, request_code, "\x00" * 188)
120+
what = "#{request.size}-byte version #{version} mode 7 imp #{implementation} req #{request_code} message"
121+
vprint_status("#{host}:#{rport} probing with #{request.size}-byte #{what}")
122+
responses = probe(host, datastore['RPORT'].to_i, request)
123+
handle_responses(host, request, responses, what)
124+
Rex.sleep(sleep_time)
125+
end
126+
end
127+
end
128+
end
129+
130+
# Sends a series of small, short datagrams, looking for a reply
131+
def fuzz_short(host)
132+
print_status("#{host}:#{rport} fuzzing short messages")
133+
0.upto(4) do |size|
134+
request = SecureRandom.random_bytes(size)
135+
what = "short #{request.size}-byte random message"
136+
vprint_status("#{host}:#{rport} probing with #{what}")
137+
responses = probe(host, datastore['RPORT'].to_i, request)
138+
handle_responses(host, request, responses, what)
139+
Rex.sleep(sleep_time)
140+
end
141+
end
142+
143+
# Sends a series of random, full-sized datagrams, looking for a reply
144+
def fuzz_random(host)
145+
print_status("#{host}:#{rport} fuzzing random messages")
146+
0.upto(5) do
147+
# TODO: is there a better way to pick this size? Should more than one be tried?
148+
request = SecureRandom.random_bytes(48)
149+
what = "random #{request.size}-byte message"
150+
vprint_status("#{host}:#{rport} probing with #{what}")
151+
responses = probe(host, datastore['RPORT'].to_i, request)
152+
handle_responses(host, request, responses, what)
153+
Rex.sleep(sleep_time)
154+
end
155+
end
156+
157+
# Sends a series of different version + mode combinations
158+
def fuzz_version_mode(host, short)
159+
print_status("#{host}:#{rport} fuzzing #{short ? 'short ' : nil}version and mode combinations")
160+
@versions.each do |version|
161+
@modes.each do |mode|
162+
request = Rex::Proto::NTP::NTPGeneric.new
163+
request.version = version
164+
request.mode = mode
165+
unless short
166+
# TODO: is there a better way to pick this size? Should more than one be tried?
167+
request.payload = SecureRandom.random_bytes(16)
168+
end
169+
what = "#{request.size}-byte #{short ? 'short ' : nil}version #{version} mode #{mode} message"
170+
vprint_status("#{host}:#{rport} probing with #{what}")
171+
responses = probe(host, datastore['RPORT'].to_i, request)
172+
handle_responses(host, request, responses, what)
173+
Rex.sleep(sleep_time)
174+
end
175+
end
176+
end
177+
178+
# Sends +message+ to +host+ on UDP port +port+, returning all replies
179+
def probe(host, port, message)
180+
replies = []
181+
udp_sock.sendto(message, host, port, 0)
182+
reply = udp_sock.recvfrom(65535, datastore['WAIT'] / 1000.0)
183+
while reply && reply[1]
184+
replies << reply
185+
reply = udp_sock.recvfrom(65535, datastore['WAIT'] / 1000.0)
186+
end
187+
replies
188+
end
189+
190+
def handle_responses(host, request, responses, what)
191+
problems = []
192+
descriptions = []
193+
responses.select! { |r| r[1] }
194+
return if responses.empty?
195+
responses.each do |response|
196+
data = response[0]
197+
descriptions << Rex::Proto::NTP.describe(data)
198+
problems << 'large response' if request.size < data.size
199+
ntp_req = Rex::Proto::NTP::NTPGeneric.new(request)
200+
ntp_resp = Rex::Proto::NTP::NTPGeneric.new(data)
201+
problems << 'version mismatch' if ntp_req.version != ntp_resp.version
202+
end
203+
204+
problems << 'multiple responses' if responses.size > 1
205+
problems.sort!
206+
problems.uniq!
207+
208+
description = descriptions.join(',')
209+
if problems.empty?
210+
vprint_status("#{host}:#{rport} -- Received '#{description}' to #{what}")
211+
else
212+
print_good("#{host}:#{rport} -- Received '#{description}' to #{what}: #{problems.join(',')}")
213+
end
214+
end
215+
end

0 commit comments

Comments
 (0)