Add NTP fuzzer from @jhart-r7

Looks good to me!
bug/bundler_fix
Tod Beardsley 2014-07-21 12:38:12 -05:00
commit ffafd4c01f
No known key found for this signature in database
GPG Key ID: 1EFFB682ADB9F193
6 changed files with 462 additions and 3 deletions

3
lib/rex/proto/ntp.rb Normal file
View File

@ -0,0 +1,3 @@
# -*- coding: binary -*-
require 'rex/proto/ntp/constants'
require 'rex/proto/ntp/modes'

View File

@ -0,0 +1,12 @@
# -*- coding: binary -*-
module Rex
module Proto
module NTP
VERSIONS = (0..7).to_a
MODES = (0..7).to_a
MODE_6_OPERATIONS = (0..31).to_a
MODE_7_IMPLEMENTATIONS = (0..255).to_a
MODE_7_REQUEST_CODES = (0..255).to_a
end
end
end

130
lib/rex/proto/ntp/modes.rb Normal file
View File

@ -0,0 +1,130 @@
# -*- coding: binary -*-
require 'bit-struct'
module Rex
module Proto
module NTP
# A very generic NTP message
#
# Uses the common/similar parts from versions 1-4 and considers everything
# after to be just one big field. For the particulars on the different versions,
# see:
# http://tools.ietf.org/html/rfc958#appendix-B
# http://tools.ietf.org/html/rfc1059#appendix-B
# pages 45/48 of http://tools.ietf.org/pdf/rfc1119.pdf
# http://tools.ietf.org/html/rfc1305#appendix-D
# http://tools.ietf.org/html/rfc5905#page-19
class NTPGeneric < BitStruct
# 0 1 2 3
# 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
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
# |LI | VN | mode| Stratum | Poll | Precision |
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
unsigned :li, 2, default: 0
unsigned :version, 3, default: 0
unsigned :mode, 3, default: 0
unsigned :stratum, 8, default: 0
unsigned :poll, 8, default: 0
unsigned :precision, 8, default: 0
rest :payload
end
# An NTP control message. Control messages are only specified for NTP
# versions 2-4, but this is a fuzzer so why not try them all...
class NTPControl < BitStruct
# 0 1 2 3
# 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
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
# |00 | VN | 6 |R E M| op | Sequence |
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
# | status | association id |
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
# | offset | count |
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
unsigned :reserved, 2, default: 0
unsigned :version, 3, default: 0
unsigned :mode, 3, default: 6
unsigned :response, 1, default: 0
unsigned :error, 1, default: 0
unsigned :more, 1, default: 0
unsigned :operation, 5, default: 0
unsigned :sequence, 16, default: 0
unsigned :status, 16, default: 0
unsigned :association_id, 16, default: 0
# TODO: there *must* be bugs in the handling of these next two fields!
unsigned :payload_offset, 16, default: 0
unsigned :payload_size, 16, default: 0
rest :payload
end
# An NTP "private" message. Private messages are only specified for NTP
# versions 2-4, but this is a fuzzer so why not try them all...
class NTPPrivate < BitStruct
# 0 1 2 3
# 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
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
# |R M| VN | 7 |A| Sequence | Implementation| Req code |
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
# | err | Number of data items | MBZ | Size of data item |
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
unsigned :response, 1, default: 0
unsigned :more, 1, default: 0
unsigned :version, 3, default: 0
unsigned :mode, 3, default: 7
unsigned :auth, 1, default: 0
unsigned :sequence, 7, default: 0
unsigned :implementation, 8, default: 0
unsigned :request_code, 8, default: 0
unsigned :error, 4, default: 0
unsigned :record_count, 12, default: 0
unsigned :mbz, 4, default: 0
unsigned :record_size, 12, default: 0
rest :payload
def records
records = []
1.upto(record_count) do |record_num|
records << payload[record_size*(record_num-1), record_size]
end
records
end
end
def self.ntp_control(version, operation, payload = nil)
n = NTPControl.new
n.version = version
n.operation = operation
if payload
n.payload_offset = 0
n.payload_size = payload.size
n.payload = payload
end
n.to_s
end
def self.ntp_private(version, implementation, request_code, payload = nil)
n = NTPPrivate.new
n.version = version
n.implementation = implementation
n.request_code = request_code
n.payload = payload if payload
n.to_s
end
def self.ntp_generic(version, mode)
n = NTPGeneric.new
n.version = version
n.mode = mode
n.to_s
end
# Parses the given message and provides a description about the NTP message inside
def self.describe(message)
ntp = NTPGeneric.new(message)
"#{message.size}-byte version #{ntp.version} mode #{ntp.mode} reply"
end
end
end
end

