Analysis
When entering the challenge page we see a text area input for a “Super Secure HTML Viewer”

Testing it out with some HTML we see that it renders the HTML and that we have the input in a query parameter https://challenge-0122-challenge.intigriti.io/result?payload=%3Ch1%3Ehello%3C%2Fh1%3E
.

When we try to enter a script tag, nothing happens. Taking a look at the DOM we see that the tag is not rendered, so there’s probably some filtering going on.

Taking a look at the source of the page we find that there’s one JavaScript file being loaded. Opening the file we see that it’s a minified bundle, but we get some information out of it.
The first line contains a file name for the license information of used packages.
/*! For license information please see main.02a05519.js.LICENSE.txt */
Lets open the file and take a look at the packages used in the app.
/* object-assign (c) Sindre Sorhus @license MIT */ /*! @license DOMPurify 2.3.4 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/2.3.4/LICENSE */ /** * React Router v6.2.1 * * Copyright (c) Remix Software Inc. * * This source code is licensed under the MIT license found in the * LICENSE.md file in the root directory of this source tree. * * @license MIT */ /** @license React v0.20.2 * scheduler.production.min.js * * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ /** @license React v17.0.2 * react-dom.production.min.js * * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ /** @license React v17.0.2 * react-jsx-runtime.production.min.js * * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ /** @license React v17.0.2 * react.production.min.js * * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */
So the app is built using React, and is using DOMPurify 2.3.4. DOMPurify 2.3.4 is the latest version at the time of this write-up and has no known bypasses. So let’s dig deeper.
The last line in the minified js bundle tells us that there’s a source map.
//# sourceMappingURL=main.02a05519.js.map
Let’s take a look at the Debugger tab in the dev-tools and see what we can find out.

