276 lines
10 KiB
Ruby
276 lines
10 KiB
Ruby
#
|
|
# Copyright (c) 2006-2022 Wade Alcorn - wade@bindshell.net
|
|
# Browser Exploitation Framework (BeEF) - http://beefproject.com
|
|
# See the file 'doc/COPYING' for copying permission
|
|
#
|
|
module BeEF
|
|
module Extension
|
|
module Dns
|
|
# Provides the core DNS nameserver functionality. The nameserver handles incoming requests
|
|
# using a rule-based system. A list of user-defined rules is used to match against incoming
|
|
# DNS requests. These rules generate a response that is either a resource record or a
|
|
# failure code.
|
|
class Server < Async::DNS::Server
|
|
include Singleton
|
|
|
|
def initialize
|
|
super()
|
|
@lock = Mutex.new
|
|
@database = BeEF::Core::Models::Dns::Rule
|
|
@data_chunks = {}
|
|
end
|
|
|
|
# Adds a new DNS rule. If the rule already exists, its current ID is returned.
|
|
#
|
|
# @example Adds an A record for browserhacker.com with the IP address 1.2.3.4
|
|
#
|
|
# dns = BeEF::Extension::Dns::Server.instance
|
|
#
|
|
# id = dns.add_rule(
|
|
# :pattern => 'browserhacker.com',
|
|
# :resource => Resolv::DNS::Resource::IN::A,
|
|
# :response => '1.2.3.4'
|
|
# )
|
|
#
|
|
# @param rule [Hash] hash representation of rule
|
|
# @option rule [String, Regexp] :pattern match criteria
|
|
# @option rule [Resolv::DNS::Resource::IN] :resource resource record type
|
|
# @option rule [String, Array] :response server response
|
|
#
|
|
# @return [String] unique 8-digit hex identifier
|
|
def add_rule(rule = {})
|
|
@lock.synchronize do
|
|
# Temporarily disable warnings regarding IGNORECASE flag
|
|
verbose = $VERBOSE
|
|
$VERBOSE = nil
|
|
pattern = Regexp.new(rule[:pattern], Regexp::IGNORECASE)
|
|
$VERBOSE = verbose
|
|
|
|
@database.find_or_create_by(
|
|
resource: rule[:resource].to_s,
|
|
pattern: pattern.source,
|
|
response: rule[:response]
|
|
).id
|
|
end
|
|
end
|
|
|
|
# Retrieves a specific rule given its identifier.
|
|
#
|
|
# @param id [String] unique identifier for rule
|
|
#
|
|
# @return [Hash] hash representation of rule (empty hash if rule wasn't found)
|
|
def get_rule(id)
|
|
@lock.synchronize do
|
|
rule = @database.find(id)
|
|
return to_hash(rule)
|
|
rescue ActiveRecord::RecordNotFound
|
|
return nil
|
|
end
|
|
end
|
|
|
|
# Removes the given DNS rule.
|
|
#
|
|
# @param id [String] rule identifier
|
|
#
|
|
# @return [Boolean] true if rule was removed, otherwise false
|
|
def remove_rule!(id)
|
|
@lock.synchronize do
|
|
begin
|
|
rule = @database.find(id)
|
|
return true if !rule.nil? && rule.destroy
|
|
rescue ActiveRecord::RecordNotFound
|
|
return nil
|
|
end
|
|
return false
|
|
end
|
|
end
|
|
|
|
# Returns an AoH representing the entire current DNS ruleset.
|
|
#
|
|
# Each element is a hash with the following keys:
|
|
#
|
|
# * <code>:id</code>
|
|
# * <code>:pattern</code>
|
|
# * <code>:resource</code>
|
|
# * <code>:response</code>
|
|
#
|
|
# @return [Array<Hash>] DNS ruleset (empty array if no rules are currently defined)
|
|
def get_ruleset
|
|
@lock.synchronize { @database.all { |rule| to_hash(rule) } }
|
|
end
|
|
|
|
# Removes the entire DNS ruleset.
|
|
#
|
|
# @return [Boolean] true if ruleset was destroyed, otherwise false
|
|
def remove_ruleset!
|
|
@lock.synchronize do
|
|
return true if @database.destroy_all
|
|
end
|
|
end
|
|
|
|
# Starts the DNS server.
|
|
#
|
|
# @param options [Hash] server configuration options
|
|
# @option options [Array<Array>] :upstream upstream DNS servers (if ommitted, unresolvable
|
|
# requests return NXDOMAIN)
|
|
# @option options [Array<Array>] :listen local interfaces to listen on
|
|
def run(options = {})
|
|
@lock.synchronize do
|
|
Thread.new do
|
|
EventMachine.next_tick do
|
|
upstream = options[:upstream] || nil
|
|
|
|
listen = options[:listen] || nil
|
|
# listen is called enpoints in Async::DNS
|
|
@endpoints = listen
|
|
|
|
if upstream
|
|
resolver = Async::DNS::Resolver.new(upstream)
|
|
@otherwise = proc { |t| t.passthrough!(resolver) }
|
|
end
|
|
|
|
begin
|
|
# super(:listen => listen)
|
|
Thread.new { super() }
|
|
rescue RuntimeError => e
|
|
if e.message =~ /no datagram socket/ || e.message =~ /no acceptor/ # the port is in use
|
|
print_error "[DNS] Another process is already listening on port #{options[:listen]}"
|
|
print_error 'Exiting...'
|
|
exit 127
|
|
else
|
|
raise
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# Entry point for processing incoming DNS requests. Attempts to find a matching rule and
|
|
# sends back its associated response.
|
|
#
|
|
# @param name [String] name of the resource record being looked up
|
|
# @param resource [Resolv::DNS::Resource::IN] query type (e.g. A, CNAME, NS, etc.)
|
|
# @param transaction [RubyDNS::Transaction] internal RubyDNS class detailing DNS question/answer
|
|
def process(name, resource, transaction)
|
|
@lock.synchronize do
|
|
resource = resource.to_s
|
|
|
|
print_debug "Received DNS request (name: #{name} type: #{format_resource(resource)})"
|
|
|
|
# no need to parse AAAA resources when data is extruded from client. Also we check if the FQDN starts with the 0xb3 string.
|
|
# this 0xb3 is convenient to clearly separate DNS requests used to extrude data from normal DNS requests than should be resolved by the DNS server.
|
|
if format_resource(resource) == 'A' && name.match(/^0xb3/)
|
|
reconstruct(name.split('0xb3').last)
|
|
catch(:done) do
|
|
transaction.fail!(:NXDomain)
|
|
end
|
|
return
|
|
end
|
|
|
|
catch(:done) do
|
|
# Find rules matching the requested resource class
|
|
resources = @database.where(resource: resource)
|
|
throw :done if resources.length == 0
|
|
|
|
# Narrow down search by finding a matching pattern
|
|
resources.each do |rule|
|
|
pattern = Regexp.new(rule.pattern)
|
|
|
|
next unless name =~ pattern
|
|
|
|
print_debug "Found matching DNS rule (id: #{rule.id} response: #{rule.response})"
|
|
proc { |_t| eval(rule.callback) }.call(transaction)
|
|
throw :done
|
|
end
|
|
|
|
if @otherwise
|
|
print_debug 'No match found, querying upstream servers'
|
|
@otherwise.call(transaction)
|
|
else
|
|
print_debug 'No match found, sending NXDOMAIN response'
|
|
transaction.fail!(:NXDomain)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
# Collects and reconstructs data extruded by the client and found in subdomain, with structure like:
|
|
# 0.1.5.4c6f72656d20697073756d20646f6c6f722073697420616d65742c20636f6e7.browserhacker.com
|
|
# [...]
|
|
# 0.5.5.7565207175616d206469676e697373696d2065752e.browserhacker.com
|
|
def reconstruct(data)
|
|
split_data = data.split('.')
|
|
pack_id = split_data[0]
|
|
seq_num = split_data[1]
|
|
seq_tot = split_data[2]
|
|
data_chunk = split_data[3] # this might change if we store more than 63 bytes in a chunk (63 is the limitation from RFC)
|
|
|
|
unless pack_id.match(/^(\d)+$/) && seq_num.match(/^(\d)+$/) && seq_tot.match(/^(\d)+$/)
|
|
print_debug "[DNS] Received invalid chunk:\n #{data}"
|
|
return
|
|
end
|
|
|
|
print_debug "[DNS] Received chunk (#{seq_num} / #{seq_tot}) of packet (#{pack_id}): #{data_chunk}"
|
|
|
|
if @data_chunks[pack_id].nil?
|
|
# no previous chunks received, create new Array to store chunks
|
|
@data_chunks[pack_id] = Array.new(seq_tot.to_i)
|
|
@data_chunks[pack_id][seq_num.to_i - 1] = data_chunk
|
|
else
|
|
# previous chunks received, update Array
|
|
@data_chunks[pack_id][seq_num.to_i - 1] = data_chunk
|
|
if @data_chunks[pack_id].all? && @data_chunks[pack_id] != 'DONE'
|
|
# means that no position in the array is false/nil, so we received all the packet chunks
|
|
packet_data = @data_chunks[pack_id].join('')
|
|
decoded_packet_data = packet_data.scan(/../).map { |n| n.to_i(16) }.pack('U*')
|
|
print_debug "[DNS] Packet data fully received: #{packet_data}. \n Converted from HEX: #{decoded_packet_data}"
|
|
|
|
# we might get more DNS requests for the same chunks sometimes, once every chunk of a packet is received, mark it
|
|
@data_chunks[pack_id] = 'DONE'
|
|
end
|
|
end
|
|
end
|
|
|
|
# Helper method that converts a DNS rule to a hash.
|
|
#
|
|
# @param rule [BeEF::Core::Models::Dns::Rule] rule to be converted
|
|
#
|
|
# @return [Hash] hash representation of DNS rule
|
|
def to_hash(rule)
|
|
hash = {}
|
|
hash[:id] = rule.id
|
|
hash[:pattern] = rule.pattern
|
|
hash[:resource] = format_resource(rule.resource)
|
|
hash[:response] = rule.response
|
|
|
|
hash
|
|
end
|
|
|
|
# Verifies that the given ID is valid.
|
|
#
|
|
# @param id [String] identifier to validate
|
|
#
|
|
# @return [Boolean] true if ID is valid, otherwise false
|
|
def is_valid_id?(id)
|
|
BeEF::Filters.hexs_only?(id) &&
|
|
!BeEF::Filters.has_null?(id) &&
|
|
!BeEF::Filters.has_non_printable_char?(id) &&
|
|
id.length == 8
|
|
end
|
|
|
|
# Helper method that formats the given resource class in a human-readable format.
|
|
#
|
|
# @param resource [Resolv::DNS::Resource::IN] resource class
|
|
#
|
|
# @return [String] resource name stripped of any module/class names
|
|
def format_resource(resource)
|
|
/::(\w+)$/.match(resource)[1]
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|