View File

@ -0,0 +1,215 @@
# encoding: UTF-8
##
# This module requires Metasploit: http//metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'msf/core'
require 'rex/proto/ntp'
require 'securerandom'
class Metasploit3 < Msf::Auxiliary
include Msf::Auxiliary::Fuzzer
include Msf::Exploit::Remote::Udp
include Msf::Auxiliary::Scanner
def initialize
super(
'Name' => 'NTP Protocol Fuzzer',
'Description' => %q(
A simplistic fuzzer for the Network Time Protocol that sends the
following probes to understand NTP and look for anomalous NTP behavior:
* All possible combinations of NTP versions and modes, even if not
allowed or specified in the RFCs
* Short versions of the above
* Short, invalid datagrams
* Full-size, random datagrams
* All possible NTP control messages
* All possible NTP private messages
This findings of this fuzzer are not necessarily indicative of bugs,
let alone vulnerabilities, rather they point out interesting things
that might deserve more attention. Furthermore, this module is not
particularly intelligent and there are many more areas of NTP that
could be explored, including:
* Warn if the response is 100% identical to the request
* Warn if the "mode" (if applicable) doesn't align with what we expect,
* Filter out the 12-byte mode 6 unsupported opcode errors.
* Fuzz the control message payload offset/size/etc. There be bugs
),
'Author' => 'Jon Hart <jon_hart[at]rapid7.com>',
'License' => MSF_LICENSE
)
register_options(
[
Opt::RPORT(123),
OptInt.new('SLEEP', [true, 'Sleep for this many ms between requests', 0]),
OptInt.new('WAIT', [true, 'Wait this many ms for responses', 250])
], self.class)
register_advanced_options(
[
OptString.new('VERSIONS', [false, 'Specific versions to fuzz (csv)', '2,3,4']),
OptString.new('MODES', [false, 'Modes to fuzz (csv)', nil]),
OptString.new('MODE_6_OPERATIONS', [false, 'Mode 6 operations to fuzz (csv)', nil]),
OptString.new('MODE_7_IMPLEMENTATIONS', [false, 'Mode 7 implementations to fuzz (csv)', nil]),
OptString.new('MODE_7_REQUEST_CODES', [false, 'Mode 7 request codes to fuzz (csv)', nil])
], self.class)
end
def sleep_time
datastore['SLEEP'] / 1000.0
end
def check_and_set(setting)
thing = setting.upcase
const_name = thing.to_sym
var_name = thing.downcase
if datastore.key?(thing)
instance_variable_set("@#{var_name}", datastore[thing].split(/[^\d]/).select { |v| !v.empty? }.map { |v| v.to_i })
unsupported_things = instance_variable_get("@#{var_name}") - Rex::Proto::NTP.const_get(const_name)
fail "Unsupported #{thing}: #{unsupported_things}" unless unsupported_things.empty?
else
instance_variable_set("@#{var_name}", Rex::Proto::NTP.const_get(const_name))
end
end
def run_host(ip)
# check and set the optional advanced options
check_and_set('VERSIONS')
check_and_set('MODES')
check_and_set('MODE_6_OPERATIONS')
check_and_set('MODE_7_IMPLEMENTATIONS')
check_and_set('MODE_7_REQUEST_CODES')
connect_udp
fuzz_version_mode(ip, true)
fuzz_version_mode(ip, false)
fuzz_short(ip)
fuzz_random(ip)
fuzz_control(ip) if @modes.include?(6)
fuzz_private(ip) if @modes.include?(7)
disconnect_udp
end
# Sends a series of NTP control messages
def fuzz_control(host)
@versions.each do |version|
print_status("#{host}:#{rport} fuzzing version #{version} control messages (mode 6)")
@mode_6_operations.each do |op|
request = Rex::Proto::NTP.ntp_control(version, op)
what = "#{request.size}-byte version #{version} mode 6 op #{op} message"
vprint_status("#{host}:#{rport} probing with #{request.size}-byte #{what}")
responses = probe(host, datastore['RPORT'].to_i, request)
handle_responses(host, request, responses, what)
Rex.sleep(sleep_time)
end
end
end
# Sends a series of NTP private messages
def fuzz_private(host)
@versions.each do |version|
print_status("#{host}:#{rport} fuzzing version #{version} private messages (mode 7)")
@mode_7_implementations.each do |implementation|
@mode_7_request_codes.each do |request_code|
request = Rex::Proto::NTP.ntp_private(version, implementation, request_code, "\x00" * 188)
what = "#{request.size}-byte version #{version} mode 7 imp #{implementation} req #{request_code} message"
vprint_status("#{host}:#{rport} probing with #{request.size}-byte #{what}")
responses = probe(host, datastore['RPORT'].to_i, request)
handle_responses(host, request, responses, what)
Rex.sleep(sleep_time)
end
end
end
end
# Sends a series of small, short datagrams, looking for a reply
def fuzz_short(host)
print_status("#{host}:#{rport} fuzzing short messages")
0.upto(4) do |size|
request = SecureRandom.random_bytes(size)
what = "short #{request.size}-byte random message"
vprint_status("#{host}:#{rport} probing with #{what}")
responses = probe(host, datastore['RPORT'].to_i, request)
handle_responses(host, request, responses, what)
Rex.sleep(sleep_time)
end
end
# Sends a series of random, full-sized datagrams, looking for a reply
def fuzz_random(host)
print_status("#{host}:#{rport} fuzzing random messages")
0.upto(5) do
# TODO: is there a better way to pick this size? Should more than one be tried?
request = SecureRandom.random_bytes(48)
what = "random #{request.size}-byte message"
vprint_status("#{host}:#{rport} probing with #{what}")
responses = probe(host, datastore['RPORT'].to_i, request)
handle_responses(host, request, responses, what)
Rex.sleep(sleep_time)
end
end
# Sends a series of different version + mode combinations
def fuzz_version_mode(host, short)
print_status("#{host}:#{rport} fuzzing #{short ? 'short ' : nil}version and mode combinations")
@versions.each do |version|
@modes.each do |mode|
request = Rex::Proto::NTP::NTPGeneric.new
request.version = version
request.mode = mode
unless short
# TODO: is there a better way to pick this size? Should more than one be tried?
request.payload = SecureRandom.random_bytes(16)
end
what = "#{request.size}-byte #{short ? 'short ' : nil}version #{version} mode #{mode} message"
vprint_status("#{host}:#{rport} probing with #{what}")
responses = probe(host, datastore['RPORT'].to_i, request)
handle_responses(host, request, responses, what)
Rex.sleep(sleep_time)
end
end
end
# Sends +message+ to +host+ on UDP port +port+, returning all replies
def probe(host, port, message)
replies = []
udp_sock.sendto(message, host, port, 0)
reply = udp_sock.recvfrom(65535, datastore['WAIT'] / 1000.0)
while reply && reply[1]
replies << reply
reply = udp_sock.recvfrom(65535, datastore['WAIT'] / 1000.0)
end
replies
end
def handle_responses(host, request, responses, what)
problems = []
descriptions = []
responses.select! { |r| r[1] }
return if responses.empty?
responses.each do |response|
data = response[0]
descriptions << Rex::Proto::NTP.describe(data)
problems << 'large response' if request.size < data.size
ntp_req = Rex::Proto::NTP::NTPGeneric.new(request)
ntp_resp = Rex::Proto::NTP::NTPGeneric.new(data)
problems << 'version mismatch' if ntp_req.version != ntp_resp.version
end
problems << 'multiple responses' if responses.size > 1
problems.sort!
problems.uniq!
description = descriptions.join(',')
if problems.empty?
vprint_status("#{host}:#{rport} -- Received '#{description}' to #{what}")
else
print_good("#{host}:#{rport} -- Received '#{description}' to #{what}: #{problems.join(',')}")
end
end
end

