Words With Friends is a Scrabble-like game, which boasts being the world’s most popular word game.

Through a combination of an intercepting proxy and a partially reverse engineered Word with Friends client, we made a number of interesting discoveries. Among them are:

How to make your average points per move and your weekly points tally ridiculously high.

How to see the letters your opponent has, and the next letters that will be drawn.

How to play invalid “words”.

How to play words that are not connected to any other words on the board.

Getting Started: Using an Intercepting Proxy

I’ll describe how to do this with an iOS device running Words With Friends and a Mac. Details for other computers and devices need to be found elsewhere.

We are going to set it up so you can inspect and intercept the iOS communications on your Mac. Download the Burp proxy on your Mac (the free version is sufficient), and follow these instructions to get the Mac intercepting un-encrypted communications from the iPad. Then start your iOS browser and go to an http site (not https) to verify that the the communications are passing through the Mac. If things seem stuck, make sure the intercept is turned off. This youtube tutorial is a decent introduction for those who have not used Burp before.

The next step is to be able to get encrypted (https) communications passing through the Mac. To do so, you need to install the Burp root certificate (from your Mac) onto your iOS device. This can be done following these instructions.

You should now have everything from your iOS device going through Burp on your Mac. Verify this by visiting an https website in your browser, and checking on Burp that the communications are traveling through.

After you have complete this, you may now start Words With Friends. The communications should be traveling through Burp. The fun begins now.

The Mystery of Billy The Kid

If you have a version of Words With Friends that supports a global leaderboard, you may have noticed that nearly every week there is a guy named Billy The Kid who is on the top. If you looked further, you may have noticed his average word per move is almost always 1999 points. How can one possibly average this many points per word? Answer: he set up his client to lie to the Words With Friends server about his score, and you can too.

If you turn the Burp intercept on immediately prior to making a move, you will see something like this for the content (potentially sensitive content blurred out):

The version of Words With Friends here uses XML, but later versions use JSON. Regardless, notice the highlighted “points=33”. You can change that points value to a large value and the server will believe it provided that you keep the new value under 2000. Hence, in Burp intercept, we could change it to something like 1933:

This will not affect the score on the clients, which is computed independently, but it will be figured into your average and weekly points tally. The above case interception was done on my son’s account while he was playing. He was very excited to see how much his average shot up after a few plays – see below (full identity blurred out). He was #1 in the world until Billy The Kid showed up a couple days later.

For those concerned parents, no I am not teaching my child to hack and cheat, but instead asked him to volunteer for my proof-of-concept experiment. We only did it this one session! 🙂

Digging Deeper: Building Your Own Words With Friends Client

Looking again at the HTTP POST for playing a word, the next question we ask is whether we can play arbitrary words by changing the words parameter in the query string. It doesn’t work. When I first tried it, I assumed it was the board_checksum in the POST body that was making it fail, but in retrospect I now think the text tag might have also caused a problem.

To deal with these problems, I Googled for Words with Friends board_checksum, and I found a partially reverse engineered Python client written by Jahn Veach. This code is really nicely written, but does not yet provide the server API calls to play moves. Nevertheless, Jahn had done most of the hard part in terms of providing a Words with Friends Python client.

I made a local copy and updated Jahn’s code to make it play words and do a few other communications with the server. Also, Jahn’s code expects XML communications, which is what you saw in the HTTP POST in the previous section, but more recent Words With Friends clients use JSON. I like JSON much better than XML, so I updated his code to use JSON.

I’m not going to make my code available, because last thing I want is a bunch of script kiddies cheating and ruining the game for everyone. But for those who want to go further than what I did, I’ll tell you a bit about it (and please, don’t make cheating clients publicly available).

The main thing you need to communicate with the Words With Friends server is the cookie, which you can intercept through Burp. Alternatively, Jahn tried to set up his code so that you can log in, but I just use my intercepted cookie to communicate with the server. Once you have the cookie, the following Python API call can be used to make a move:

