Holiday Hack Challenge 2023 Report Cody Travis <cwtravis@gmail.com>
Top

Na'an

Difficulty:

Description:

Shifty McShuffles is hustling cards on Film Noir Island. Outwit that meddling elf and win!


Solution

Solution

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.

Shifty's Card Shuffle Rules

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.

Draw

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.

NaN Injection

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 won 2 points

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 Win!


But Why?

But Why?

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:

Python Source in Error

If I clean up the source code its easier to read.

  </>
Python
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)
Python Source

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.

NaN Both Min and Max