View File

@ -0,0 +1,83 @@
# -*- coding: binary -*-
#
require 'rex/proto/ntp/modes'
describe "Rex::Proto::NTP mode message handling" do
before do
@payload = 'R7' * 7
end
describe Rex::Proto::NTP::NTPControl do
before do
@control_raw = "\x1e\x05\x12\x34\x12\x34\x12\x34\x00\x00\x00\x0e" + @payload
@control = Rex::Proto::NTP::NTPControl.new
@control.version = 3
@control.response = 0
@control.more = 0
@control.operation = 5
@control.sequence = 0x1234
@control.association_id = 0x1234
@control.status = 0x1234
@control.payload_offset = 0
@control.payload_size = 14
@control.payload = @payload
end
it 'Generates control NTP messages correctly' do
@control_raw.should == @control.to_s
end
it 'Parses control NTP messages correctly' do
parsed_raw = Rex::Proto::NTP::NTPControl.new(@control_raw)
@control.should == parsed_raw
end
end
describe Rex::Proto::NTP::NTPGeneric do
before do
@generic_raw = "\xcc\x12\x34\x56" + @payload
@generic = Rex::Proto::NTP::NTPGeneric.new
@generic.li = 3
@generic.version = 1
@generic.mode = 4
@generic.stratum = 0x12
@generic.poll = 0x34
@generic.precision = 0x56
@generic.payload = @payload
end
it 'Generates generic NTP messages correctly' do
@generic_raw.should == @generic.to_s
end
it 'Parses generic NTP messages correctly' do
parsed_raw = Rex::Proto::NTP::NTPGeneric.new(@generic_raw)
@generic.should == parsed_raw
end
end
describe Rex::Proto::NTP::NTPPrivate do
before do
@private_raw = "\x1f\x5a\x01\x99\x00\x00\x00\x00" + @payload
@private = Rex::Proto::NTP::NTPPrivate.new
@private.response = 0
@private.more = 0
@private.version = 3
@private.mode = 7
@private.auth = 0
@private.sequence = 90
@private.implementation = 1
@private.request_code = 153
@private.payload = @payload
end
it 'Generates private NTP messages correctly' do
@private_raw.should == @private.to_s
end
it 'Parses private NTP messages correctly' do
parsed_raw = Rex::Proto::NTP::NTPPrivate.new(@private_raw)
@private.should == parsed_raw
end
end
end