def api_send_move( url, move_string, points, words, cookie ): headers = {'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'NewWordsWithFriendsFree/3.403 CFNetwork/711.3.18 Darwin/14.0.0', 'Cookie': cookie } params = { 'points': str(points), 'words': words } r = requests.post( url, headers=headers, params=params, data=move_string ) return r.text

The move_string is a JSON string that can be obtained by a json.dumps( ) of a structure like this:

jsonMove = { "move": { "from_y": moveObj.fromY, "from_x": moveObj.fromX, "move_index": moveObj.moveIndex, "text": moveObj._text, "to_x": moveObj.toX, "to_y": moveObj.toY, "board_checksum": moveObj.boardChecksum, "promoted": moveObj.promoted, "game_id": moveObj.gameId } }

The moveObj is an object from the Move class in Jahn’s code. I have Python code that populates this. Currently my code only allows contiguous moves. The code is below, where selected is a list containing the letters selected from your latter rack and h_or_v tells whether the word is horizontal or vertical:

moveObj = Move() moveObj.gameId = game.id moveObj.userId = player_id moveObj.fromX = x_coord moveObj.fromY = y_coord if h_or_v == 'h': # horiztonal moveObj.toX = x_coord + len(word)-1 moveObj.toY = y_coord else: # vertical moveObj.toX = x_coord moveObj.toY = y_coord + len(word)-1 moveObj.moveIndex = len(game.moves) moveObj.text = None if (len(word)) == 1: moveObj.promoted = 3 # one letter words have promoted = 3 elif h_or_v == 'h': moveObj.promoted = 1 # horizontal moves have promoted = 1 else: moveObj.promoted = 2 # vertical moves have promoted = 2 moveObj.boardChecksum = None # This will be computed when board is updated moveObj.player = current_user moveObj._text = "" for i in range(len(word)): for j in range(len(rack)): if selected[j] == i+1: moveObj._text += str(rack[j]) + "," workingGame.addMove( moveObj )

If you want to play a word that goes across other letters already played (i.e. non-contiguous moves), then the moveObj._text needs to include “,*” to represent the letters that were already on the board. Additionally, the moveObj.toX or moveObj.toY needs to be adjusted for those letters as well.

As I mentioned above, not only did I need the board_checksum to make this work, but I also think the text XML tag (or JSON tag) was needed. It contains the identity of the letters you played. That identity is according to the LETTER_MAP in Jahn’s code. The code above computes both the board_checksum and the text tags.

I also had to update the code to get a list of your games in JSON format. I threw in a few kludges to make it work (this needs to be cleaned up!):

def api_get_games( url, cookie ): headers = {'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'NewWordsWithFriendsFree/3.403 CFNetwork/711.3.18 Darwin/14.0.0', 'Cookie': cookie } params = { 'game_type': 'WordGame', 'include_invitations': 'true', 'games_since':'0001-12-30%2000:00:00+00:00', 'get_current_user':'true', # move_since seems to represent a time in milliseconds, where time 0 is approx 14 December 2009 'moves_since':'195000000000', 'last_read_chats_since': '1455000000', 'chat_messages_since': '9783000000' } r = requests.get( url, headers=headers, params=params )

Playing Invalid Words With Python Client

The first thing I wanted to try was playing an arbitrary collection of letters to see if Words With Friends would accept it. I expected this to work for two reasons:

Somebody once played the non-word “IZ” against me, which set me off to Googling about invalid words. I came across this blog, which describes a Words With Friends hacked client that allows you to play arbitrary words.

The next thing I needed was a candidate player to try it on. I’ve been playing Words With Friends long enough that I do not even blink when I see words like QOPH, ZLOTE, HADJI, AZINE, GHI, OAKUM, etc…, so if I’m going to try an arbitrary word, it needs to be against somebody who is pretty obviously cheating (i.e. using external tools to assist in the game).

My candidate: Donna (full identity omitted), who has played a number of suspicious words against me including: KIAUGH, SHEQEL, QINDARS, AECIUM, TRIJET, ARCHAIST, SCHAV, BULIMIC, SHULN. I think she’s cheating: while it is possible that a good player would know a few of these, knowing all of these is extremely unlikely, and being able to identify those words amongst a set of scrambled letters is even more unlikely.

So, here’s what I did. As you can see, from the legitimate client, AIRESHOR is not a word:

Using my Python client, I attempted to play it (only showing part of the board):

The legitimate client accepted it:

There you have it: no server-side enforcement of the rules!

Donna, if you’re out there reading this blog, sorry, but next time you’re just going to have to cheat a lot harder to beat me again.

Playing Disconnected Words

The next thought: if I can play invalid words, can I also play disconnected words? Yep:

Knowing Your Opponent’s Letters and the Next Letters to Be Drawn

Further inspection of the communications with Burp reveals that a random_seed (or random-seed) is sent from the server to the clients. Jahn’s code tells everything you need to know about this. The value is fed into a Mersenne Twister Pseudo Random Number Generator (mersenne.Mersenne in the code), which by the way is not cryptography secure, but that’s the least of the problems here. The Mersenne Twister is used to select the letters you and your opponent draw next from the letter rack (drawFromLetterBag in the code).

The clients keep track of the letters you have and the letters your opponent have. One can simply output those letters if they wanted to build a cheating client.

Please Don’t Cheat

I will never understand what joy people get knowing that they can beat somebody by cheating. My Python client was not developed for the purpose of cheating, but instead was something that I developed out of curiosity. As stated above, it was motivated by understanding how Billy The Kid was getting such a high average, and how somebody played an invalid word against me. I have mostly used it for proof of concepts, though I did use it to get revenge against Donna, a very serious cheater. That’s about it.

I love playing Words With Friends, and it burns me up when people like Donna cheat. Yes, these people are always going to exist, but if somebody built a client like mine and made it public, then it’s only going to get a lot worse for everybody. With great power comes … ahh, forget it, you already know that cliché.

What Should (Have) Zynga Do(ne)?

If Zynga would have architected the game according to security guidance, they would have done all rules enforcement on server side, and they should also be processing the letter draw from the server side rather than passing a seed to the clients and trusting them to do it.

I very much doubt that they are going to completely re-architect their game because of this. So, what are the dirty shortcut solutions they could have done to prevent people like me and Jahn from looking under the hood?

Prevent reverse engineering : code obfuscation. Sure, the security purists will tell you that that only slows down a hacker but never stops the hacker. My response: Words With Friends is 7 years old with no public hacked client. Given that it is one of the most popular games out there, and given how easy it is to do what Jahn and I have done, it seems to me that the hackers don’t even have the time or motivation to do the simple stuff against Words With Friends, let alone the hard stuff. And by the way, I truly wish those hackers good luck in their efforts to beat something like Arxan. I fully agree that it is not impossible, but in practice, good luck. Alternatively, Zynga could have used the lower cost Dexguard to provide some level of hurdle to prevent reverse engineering.

Prevent intercepting proxy : I would not have been able to do what I did had Zynga used certificate pinning. It is certainly possible that certificate pinning could be bypassed, but it would require a lot more work, especially if obfuscation is used. Update: See comment from Parsia about certificate pinning.

The two suggestions together might have been all they needed.

Zynga has a bug bounty program. They offer no financial reward, but the Hall of Fame is attractive to some white hats. I did not expect that the issues in this blog would qualify for their bug bounty program, but I emailed them nevertheless on 27th February 2016. They have not replied or acknowledged my email.

If I were Zynga, I would enforce a mandatory upgrade of clients and include the above two recommendations in it. If they force incompatibility with previous clients, then they may be able to prevent somebody from developing a cheating client that could spoil the game for everyone.

How To Play Words With Friends Like a Pro (Without Cheating!)

Alright, now I feel compelled to conclude my blog with some tips and tricks to the game to help get your average over 30 points per move and your average game score around 400. So here we go:

Before you look at the board, look at the letters in your hand to see if you can form a bingo (i.e. using all 7 letters).

When looking at the letters in your hand to form a bingo, group suffixes such as the following together: ING, ERS, ER, ED, IER, IST, IEST, ISH, ….

When looking at the letters in your hand to form a bingo, group prefixes such as the following together: OVER, OUT, UN, RE, ….

Don’t always play with the greedy strategy: if you have N and G in your hand but not I, then save the two letters until you draw an I so that you later have ING. A small sacrifice in points in the earlier move can be a big gain in points later.

Don’t play an S or a blank unless it is increasing your score by at least 10 more points than the best word you can find without playing the S/blank.

Don’t be satisfied with less than 35 points when playing the letter X. X can be combined with any vowel to form a 2-letter word (AX, EX, XI, OX, XU) so forming two 2-letter words with the X on a double-point square will give you at least 35 points most of the time.

Know all two letter words (GI should also be on the list).

Know the English spelling of the letters in the Greek and Hebrew alphabets.

Remember, at the end of the game the person who goes out first gets a bonus: the total points of letters still in his opponent’s rack are subtracted from the opponent’s score and added to his score. It’s mathematically equivalent to adding a bonus of double the number of points remaining in that rack and not subtracting any from the other rack. For this reason, rather than maximising your points per word at the end of the game, instead try to maximise your points per word minus two times the sum of the points of the letters that remain in your rack after your word is played.

Don’t be afraid to swap letters when you have junk. I do it two or three times a game on average. BTW, swapping letters does not affect your weekly average points per move.

Take your time! This is not Scrabble, so don’t be afraid to spend more than a few minutes to find that big word!

Of all the features that Words With Friends has, the one that I think is most exploitable is the word-meter, because it can often tell you enough information about where the best word is that you can figure out that best word. As an example, I once thought I had a great word: ZAIRE for 68 points. But when I checked it with the word-meter, it went up only a few bars. I then estimated how big the best word must be based upon the percentage of bars showing up. The estimate turned out to be close to 300 points. Next, I looked on the board for where would it be possible to get a word that big, and the only way it could possibly happen, I reasoned, was a word covering both a triple word score and a double word score, and the Z was on the triple letter score. Then noticing the ING combination (the N was already on the board, I had I and G), it was only a matter of a couple minutes before I found my highest word ever: TEAZLING for 275 points (and no, I never heard that word before).

Have fun. My longest words are DISJUNCTION and OVERGRAZING (11 letters each), my biggest word is 275 points, and my highest scoring game is 800 (against a really weak opponent, but I have got in the 700s a few times against decent opponents). What’s yours?