├── requirements.txt ├── multi-spyrai.sh ├── utils.py ├── config.py ├── README.md ├── test_srv.py ├── spyrai ├── test_comm.py ├── parsing_notes.md ├── .gitignore ├── logger.py ├── parser.py └── agent.py /requirements.txt: -------------------------------------------------------------------------------- 1 | netaddr>=0.7 2 | retrying>=1.3 3 | 4 | -------------------------------------------------------------------------------- /multi-spyrai.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while read c; do 4 | ./spyrai $c 23 & 5 | done<$1 6 | 7 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | 3 | def haxorview(buf): 4 | buf = binascii.hexlify(buf).decode('ascii') 5 | return " ".join(buf[i:i+2] for i in range(0, len(buf), 2)) 6 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | #logging 2 | console_log_format = "%(asctime)s %(name)s: %(message)s" 3 | file_log_format = "%(asctime)s %(name)s: %(message)s" 4 | log_dir = "log/" 5 | log_level = "INFO" 6 | console_to_file = False 7 | 8 | #network 9 | ping_interval = 10 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spyrai 2 | Mirai botnet client 3 | 4 | ## Installation 5 | 6 | ```bash 7 | git clone https://github.com/srozb/spyrai.git 8 | pip install -r requirements.txt 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```bash 14 | ./spyrai cnc_host port 15 | # or 16 | ./multi-spyrai.sh cnclist.txt 17 | ``` 18 | -------------------------------------------------------------------------------- /test_srv.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from binascii import hexlify, unhexlify 3 | 4 | test = ["00220000012C03012939511920020C0131190F3235352E3235352E3235352E323535", 5 | "0000002200000E100301293951001A020C0131190F3235352E3235352E3235352E323535", 6 | "00000022000007080301293951001A020C0131190F3235352E3235352E3235352E323535", 7 | "00190000025809016855a50120020702353300053635353030", 8 | "000e0000012c04011882e9952000", 9 | ] 10 | 11 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 12 | s.bind(('localhost', 2323)) 13 | s.listen(1) 14 | connection, address = s.accept() 15 | buf = connection.recv(1024) 16 | if len(buf) > 0: 17 | print("RECV: {}".format(hexlify(buf))) 18 | print("sending test payloads.") 19 | for t in test: 20 | print("SEND: {}".format(t)) 21 | connection.sendall(unhexlify(t)) 22 | buf = connection.recv(1024) 23 | print("{} tests finished.".format(len(test))) 24 | -------------------------------------------------------------------------------- /spyrai: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from sys import argv, exit 4 | from agent import Agent 5 | from logger import Logger 6 | import config 7 | 8 | 9 | def print_usage(): 10 | print("Spyrai - a mirai C2 client") 11 | print("usage:") 12 | print("{} [host] [port]".format(argv[0])) 13 | 14 | def main(): 15 | l = Logger("main", to_file=config.console_to_file) 16 | if len(argv) != 3: 17 | print_usage() 18 | exit(-1) 19 | host, port = argv[1:] 20 | l.warn("Spyrai started.") 21 | spy = Agent(host, port) 22 | try: 23 | spy.Spy() 24 | except ConnectionResetError: 25 | l.error("Connection Reset.") 26 | exit(-2) 27 | except ConnectionRefusedError: 28 | l.error("Server refused our connection.") 29 | except KeyboardInterrupt: 30 | l.info("Goodbye.") 31 | l.info("stats: {commands} commands and {pong} pongs received.".format( 32 | **spy.stats)) 33 | exit(0) 34 | 35 | if __name__ == "__main__": 36 | main() 37 | -------------------------------------------------------------------------------- /test_comm.py: -------------------------------------------------------------------------------- 1 | from binascii import unhexlify as uh 2 | import parser 3 | 4 | a = "00220000012C03012939511920020C0131190F3235352E3235352E3235352E323535" 5 | b = "0000002200000E100301293951001A020C0131190F3235352E3235352E3235352E323535" 6 | c = "00000022000007080301293951001A020C0131190F3235352E3235352E3235352E323535" 7 | d = "00190000025809016855a50120020702353300053635353030" 8 | e = "000e0000012c04011882e9952000" 9 | f = "000e0000000a01016d9d984620000000" 10 | 11 | data = (a, b, c, d, e, f) 12 | 13 | from parser import Parse 14 | 15 | def simulate_agent(data): 16 | m = parser.Parse(data) 17 | print("Parsed message: {}".format(str(m))) 18 | print("ATK_VEC_{atk_vec} for {duration} seconds".format(**m._asdict())) 19 | for t in m.targets: 20 | print("TARGET: {IP}/{mask}".format(**t._asdict())) 21 | for o in m.options: 22 | print("OPTIONS: {var}={val}".format(**o._asdict())) 23 | 24 | for d in data: 25 | d = uh(d) 26 | print("test: {}\nparsed: {}".format(d, Parse(d))) 27 | simulate_agent(d) 28 | -------------------------------------------------------------------------------- /parsing_notes.md: -------------------------------------------------------------------------------- 1 | # Mirai C2 message parsing notes 2 | 3 | ## Example message 4 | ``` 5 | 00 22 00 00 01 2C 03 01 29 39 51 19 20 02 0C 01 31 19 0F 3235352E3235352E3235352E323535" 6 | 00 00 00 22 00 00 0E 10 03 01 29 39 51 00 1A 02 0C 01 31 19 0F 3235352E3235352E3235352E323535" 7 | 00 00 00 22 00 00 07 08 03 01 29 39 51 00 1A 02 0C 01 31 19 0F 3235352E3235352E3235352E323535" 8 | | | | | | | | | | | 9 | 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 - 10 | PONG | LEN | duration |AV|TC| Target IP |MK|OC|OP|OL|OV|OP|OL|OV 11 | ``` 12 | 13 | ## Legend 14 | ``` 15 | AV - Attack vector 16 | TC - Target count 17 | MK - IP netmask 18 | OC - Options count 19 | OP - Option type 20 | OL - Option value length 21 | OV - Option value 22 | ``` 23 | 24 | ## Parsed 25 | ``` 26 | (duration=300, atk_vec='SYN', targets=[Target(IP='41.57.81.25', mask=32)], opts=[Options(var=12, val_len=1, val="b'1'"), Options(var=25, val_len=15, val="b'255.255.255.255'")]) 27 | (duration=3600, atk_vec='SYN', targets=[Target(IP='41.57.81.0', mask=26)], opts=[Options(var=12, val_len=1, val="b'1'"), Options(var=25, val_len=15, val="b'255.255.255.255'")]) 28 | (duration=1800, atk_vec='SYN', targets=[Target(IP='41.57.81.0', mask=26)], opts=[Options(var=12, val_len=1, val="b'1'"), Options(var=25, val_len=15, val="b'255.255.255.255'")]) 29 | ``` 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import config 3 | from os import mkdir 4 | 5 | 6 | class Logger(): 7 | 8 | def __init__(self, mod_name, to_file=True): 9 | self._CreateLogDir() 10 | self.mod_name = mod_name 11 | self.logger = logging.getLogger(mod_name) 12 | self.logger.setLevel(config.log_level) 13 | self.logger.addHandler(self._GetConsoleHandler()) 14 | if to_file: 15 | self.logger.addHandler(self._GetFileHandler()) 16 | 17 | def _CreateLogDir(self): 18 | try: 19 | mkdir(config.log_dir) 20 | except FileExistsError: 21 | pass 22 | 23 | def _GetConsoleHandler(self): 24 | formatter = logging.Formatter(config.console_log_format) 25 | ch = logging.StreamHandler() 26 | ch.setFormatter(formatter) 27 | return ch 28 | 29 | def _GetFileHandler(self): 30 | formatter = logging.Formatter(config.file_log_format) 31 | fh = logging.FileHandler("{}/spyrai-{}.log".format(config.log_dir, 32 | self.mod_name)) 33 | fh.setFormatter(formatter) 34 | fh.setLevel(config.log_level) 35 | return fh 36 | 37 | def debug(self, buf): 38 | self.logger.debug(buf) 39 | 40 | def info(self, buf): 41 | self.logger.info(buf) 42 | 43 | def warn(self, buf): 44 | self.logger.warn(buf) 45 | 46 | def error(self, buf): 47 | self.logger.error(buf) 48 | 49 | def critical(self, buf): 50 | self.logger.critical(buf) 51 | -------------------------------------------------------------------------------- /parser.py: -------------------------------------------------------------------------------- 1 | from struct import unpack 2 | from netaddr import IPAddress 3 | from collections import namedtuple 4 | 5 | 6 | ATK_VEC = ["UDP", "VSE", "DNS", "SYN", "ACK", "STOMP", "GREIP", "GREETH", 7 | "PROXY", "UDP_PLAIN", "HTTP"] 8 | 9 | ATK_OPT = ["PAYLOAD_SIZE", "PAYLOAD_RAND", "IP_TOS", "IP_IDENT", "IP_TTL", 10 | "IP_DF", "SPORT", "DPORT", "DOMAIN", "DNS_HDR_ID", "TCPCC", "URG", "ACK", "PSH", 11 | "RST", "SYN", "FIN", "SEQRND", "ACKRND", "GRE_CONSTIP", "METHOD", "POST_DATA", 12 | "PATH", "HTTPS", "CONNS", "SOURCE"] 13 | 14 | #Prepare structures 15 | Target = namedtuple("Target", "IP mask") 16 | Opts = namedtuple("Options", "var val_len val") 17 | Message = namedtuple("Message", "duration atk_vec targets options") 18 | 19 | def _RemoveLeadingBytesIfNeeded(buf): 20 | if len(buf) == unpack(">H", buf[2:4])[0] + 2: 21 | #need to remove 2 leading bytes - probably ping reply 22 | return buf[2:] 23 | elif len(buf) != unpack(">H", buf[0:2])[0]: 24 | #reported len != recv len so give up. 25 | raise Exception("couldn't determine message length.") 26 | #no need to do anything. 27 | return buf 28 | 29 | def _ParseTargets(buf): 30 | targets = [] 31 | targets_len = buf[0] 32 | buf = buf[1:] 33 | for i in range(targets_len): 34 | ip = str(IPAddress((unpack(">I", buf[:4]))[0])) 35 | mask = buf[4] 36 | targets.append(Target(ip, mask)) 37 | buf = buf[5:] 38 | return targets 39 | 40 | def _ParseOpts(buf): 41 | opts = [] 42 | opts_len = buf[0] 43 | buf = buf[1:] 44 | for i in range(opts_len): 45 | try: 46 | var = ATK_OPT[buf[0]] 47 | except IndexError: 48 | var = buf[0] 49 | val_len = buf[1] 50 | try: 51 | val = buf[2:2+val_len].decode('ascii') 52 | except: 53 | val = str(buf[2:2+val_len]) 54 | opts.append(Opts(var, val_len, val)) 55 | buf = buf[2+val_len:] 56 | return opts 57 | 58 | def Parse(buf): 59 | buf = _RemoveLeadingBytesIfNeeded(buf) 60 | duration = unpack(">I", buf[2:6])[0] 61 | atk_vec = ATK_VEC[buf[6]] 62 | targets = _ParseTargets(buf[7:]) 63 | options = _ParseOpts(buf[8+5*len(targets):]) 64 | return Message(duration, atk_vec, targets, options) 65 | -------------------------------------------------------------------------------- /agent.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import time 3 | import parser 4 | import config 5 | from logger import Logger 6 | from utils import haxorview 7 | from retrying import retry 8 | 9 | 10 | class Agent(): 11 | 12 | def __init__(self, host, port): 13 | self.host = host 14 | self.port = int(port) 15 | self.socket = None 16 | self.l = Logger("{}-{}_{}".format(__name__, host, port)) 17 | self.stats = {'ping': 0, 'pong': 0, 'commands': 0, 'conn_fails': 0} 18 | 19 | def __SetupSocket(self): 20 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 21 | 22 | def __ResetSocket(self): 23 | self.l.debug("Socket reset.") 24 | self.socket.close() 25 | self.__SetupSocket() 26 | 27 | @retry(wait_exponential_multiplier=1000, wait_exponential_max=600000) 28 | def __Connect(self): 29 | self.l.debug("Connecting to {}:{}".format(self.host, self.port)) 30 | self.__SetupSocket() 31 | self.socket.connect((self.host, self.port)) 32 | self.l.debug("Connection established.") 33 | 34 | def __ProcessReply(self, data): 35 | hex_data = haxorview(data) 36 | self.l.debug("{} bytes recv, hexdump:\n{}".format(len(data), hex_data)) 37 | if len(data) < 4: 38 | self.stats['pong'] += 1 39 | else: 40 | self.stats['commands'] += 1 41 | try: 42 | m = parser.Parse(data) 43 | self.l.debug("Parsed message: {}".format(str(m))) 44 | self.l.warn("ATK_VEC_{m.atk_vec} for {m.duration} seconds"\ 45 | .format(m=m)) 46 | for t in m.targets: 47 | self.l.warn("TARGET: {t.IP}/{t.mask}".format(t=t)) 48 | for o in m.options: 49 | self.l.warn("OPTIONS: {o.var}={o.val}".format(o=o)) 50 | except: 51 | self.l.error("Unable to parse:\n{}".format(hex_data)) 52 | 53 | def __SayHello(self): 54 | self.socket.sendall(b'\x00\x00\x00\x01') 55 | self.socket.sendall(b'\x00') 56 | self.l.debug("Hello sent.") 57 | 58 | def __Ping(self): 59 | self.socket.sendall(b'\x00\x00') 60 | self.stats['ping'] += 1 61 | 62 | def __Recv(self): 63 | return self.socket.recv(1024) 64 | 65 | def __Communicate(self): 66 | self.__Ping() 67 | data = self.__Recv() 68 | self.__ProcessReply(data) 69 | time.sleep(config.ping_interval) 70 | if not self.stats['ping'] % 100: 71 | self.l.info("STATS - ping:{ping}/{pong}, cmds:{commands}, reconn:{conn_fails}".format(**self.stats)) 72 | 73 | def __DoSpy(self): 74 | self.__SayHello() 75 | self.l.info("Bot registered.") 76 | while True: 77 | try: 78 | self.__Communicate() 79 | except (BrokenPipeError, ConnectionRefusedError, 80 | ConnectionResetError) as e: 81 | self.stats['conn_fails'] += 1 82 | self.l.warn("Connection error ({}). Reconnecting...".format(e)) 83 | self.__Connect() 84 | 85 | def Spy(self): 86 | self.__Connect() 87 | self.__DoSpy() 88 | --------------------------------------------------------------------------------