commit d904c9ab97ab935b308e6424d77c74ce14c76046 Author: acidvegas Date: Thu May 25 16:03:39 2023 -0400 Initial commit diff --git a/.screens/ircp.png b/.screens/ircp.png new file mode 100644 index 0000000..9e5f07a Binary files /dev/null and b/.screens/ircp.png differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..016e197 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2023, acidvegas + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac37117 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# Internet Relay Chat Probe (IRCP) + +![](.screens/ircp.png) + +A robust information gathering tool for large scale reconnaissance on [Internet Relay Chat](https://en.wikipedia.org/wiki/Internet_Relay_Chat) servers, made for future usage with [internetrelaychat.org](https://internetrelaychat.org) for public statistics on the protocol. + +Meant to be used in combination with [masscan](https://github.com/robertdavidgraham/masscan) checking **0.0.0.0/0** *(the entire IPv4 range)* for port **6667**. + +The idea is to create a *proof-of-concept* documenting how large-scale information gathering on the IRC protocol can be malicious & invasive to privacy. + +## Order of Operations +First, an attempt to connect using SSL/TLS on port 6697 is made, which if it fails, will fall back to a standard connection on port 6667. + +Once connected, server information is gathered from `LUSERS`, `VERSION`, `LINKS`, `MAP`, `ADMIN`, `MOTD`, `LIST`, replies. + +An attempt to register a nickname is then made by trying to contact NickServ. + +Next, every channel is joined with a `WHO` command sent & every new nick found gets a `WHOIS`. + +## Collected Information +All of the raw data from a server is logged & stored. The categories below are stored seperately & hilight the key information we are after: + +###### Server Information +| Numeric | Title | +| ------- | -------------- | +| 001 | RPL_WELCOME | +| 002 | RPL_YOURHOST | +| 003 | RPL_CREATED | +| 004 | RPL_MYINFO | +| 005 | RPL_ISUPPORT | +| 372 | RPL_MOTD | +| 351 | RPL_VERSION | +| 364 | RPL_LINKS | +| 006 | RPL_MAP | +| 018 | RPL_MAPUSERS | +| 257 | RPL_ADMINLOC1 | +| 258 | RPL_ADMINLOC2 | +| 259 | RPL_ADMINEMAIL | + +###### Statistics Information (LUSERS) +| Numeric | Title | +| ------- | ----------------- | +| 250 | RPL_STATSCONN | +| 251 | RPL_LUSERCLIENT | +| 252 | RPL_LUSEROP | +| 254 | RPL_LUSERCHANNELS | +| 255 | RPL_LUSERME | +| 265 | RPL_LOCALUSERS | +| 266 | RPL_GLOBALUSERS | + +###### Channel Information +| Numeric | Title | +| ------- | ------------ | +| 332 | RPL_TOPIC | +| 353 | RPL_NAMREPLY | +| 322 | RPL_LIST | + +###### User Information (WHOIS/WHO) +| Numeric | Title | +| ------- | ----------------- | +| 311 | RPL_WHOISUSER | +| 307 | RPL_WHOISREGNICK | +| 312 | RPL_WHOISSERVER | +| 671 | RPL_WHOISSECURE | +| 319 | RPL_WHOISCHANNELS | +| 320 | RPL_WHOISSPECIAL | +| 276 | RPL_WHOISCERTFP | +| 330 | RPL_WHOISACCOUNT | +| 338 | RPL_WHOISACTUALLY | +| 352 | RPL_WHOREPLY | + +###### Bad Numerics +| Numeric | Title | +| ------- | -------------------- | +| 470 | ERR_LINKCHANNEL | +| 471 | ERR_CHANNELISFULL | +| 473 | ERR_INVITEONLYCHAN | +| 474 | ERR_BANNEDFROMCHAN | +| 475 | ERR_BADCHANNELKEY | +| 477 | ERR_NEEDREGGEDNICK | +| 489 | ERR_SECUREONLYCHAN | +| 519 | ERR_TOOMANYUSERS | +| 520 | ERR_OPERONLY | +| 464 | ERR_PASSWDMISMATCH | +| 465 | ERR_YOUREBANNEDCREEP | +| 466 | ERR_YOUWILLBEBANNED | +| 421 | ERR_UNKNOWNCOMMAND | + +## Todo +* Capture `IRCOPS` & `STATS p` command outputs +* Built in identd & CTCP replies +* Checking for IPv6 availability *(Need to find the server DNS, link names are not required to have DNS entries)* +* Random nick changes for stealth on larger networks +* Create a helper script for parsing logs & generating statistics on data +* Parse only certain information for numerics to cut down on log sizes *(Important for scaling)* + +## Mirrors +- [acid.vegas](https://git.acid.vegas/ircp) +- [GitHub](https://github.com/acidvegas/ircp) +- [GitLab](https://gitlab.com/acidvegas/ircp) +- [SuperNETs](https://git.supernets.org/acidvegas/ircp) \ No newline at end of file diff --git a/ircp.py b/ircp.py new file mode 100644 index 0000000..ed3070b --- /dev/null +++ b/ircp.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python +# internet relay chat probe for https://internetrelaychat.org/ - developed by acidvegas in python (https://git.acid.vegas/ircp) + +import asyncio +import copy +import json +import os +import random +import socket +import ssl +import sys +import time + +class settings: + errors = False # Show connection errors + nickname = 'IRCP' + username = 'ircp' + realname = 'internetrelaychat.org' + ns_mail = 'ircp@internetrelaychat.org' + ns_pass = 'changeme' + vhost = None # Bind to a specific IP address + +class throttle: + channels = 3 # Maximum number of channels to scan at once + delay = 120 # Delay before registering nick (if enabled) & sending /LIST + join = 10 # Delay between channel joins + part = 3 # Delay before leaving a channel + threads = 100 # Maximum number of threads running + timeout = 15 # Timeout for all sockets + whois = 3 # Delay between WHOIS requests + ztimeout = 200 # Timeout for zero data from server + +snapshot = { + 'server' : None, + 'host' : None, + 'raw' : [], # All non-classified data is stored in here for analysis + 'NOTICE' : None, + 'services' : False, + 'ssl' : True, + + # server information + '001' : None, # RPL_WELCOME + '002' : None, # RPL_YOURHOST + '003' : None, # RPL_CREATED + '004' : None, # RPL_MYINFO + '005' : None, # RPL_ISUPPORT + '372' : None, # RPL_MOTD + '351' : None, # RPL_VERSION + '364' : None, # RPL_LINKS + '006' : None, # RPL_MAP + '018' : None, # RPL_MAPUSERS + '257' : None, # RPL_ADMINLOC1 + '258' : None, # RPL_ADMINLOC2 + '259' : None, # RPL_ADMINEMAIL + + # statistic information (lusers) + '250' : None, # RPL_STATSCONN + '251' : None, # RPL_LUSERCLIENT + '252' : None, # RPL_LUSEROP + '254' : None, # RPL_LUSERCHANNELS + '255' : None, # RPL_LUSERME + '265' : None, # RPL_LOCALUSERS + '266' : None, # RPL_GLOBALUSERS + + # channel information + '332' : None, # RPL_TOPIC + '353' : None, # RPL_NAMREPLY + '322' : None, # RPL_LIST + + # user information (whois/who) + '311' : None, # RPL_WHOISUSER + '307' : None, # RPL_WHOISREGNICK + '312' : None, # RPL_WHOISSERVER + '671' : None, # RPL_WHOISSECURE + '319' : None, # RPL_WHOISCHANNELS + '320' : None, # RPL_WHOISSPECIAL + '276' : None, # RPL_WHOISCERTFP + '330' : None, # RPL_WHOISACCOUNT + '338' : None, # RPL_WHOISACTUALLY + '352' : None, # RPL_WHOREPLY + + # bad numerics + '470' : None, # ERR_LINKCHANNEL + '471' : None, # ERR_CHANNELISFULL + '473' : None, # ERR_INVITEONLYCHAN + '474' : None, # ERR_BANNEDFROMCHAN + '475' : None, # ERR_BADCHANNELKEY + '477' : None, # ERR_NEEDREGGEDNICK + '489' : None, # ERR_SECUREONLYCHAN + '519' : None, # ERR_TOOMANYUSERS + '520' : None, # ERR_OPERONLY + '464' : None, # ERR_PASSWDMISMATCH + '465' : None, # ERR_YOUREBANNEDCREEP + '466' : None, # ERR_YOUWILLBEBANNED + '421' : None # ERR_UNKNOWNCOMMAND (unreal command throttling) +} + +def debug(data): + print('{0} | [~] - {1}'.format(time.strftime('%I:%M:%S'), data)) + +def error(data, reason=None): + if settings.errors: + print('{0} | [!] - {1} ({2})'.format(time.strftime('%I:%M:%S'), data, str(reason))) if reason else print('{0} | [!] - {1}'.format(time.strftime('%I:%M:%S'), data)) + +def rndnick(): + prefix = random.choice(['st','sn','cr','pl','pr','fr','fl','qu','br','gr','sh','sk','tr','kl','wr','bl']+list('bcdfgklmnprstvwz')) + midfix = random.choice(('aeiou'))+random.choice(('aeiou'))+random.choice(('bcdfgklmnprstvwz')) + suffix = random.choice(['ed','est','er','le','ly','y','ies','iest','ian','ion','est','ing','led','inger']+list('abcdfgklmnprstvwz')) + return prefix+midfix+suffix + +def ssl_ctx(): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + +class probe: + def __init__(self, server, semaphore): + self.server = server + self.semaphore = semaphore + self.snapshot = copy.deepcopy(snapshot) # <--- GET FUCKED PYTHON + self.channels = {'all':list(), 'current':list()} + self.cchannels = dict() + self.nicks = {'all':list(), 'check':list()} + self.loops = {'init':None,'chan':None,'nick':None} + self.reader = None + self.writer = None + + async def run(self): + async with self.semaphore: + try: + await self.connect() + except Exception as ex: + error(self.server.ljust(18) + ' | failed to connect using SSL/TLS', ex) + try: + await self.connect(True) + except Exception as ex: + error(self.server.ljust(18) + ' | failed to connect', ex) + + async def raw(self, data): + self.writer.write(data[:510].encode('utf-8') + b'\r\n') + await self.writer.drain() + + async def connect(self, fallback=False): + options = { + 'host' : self.server, + 'port' : 6667 if fallback else 6697, + 'limit' : 1024, + 'ssl' : None if fallback else ssl_ctx(), + 'family' : 2, # 2 = IPv4 | 10 = IPv6 (TODO: Check for IPv6 using server DNS) + 'local_addr' : settings.vhost + } + identity = { + 'nick': settings.nickname if settings.nickname else rndnick(), + 'user': settings.username if settings.username else rndnick(), + 'real': settings.realname if settings.realname else rndnick() + } + self.reader, self.writer = await asyncio.wait_for(asyncio.open_connection(**options), throttle.timeout) + await self.raw('USER {0} 0 * :{1}'.format(identity['user'], identity['real'])) + await self.raw('NICK ' + identity['nick']) + await self.listen() + for item in [rm for rm in self.snapshot if not self.snapshot[rm]]: + del self.snapshot[item] + with open(f'logs/{self.server.split()[0]}.json', 'w') as fp: + json.dump(self.snapshot, fp) + if '|' in self.server: + debug(self.server + 'finished scanning') + else: + debug(self.server.ljust(18) + ' | finished scanning') + + async def loop_initial(self): + try: + await asyncio.sleep(throttle.delay) + login = { + 'pass': settings.ns_pass if settings.ns_pass else rndnick(), + 'mail': settings.ns_mail if settings.ns_mail else '{rndnick()}@{rndnick()}.'+random.choice(('com','net','org')) + } + for command in ('ADMIN', 'VERSION', 'LINKS', 'MAP', 'PRIVMSG NickServ :REGISTER {0} {1}'.format(login['pass'], login['mail']), 'LIST'): + try: + await self.raw(command) + except: + break + else: + await asyncio.sleep(1.5) + if not self.channels['all']: + error(self.server + 'no channels found') + await self.raw('QUIT') + except asyncio.CancelledError: + pass + + async def loop_channels(self): + try: + while self.channels['all']: + while len(self.channels['current']) >= throttle.channels: + await asyncio.sleep(1) + chan = random.choice(self.channels['all']) + self.channels['all'].remove(chan) + try: + await self.raw('JOIN ' + chan) + except: + break + else: + await asyncio.sleep(throttle.join) + del self.cchannels[chan] + while self.nicks['check']: + await asyncio.sleep(1) + self.loops['nick'].cancel() + await self.raw('QUIT') + except asyncio.CancelledError: + pass + + async def loop_whois(self): + try: + while True: + if self.nicks['check']: + nick = random.choice(self.nicks['check']) + self.nicks['check'].remove(nick) + try: + await self.raw('WHOIS ' + nick) + except: + break + else: + await asyncio.sleep(throttle.whois) + else: + await asyncio.sleep(1) + except asyncio.CancelledError: + pass + + def parsers(self, line): + args = line.split() + numeric = args[1] + data = args[3:][1:] + if numeric == '001' and len(args) >= 7: + return args[6] if data.lower().startswith('welcome to the ') else line + + async def listen(self): + try: + while not self.reader.at_eof(): + data = await asyncio.wait_for(self.reader.readuntil(b'\r\n'), throttle.ztimeout) + line = data.decode('utf-8').strip() + #debug(line) + args = line.split() + numeric = args[1] + if line.startswith('ERROR :Closing Link:'): + raise Exception('Connection has closed.') + elif args[0] == 'PING': + await self.raw('PONG ' + args[1][1:]) + elif numeric == '001': #RPL_WELCOME + host = args[0][1:] + self.snapshot['server'] = self.server + self.snapshot['host'] = host + if len(host) > 25: + self.server = f'{self.server.ljust(18)} | {host[:25]} | ' + else: + self.server = f'{self.server.ljust(18)} | {host.ljust(25)} | ' + debug(self.server + 'connected') + self.loops['init'] = asyncio.create_task(self.loop_initial()) + elif numeric == '322' and len(args) >= 5: # RPL_LIST + chan = args[3] + users = args[4] + self.channels['all'].append(chan) + self.cchannels[chan] = users + elif numeric == '323': # RPL_LISTEND + if self.channels['all']: + debug(self.server + 'found {0} channel(s)'.format(str(len(self.channels['all'])))) + self.loops['chan'] = asyncio.create_task(self.loop_channels()) + self.loops['nick'] = asyncio.create_task(self.loop_whois()) + elif numeric == '352' and len(args) >= 8: # RPL_WHORPL + nick = args[7] + if nick not in self.nicks['all']: + self.nicks['all'].append(nick) + self.nicks['check'].append(nick) + elif numeric == '366' and len(args) >= 4: # RPL_ENDOFNAMES + chan = args[3] + self.channels['current'].append(chan) + if chan in self.cchannels: + debug(self.server + f'scanning {self.cchannels[chan].ljust(4)} users in {chan}') + else: + debug(self.server + f'scanning users in {chan}') + await self.raw('WHO ' + chan) + await asyncio.sleep(throttle.part) + await self.raw('PART ' + chan) + self.channels['current'].remove(chan) + elif numeric == '421' and len(args) >= 3: # ERR_UNKNOWNCOMMAND + msg = ' '.join(args[2:]) + if 'You must be connected for' in msg: + error(self.server + 'delay found', msg) + elif numeric == '433': # ERR_NICKINUSE + if not settings.nickname: + await self.raw('NICK ' + rndnick()) + else: + await self.raw('NICK ' + settings.nickname + str(random.randint(1000,9999))) + elif numeric == '464': # ERR_PASSWDMISMATCH + error(self.server + 'network has a password') + elif numeric == 'NOTICE': + nick = args[0].split('!')[1:] + msg = ' '.join(args[3:])[1:] + if nick == 'NickServ': + self.snapshot['services'] = True + for i in ('You must have been using this nick for','You must be connected for','not connected long enough','Please wait', 'You cannot list within the first'): + if i in msg: + error(self.server + 'delay found', msg) + elif numeric == 'PRIVMSG': + nick = args[0].split('!')[0][1:] + if nick == 'NickServ': + self.snapshot['services'] = True + if numeric in self.snapshot: + if not self.snapshot[numeric]: + self.snapshot[numeric] = line + elif line not in self.snapshot[numeric]: + if type(self.snapshot[numeric]) == list: + self.snapshot[numeric].append(line) + elif type(self.snapshot[numeric]) == str: + self.snapshot[numeric] = [self.snapshot[numeric], line] + else: + self.snapshot['raw'].append(line) + except (UnicodeDecodeError, UnicodeEncodeError): + pass + except Exception as ex: + if '|' in self.server: + error(self.server + 'fatal error occured', ex) + else: + error(self.server.ljust(18) + 'fatal error occured', ex) + finally: + for item in self.loops: + if self.loops[item]: + self.loops[item].cancel() + +async def main(targets): + sema = asyncio.BoundedSemaphore(throttle.threads) # B O U N D E D S E M A P H O R E G A N G + jobs = list() + for target in targets: + jobs.append(asyncio.ensure_future(probe(target, sema).run())) + await asyncio.gather(*jobs) + +# Main +print('#'*56) +print('#{:^54}#'.format('')) +print('#{:^54}#'.format('Internet Relay Chat Probe (IRCP)')) +print('#{:^54}#'.format('Developed by acidvegas in Python')) +print('#{:^54}#'.format('https://git.acid.vegas/ircp')) +print('#{:^54}#'.format('')) +print('#'*56) +if len(sys.argv) != 2: + raise SystemExit('error: invalid arguments') +else: + targets_file = sys.argv[1] +if not os.path.isfile(targets_file): + raise SystemExit('error: invalid file path') +else: + targets = [line.rstrip() for line in open(targets_file).readlines() if line] + found = len(targets) + debug(f'loaded {found:,} targets') + targets = [target for target in targets if not os.path.isfile(f'logs/{target}.json')] # Do not scan targets we already have logged for + if len(targets) < found: + debug(f'removed {found-len(targets):,} targets we already have logs for already') + random.shuffle(targets) + try: + os.mkdir('logs') + except FileExistsError: + pass + loop = asyncio.get_event_loop() + loop.run_until_complete(main(targets)) + debug('IRCP has finished probing!') \ No newline at end of file