diff --git a/.screens/ircp.png b/.screens/ircp.png
Binary files differ.
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2023, acidvegas <acid.vegas@acid.vegas>
+
+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
@@ -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
@@ -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
| | | |