| Holiday Hack Challenge 2023 Report | Cody Travis <cwtravis@gmail.com> |
Difficulty: |
|
Shifty's Card Shuffle is a card game where both players select 5 unique cards 0-9 and the highest and lowest unique card wins points. Cards with the same value cancel out.
I definitely do not trust this guy. I used BURP http proxy to inspect the game's web traffic as I played. The best I could do is draw where both of us get no points or both of us get one point.
So whenever a hand is played, the player (us) sends the server a json POST request containing the cards. The server-side app then reads our cards, generates shifty's hand, and then decides who won.
There are some big hints that tell us what to do. There is a hint which leads to an article about NaN value injection. The name of the challenge is also called Na'an, which is another clue leaning toward NaN injection. What happens if I replace all my cards with Nan values? I turned on http intercept in BURP so I could edit my requests before they are sent to the server.
When I set my entire hand to all NaN's, the game decided that I had BOTH the highest and lowest card at the same time!
I just repeated this process until I had 10 points. Shifty had to concede defeat and declare me the winner. The objective was also marked complete in my badge when I won.
I know that NaN injection confuses Shifty into thinking I had the highest and lowest card, but why?
One way to determine what happened was to look at the source code. The server is configured to respond to errors with a very verbose error message which contains the source of the function that had the error. To cause an error like this, just submit a POST /action request an put in any string for the cards instead of a number. You will get this error:
If I clean up the source code its easier to read.
def play_cards(csv_card_choices, request_id):
try:
f = StringIO(csv_card_choices)
reader = csv.reader(f, delimiter=',')
player_cards = []
for row in reader:
for n in row:
n = float(n)
if is_valid_whole_number_choice(n) and n not in [x['num'] for x in player_cards]:
player_cards.append({
'owner':'p',
'num':n
})
break
if len(player_cards) != 5:
return jsonify({"request":False,"data": f"Requires 5 unique values but was given \"{csv_card_choices}\""})
player_cards = sorted(player_cards, key=lambda d: d['num'])
shiftys_cards = shifty_mcshuffles_choices( player_cards )
all_cards = []
for p in player_cards:
if p['num'] not in [x['num'] for x in shiftys_cards]:
all_cards.append(p)
for s in shiftys_cards:
if s['num'] not in [x['num'] for x in player_cards]:
all_cards.append(s)
maxItem = False
minItem = False
if bool(len(all_cards)):
maxItem = max(all_cards, key=lambda x:x['num'])
minItem = min(all_cards, key=lambda x:x['num'])
p_starting_value = int(session.get('player',0))
s_starting_value = int(session.get('shifty',0))
if bool(maxItem):
if maxItem['owner'] == 'p':
session['player'] = str( p_starting_value + 1 )
else:
session['shifty'] = str( s_starting_value + 1 )
if bool(minItem):
if minItem['owner'] == 'p':
session['player'] = str( int(session.get('player',0)) + 1 )
else:
session['shifty'] = str( int(session.get('shifty',0)) + 1 )
score_message, win_lose_tie_na = win_lose_tie_na_calc( int(session.get('player',0)), int(session.get('shifty',0)) )
play_message = 'Ha, we tied!'
if int(session['player']) - p_starting_value > int(session['shifty']) - s_starting_value:
play_message = 'Darn, how did I lose that hand!'
elif int(session['player']) - p_starting_value < int(session['shifty']) - s_starting_value:
play_message = 'I win and you lose that hand!'
if win_lose_tie_na in ['w','l','t']:
session['player'] = '0'
session['shifty'] = '0'
msg = { "request":True, "data": {
'player_cards':player_cards,
'shiftys_cards':shiftys_cards,
'maxItem':maxItem,
'minItem':minItem,
'player_score':int(session['player']),
'shifty_score':int(session['shifty']),
'score_message': score_message,
'win_lose_tie_na': win_lose_tie_na,
'play_message':play_message,
} }
if win_lose_tie_na == "w":
msg["data"]['conduit'] = { 'hash': hmac.new(submissionKey.encode('utf8'), request_id.encode('utf8'), sha256).hexdigest(), 'resourceId': request_id }
return jsonify( msg )
except Exception as e:
err = f"{type(e).__name__} at line {e.__traceback__.tb_lineno} of {__file__}: {e}"
raise ValueError(err)
Looking through the source it looks like the function will parse the csv string of card numbers and then create a list of all cards that have unique values. The cards retain their ownership info, so when it does a min and max test on the list of card values it can tell who owned which.
There is an issue with NaN in Python. If you test a list for min and max with NaN values in the list, any NaN that comes before a number will be considered the max or min.
In the test I did in the Python console, it shows that in a list of numbers, NaN will always be the min or max if it comes first.