Here we find the original source files used for the bundle. Taking a look at the files we find out that App.js
and index.js
is some boilerplate code for the app.
The router.js
file contains some interesting values.
import { BrowserRouter, Routes, Route } from "react-router-dom";
import I0x1C from "./pages/I0x1C";
import I0x1 from "./pages/I0x1";
const identifiers = {
I0x1: "UmVzdWx0",
I0x2: "cGF5bG9hZEZyb21Vcmw=",
I0x3: "cXVlcnlSZXN1bHQ=",
I0x4: "bG9jYXRpb24=",
I0x5: "c2VhcmNo",
I0x6: "Z2V0",
I0x7: "cGF5bG9hZA==",
I0x8: "cmVzdWx0",
I0x9: "X19odG1s",
I0xA: "PGgxIHN0eWxlPSdjb2xvcjogIzAwYmZhNSc+Tm90aGluZyBoZXJlITwvaDE+",
I0xB: "aGFuZGxlQXR0cmlidXRlcw==",
I0xC: "ZWxlbWVudA==",
I0xD: "Y2hpbGQ=",
I0xE: "Y2hpbGRyZW4=",
I0xF: "YXR0cmlidXRlcw==",
I0x10: "Z2V0QXR0cmlidXRl",
I0x11: "ZGF0YS1kZWJ1Zw==",
I0x12: "c2FuaXRpemVIVE1M",
I0x13: "aHRtbE9iag==",
I0x14: "dGVtcGxhdGU=",
I0x15: "c2FuaXRpemU=",
I0x16: "Y3JlYXRlRWxlbWVudA==",
I0x17: "aW5uZXJIVE1M",
I0x18: "YXBwZW5kQ2hpbGQ=",
I0x19: "Z2V0RWxlbWVudHNCeVRhZ05hbWU=",
I0x1A: "Y29udGVudA==",
I0x1B: "cmVtb3ZlQ2hpbGQ=",
I0x1C: "SG9tZQ==",
I0x1D: "c2V0UGF5bG9hZA==",
I0x1E: "ZWRpdG9yUmVm",
I0x1F: "bmF2aWdhdGU=",
I0x20: "aGFuZGxlU3VibWl0",
I0x21: "ZXZlbnQ=",
I0x22: "cHJldmVudERlZmF1bHQ=",
I0x23: "L3Jlc3VsdD9wYXlsb2FkPQ==",
I0x24: "dmFsdWU=",
I0x25: "a2V5",
I0x26: "VGFi",
I0x27: "c2hpZnRLZXk=",
I0x28: "c2V0UmFuZ2VUZXh0",
I0x29: "ICAgIA==",
I0x2A: "c2VsZWN0aW9uU3RhcnQ=",
I0x2B: "ZW5k",
I0x2C: "bGluZVN0YXJ0",
I0x2D: "c3RhcnQ=",
I0x2E: "bGVuZ3Ro",
I0x2F: "c2xpY2U=",
I0x30: "c2V0U2VsZWN0aW9uUmFuZ2U=",
I0x31: "Cg==",
I0x32: "Ym9keQ==",
I0x33: "dGFyZ2V0",
I0x34: "Y3VycmVudA==",
};
export default function Router() {
return (
<BrowserRouter>
<Routes>
<Route path="/">
<Route index element={<I0x1C identifiers={identifiers} />} />
<Route path="result" element={<I0x1 identifiers={identifiers} />} />
</Route>
</Routes>
</BrowserRouter>
);
}
In the pages
folder we find the two pages we came across, the input page and the result page.
Input page
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import "../../App.css";
function I0x1C({ identifiers }) {
const [I0x7, I0x1D] = useState("");
const I0x1E = useRef();
const I0x1F = useNavigate();
function I0x20(I0x21) {
I0x21[window.atob(identifiers["I0x22"])]();
I0x1F(`${window.atob(identifiers["I0x23"])}${encodeURIComponent(I0x7)}`);
}
return (
<div className="App">
<h1>Super Secure HTML Viewer</h1>
<form onSubmit={I0x20}>
<textarea
ref={I0x1E}
value={I0x7}
spellCheck={false}
onChange={(e) =>
I0x1D(
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x24"])
]
)
}
onKeyDown={(e) => {
if (
e[window.atob(identifiers["I0x25"])] ===
window.atob(identifiers["I0x26"])
) {
e[window.atob(identifiers["I0x22"])]();
if (!e[window.atob(identifiers["I0x27"])]) {
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x28"])
](
window.atob(identifiers["I0x29"]),
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x2A"])
],
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x2A"])
],
window.atob(identifiers["I0x2B"])
);
I0x1D(
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x24"])
]
);
} else {
let I0x2C = 0;
for (
let i =
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x2A"])
] - 1;
i > 0;
i--
) {
if (
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x24"])
][i] === window.atob(identifiers["I0x31"])
) {
I0x2C = i + 1;
break;
}
}
if (
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x24"])
][window.atob(identifiers["I0x2F"])](I0x2C, I0x2C + 4) ===
window.atob(identifiers["I0x29"])
) {
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x28"])
](
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x24"])
][window.atob(identifiers["I0x2F"])](I0x2C + 4),
I0x2C,
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x24"])
][window.atob(identifiers["I0x2E"])],
window.atob(identifiers["I0x2D"])
);
while (
e[window.atob(identifiers["I0x33"])][
window.atob(identifiers["I0x24"])
][I0x2C] == " "
) {
I0x2C++;
}
I0x1E[window.atob(identifiers["I0x34"])][
window.atob(identifiers["I0x30"])
](I0x2C, I0x2C);
}
}
}
}}
></textarea>
<button type="submit">Parse</button>
</form>
</div>
);
}
export default I0x1C;
Result page
import { useState } from "react";
import DOMPurify from "dompurify";
import "../../App.css";
function I0x1({ identifiers }) {
const [I0x2, _] = useState(() => {
const I0x3 = new URLSearchParams(
window[window.atob(identifiers["I0x4"])][window.atob(identifiers["I0x5"])]
)[window.atob(identifiers["I0x6"])](window.atob(identifiers["I0x7"]));
if (I0x3) {
const I0x8 = {};
I0x8[window.atob(identifiers["I0x9"])] = I0x3;
return I0x8;
}
const I0x8 = {};
I0x8[window.atob(identifiers["I0x9"])] = window.atob(identifiers["I0xA"]);
return I0x8;
});
function I0xB(I0xC) {
for (const I0xD of I0xC[window.atob(identifiers["I0xE"])]) {
if (
window.atob(identifiers["I0x11"]) in
I0xD[window.atob(identifiers["I0xF"])]
) {
new Function(
I0xD[window.atob(identifiers["I0x10"])](
window.atob(identifiers["I0x11"])
)
)();
}
I0xB(I0xD);
}
}
function I0x12(I0x13) {
I0x13[window.atob(identifiers["I0x9"])] = DOMPurify[
window.atob(identifiers["I0x15"])
](I0x13[window.atob(identifiers["I0x9"])]);
let I0x14 = document[window.atob(identifiers["I0x16"])](
window.atob(identifiers["I0x14"])
);
I0x14[window.atob(identifiers["I0x17"])] =
I0x13[window.atob(identifiers["I0x9"])];
document[window.atob(identifiers["I0x32"])][
window.atob(identifiers["I0x18"])
](I0x14);
I0x14 = document[window.atob(identifiers["I0x19"])](
window.atob(identifiers["I0x14"])
)[0];
I0xB(I0x14[window.atob(identifiers["I0x1A"])]);
document[window.atob(identifiers["I0x32"])][
window.atob(identifiers["I0x1B"])
](I0x14);
return I0x13;
}
return (
<div className="App">
<h1>Here is the result!</h1>
<div id="viewer-container" dangerouslySetInnerHTML={I0x12(I0x2)}></div>
</div>
);
}
export default I0x1;
So it seems to be some kind of obfuscated code using the values found in router.js
.
At the end of the result page we can see the use of dangerouslySetInnerHTML
so lets focus on this page.
Deobfuscation
After decoding the base64 encoded values in router.js
we end up with the following.
const identifiers = {
I0x1: "Result",
I0x2: "payloadFromUrl",
I0x3: "queryResult",
I0x4: "location",
I0x5: "search",
I0x6: "get",
I0x7: "payload",
I0x8: "result",
I0x9: "__html",
I0xA: "<h1 style='color: #00bfa5'>Nothing here!</h1>",
I0xB: "handleAttributes",
I0xC: "element",
I0xD: "child",
I0xE: "children",
I0xF: "attributes",
I0x10: "getAttribute",
I0x11: "data-debug",
I0x12: "sanitizeHTML",
I0x13: "htmlObj",
I0x14: "template",
I0x15: "sanitize",
I0x16: "createElement",
I0x17: "innerHTML",
I0x18: "appendChild",
I0x19: "getElementsByTagName",
I0x1A: "content",
I0x1B: "removeChild",
I0x1C: "Home",
I0x1D: "setPayload",
I0x1E: "editorRef",
I0x1F: "navigate",
I0x20: "handleSubmit",
I0x21: "event",
I0x22: "preventDefault",
I0x23: "/result?payload=",
I0x24: "value",
I0x25: "key",
I0x26: "Tab",
I0x27: "shiftKey",
I0x28: "setRangeText",
I0x29: " ",
I0x2A: "selectionStart",
I0x2B: "end",
I0x2C: "lineStart",
I0x2D: "start",
I0x2E: "length",
I0x2F: "slice",
I0x30: "setSelectionRange",
I0x31: "\n",
I0x32: "body",
I0x33: "target",
I0x34: "current",
};
Now we can use the decoded values in the obfuscated code.
After replacing all window.atob(identifiers["I0xNN"])
calls in the result page we get the following.
import { useState } from "react";
import DOMPurify from "dompurify";
import "../../App.css";
function I0x1({ identifiers }) {
const [I0x2, _] = useState(() => {
const I0x3 = new URLSearchParams(
window['location']['search']
)['get']('payload');
if (I0x3) {
const I0x8 = {};
I0x8['__html'] = I0x3;
return I0x8;
}
const I0x8 = {};
I0x8['__html'] = "<h1 style='color: #00bfa5'>Nothing here!</h1>";
return I0x8;
});
function I0xB(I0xC) {
for (const I0xD of I0xC['children']) {
if (
'data-debug' in
I0xD['attributes']
) {
new Function(
I0xD['getAttribute'](
'data-debug'
)
)();
}
I0xB(I0xD);
}
}
function I0x12(I0x13) {
I0x13['__html'] = DOMPurify[
'sanitize'
](I0x13['__html']);
let I0x14 = document['createElement'](
'template'
);
I0x14['innerHTML'] =
I0x13['__html'];
document['body'][
'appendChild'
](I0x14);
I0x14 = document['getElementsByTagName'](
'template'
)[0];
I0xB(I0x14['content']);
document['body'][
'removeChild'
](I0x14);
return I0x13;
}
return (
<div className="App">
<h1>Here is the result!</h1>
<div id="viewer-container" dangerouslySetInnerHTML={I0x12(I0x2)}></div>
</div>
);
}
export default I0x1;
Let’s clean up the code a bit more and then start our analysis.
import { useState } from "react";
import DOMPurify from "dompurify";
import "../../App.css";
function ResultPage({ identifiers }) {
const [payloadState, _] = useState(() => {
const payload = new URLSearchParams(window.location.search).get('payload');
if (payload) {
const payloadObj = {};
payloadObj.__html = payload;
return payloadObj;
}
const payloadObj = {};
payloadObj.__html = "<h1 style='color: #00bfa5'>Nothing here!</h1>";
return payloadObj;
});
function checkDataDebug(elem) {
for (const child of elem.children) {
if ('data-debug' in child.attributes) {
new Function(child.getAttribute('data-debug'))();
}
checkDataDebug(child);
}
}
function getHtmlContent(payloadObj) {
payloadObj.__html = DOMPurify.sanitize(payloadObj.__html);
let templateElem = document.createElement('template');
templateElem.innerHTML = payloadObj.__html;
document.body.appendChild(templateElem);
templateElem = document.getElementsByTagName('template')[0];
checkDataDebug(templateElem.content);
document.body.removeChild(templateElem);
return payloadObj;
}
return (
<div className="App">
<h1>Here is the result!</h1>
<div id="viewer-container" dangerouslySetInnerHTML={getHtmlContent(payloadState)}></div>
</div>
);
}
export default ResultPage;
Exploitation
Now we can start to analyze the code. And as seen, the component sets the inner HTML of the div
using dangerouslySetInnerHTML
with the result of getHtmlContent
with payloadState
as the argument. Let’s start by taking a look at what the payloadState
is.
const [payloadState, _] = useState(() => {
const payload = new URLSearchParams(window.location.search).get("payload");
if (payload) {
const payloadObj = {};
payloadObj.__html = payload;
return payloadObj;
}
const payloadObj = {};
payloadObj.__html = "<h1 style='color: #00bfa5'>Nothing here!</h1>";
return payloadObj;
});
From this code we can see that the payloadState
constant is an object with an __html
property, which is set to the value provided by the payload
parameter. If there’s no payload
parameter the __html
property is set to a default h1 element.
Now we know how the input to the getHtmlContent
function is generated, let’s take a look at the function.
function getHtmlContent(payloadObj) {
payloadObj.__html = DOMPurify.sanitize(payloadObj.__html);
let templateElem = document.createElement("template");
templateElem.innerHTML = payloadObj.__html;
document.body.appendChild(templateElem);
templateElem = document.getElementsByTagName("template")[0];
checkDataDebug(templateElem.content);
document.body.removeChild(templateElem);
return payloadObj;
}
So this function first sanitizes the input from the payload
parameter, then creates a template
element with the contents of the sanitized input and appends it to the body
element. Then the function checkDataDebug
is called using the content of the newly created element. After this the element is removed from the DOM and finally the sanitized input object is returned.
There’s nothing obvious we can abuse here, so let’s have a look at the checkDataDebug
function.
function checkDataDebug(elem) {
for (const child of elem.children) {
if ("data-debug" in child.attributes) {
new Function(child.getAttribute("data-debug"))();
}
checkDataDebug(child);
}
}
Here we can see that the function iterates over each child element of the template element provided from the getHtmlContent
function, and if a element has the attribute data-debug
it uses the value of the attribute to create a new Function
object and immediately calling the Function
object.
Taking a look at the examples in the documentation for the Function
constructor we can find out that it takes a string as the function body and optional strings as argument names.
Now we are able to create a payload that will execute arbitrary JavaScript using the data-debug
attribute. Let’s try with the following payload: <p data-debug="alert(document.domain)"></p>
.
When submitting this we trigger the alert.

Now we have our final payload:https://challenge-0122-challenge.intigriti.io/result?payload=%3Cp%20data-debug%3D%22alert(document.domain)%22%3E%3C/p%3E