IRCP

- information gathering tool for irc servers
git clone git://git.acid.vegas/IRCP.git
Log | Files | Refs | Archive | README | LICENSE

commit d904c9ab97ab935b308e6424d77c74ce14c76046
Author: acidvegas <acid.vegas@acid.vegas>
Date: Thu, 25 May 2023 16:03:39 -0400

Initial commit

Diffstat:
A.screens/ircp.png | 0
ALICENSE | 15+++++++++++++++
AREADME.md | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aircp.py | 365+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

4 files changed, 482 insertions(+), 0 deletions(-)

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