|
# rfcat powered challenge server |
|
# 2017 - @leonjza |
|
# |
|
# BSidesCPT 2017 |
|
|
|
import base64 |
|
import binascii |
|
import datetime |
|
import os |
|
import pickle |
|
import threading |
|
import time |
|
from itertools import izip, cycle |
|
|
|
import rflib |
|
|
|
# information about our comms |
|
FREQUENCY = 868195500 |
|
SYNCWORD = 0xd0cb |
|
MODULATION = rflib.MOD_ASK_OOK |
|
BAUDRATE = 4800 |
|
MAX_PACKET_LEN = 150 |
|
|
|
# setup the radio |
|
d = rflib.RfCat() |
|
d.setFreq(FREQUENCY) |
|
d.setMdmSyncWord(SYNCWORD) |
|
d.setMdmModulation(MODULATION) |
|
d.makePktFLEN(MAX_PACKET_LEN) |
|
d.setMdmDRate(BAUDRATE) |
|
|
|
game_process_states = [ |
|
(0, 'New user connected'), |
|
(1, '88 Miles a hour'), |
|
(2, '1.21 Gigawatts'), # winner winner, no chicken dinner |
|
] |
|
gamestate_file = 'gamestate.pkl' |
|
|
|
|
|
def xor(payload): |
|
""" |
|
The xor method for the final unlock payload! |
|
|
|
:param payload: |
|
:return: |
|
""" |
|
|
|
key = 'fourth dimensionally!' |
|
|
|
return ''.join(chr(ord(c) ^ ord(k)) for c, k in izip(payload, cycle(key))) |
|
|
|
|
|
class ChallengePlayers(object): |
|
""" |
|
Affectively the 'state' of the game. A thread is |
|
created to save this object periodically. |
|
""" |
|
|
|
player_status_model = { |
|
'last_update': datetime.datetime.now(), |
|
'progress': 0 |
|
} |
|
|
|
def __init__(self): |
|
self.player_statuses = {} |
|
|
|
def update_player(self, name, progress=0): |
|
|
|
# never seen this player before? add them! |
|
if name not in self.player_statuses: |
|
self.player_statuses[name] = self.player_status_model |
|
|
|
return |
|
|
|
# update the status |
|
self.player_statuses[name]['progress'] = progress |
|
|
|
def get_player(self, name): |
|
|
|
if name in self.player_statuses: |
|
return self.player_statuses[name] |
|
|
|
return None |
|
|
|
def __str__(self): |
|
|
|
players = [] |
|
for player, player_state in self.player_statuses.iteritems(): |
|
players.append('<Player:{0}, LastUpdate:{1}, Progress:{2}>'.format(player, player_state['last_update'], |
|
player_state['progress'])) |
|
|
|
return '\n'.join(players) |
|
|
|
|
|
class GameLogic(object): |
|
""" |
|
This class defines the logic for a 'move' |
|
that has been made. |
|
""" |
|
|
|
def __init__(self, source, message_data, current_state): |
|
self.source = source |
|
self.message = message_data |
|
self.player_state = current_state |
|
|
|
def process_move(self): |
|
""" |
|
Process the message received from a source as a move. |
|
|
|
The return tuple indicates if an update is needed, |
|
what the new progress value should be and what message |
|
should be broadcast. |
|
|
|
Methods processing the actual progress updates may still |
|
perform further validation to ensure that the games logic |
|
is followed. |
|
|
|
:return: |
|
""" |
|
|
|
# first, newly connected players simply get a entry |
|
# in the games state. They should not have an existing |
|
# state and therefore are just recorded. |
|
if not self.player_state: |
|
return self._new_player_connected() |
|
|
|
if 'status' in self.message.lower(): |
|
return self._get_player_status() |
|
|
|
if any(x in self.message.lower() for x in ['88 miles an hour', 'eighty-eight miles an hour']): |
|
return self._update_stage_one() |
|
|
|
if 'unlock' in self.message.lower(): |
|
return self._update_stage_two() |
|
|
|
# if we had no idea what to do, default to nothing |
|
print('GameLogic can\'t do anything with this message: {0}'.format(self.message)) |
|
return False, None, None |
|
|
|
def _new_player_connected(self): |
|
""" |
|
Process a new connection. |
|
|
|
:return: |
|
""" |
|
|
|
return True, 0, 'Welcome {0}! The temporal displacement occurred at ' \ |
|
'exactly 1:20 AM and zero seconds! I just cant remember ' \ |
|
'how fast we went...'.format(self.source) |
|
|
|
def _get_player_status(self): |
|
""" |
|
Return a players status. |
|
|
|
:return: |
|
""" |
|
|
|
if not self.player_state: |
|
print('Somehow, we got a player status request for an unknown player. Damn hackers!') |
|
return False, None, 'Damn, I\'m late for school!' |
|
|
|
return False, None, '{0} is at stage: {1}'.format(self.source, self.player_state['progress']) |
|
|
|
def _update_stage_one(self): |
|
""" |
|
Set a players progress to 1 after the first |
|
flag has been correctly sent. |
|
|
|
:return: |
|
""" |
|
|
|
if not self.player_state: |
|
print('Somehow, we got a stage one flag an unknown player. Damn hackers!') |
|
return False, None, 'Damn, I\'m late for school!' |
|
|
|
return True, 1, '{0} has progressed to stage 1!'.format(self.source) |
|
|
|
def _update_stage_two(self): |
|
""" |
|
Process a stage two flag. |
|
|
|
:return: |
|
""" |
|
|
|
if not self.player_state: |
|
print('Somehow, we got a stage two flag an unknown player. Damn hackers!') |
|
return False, None, 'Damn, I\'m late for school!' |
|
|
|
# ensure the player has reached stage 1 at least |
|
if self.player_state['progress'] < 1: |
|
print('{0} tried to unlock without reaching progress level 1'.format(self.source)) |
|
return False, None, '{0}, slow down!!'.format(self.source) |
|
|
|
try: |
|
|
|
# get the base64 part and decode and run the xor |
|
_, payload_data = self.message.split(':') |
|
payload = xor(base64.b64decode(payload_data)) |
|
|
|
# get the user and flag |
|
payload_source, payload_flag = payload.split(':') |
|
|
|
# validate the user in the message as well as the user |
|
# in the encrypted flag. |
|
if self.source != payload_source: |
|
print('Received an unlock, but the source and payload source did not match') |
|
return False, None, '{0}, you\'re not thinking fourth dimensionally'.format(self.source) |
|
|
|
# check that the flag is correct |
|
if payload_flag.lower() == '1.21 gigawatts': |
|
return True, 2, '{0} has progressed to stage 2. The box should unlock!'.format(self.source) |
|
|
|
# |
|
# Something to unlock that box should go here! |
|
# |
|
|
|
except Exception as e: |
|
print('Invalid unlock payload from {0}'.format(self.source)) |
|
print('Error was: {0}'.format(e)) |
|
|
|
return False, None, '{0}, if my calculations are correct, when ' \ |
|
'this baby hits eighty-eight miles per hour, ' \ |
|
'you\'re gonna see some serious shit.'.format(self.source) |
|
|
|
# if the incorrect flag was given, nope the heck out |
|
print('Invalid flag received: {0}'.format(payload_flag)) |
|
return False, None, 'All right. This is an oldie, but, uh... well, it\'s an oldie where I come from.' |
|
|
|
|
|
class RadioState(object): |
|
""" |
|
A globally unique radio 'state'. |
|
|
|
This class is shared between the two threads used to |
|
send and receive messages. |
|
""" |
|
|
|
send_state = 1 |
|
receive_state = 0 |
|
|
|
def __init__(self, username): |
|
# default to receive state |
|
self.state = self.receive_state |
|
|
|
self.name = username |
|
self.state_change_needed = False |
|
self.new_state = None |
|
self.message_queue = [] |
|
|
|
def get_state(self): |
|
return self.state |
|
|
|
def set_receive_state(self): |
|
self.state = self.receive_state |
|
|
|
def is_receive_state(self): |
|
return self.state == self.receive_state |
|
|
|
def set_send_state(self): |
|
self.state = self.send_state |
|
|
|
def is_send_state(self): |
|
return self.state == self.send_state |
|
|
|
def want_state_change(self): |
|
return self.state_change_needed |
|
|
|
def change_state_to(self, new_state): |
|
self.state_change_needed = True |
|
self.new_state = new_state |
|
|
|
def change_state(self): |
|
self.state = self.new_state |
|
self.state_change_needed = False |
|
self.new_state = None |
|
|
|
def queue_new_message(self, message_data): |
|
|
|
# Lets chunk up the message and send it into parts |
|
# that will successfully move across the rf frames. |
|
# 50 characters seem to be 'it'. |
|
parts = [message_data[i:i + 50] for i in range(0, len(message_data), 50)] |
|
|
|
for part in parts: |
|
self.message_queue.append(binascii.hexlify( |
|
self.name + ': ' + part).ljust(MAX_PACKET_LEN, '0')) |
|
|
|
def get_messages_from_queue(self): |
|
|
|
# count the pending messages |
|
message_count = len(self.message_queue) |
|
|
|
# if we have messages to send, pop messages off |
|
# of the queue up to where we have counted. This |
|
# should hopefully help in case more messages |
|
# arrive as we are running *this* logic. |
|
if message_count > 0: |
|
return [self.message_queue.pop(0) for _ in xrange(message_count)] |
|
|
|
return None |
|
|
|
def __repr__(self): |
|
return '<State:{0} StateChangeNeeded:{1} NewState:{2}>'.format(self.state, self.state_change_needed, |
|
self.new_state) |
|
|
|
|
|
class ChallengeStateSaveThread(threading.Thread): |
|
""" |
|
A helper thread used to save the game state |
|
periodically. You know, incase shit does |
|
down or something. |
|
""" |
|
|
|
def __init__(self, challenge_player_state, *args, **kwargs): |
|
super(ChallengeStateSaveThread, self).__init__(*args, **kwargs) |
|
self.state = challenge_player_state |
|
|
|
self.current_thread = None |
|
|
|
def run(self): |
|
""" |
|
Every 3 seconds, dump the player state object to file. |
|
|
|
:return: |
|
""" |
|
|
|
self.current_thread = threading.currentThread() |
|
|
|
while True: |
|
|
|
# check if a stop is needed for this thread |
|
if self.should_stop(): |
|
break |
|
|
|
# wait |
|
time.sleep(1) |
|
|
|
# save the game state |
|
with open(gamestate_file, 'wb') as output: |
|
pickle.dump(self.state, output, pickle.HIGHEST_PROTOCOL) |
|
|
|
def should_stop(self): |
|
|
|
return hasattr(self.current_thread, 'stop') and getattr(self.current_thread, 'stop', True) |
|
|
|
|
|
class HintBroadcastThread(threading.Thread): |
|
""" |
|
This thread handles hint broadcasting at certain intervals. |
|
""" |
|
|
|
def __init__(self, radio_state, *args, **kwargs): |
|
super(HintBroadcastThread, self).__init__(*args, **kwargs) |
|
self.state = radio_state |
|
self.current_thread = None |
|
|
|
def run(self): |
|
|
|
self.current_thread = threading.currentThread() |
|
|
|
while True: |
|
|
|
if self.should_stop(): |
|
break |
|
|
|
# get the pending messages to send |
|
message_hint = 'hint:' + base64.encodestring(xor('If we could somehow... harness this lightning; ' + |
|
'channel it into the Flux Capacitor, it just might work.')) |
|
self.state.queue_new_message(message_hint.strip()) |
|
|
|
# get the pending messages to send |
|
messages_to_send = self.state.get_messages_from_queue() |
|
|
|
# if there are no messages to send, gtfo |
|
if not messages_to_send: |
|
time.sleep(0.5) |
|
continue |
|
|
|
# indicate that we need to change state |
|
self.state.change_state_to(state.send_state) |
|
|
|
# poll the state to see if we can send the message |
|
while not self.state.is_send_state(): |
|
time.sleep(0.5) |
|
|
|
# send the messages |
|
for message_data in messages_to_send: |
|
for _ in range(3): |
|
d.RFxmit(data=message_data, repeat=1) |
|
|
|
# change back to the receiving state |
|
self.reverse_state_to_receive() |
|
|
|
# wait 30 seconds before broadcasting again |
|
print('sleeping for 30, then sending hints again') |
|
|
|
# sleep for 30 seconds, but every second check if we |
|
# should stop this thread. |
|
for _ in range(30): |
|
if self.should_stop(): |
|
break |
|
time.sleep(1) |
|
|
|
def reverse_state_to_receive(self): |
|
self.state.change_state_to(state.receive_state) |
|
self.state.change_state() |
|
|
|
def should_stop(self): |
|
return hasattr(self.current_thread, 'stop') and getattr(self.current_thread, 'stop', True) |
|
|
|
|
|
class ListenThread(threading.Thread): |
|
""" |
|
The listening thread. |
|
|
|
If the global radio state is configured to listen, |
|
this thread will listen for a new packet for 1 second. |
|
|
|
If a request to flip the state is waiting the run() method |
|
will call to flip this and let the radio send stuff. |
|
""" |
|
|
|
def __init__(self, radio_state, challenge_player_state, *args, **kwargs): |
|
|
|
super(ListenThread, self).__init__(*args, **kwargs) |
|
self.state = radio_state |
|
self.player_state = challenge_player_state |
|
|
|
# get a handle on the current thread. We use this to |
|
# get the signal to quit of needed. |
|
self.current_thread = None |
|
self.received_messages = [] |
|
|
|
def run(self): |
|
|
|
self.current_thread = threading.currentThread() |
|
|
|
while True: |
|
|
|
# check if a stop is needed for this thread |
|
if self.should_stop(): |
|
break |
|
|
|
# process packet |
|
if self.state.is_receive_state(): |
|
self.listen_for_packet() |
|
else: |
|
# wait half a second to check state again |
|
time.sleep(0.5) |
|
|
|
# check if a state change is needed |
|
self.check_for_state_change() |
|
|
|
def should_stop(self): |
|
|
|
return hasattr(self.current_thread, 'stop') and getattr(self.current_thread, 'stop', True) |
|
|
|
def listen_for_packet(self): |
|
""" |
|
Listens for a new packet for 1 second. |
|
|
|
If a message is received, it is simply printed to the |
|
screen. |
|
|
|
:return: |
|
""" |
|
|
|
try: |
|
pkt, _ = d.RFrecv(timeout=1000) |
|
decoded_pkt = binascii.unhexlify(pkt) |
|
|
|
# skip if we have already received this message |
|
if decoded_pkt in self.received_messages: |
|
return |
|
|
|
self.received_messages.append(decoded_pkt) |
|
self.trim_messages(total=2) |
|
|
|
# handle the incoming message |
|
self.handle_incoming_message(incoming=decoded_pkt) |
|
|
|
except (rflib.ChipconUsbTimeoutException, TypeError): |
|
pass |
|
|
|
def check_for_state_change(self): |
|
|
|
if self.state.want_state_change(): |
|
self.state.change_state() |
|
|
|
def trim_messages(self, total): |
|
self.received_messages = self.received_messages[-total:] |
|
|
|
def handle_incoming_message(self, incoming): |
|
""" |
|
Handle an incoming, decoded frame with the games logic |
|
class. |
|
|
|
:param incoming: |
|
:return: |
|
""" |
|
|
|
message_tuple = incoming.split(':') |
|
source = message_tuple[0].strip() |
|
message_data = ':'.join(message_tuple[1:]).strip() |
|
|
|
print('\nFrom: {0} - Message: {1}'.format(source, message_data)) |
|
|
|
# process the incoming message, taking the games logic into account |
|
game_logic = GameLogic(source=source, |
|
message_data=message_data, |
|
current_state=self.player_state.get_player(source)) |
|
|
|
should_update, new_progress, broadcast_message = game_logic.process_move() |
|
|
|
if should_update: |
|
self.player_state.update_player(source, progress=new_progress) |
|
|
|
if broadcast_message: |
|
print('Sending response: {0}'.format(broadcast_message)) |
|
state.queue_new_message(message_data=broadcast_message) |
|
|
|
|
|
class SendThread(threading.Thread): |
|
""" |
|
The sending thread. |
|
|
|
If the global radio state is configured to send, |
|
this thread will pick up the message to send and |
|
handle the state change to stop the radion from |
|
listening and instead send frames. |
|
""" |
|
|
|
def __init__(self, radio_state, challenge_player_state, *args, **kwargs): |
|
|
|
super(SendThread, self).__init__(*args, **kwargs) |
|
self.state = radio_state |
|
self.player_state = challenge_player_state |
|
|
|
# get a handle on the current thread. We use this to |
|
# get the signal to quit of needed. |
|
self.current_thread = None |
|
self.received_messages = [] |
|
|
|
def run(self): |
|
|
|
# get a handle on the current thread. We use this to |
|
# get the signal to quit of needed. |
|
self.current_thread = threading.currentThread() |
|
|
|
while True: |
|
|
|
# check if a stop is needed for this thread |
|
if self.should_stop(): |
|
break |
|
|
|
# get the pending messages to send |
|
messages_to_send = self.state.get_messages_from_queue() |
|
|
|
# if there are no messages to send, gtfo |
|
if not messages_to_send: |
|
time.sleep(0.5) |
|
continue |
|
|
|
# indicate that we need to change state |
|
self.state.change_state_to(state.send_state) |
|
|
|
# poll the state to see if we can send the message |
|
while not self.state.is_send_state(): |
|
time.sleep(0.5) |
|
|
|
# send the messages |
|
for message_data in messages_to_send: |
|
for _ in range(3): |
|
d.RFxmit(data=message_data, repeat=1) |
|
|
|
# change back to the receiving state |
|
self.reverse_state_to_receive() |
|
|
|
def should_stop(self): |
|
|
|
return hasattr(self.current_thread, 'stop') and getattr(self.current_thread, 'stop', True) |
|
|
|
def reverse_state_to_receive(self): |
|
self.state.change_state_to(state.receive_state) |
|
self.state.change_state() |
|
|
|
|
|
if __name__ == '__main__': |
|
|
|
# try and load a saved player state file |
|
if os.path.exists(gamestate_file): |
|
print('Loading a saved game state from: {0}'.format(gamestate_file)) |
|
with open(gamestate_file, 'rb') as state_file: |
|
gamestate = pickle.load(state_file) |
|
|
|
else: |
|
gamestate = ChallengePlayers() |
|
|
|
# prepare the radio state |
|
state = RadioState(username='Pu') |
|
|
|
# prep and start threads. |
|
listen_thread = ListenThread(radio_state=state, challenge_player_state=gamestate, name='listen-thread') |
|
send_thread = SendThread(radio_state=state, challenge_player_state=gamestate, name='send-thread') |
|
state_save_thread = ChallengeStateSaveThread(challenge_player_state=gamestate, name='game-state-save-thread') |
|
hint_broadcast_thread = HintBroadcastThread(radio_state=state, name='hint-broadcast-thread') |
|
|
|
listen_thread.start() |
|
send_thread.start() |
|
state_save_thread.start() |
|
hint_broadcast_thread.start() |
|
|
|
# broadcast that a new user has joined! |
|
state.queue_new_message(message_data='joined the network') |
|
print('%help for help.') |
|
|
|
while True: |
|
|
|
message = raw_input('game server> ') |
|
message = message.strip() |
|
|
|
if message == '%help': |
|
print('Help Menu:\n' |
|
'\n' |
|
'%state: print the radios current state\n' |
|
'%help: this menu, but good luck getting some\n' |
|
'%exit: go back to 1985\n') |
|
continue |
|
|
|
if message == '%state': |
|
print('Radio State:') |
|
print(state) |
|
print('\n') |
|
|
|
print('Player States:') |
|
print(gamestate) |
|
print('\n') |
|
|
|
print('Message queue') |
|
for m in state.message_queue: |
|
print('Message: {0}'.format(m)) |
|
continue |
|
|
|
if message == '': |
|
continue |
|
|
|
if message == '%exit': |
|
break |
|
|
|
# queue a new message send! |
|
state.queue_new_message(message_data=message) |
|
|
|
# stop the threads |
|
listen_thread.stop = True |
|
send_thread.stop = True |
|
state_save_thread.stop = True |
|
hint_broadcast_thread.stop = True |