View File

@ -133,9 +133,7 @@ class Msftidy
break
end
else
if line =~ /^\s*(require|load)\s+['"]nokogiri['"]/
has_nokogiri = true
end
has_nokogiri = line_has_require?(line, 'nokogiri')
end
end
error(msg) if has_nokogiri_xml_parser
@ -201,6 +199,23 @@ class Msftidy
end
end
# See if 'require "rubygems"' or equivalent is used, and
# warn if so. Since Ruby 1.9 this has not been necessary and
# the framework only suports 1.9+
def check_rubygems
@source.each_line do |line|
if line_has_require?(line, 'rubygems')
warn("Explicitly requiring/loading rubygems is not necessary")
break
end
end
end
# Does the given line contain a require/load of the specified library?
def line_has_require?(line, lib)
line =~ /^\s*(require|load)\s+['"]#{lib}['"]/
end
def check_snake_case_filename
sep = File::SEPARATOR
good_name = Regexp.new "^[a-z0-9_#{sep}]+\.rb$"
@ -578,6 +593,7 @@ def run_checks(full_filepath)
tidy.check_mode
tidy.check_shebang
tidy.check_nokogiri
tidy.check_rubygems
tidy.check_ref_identifiers
tidy.check_old_keywords
tidy.check_verbose_option