Siunam's Website

My personal website

Home About Blog Writeups Projects E-Portfolio

tcl-tac-toe

Overview

Background

Author: BobbySinclusto

Time to tackle tcl-tac-toe: the tricky trek towards top-tier triumph

http://tcl-tac-toe.chals.damctf.xyz/

http://161.35.58.232/

Enumeration

Home page:

In here, we can play the Tic-Tac-Toe game:

When we click one of those cells, it’ll send the following POST request:

Now, let’s view the source code!

┌[siunam♥earth]-(~/ctf/DamCTF-2023/web/tcl-tac-toe)-[2023.04.08|11:37:13(HKT)]
└> file tcl-tac-toe.zip 
tcl-tac-toe.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam♥earth]-(~/ctf/DamCTF-2023/web/tcl-tac-toe)-[2023.04.08|11:37:14(HKT)]
└> unzip tcl-tac-toe.zip 
Archive:  tcl-tac-toe.zip
   creating: tcl-tac-toe/
  inflating: tcl-tac-toe/Dockerfile  
   creating: tcl-tac-toe/app/
  inflating: tcl-tac-toe/app/app.tcl  
   creating: tcl-tac-toe/app/static/
  inflating: tcl-tac-toe/app/static/index.css  
  inflating: tcl-tac-toe/app/static/index.html  
  inflating: tcl-tac-toe/app/static/index.js

In /Dockerfile, we see this:

RUN wget https://wapp.tcl-lang.org/home/zip/wapp.zip --no-check-certificate && unzip wapp.zip -d /usr/lib && echo pkg_mkIndex /usr/lib/wapp | tclsh

As you can see, it’s using a web application framework called “Wapp”, which is a framework for writing web applications in Tcl. Tool Command Language (Tcl) is a powerful scripting language with programming features. It is available across Unix, Windows and Mac OS platforms. Tcl is used for Web and desktop applications, networking, administration, testing, rapid prototyping, scripted applications and graphical user interfaces (GUI).

In /app/app.tcl, we see the main application.

First off, let’s find where the flag is.

In procedure (function) wapp-page-update_board{}, we can see how the flag is being read:

[...]
proc wapp-page-update_board {} {
    # allow cross-origin requests because otherwise the ssl reverse proxy thing breaks
    wapp-allow-xorigin-params
    # get prev_board, new_board, signature
    set prev_board [wapp-param prev_board]
    set new_board [wapp-param new_board]
    set signature [wapp-param signature]

    # verify previous board signature
    if [verify $prev_board $signature] {
        # verify move
        if [valid_move $prev_board $new_board] {
            set message {}
            set winner [check_win $new_board]
            if {$winner == "tie"} {
                set message "Cat's game!"
            } elseif {$winner == "X"} {
                set flag [get_file_contents "../flag"]
                set message "Impossible! You won against the unbeatable AI! $flag"
            } elseif {$winner == "O"} {
                set message "Haha I win!"
            } else {
                set new_board [computer_make_move $new_board]
                # Check if computer won or it tied the game
                set winner [check_win $new_board]
                if {$winner == "O"} {
                    set message "Haha I win!"
                } elseif {$winner == "tie"} {
                    set message "Cat's game!"
                }
            }
            # compute signature of new board
            set signature [sign $new_board]
            # send the new board, signature, and message
            wapp "$new_board,$signature,$message"
        } else {
            wapp "$prev_board,$signature,Invalid move!"
        }
    } else {
        wapp "$prev_board,$signature,No hacking allowed!"
    }
}
[...]

Flag flow:

If the previous board signature is verified > If the move is verified > If the winner is ourself (X), read the flag and display it

That being said, we need to win the game in order to get the flag!

Now, when we send a POST request to /update_board, it’ll send 3 parameters: prev_board, new_board, signature.

Then, it’ll first verify previous board signature. Let’s look at that procedure!

Procedure verify {}, sign {}, wapp-page-index.js {}:

[...]
proc sign {msg} {
    return [exec << $msg openssl dgst -sha256 -sign key.pem -hex -r | cut -d { } -f1]
}

proc verify {msg signature} {
    return [expr {[sign $msg] == $signature}]
}
[...]
proc wapp-page-index.js {} {
    wapp-mimetype text/javascript
    # Start with an empty board
    wapp "var gameBoard = \['', '', '', '', '', '', '', '', ''\];\nvar signature = \"[sign {- - - - - - - - -}]\";\n"
    wapp [get_file_contents "static/index.js"]
}
[...]

The expr will evaluate the output of procedure sign {} is equal to the correct $signature, and the correct signature is in /index.js. Also, the signature is generated via openssl, and digested via SHA256.

