diff --git a/badmath/Flagger.py b/badmath/Flagger.py new file mode 100644 index 0000000..8722ea1 --- /dev/null +++ b/badmath/Flagger.py @@ -0,0 +1,31 @@ +import asynchat +import asyncore +import socket + +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 diff --git a/badmath/Gyopi.py b/badmath/Gyopi.py new file mode 100644 index 0000000..2eb26f4 --- /dev/null +++ b/badmath/Gyopi.py @@ -0,0 +1,255 @@ +import irc +import badmath +import time +import os +import traceback +import pickle +from hashlib import sha256 + +import Flagger + +class Gyopi(irc.Bot): + STATE_FN = 'pi.state' + + SALT = b'this is questionable.' + + FLAG_DEFAULT = 'dirtbags' + MAX_ATTEMPT_RATE = 3 + NOBODY = '\002[nobody]\002' + + FLAG_HOST = b'ctf1.lanl.gov' +# FLAG_HOST = b'localhost' + + def __init__(self, host, dataPath, flagger): + irc.Bot.__init__(self, host, ['gyupi'], 'Gyupi', ['#badmath']) + + self._dataPath = dataPath + + self._flag = flagger + + try: + self._loadState() + except: + traceback.print_exc() + self._lvl = 0 + self._flag.set_flag( self.FLAG_DEFAULT ) + + self._lastAttempt = {} + self._affiliations = {} + self._newPuzzle() + + 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 cmd_join(self, sender, forum, addl): + """On join, announce who has the flag.""" + if sender.name() in self.nicks: + self._tellFlag(forum) + self._tellPuzzle(forum) + + def _newPuzzle(self): + """Create a new puzzle.""" + self._key, self._puzzle, self._banned = badmath.mkPuzzle(self._lvl) + + def _loadState(self): + """Load the last state from the stateFile.""" + statePath = os.path.join(self._dataPath, self.STATE_FN) + stateFile = open( statePath, 'br' ) + state = pickle.load(stateFile) + self._lvl = state['lvl'] + self._flag.set_flag( state['flag'] ) + self._lastAttempt = state['lastAttempt'] + self._affiliations = state['affiliations'] + self._puzzle = state['puzzle'] + self._key = state['key'] + self._banned = state['banned'] + self._tokens = state.get('tokens', []) + + def _saveState(self): + """Write the current state to file.""" + state = {'lvl': self._lvl, + 'flag': self._flag.flag, + 'lastAttempt': self._lastAttempt, + 'affiliations': self._affiliations, + 'puzzle': self._puzzle, + 'key': self._key, + 'banned': self._banned, + 'tokens': self._tokens} + + # Do the write as an atomic move operation + statePath = os.path.join(self._dataPath, self.STATE_FN) + stateFile = open(statePath + '.tmp', 'wb') + pickle.dump(state, stateFile) + stateFile.close() + os.move( statePath + '.tmp', statePath) + + def _tellFlag(self, forum): + """Announce who owns the flag.""" + forum.msg('%s has the flag.' % (self._flag.flag)) + forum.msg('Difficulty level is %d' % self._lvl) + + def _tellPuzzle(self, forum): + """Announce the current puzzle.""" + forum.msg('The problem is: %s' % ' '.join( map(str, self._puzzle))) + + def _getStations(self): + stations = {} + with open(os.path.join(STORAGE, 'stations.txt')) as file: + lines = file.readlines() + for line in lines: + try: + name, file = line.split(':') + except: + continue + stations[name] = file + + return stations + + def _giveToken(self, user, forum): + """Hand a Jukebox token to the user.""" + + token = self._jukebox.mkToken(user) + + forum.msg('You get a jukebox token: %s' % token) + forum.msg('Use this with the !set command to change the music.') + forum.msg('This token is specific to your user name, and is only ' + 'useable once.') + + def _useToken(self, user, forum, token, station): + """Use the given token, and change the current station to station.""" + try: + station = int(station) + stations = self._getStations() + assert station in stations + except: + forum.msg('%s: Invalid Station (%s)' % station) + return + + if token in self._tokens[user]: + self._tokens[user].remove(token) + + + def cmd_privmsg(self, sender, forum, addl): + text = addl[0] + who = sender.name() + 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 + if args: + self._affiliations[who] = args + team = self._affiliations.get(who, self.NOBODY) + forum.msg('%s is playing for %s' % (who, team)) + elif cmd.startswith('w'): + forum.msg('Teams:') + for player in self._affiliations: + forum.msg('%s: %s' % (player, self._affiliations[player])) + elif cmd.startswith('embrace'): + # Embrace + forum.ctcp('ACTION', 'is devoid of emotion.') + elif cmd.startswith('f'): + # Flag + self._tellFlag(forum) + elif cmd.startswith('h'): + # Help + forum.msg('Goal: Help me with my math homework, FROM ANOTHER DIMENSION!') + forum.msg('Goal: The current winner gets to control the contest music.') + forum.msg('Commands: !help, !flag, !register [TEAM], !solve SOLUTION,!? EQUATION, !ops, !problem', '!who') + elif cmd.startswith('prob'): + self._tellPuzzle(forum) + elif cmd.startswith('solve') and args: + # Solve + team = self._affiliations.get(who) + lastAttempt = time.time() - self._lastAttempt.get(team, 0) + answer = badmath.solve(self._key, self._puzzle) + try: + attempt = int(''.join(args).strip()) + except: + forum.msg("%s: Answers are always integers.") + if not team: + forum.msg('%s: register first (!register TEAM).' % who) + elif self._flag.flag == team: + forum.msg('%s: Greedy, greedy.' % who) + elif lastAttempt < self.MAX_ATTEMPT_RATE: + forum.msg('%s: Wait at least %d seconds between attempts' % + (team, self.MAX_ATTEMPT_RATE)) + elif answer == attempt: + self._flag.set_flag( team ) + self._lvl = self._lvl + 1 + self._tellFlag(forum) + self._newPuzzle() + self._tellPuzzle(forum) +# self._giveToken(who, sender) + self._saveState() + else: + forum.msg('%s: %s != %s' % (who, attempt, answer)) + forum.msg('%s: That is not correct.' % who) + + # Test a simple one op command. + elif cmd.startswith('?'): + try: + tokens = badmath.parse(''.join(args)) + except (ValueError) as msg: + forum.msg('%s: %s' % (who, msg)) + return + + if len(tokens) > 3: + forum.msg('%s: You can only test one op at a time.' % who) + + for num in self._banned: + if num in tokens: + forum.msg('%s: You can\'t test numbers in the ' + 'puzzle.' % who) + return + + try: + result = badmath.solve(self._key, tokens) + forum.msg('%s: %s -> %d' % (who, ''.join(args), result)) + except: + forum.msg("%s: That doesn't work at all." % who) + + elif cmd == 'birdzerk': + self._saveState() + + elif cmd == 'traceback': + forum.msg(self.last_tb or 'No traceback') + +if __name__ == '__main__': + import optparse + + p = optparse.OptionParser() + p.add_option('-h', '--host', dest='ircHost', default='localhost', + 'IRC Host to connect to.') + p.add_option('-f', '--flagd', dest='flagd', default='localhost', + 'Flag Server to connect to') + p.add_option('-p', '--password', dest='password', + default='badmath:::a41c6753210c0bdafd84b3b62d7d1666', + help='Flag server password') + p.add_option('-d', '--path', dest='path', default='/var/lib/badmath', + 'Path to where we can store state info.') + + opts, args = p.parse_args() + + flagger = Flagger.Flagger(opts.flagd, opts.password.encode('utf-8')) + gyopi = Gyopi((opts.ircHost, 6667), opts.path, flagger) + irc.run_forever() diff --git a/badmath/Jukebox.py b/badmath/Jukebox.py new file mode 100644 index 0000000..4977aaf --- /dev/null +++ b/badmath/Jukebox.py @@ -0,0 +1,56 @@ +import subprocess +import os + +class Jukebox: + + SALT = 'this is unreasonable.' + + def __init__(self, dataDir, tokens): + + self._dataDir = dataDir + self.tokens = tokens + + self.station = None + self._player = None + + def getStations(self): + stations = {} + with open(os.path.join(STORAGE, 'stations.txt')) as file: + lines = file.readlines() + for line in lines: + try: + name, file = line.split(':') + except: + continue + stations[name] = file + return stations + + def play(self, user, token, station): + """Switch to the given station, assuming it and the token are valid. + raises a ValueError when either the station or token is unknown.""" + + station = int(station) + stations = self.getStations() + if station not in stations: + raise ValueError('Invalid Station (%s)' % station) + + if token not in self.tokens: + raise ValueError('Invalid Token (%s)' % token) + + self.tokens.remove(token) + self._changeStation( stations[station] ) + + def mkToken(self, user): + """Generate a token for the given user. The token is a randomly + generate bit of text.""" + hash = sha256(self.SALT) + hash.update(bytes(user, 'utf-8')) + hash.update(bytes(str(time.time()), 'utf-8')) + token = has.hex_digest()[:10] + + self.tokens.append(token) + + return token + + def _changeStation(self, file): + diff --git a/badmath/badmath.py b/badmath/badmath.py new file mode 100644 index 0000000..c9a48d0 --- /dev/null +++ b/badmath/badmath.py @@ -0,0 +1,118 @@ +import random +import math + +OPS = [lambda a, b: a + b, + lambda a, b: a - b, + lambda a, b: a * b, + lambda a, b: a // b, + lambda a, b: a % b, + lambda a, b: a ^ b, + lambda a, b: a | b, + lambda a, b: a & b, + lambda a, b: max(a,b), + lambda a, b: min(a,b), + lambda a, b: a+b//2, + lambda a, b: ~b, + lambda a, b: a + b + 3, + lambda a, b: max(a,b)//2, + lambda a, b: min(a,b)*3, + lambda a, b: a % 2, + lambda a, b: math.degrees(b + a), + lambda a, b: ~(a & b), + lambda a, b: ~(a ^ b), + lambda a, b: a + b - a%b, + lambda a, b: math.factorial(a)//math.factorial(a-b) if a > b else 0, + lambda a, b: (b%a) * (a%b), + lambda a, b: math.factorial(a)%b, + lambda a, b: int(math.sin(a)*b), + lambda a, b: b + a%2, + lambda a, b: a - 1 + b%3, + lambda a, b: a & 0xaaaa, + lambda a, b: 5 if a == b else 6, + lambda a, b: b % 17, + lambda a, b: int( cos( math.radians(b) ) * a )] + +SYMBOLS = '.,<>?/!@#$%^&*()_+="~|;:' +MAX = 100 + +PLAYER_DIR = '' + +def mkPuzzle(lvl): + """Make a puzzle. The puzzle is a simple integer math equation. The trick + is that the math operators don't do what you might expect, and what they do + is randomized each time (from a set list of functions). The equation is + evaluated left to right, with no other order of operations. + + The level determins both the length of the puzzle, and what functions are + enabled. The number of operators is half the level+2, and the number of + functions enabled is equal to the level. + + returns the key, puzzle, and the set of numbers used. + """ + + ops = OPS[:lvl + 1] + length = (lvl + 2)//2 + + key = {} + + bannedNums = set() + + puzzle = [] + for i in range(length): + num = random.randint(1,MAX) + bannedNums.add(num) + puzzle.append( num ) + symbol = random.choice(SYMBOLS) + if symbol not in key: + key[symbol] = random.randint(0, len(ops) - 1) + puzzle.append( symbol ) + + num = random.randint(1,MAX) + bannedNums.add(num) + puzzle.append( num ) + + return key, puzzle, bannedNums + +def parse(puzzle): + """Parse a puzzle string. If the string contains symbols not in + SYMBOLS, a ValueError is raised.""" + + parts = [puzzle] + for symbol in SYMBOLS: + newParts = [] + for part in parts: + if symbol in part: + terms = part.split(symbol) + newParts.append( terms.pop(0)) + while terms: + newParts.append(symbol) + newParts.append( terms.pop(0) ) + else: + newParts.append(part) + parts = newParts + + finalParts = [] + for part in parts: + part = part.strip() + if part in SYMBOLS: + finalParts.append( part ) + else: + try: + finalParts.append( int(part) ) + except: + raise ValueError("Invalid symbol: %s" % part) + + return finalParts + +def solve(key, puzzle): + + puzzle = list(puzzle) + stack = puzzle.pop(0) + + while puzzle: + symbol = puzzle.pop(0) + nextVal = puzzle.pop(0) + op = OPS[key[symbol]] + stack = op(stack, nextVal) + + return stack diff --git a/badmath/run b/badmath/run new file mode 100755 index 0000000..4477536 --- /dev/null +++ b/badmath/run @@ -0,0 +1,8 @@ +#! /bin/sh + +[ -f /var/lib/ctf/disabled/badmath ] && exit 0 + +DATA_PATH=/var/lib/badmath +mkdir -p $DATA_PATH + +exec envuidgid ctf python3.0 usr/lib/ctf/badmath/Gyopi.py --data=$DATA_PATH diff --git a/badmath/test.py b/badmath/test.py new file mode 100644 index 0000000..be736dc --- /dev/null +++ b/badmath/test.py @@ -0,0 +1,4 @@ +import Pi, irc + +pi = Pi.pi(('irc.lanl.gov', 6667), '') +irc.run_forever()