diff --git a/kevin/Makefile b/kevin/Makefile new file mode 100644 index 0000000..36b41f9 --- /dev/null +++ b/kevin/Makefile @@ -0,0 +1,26 @@ +FAKE = fakeroot -s fake -i fake +INSTALL = $(FAKE) install + +all: kevin.tce + +kevin.tce: target + $(FAKE) sh -c 'cd target && tar -czf - --exclude=placeholder --exclude=*~ .' > $@ + + +target: kevin.py irc.pyc run log.run + $(INSTALL) -d target/usr/lib/ctf + $(INSTALL) kevin.py irc.py target/usr/lib/ctf + + $(INSTALL) --owner=100 -d target/var/lib/ctf/kevin/tokens + + $(INSTALL) -d target/var/service/kevin + $(INSTALL) run target/var/service/kevin/run + + $(INSTALL) -d target/var/service/kevin/log + $(INSTALL) log.run target/var/service/kevin/log/run + +clean: + rm -rf target kevin.tce fake + +%.pyc: %.py + python3 -c 'import $*' diff --git a/kevin/irc.py b/kevin/irc.py new file mode 100755 index 0000000..e57338b --- /dev/null +++ b/kevin/irc.py @@ -0,0 +1,528 @@ +#! /usr/bin/env python3 + +import asynchat +import asyncore +import socket +import sys +import traceback +import time + +class IRCHandler(asynchat.async_chat): + """IRC Server connection. + + This is the one you want to derive your connection classes from. + + """ + + debug = False + heartbeat_interval = 1 # seconds per heartbeat + + def __init__(self, host=None, nick=None, gecos=None): + asynchat.async_chat.__init__(self) + self.line = b'' + self.timers = [] + self.last_heartbeat = 0 + self.set_terminator(b'\r\n') + if host: + self.open_connection(host, nick, gecos) + + def dbg(self, msg): + if self.debug: + print(msg) + + def open_connection(self, host, nick, gecos): + self.nick = nick + self.gecos = gecos + self.host = host + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.connect(host) + + def handle_connect(self): + self.write(['NICK', self.nick]) + self.write(['USER', self.nick, '+iw', self.nick], self.gecos) + + def connect(self, host): + self.waiting = False + asynchat.async_chat.connect(self, host) + + def heartbeat(self): + """Invoke all timers.""" + + if not self.timers: + return + timers, self.timers = self.timers, [] + now = time.time() + for t, cb in timers: + if t > now: + self.timers.append((t, cb)) + else: + cb() + + def add_timer(self, secs, callback): + """After secs seconds, call callback""" + self.timers.append((time.time() + secs, callback)) + + def readable(self): + """Called by asynchat to see if we're readable. + + We hook our heartbeat in here. + """ + + now = time.time() + if now > self.last_heartbeat + self.heartbeat_interval: + self.heartbeat() + self.last_heartbeat = now + + if self.connected: + return asynchat.async_chat.readable(self) + else: + return False + + def collect_incoming_data(self, data): + """Called by asynchat when data arrives""" + self.line += data + + def found_terminator(self): + """Called by asynchat when it finds the terminating character. + """ + line = self.line.decode('utf-8') + self.line = b'' + self.parse_line(line) + + def write(self, args, text=None): + """Send out an IRC command + + This function helps to prevent you from shooting yourself in the + foot, by forcing you to send commands that are in a valid format + (although it doesn't check the validity of the actual commands). + + As we all know, IRC commands take the form + + :COMMAND ARG1 ARG2 ARG3 ... :text string + + where 'text string' is optional. Well, that's exactly how this + function works. Args is a list of length at least one, and text + string is a string. + + write(['PRIVMSG', nick], 'Hello 12') + + will send + + PRIVMSG nick :Hello 12 + + """ + + cmdstr = ' '.join(args) + if text: + cmdstr = '%s :%s' % (cmdstr, text) + self.dbg('-> %s' % cmdstr) + try: + line = '%s\n' % cmdstr + self.send(line.encode('utf-8')) + except socket.error: + pass + + + def parse_line(self, line): + """Parse a server-provided line + + This does all the magic of parsing those ill-formatted IRC + messages. It will also decide if a PRIVMSG or NOTICE is using + CTCP (the client-to-client protocol, which by convention is any + of the above messages with ^A on both ends of the text. + + This function goes on to invoke self.eval_triggers on the parsed + data like this: + + self.eval_triggers(operation, arguments, text) + + where operation and text are strings, and arguments is a list. + + It returns the same tuple (op, args, text). + + """ + + if (line[0] == ':'): + with_uname = 1 + line = line [1:] + else: + with_uname = 0 + try: + [args, text] = line.split(' :', 1) + args = args.split() + except ValueError: + args = line.split() + text = '' + if (with_uname != 1): + op = args[0] + elif ((args[1] in ["PRIVMSG", "NOTICE"]) and + (text and (text[0] == '\001') and (text[-1] == '\001'))): + op = "C" + args[1] + text = text[1:-1] + else: + op = args[1] + self.dbg("<- %s %s %s" % (op, args, text)) + self.handle(op, args, text) + return (op, args, text) + + + def handle(self, op, args, text): + """Take action on a server message + + Right now, just invokes + + self.do_[op](args, text) + + where [op] is the operation passed in. + + This is a good method to overload if you want a really advanced + client supporting bindings. + + """ + try: + method = getattr(self, "do_" + lower(op)) + except AttributeError: + self.dbg("Unhandled: %s" % (op, args, text)) + return + method(args, text) + + +class Recipient: + """Abstract recipient object""" + + def __init__(self, interface, name): + self._interface = interface + self._name = name + + def __repr__(self): + return 'Recipient(%s)' % self.name() + + def name(self): + return self._name + + def is_channel(self): + return False + + def write(self, cmd, addl): + """Write a raw IRC command to our interface""" + + self._interface.write(cmd, addl) + + def cmd(self, cmd, text): + """Send a command to ourself""" + + self.write([cmd, self._name], text) + + def msg(self, text): + """Tell the recipient something""" + + self.cmd("PRIVMSG", text) + + def notice(self, text): + """Send a notice to the recipient""" + + self.cmd("NOTICE", text) + + def ctcp(self, command, text): + """Send a CTCP command to the recipient""" + + return self.msg("\001%s %s\001" % (command.upper(), text)) + + def act(self, text): + """Send an action to the recipient""" + + return self.ctcp("ACTION", text) + + def cnotice(self, command, text): + """Send a CTCP notice to the recipient""" + + return self.notice("\001%s %s\001" % (command.upper(), text)) + +class Channel(Recipient): + def __repr__(self): + return 'Channel(%s)' % self.name() + + def is_channel(self): + return True + +class User(Recipient): + def __init__(self, interface, name, user, host, op=False): + Recipient.__init__(self, interface, name) + self.user = user + self.host = host + self.op = op + + def __repr__(self): + return 'User(%s, %s, %s)' % (self.name(), self.user, self.host) + +def recipient(interface, name): + if name[0] in ["&", "#"]: + return Channel(interface, name) + else: + return User(interface, name, None, None) + +class SmartIRCHandler(IRCHandler): + """This is like the IRCHandler, except it creates Recipient objects + for IRC messages. The intent is to make it easier to write stuff + without knowledge of the IRC protocol. + + """ + + def recipient(self, name): + return recipient(self, name) + + def err(self, exception): + if self.debug: + traceback.print_exception(*exception) + + def handle(self, op, args, text): + """Parse more, creating objects and stuff + + makes a call to self.handle_op(sender, forum, addl) + + sender is always a Recipient object; if you want to reply + privately, you can send your reply to sender. + + forum is a Recipient object corresponding with the forum over + which the message was carried. For user-to-user PRIVMSG and + NOTICE commands, this is the same as sender. For those same + commands sent to a channel, it is the channel. Thus, you can + always send a reply to forum, and it will be sent back in an + appropriate manner (ie. the way you expect). + + addl is a tuple, containing additional information which might + be relelvant. Here's what it will contain, based on the server + operation: + + op | addl + ---------+---------------- + PRIVMSG | text of the message + NOTICE | text of the notice + CPRIVMSG | CTCP command, text of the command + CNOTICE | CTCP response, text of the response + KICK * | victim of kick, kick text + MODE * | all mode args + JOIN * | empty + PART * | empty + QUIT | quit message + PING | ping text + NICK ! | old nickname + others | all arguments; text is last element + + * The forum in these items is the channel to which the action + pertains. + ! The sender for the NICK command is the *new* nickname. This + is so you can send messages to the sender object and they'll + go to the right place. + """ + + try: + sender = User(self, *unpack_nuhost(args)) + except ValueError: + sender = None + forum = None + addl = () + + if op in ("PRIVMSG", "NOTICE"): + # PRIVMSG ['neale!~user@127.0.0.1', 'PRIVMSG', '#hydra'] firebot, foo + # PRIVMSG ['neale!~user@127.0.0.1', 'PRIVMSG', 'firebot'] firebot, foo + try: + if args[2][0] in '#&': + forum = self.recipient(args[2]) + else: + forum = sender + addl = (text,) + except IndexError: + addl = (text, args[1]) + elif op in ("CPRIVMSG", "CNOTICE"): + forum = self.recipient(args[2]) + splits = text.split(" ") + if splits[0] == "DCC": + op = "DC" + op + addl = (splits[1],) + tuple(splits[2:]) + else: + addl = (splits[0],) + tuple(splits[1:]) + elif op in ("KICK",): + forum = self.recipient(args[2]) + addl = (self.recipient(args[3]), text) + elif op in ("MODE",): + forum = self.recipient(args[2]) + addl = args[3:] + elif op in ("JOIN", "PART"): + try: + forum = self.recipient(args[2]) + except IndexError: + forum = self.recipient(text) + elif op in ("QUIT",): + addl = (text,) + elif op in ("PING", "PONG"): + # PING ['PING'] us.boogernet.org. + # PONG ['irc.foonet.com', 'PONG', 'irc.foonet.com'] 1114199424 + addl = (text,) + elif op in ("NICK",): + # NICK ['brad!~brad@10.168.2.33', 'NICK'] bradaway + # + # The sender is the new nickname here, in case you want to + # send something to the sender. + + # Apparently there are two different standards for this + # command. + if text: + sender = self.recipient(text) + else: + sender = self.recipient(args[2]) + addl = (unpack_nuhost(args)[0],) + elif op in ("INVITE",): + # INVITE [u'pflarr!~pflarr@www.clanspum.net', u'INVITE', u'gallium', u'#mysterious'] + # INVITE [u'pflarr!~pflarr@www.clanspum.net', u'INVITE', u'gallium'] #mysterious + if len(args) > 3: + forum = self.recipient(args[3]) + else: + forum = self.recipient(text) + else: + try: + int(op) + except ValueError: + self.dbg("WARNING: unknown server code: %s" % op) + addl = tuple(args[3:]) + (text,) + + try: + self.handle_cooked(op, sender, forum, addl) + except SystemExit: + raise + except: + self.err(sys.exc_info()) + + def handle_cooked(self, op, sender, forum, addl): + try: + func = getattr(self, 'cmd_' + op.lower()) + except AttributeError: + self.unhandled(op, sender, forum, addl) + return + func(sender, forum, addl) + + def cmd_ping(self, sender, forum, addl): + self.write(['PONG'], addl[0]) + + def unhandled(self, op, sender, forum, addl): + """Handle all the stuff that had no handler. + + This is a special handler in that it also gets the server code + as the first argument. + + """ + + self.dbg("unhandled: %s" % ((op, sender, forum, addl),)) + + +class Bot(SmartIRCHandler): + """A simple bot. + + This automatically joins the channels you pass to the constructor, + tries to use one of the nicks provided, and reconnects if it gets + booted. You can use this as a base for more sophisticated bots. + + """ + + def __init__(self, host, nicks, gecos, channels): + self.nicks = nicks + self.channels = channels + self.waiting = True + self._spool = [] + SmartIRCHandler.__init__(self, host, nicks[0], gecos) + + def despool(self, target, lines): + """Slowly despool a bunch of lines to a target + + Since the IRC server will block all output if we send it too + fast, use this to send large multi-line responses. + + """ + + self._spool.append((target, list(lines))) + + def heartbeat(self): + SmartIRCHandler.heartbeat(self) + + # Despool data + if self._spool: + # Take the first one on the queue, and put it on the end + which = self._spool[0] + del self._spool[0] + self._spool.append(which) + + # Despool a line + target, lines = which + if lines: + line = lines[0] + target.msg(line) + del lines[0] + else: + self._spool.remove(which) + + def announce(self, text): + for c in self.channels: + self.write(['PRIVMSG', c], text) + + def err(self, exception): + SmartIRCHandler.err(self, exception) + self.announce('*bzert*') + + def cmd_001(self, sender, forum, addl): + for c in self.channels: + self.write(['JOIN'], c) + + def writable(self): + if not self.waiting: + return asynchat.async_chat.writable(self) + else: + return False + + def write(self, *args): + SmartIRCHandler.write(self, *args) + + def close(self, final=False): + SmartIRCHandler.close(self) + if not final: + self.dbg("Connection closed, reconnecting...") + self.waiting = True + self.connected = 0 + # Wait a bit and reconnect + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.add_timer(23, lambda : self.connect(self.host)) + + def handle_close(self): + self.close() + + +## +## Miscellaneous IRC functions +## + +def unpack_nuhost(nuhost): + """Unpack nick!user@host + + Frequently, the first argument in a server message is in + nick!user@host format. You can just pass your whole argument list + to this function and get back a tuple containing: + + (nick, user, host) + + """ + + try: + [nick, uhost] = nuhost[0].split('!', 1) + [user, host] = uhost.split('@', 1) + except ValueError: + raise ValueError("not in nick!user@host format") + return (nick, user, host) + +def run_forever(timeout=2.0): + """Run your clients forever. + + Just a handy front-end to asyncore.loop, so you don't have to import + asyncore yourself. + + """ + + asyncore.loop(timeout) diff --git a/kevin/kevin.py b/kevin/kevin.py new file mode 100755 index 0000000..cba0525 --- /dev/null +++ b/kevin/kevin.py @@ -0,0 +1,147 @@ +#! /usr/bin/env python3 + +import irc +import os +import optparse +import asynchat +import socket +import asyncore +from urllib.parse import quote_plus as quote + +nobody = '\002[nobody]\002' + +class Flagger(asynchat.async_chat): + """Connection to flagd""" + + def __init__(self, addr, auth): + asynchat.async_chat.__init__(self) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.connect((addr, 6668)) + self.push(auth + b'\n') + self.flag = None + + def handle_read(self): + msg = self.recv(4096) + raise ValueError("Flagger died: %r" % msg) + + def handle_error(self): + # If we lose the connection to flagd, nobody can score any + # points. Terminate everything. + asyncore.close_all() + asynchat.async_chat.handle_error(self) + + def set_flag(self, team): + if team: + eteam = team.encode('utf-8') + else: + eteam = b'' + self.push(eteam + b'\n') + self.flag = team + + +class Kevin(irc.Bot): + def __init__(self, host, flagger, tokens, victims): + irc.Bot.__init__(self, host, ['kevin'], 'Kevin', ['#kevin']) + self.flagger = flagger + self.tokens = tokens + self.victims = victims + self.affiliation = {} + + def cmd_join(self, sender, forum, addl): + if sender.name() in self.nicks: + self.tell_flag(forum) + + def err(self, exception): + """Save the traceback for later inspection""" + irc.Bot.err(self, exception) + t,v,tb = exception + info = [] + while 1: + info.append('%s:%d(%s)' % (os.path.basename(tb.tb_frame.f_code.co_filename), + tb.tb_lineno, + tb.tb_frame.f_code.co_name)) + tb = tb.tb_next + if not tb: + break + del tb # just to be safe + infostr = '[' + '] ['.join(info) + ']' + self.last_tb = '%s %s %s' % (t, v, infostr) + print(self.last_tb) + + def tell_flag(self, forum): + forum.msg('%s has the flag.' % (self.flagger.flag or nobody)) + + def cmd_privmsg(self, sender, forum, addl): + text = addl[0] + if text.startswith('!'): + parts = text[1:].lower().split(' ', 1) + cmd = parts[0] + if len(parts) > 1: + args = parts[1] + else: + args = None + if cmd.startswith('r'): + # Register + who = sender.name() + if args: + self.affiliation[who] = args + team = self.affiliation.get(who, nobody) + forum.msg('%s is playing for %s' % (who, team)) + elif cmd.startswith('e'): + # Embrace + forum.ctcp('ACTION', 'hugs %s' % sender.name()) + elif cmd.startswith('f'): + # Flag + self.tell_flag(forum) + elif cmd.startswith('h'): + # Help + forum.msg('Goal: Obtain a token with social engineering.') + forum.msg('Commands: !help, !flag, !register [TEAM], !claim TOKEN, !victims, !embrace') + elif cmd.startswith('c') and args: + # Claim + sn = sender.name() + team = self.affiliation.get(sn) + token = quote(args, safe='') + fn = os.path.join(self.tokens, token) + if not team: + forum.msg('%s: register first (!register TEAM).' % sn) + elif self.flagger.flag == team: + forum.msg('%s: Greedy, greedy.' % sn) + elif not os.path.exists(fn): + forum.msg('%s: Token does not exist (possibly already claimed).' % sn) + else: + os.unlink(fn) + self.flagger.set_flag(team) + self.tell_flag(forum) + elif cmd.startswith('v'): + # Victims + # Open the file each time, so it can change + try: + for line in open(self.victims): + forum.msg(line.strip()) + except IOError: + forum.msg('There are no victims!') + elif cmd == 'traceback': + forum.msg(self.last_tb or 'No traceback') + +def main(): + p = optparse.OptionParser() + p.add_option('-t', '--tokens', dest='tokens', default='./tokens', + help='Directory containing tokens') + p.add_option('-v', '--victims', dest='victims', default='victims.txt', + help='File containing victims information') + p.add_option('-i', '--ircd', dest='ircd', default='localhost', + help='IRC server to connect to') + p.add_option('-f', '--flagd', dest='flagd', default='localhost', + help='Flag server to connect to') + p.add_option('-p', '--password', dest='password', + default='kevin:::7db3e44d53d4a466f8facd7b7e9aa2b7', + help='Flag server password') + opts, args = p.parse_args() + + f = Flagger(opts.flagd, opts.password.encode('utf-8')) + k = Kevin((opts.ircd, 6667), f, opts.tokens, opts.victims) + irc.run_forever() + +if __name__ == '__main__': + main() diff --git a/kevin/log.run b/kevin/log.run new file mode 100755 index 0000000..c789de7 --- /dev/null +++ b/kevin/log.run @@ -0,0 +1,3 @@ +#! /bin/sh + +exec logger -t kevin diff --git a/kevin/run b/kevin/run new file mode 100755 index 0000000..62c4a5c --- /dev/null +++ b/kevin/run @@ -0,0 +1,5 @@ +#! /bin/sh + +[ -f /var/lib/ctf/disabled/kevin ] && exit 0 + +exec envuidgid ctf /usr/lib/ctf/kevin.py --victims=/var/lib/ctf/kevin/victims.txt --tokens=/var/lib/ctf/kevin/tokens diff --git a/kevin/victims.txt b/kevin/victims.txt new file mode 100644 index 0000000..c658630 --- /dev/null +++ b/kevin/victims.txt @@ -0,0 +1,4 @@ +Michael Smith (505)555-1212 SMS OK +Janet Berger (505)555-7382 +Dimwit Flathead dimwit on irc.efnet.net +Jacob Schmidt jacob@dirtbags.net