Hmm… It seems like we can’t bypass that?

Let’s see what if the previous board signature is verified.

Next, it’ll verify our move:

if [valid_move $prev_board $new_board]
[...]
proc valid_move {old_board new_board} {
    # Make sure only one spot was updated and that the spot that was updated was valid
    set diff_count 0
    for {set i 0} {$i < 9} {incr i} {
        if {[lindex $old_board $i] != [lindex $new_board $i]} {
            incr diff_count
            # Make sure space is not already occupied
            if {[lindex $old_board $i] == {X} || [lindex $old_board $i] == {O}} {
                return 0
            }
        }
    }
    return [expr {$diff_count == 1}]
}
[...]

This procedure will check there’s only one spot was updated and it’s occupied or not.

Then, what if both previous board signature and move is valided?

set winner [check_win $new_board]

It’ll run procedure check_win $new_board:

[...]
proc check_win {board} {
    set win \{\{1 2 3} {4 5 6} {7 8 9} {1 4 7} {2 5 8} {3 6 9} {1 5 9} {3 5 7\}\}
    foreach combo $win {
        foreach player {X O} {
            set count 0
            set index [lindex combo 0]
            foreach cell $combo {
                if {[lindex $board [expr {$cell - 1}]] != $player} {
                    break
                }
                incr count
            }
            if {$count == 3} {
                return $player
            }
        }
    }
    # check if it's a tie
    if {[string first {-} $board] == -1} {
        return {tie}
    }
    return {-}
}
[...]

What this procedure does is to check the following pattern has 3 X or O:

{1 2 3}
{4 5 6}
{7 8 9}
{1 4 7}
{2 5 8}
{3 6 9}
{1 5 9}
{3 5 7}

If it does, return the winner (X or O).

Let’s move on!

If there’s NO winner:

        [...]
        } else {
            set new_board [computer_make_move $new_board]
            # Check if computer won or it tied the game
            set winner [check_win $new_board]
            if {$winner == "O"} {
                set message "Haha I win!"
            } elseif {$winner == "tie"} {
                set message "Cat's game!"
            }
        }
        [...]

Run procedure computer_make_move $new_board:

[...]
proc computer_make_move {board} {
    set win \{\{1 2 3} {4 5 6} {7 8 9} {1 4 7} {2 5 8} {3 6 9} {1 5 9} {3 5 7\}\}
    # check if computer can win
    foreach combo $win {
        set count 0
        set index [lindex combo 0]
        foreach cell $combo {
            if {[lindex $board [expr {$cell - 1}]] eq {O}} {
                incr count
            } else {
                set index [expr $cell - 1]
            }
        }
        if {$count == 2} {
            if {[lindex $board $index] == {-}} {
                lset board $index {O}
                return $board
            }
        }
    }
    # check if human can win, block them if they can
    set played 0
    foreach combo $win {
        set count 0
        set index [lindex combo 0]
        foreach cell $combo {
            if {[lindex $board [expr {$cell - 1}]] eq {X}} {
                incr count
            } else {
                set index [expr $cell - 1]
            }
        }
        if {$count == 2 && [lindex $board $index] == {-}} {
            lset board $index {O}
            set played 1
        }
    }
    if {$played == 1} {
        return $board
    }
    # choose something to play if neither condition holds
    for {set i 0} {$i < 9} {incr i} {
        if {[lindex $board $i] == {-}} {
            lset board $i {O}
            return $board
        }
    }
}
[...]

It’ll check the computer and human can win. If human can win, try to block them.

After that, it’ll check the winner again via procedure check_win $new_board.

Finally, compute signature of new board:

# compute signature of new board
set signature [sign $new_board]
# send the new board, signature, and message
wapp "$new_board,$signature,$message"

Armed with above information, we can try to exploit it to win the game!

Exploitation

In the above source code analysis, we can control prev_board, new_board, signature in /update_board POST request.

Hmm… I wonder if we can forge our own signature…

However, I tried that, no dice.

Let’s play with it!

Now, what if I’m the AI (O)?

Umm… It doesn’t check I’m the AI!

Then I decided to let the AI win:

Note: You’ll need to replace the signature with the new_board signature, prev_board replace to new one, and add a new move in new_board.

Arghh… Can I still play the game AFTER it’s lost/tied?

I can!

Let’s try to win the game while it’s already lost:

Boom! We beat the game!!

I guess the reason why we beat that is the check_win procedure checks the following pattern in order:

{1 2 3}
{4 5 6}
{7 8 9}
{1 4 7}
{2 5 8}
{3 6 9}
{1 5 9}
{3 5 7}

So I guess we won the check!

Conclusion

What we’ve learned:

  1. Exploiting Logic Bug