NdH XV Wargame Write-Up: Tic-Tac-Toe (web)

Jun 26, 2017   #Hack  #Write-up 

I had the chance to attend the 15th edition of the “Nuit du Hack” in Paris on June 24-25 2017, an amazing hacking conference. With some of my friends, we spent the night in the organized wargame, trying to defeat hacking challenges.

The rules are simple: find hidden flags in the challenges to win points. The fastest you submit valid flags, the more points you win.

The flags were a simple word starting by “ndh2k17”

I’m going to publish some write-ups of challenges I broke during the wargame with my team. Here is a first one, implying a web application named “Tic-Tac-Toe”. We were the second team to submit a valid flag for this one :)

Here are the links to other related write-ups:

Let’s go!

We started this web challenge by analyzing the behavior of the given website: a very simple tic-tac-toe game, where the user is encouraged to play against the computer. As we recalled from our A.I. courses (and despite our initial intuition), it was obvious that we were not going to be able to beat the computer, as it always played the best move.

Challenge screenshot containing a Tic-Tac-Toe board

The webpage called a server at two different occasions through HTTP POST requests:

  • When the user registers its username ;
  • When the user plays a move, to get the computer’s move.

By analyzing the web page, we found an interesting javascript file, containing some obfuscated javascript code. As the HTTP requests were also quite obfuscated, we decided to spend some time trying to beautify the ugly thing.

Here are some beautified extracts containing interesting pieces of informations, with some added comments:

// This function is triggered when the user wants to play a move
s.q = function(e) {
	// Is the game running and is the selected cell free?
	if (s["x"] == "running" && e["className"] == "empty") {
		var t = s["CELLS"]["indexOf"](e);
		if (0 <= t && t <= 9) {
			s["x"] = "waiting"
			s["s"]() // This call is not interesting
			e["className"] = "x"
			s["r"]("play", "user=" + s["l"] + "&board=" + BOARD + "&move=" + t)) // see below

// This function do the actual HTTP POST request that we want to understand
s.r = function(e, t) {
	var r = new XMLHttpRequest;
	r["onreadystatechange"] = function(e) {
		r["readyState"] === XMLHttpRequest.DONE && s["y"](r["status"], r["responseText"])
	r["open"]("POST", e, false)
	r["setRequestHeader"]("Content-type", "application/x-www-form-urlencoded")

// This function is the HTTP POST callback, called for each incoming server response
s.y = function(e, t) {
	if (200 == e) {
		if (s["x"] == "register") {
		} else {
			BOARD = t
			STATE = JSON.parse(atob(BOARD.split(".")[0])) // (1)
	// [...]

// This function refreshes the board from the BOARD variable
s.s = function() {
	// [...]
	u = JSON.parse(atob(BOARD.split(".")[0])).board - 75
	s["CELLS"].forEach(function(e, i) {
 		player = (u + i) % 3 // (2) (0: nobody, 1: x, 2: o)
		u = u / 3 | 0
	// [...]

So, some things we remarked during our de-obfuscation:

  • (1) shows that only the first part of the server’s response is used to refresh the local state. However, each complete server’s response is sent untouched by the client. We guessed that the server provided a stateless mode, where he has to trust the client.
  • (2) shows that some kind of mathematical group operation is applied on a numerical state to know how the cells are filled by the different players.

Here is a typical play HTTP exchange:



With the (1) operation, we can translate the exchange to a more human-friendly state:

board={"state":"X turn","board":11430,"msgs":[]}

{"state":"X turn","board":11350,"msgs":[]}

At this point, we guessed we had to make the computer think we managed to beat him. Our idea was to prepare a winning move for us (~):

 x | x | ~
   | o | o
   |   |

The first thing was to find a board state n satisfying the (2) equation for the desired state. Since we had little time to deal with mathematical equations, we decided to brute-force all the possibilities.

target = [1, 1, 0, 0, 2, 2, 0, 0, 0]

for (j = 0; j <= 20000; j++) {
  u = j
  a = []
  for (i = 0; i < 9; i++) {
    a[i] = (u + i) % 3
    u = u / 3 | 0

  if (a.every(function(v,i) { return v === target[i]})) {
    console.log("FOUND", j+75)

The previous program gave us one unique solution (11101). So we tried!

BadSignature("Signature 'vi25qGOMPp-Np8Y_jFjE2-26hYVeDI6S6olmIjksz43gw_2raVkN-CUfbkyuODjTAEBEIrLSWN9zXDb8on0HQA' does not match",)

OK, so from the given error message, we were at this point sure that the second unknown part of each server’s response is in fact a signature, similar to JSON Web Tokens. We struggled a bit to find a way to bypass the signature verification, but eventually we determined that there was no need to bypass anything.

The (yet-unused) username field has a very, very nice property: it is signed by the server during user registration… with the same encryption key used by the board state signature. What a nice flaw :)

First request: registration username forgery



We copied the second part of the first response line (FCHSF..GDw) to the second request.

Second request: signed stateless fooling



And after applying the (1) operation on the answer…

{ state: 'won',
  board: 11110,
   [ 'Colonel eyJzdGF0ZSI6IlggdHVybiIsImJvYXJkIjo4NSwibXNncyI6W119, take us to DEFCONÂ 5.',
     'ndh2k17_T1cT4cT0eAStrangeG4me' ] }


We found this challenge a bit tricky for an easy challenge (less than 10 validations at the end), but it was really interesting :)